From 6edd5e699e6cdbba405d8d224e99c21ee1f9cef6 Mon Sep 17 00:00:00 2001 From: Samir Amin <115834029+aminsamir45@users.noreply.github.com> Date: Wed, 28 Jan 2026 12:54:59 -0800 Subject: [PATCH] fix: broaden combine_lifespans type to accept Mapping return types (#3005) --- src/fastmcp/utilities/lifespan.py | 4 +-- tests/server/test_server_lifespan.py | 43 ++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/fastmcp/utilities/lifespan.py b/src/fastmcp/utilities/lifespan.py index 09434d736..f7f00b895 100644 --- a/src/fastmcp/utilities/lifespan.py +++ b/src/fastmcp/utilities/lifespan.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import AsyncIterator, Callable +from collections.abc import AsyncIterator, Callable, Mapping from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager from typing import Any, TypeVar @@ -10,7 +10,7 @@ AppT = TypeVar("AppT") def combine_lifespans( - *lifespans: Callable[[AppT], AbstractAsyncContextManager[dict[str, Any] | None]], + *lifespans: Callable[[AppT], AbstractAsyncContextManager[Mapping[str, Any] | None]], ) -> Callable[[AppT], AbstractAsyncContextManager[dict[str, Any]]]: """Combine multiple lifespans into a single lifespan. diff --git a/tests/server/test_server_lifespan.py b/tests/server/test_server_lifespan.py index a6d4bb637..ef52e4e22 100644 --- a/tests/server/test_server_lifespan.py +++ b/tests/server/test_server_lifespan.py @@ -462,3 +462,46 @@ class TestCombineLifespans: 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", + ]