"""Tests for server_lifespan and session_lifespan behavior.""" import asyncio from collections.abc import AsyncIterator from contextlib import asynccontextmanager from typing import Any import anyio import pytest from fastmcp import Client, FastMCP from fastmcp.server.context import Context from fastmcp.server.lifespan import ContextManagerLifespan, lifespan from fastmcp.server.providers import Provider from fastmcp.utilities.lifespan import combine_lifespans class TestServerLifespan: """Test server_lifespan functionality.""" async def test_server_lifespan_basic(self): """Test that server_lifespan is entered once and persists across sessions.""" lifespan_events: list[str] = [] @asynccontextmanager async def server_lifespan(mcp: FastMCP) -> AsyncIterator[dict[str, Any]]: lifespan_events.append("enter") try: yield {"initialized": True} finally: lifespan_events.append("exit") mcp = FastMCP("TestServer", lifespan=server_lifespan) @mcp.tool def get_value() -> str: return "test" # Server lifespan should be entered when run_async starts assert lifespan_events == [] # Connect first client session async with Client(mcp) as client1: result1 = await client1.call_tool("get_value", {}) assert result1.data == "test" # Server lifespan should have been entered once assert lifespan_events == ["enter"] # Connect second client session while first is still active async with Client(mcp) as client2: result2 = await client2.call_tool("get_value", {}) assert result2.data == "test" # Server lifespan should still only have been entered once assert lifespan_events == ["enter"] # Because we're using a fastmcptransport, the server lifespan should be exited # when the client session closes assert lifespan_events == ["enter", "exit"] async def test_server_lifespan_overlapping_sessions(self): """Test that overlapping sessions keep lifespan active until all sessions close.""" lifespan_events: list[str] = [] resource_state = "missing" @asynccontextmanager async def server_lifespan(mcp: FastMCP) -> AsyncIterator[dict[str, Any]]: nonlocal resource_state lifespan_events.append("enter") resource_state = "open" try: yield {"initialized": True} finally: resource_state = "closed" lifespan_events.append("exit") mcp = FastMCP("TestServer", lifespan=server_lifespan) @mcp.tool def get_resource_state() -> str: return resource_state async with Client(mcp) as client1: result1 = await client1.call_tool("get_resource_state", {}) assert result1.data == "open" async with Client(mcp) as client2: result2 = await client2.call_tool("get_resource_state", {}) assert result2.data == "open" # client2 exited while client1 is still active; lifespan should remain open result3 = await client1.call_tool("get_resource_state", {}) assert result3.data == "open" assert lifespan_events == ["enter"] assert lifespan_events == ["enter", "exit"] async def test_server_lifespan_context_available(self): """Test that server_lifespan context is available to tools.""" @asynccontextmanager async def server_lifespan(mcp: FastMCP) -> AsyncIterator[dict]: yield {"db_connection": "mock_db"} mcp = FastMCP("TestServer", lifespan=server_lifespan) @mcp.tool def get_db_info(ctx: Context) -> str: # Access the server lifespan context assert ctx.request_context is not None # type narrowing for type checker lifespan_context = ctx.request_context.lifespan_context return lifespan_context.get("db_connection", "no_db") async with Client(mcp) as client: result = await client.call_tool("get_db_info", {}) assert result.data == "mock_db" class TestComposableLifespans: """Test composable lifespan functionality.""" async def test_lifespan_decorator_basic(self): """Test that the @lifespan decorator works like @asynccontextmanager.""" events: list[str] = [] @lifespan async def my_lifespan(server: FastMCP) -> AsyncIterator[dict[str, Any]]: events.append("enter") try: yield {"key": "value"} finally: events.append("exit") mcp = FastMCP("TestServer", lifespan=my_lifespan) @mcp.tool def get_info(ctx: Context) -> str: assert ctx.request_context is not None lifespan_context = ctx.request_context.lifespan_context return lifespan_context.get("key", "missing") assert events == [] async with Client(mcp) as client: result = await client.call_tool("get_info", {}) assert result.data == "value" assert events == ["enter"] assert events == ["enter", "exit"] async def test_lifespan_composition_two(self): """Test composing two lifespans with |.""" events: list[str] = [] @lifespan async def first_lifespan(server: FastMCP) -> AsyncIterator[dict[str, Any]]: events.append("first_enter") try: yield {"first": "a"} finally: events.append("first_exit") @lifespan async def second_lifespan(server: FastMCP) -> AsyncIterator[dict[str, Any]]: events.append("second_enter") try: yield {"second": "b"} finally: events.append("second_exit") composed = first_lifespan | second_lifespan mcp = FastMCP("TestServer", lifespan=composed) @mcp.tool def get_both(ctx: Context) -> dict: assert ctx.request_context is not None return dict(ctx.request_context.lifespan_context) async with Client(mcp) as client: result = await client.call_tool("get_both", {}) # Results should be merged assert result.data == {"first": "a", "second": "b"} # Should enter in order assert events == ["first_enter", "second_enter"] # Should exit in reverse order (LIFO) assert events == ["first_enter", "second_enter", "second_exit", "first_exit"] async def test_lifespan_composition_three(self): """Test composing three lifespans with |.""" events: list[str] = [] @lifespan async def ls_a(server: FastMCP) -> AsyncIterator[dict[str, Any]]: events.append("a_enter") try: yield {"a": 1} finally: events.append("a_exit") @lifespan async def ls_b(server: FastMCP) -> AsyncIterator[dict[str, Any]]: events.append("b_enter") try: yield {"b": 2} finally: events.append("b_exit") @lifespan async def ls_c(server: FastMCP) -> AsyncIterator[dict[str, Any]]: events.append("c_enter") try: yield {"c": 3} finally: events.append("c_exit") composed = ls_a | ls_b | ls_c mcp = FastMCP("TestServer", lifespan=composed) @mcp.tool def get_all(ctx: Context) -> dict: assert ctx.request_context is not None return dict(ctx.request_context.lifespan_context) async with Client(mcp) as client: result = await client.call_tool("get_all", {}) assert result.data == {"a": 1, "b": 2, "c": 3} assert events == ["a_enter", "b_enter", "c_enter"] assert events == [ "a_enter", "b_enter", "c_enter", "c_exit", "b_exit", "a_exit", ] async def test_lifespan_result_merge_later_wins(self): """Test that later lifespans overwrite earlier ones on key conflict.""" @lifespan async def first(server: FastMCP) -> AsyncIterator[dict[str, Any]]: yield {"key": "first", "only_first": "yes"} @lifespan async def second(server: FastMCP) -> AsyncIterator[dict[str, Any]]: yield {"key": "second", "only_second": "yes"} composed = first | second mcp = FastMCP("TestServer", lifespan=composed) @mcp.tool def get_context(ctx: Context) -> dict: assert ctx.request_context is not None return dict(ctx.request_context.lifespan_context) async with Client(mcp) as client: result = await client.call_tool("get_context", {}) # "key" should be overwritten by second assert result.data == { "key": "second", "only_first": "yes", "only_second": "yes", } async def test_lifespan_composition_with_context_manager_lifespan(self): """Test composing with ContextManagerLifespan for @asynccontextmanager functions.""" events: list[str] = [] @asynccontextmanager async def legacy_lifespan(server: FastMCP) -> AsyncIterator[dict[str, Any]]: events.append("legacy_enter") try: yield {"legacy": True} finally: events.append("legacy_exit") @lifespan async def new_lifespan(server: FastMCP) -> AsyncIterator[dict[str, Any]]: events.append("new_enter") try: yield {"new": True} finally: events.append("new_exit") # Wrap the @asynccontextmanager function explicitly composed = ContextManagerLifespan(legacy_lifespan) | new_lifespan mcp = FastMCP("TestServer", lifespan=composed) @mcp.tool def get_context(ctx: Context) -> dict: assert ctx.request_context is not None return dict(ctx.request_context.lifespan_context) async with Client(mcp) as client: result = await client.call_tool("get_context", {}) assert result.data == {"legacy": True, "new": True} assert events == [ "legacy_enter", "new_enter", "new_exit", "legacy_exit", ] async def test_backwards_compatibility_asynccontextmanager(self): """Test that existing @asynccontextmanager lifespans still work.""" @asynccontextmanager async def old_style_lifespan(server: FastMCP) -> AsyncIterator[dict[str, Any]]: yield {"old_style": True} mcp = FastMCP("TestServer", lifespan=old_style_lifespan) @mcp.tool def get_context(ctx: Context) -> dict: assert ctx.request_context is not None return dict(ctx.request_context.lifespan_context) async with Client(mcp) as client: result = await client.call_tool("get_context", {}) assert result.data == {"old_style": True} async def test_lifespan_or_requires_lifespan_instance(self): """Test that | operator requires Lifespan instances and gives helpful error.""" @lifespan async def my_lifespan(server: FastMCP) -> AsyncIterator[dict[str, Any]]: yield {"key": "value"} @asynccontextmanager async def regular_lifespan(server: FastMCP) -> AsyncIterator[dict[str, Any]]: yield {"regular": True} # Composing with non-Lifespan should raise TypeError with helpful message with pytest.raises(TypeError) as exc_info: my_lifespan | regular_lifespan # type: ignore[operator] # ty:ignore[unsupported-operator] assert "ContextManagerLifespan" in str(exc_info.value) class TestCombineLifespans: """Test combine_lifespans utility function.""" async def test_combine_lifespans_fastapi_style(self): """Test combining lifespans that yield None (FastAPI-style).""" events: list[str] = [] @asynccontextmanager async def first_lifespan(app: Any) -> AsyncIterator[None]: events.append("first_enter") try: yield finally: events.append("first_exit") @asynccontextmanager async def second_lifespan(app: Any) -> AsyncIterator[None]: events.append("second_enter") try: yield finally: events.append("second_exit") combined = combine_lifespans(first_lifespan, second_lifespan) async with combined("mock_app") as result: assert result == {} # Empty dict when lifespans yield None assert events == ["first_enter", "second_enter"] # LIFO exit order assert events == ["first_enter", "second_enter", "second_exit", "first_exit"] async def test_combine_lifespans_fastmcp_style(self): """Test combining lifespans that yield dicts (FastMCP-style).""" events: list[str] = [] @asynccontextmanager async def db_lifespan(app: Any) -> AsyncIterator[dict[str, Any]]: events.append("db_enter") try: yield {"db": "connected"} finally: events.append("db_exit") @asynccontextmanager async def cache_lifespan(app: Any) -> AsyncIterator[dict[str, Any]]: events.append("cache_enter") try: yield {"cache": "ready"} finally: events.append("cache_exit") combined = combine_lifespans(db_lifespan, cache_lifespan) async with combined("mock_app") as result: assert result == {"db": "connected", "cache": "ready"} assert events == ["db_enter", "cache_enter"] assert events == ["db_enter", "cache_enter", "cache_exit", "db_exit"] async def test_combine_lifespans_mixed_styles(self): """Test combining FastAPI-style (yield None) and FastMCP-style (yield dict).""" events: list[str] = [] @asynccontextmanager async def fastapi_lifespan(app: Any) -> AsyncIterator[None]: events.append("fastapi_enter") try: yield # FastAPI-style: yield None finally: events.append("fastapi_exit") @asynccontextmanager async def fastmcp_lifespan(app: Any) -> AsyncIterator[dict[str, Any]]: events.append("fastmcp_enter") try: yield {"mcp": "initialized"} # FastMCP-style: yield dict finally: events.append("fastmcp_exit") combined = combine_lifespans(fastapi_lifespan, fastmcp_lifespan) async with combined("mock_app") as result: # Only the dict from fastmcp_lifespan should be present assert result == {"mcp": "initialized"} assert events == ["fastapi_enter", "fastmcp_enter"] assert events == [ "fastapi_enter", "fastmcp_enter", "fastmcp_exit", "fastapi_exit", ] async def test_combine_lifespans_result_merge_later_wins(self): """Test that later lifespans overwrite earlier ones on key conflict.""" @asynccontextmanager async def first(app: Any) -> AsyncIterator[dict[str, Any]]: yield {"key": "first", "only_first": "yes"} @asynccontextmanager async def second(app: Any) -> AsyncIterator[dict[str, Any]]: yield {"key": "second", "only_second": "yes"} combined = combine_lifespans(first, second) async with combined("mock_app") as result: assert result == { "key": "second", # Overwritten by later lifespan "only_first": "yes", "only_second": "yes", } async def test_combine_lifespans_three(self): """Test combining three lifespans.""" events: list[str] = [] @asynccontextmanager async def ls_a(app: Any) -> AsyncIterator[dict[str, Any]]: events.append("a_enter") try: yield {"a": 1} finally: events.append("a_exit") @asynccontextmanager async def ls_b(app: Any) -> AsyncIterator[dict[str, Any]]: events.append("b_enter") try: yield {"b": 2} finally: events.append("b_exit") @asynccontextmanager async def ls_c(app: Any) -> AsyncIterator[dict[str, Any]]: events.append("c_enter") try: yield {"c": 3} finally: events.append("c_exit") combined = combine_lifespans(ls_a, ls_b, ls_c) async with combined("mock_app") as result: assert result == {"a": 1, "b": 2, "c": 3} assert events == ["a_enter", "b_enter", "c_enter"] assert events == [ "a_enter", "b_enter", "c_enter", "c_exit", "b_exit", "a_exit", ] async def test_combine_lifespans_empty(self): """Test combining zero lifespans.""" combined = combine_lifespans() async with combined("mock_app") as result: assert result == {} async def test_combine_lifespans_with_mapping_return_type(self): """Test combining lifespans that return Mapping (like Starlette's Lifespan). This verifies that combine_lifespans accepts lifespans returning Mapping[str, Any], which is the type that Starlette's Lifespan uses, not just dict[str, Any]. """ from collections.abc import Mapping events: list[str] = [] @asynccontextmanager async def mapping_lifespan(app: Any) -> AsyncIterator[Mapping[str, Any]]: """Simulates a Starlette-style lifespan that yields a Mapping.""" events.append("mapping_enter") try: yield {"starlette_state": "initialized"} finally: events.append("mapping_exit") @asynccontextmanager async def dict_lifespan(app: Any) -> AsyncIterator[dict[str, Any]]: events.append("dict_enter") try: yield {"fastmcp_state": "ready"} finally: events.append("dict_exit") combined = combine_lifespans(mapping_lifespan, dict_lifespan) async with combined("mock_app") as result: assert result == { "starlette_state": "initialized", "fastmcp_state": "ready", } assert events == ["mapping_enter", "dict_enter"] assert events == [ "mapping_enter", "dict_enter", "dict_exit", "mapping_exit", ] class TestLifespanTeardownShielding: """Test that async operations in lifespan teardown complete under cancellation. When a server shuts down (e.g. Ctrl-C), the cancel scope becomes active. Lifespan teardown must be shielded from cancellation so that async cleanup (closing DB connections, flushing buffers, etc.) can actually run. """ async def test_server_lifespan_async_teardown_under_cancellation(self): """Async operations in server lifespan finally block complete even when cancelled.""" events: list[str] = [] @asynccontextmanager async def server_lifespan(mcp: FastMCP) -> AsyncIterator[dict[str, Any]]: events.append("setup") try: yield {} finally: events.append("teardown_start") await asyncio.sleep(0) events.append("teardown_complete") mcp = FastMCP("TestServer", lifespan=server_lifespan) async with anyio.create_task_group() as tg: async def run_and_cancel() -> None: async with mcp._lifespan_manager(): tg.cancel_scope.cancel() tg.start_soon(run_and_cancel) assert "setup" in events assert "teardown_start" in events assert "teardown_complete" in events async def test_provider_lifespan_async_teardown_under_cancellation(self): """Async operations in provider lifespan finally block complete when cancelled.""" events: list[str] = [] class TestProvider(Provider): @asynccontextmanager async def lifespan(self) -> AsyncIterator[None]: events.append("provider_setup") try: yield finally: events.append("provider_teardown_start") await asyncio.sleep(0) events.append("provider_teardown_complete") mcp = FastMCP("TestServer", providers=[TestProvider()]) async with anyio.create_task_group() as tg: async def run_and_cancel() -> None: async with mcp._lifespan_manager(): tg.cancel_scope.cancel() tg.start_soon(run_and_cancel) assert "provider_setup" in events assert "provider_teardown_start" in events assert "provider_teardown_complete" in events async def test_composed_lifespans_async_teardown_under_cancellation(self): """Both server and provider async teardown completes under cancellation.""" events: list[str] = [] @asynccontextmanager async def server_lifespan(mcp: FastMCP) -> AsyncIterator[dict[str, Any]]: events.append("server_setup") try: yield {} finally: events.append("server_teardown_start") await asyncio.sleep(0) events.append("server_teardown_complete") class TestProvider(Provider): @asynccontextmanager async def lifespan(self) -> AsyncIterator[None]: events.append("provider_setup") try: yield finally: events.append("provider_teardown_start") await asyncio.sleep(0) events.append("provider_teardown_complete") mcp = FastMCP( "TestServer", lifespan=server_lifespan, providers=[TestProvider()], ) async with anyio.create_task_group() as tg: async def run_and_cancel() -> None: async with mcp._lifespan_manager(): tg.cancel_scope.cancel() tg.start_soon(run_and_cancel) assert events == [ "server_setup", "provider_setup", "provider_teardown_start", "provider_teardown_complete", "server_teardown_start", "server_teardown_complete", ]