From fc4d0114dbd3f08c709bed3497e4d21a4e66f026 Mon Sep 17 00:00:00 2001 From: Chris Guidry Date: Fri, 9 Jan 2026 11:47:01 -0500 Subject: [PATCH] Fix ContextVar propagation for nested ASGI lifespan calls When FastMCP runs with uvicorn, the lifespan is entered twice: 1. FastMCP's outer context (during http_app setup) 2. Starlette's ASGI lifespan (which request handlers inherit from) The second call was skipping ContextVar setup because _lifespan_result_set was already True. This caused _current_docket.get() to return None in request handlers even though server._docket was correctly set. Fix: Always set ContextVars when entering _lifespan_manager, using the already-initialized values from self._docket and self._worker. Co-Authored-By: Claude Opus 4.5 --- src/fastmcp/server/server.py | 34 ++++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index a8e9a6477..1a8cab4d3 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -197,8 +197,9 @@ class FastMCP(Generic[LifespanResultT]): # Resolve server default for background task support self._support_tasks_by_default: bool = tasks if tasks is not None else False - # Docket instance (set during lifespan for cross-task access) + # Docket and Worker instances (set during lifespan for cross-task access) self._docket = None + self._worker = None self._additional_http_routes: list[BaseRoute] = [] self._mounted_servers: list[MountedServer] = [] @@ -486,6 +487,8 @@ class FastMCP(Generic[LifespanResultT]): f"[{instance_id}] Creating Worker with kwargs={worker_kwargs}" ) async with Worker(docket, **worker_kwargs) as worker: # type: ignore[arg-type] + # Store on server instance for cross-context access + self._worker = worker # Set Worker in ContextVar so CurrentWorker can access it worker_token = _current_worker.set(worker) logger.info( @@ -514,6 +517,7 @@ class FastMCP(Generic[LifespanResultT]): f"[{instance_id}] _current_worker RESET (token={worker_token})" ) _current_worker.reset(worker_token) + self._worker = None finally: # Reset ContextVar logger.info( @@ -609,10 +613,32 @@ class FastMCP(Generic[LifespanResultT]): logger.info(f"[{instance_id}] _lifespan_manager ENTERING") if self._lifespan_result_set: - logger.info( - f"[{instance_id}] _lifespan_manager: already set, yielding without setup" + # Lifespan already ran in a parent async context. We need to set ContextVars + # again in THIS context because ContextVars don't propagate across different + # async contexts (e.g., Starlette's ASGI lifespan vs FastMCP's outer lifespan). + # Request handlers inherit from Starlette's context, so we must set here. + from fastmcp.server.dependencies import ( + _current_docket, + _current_server, + _current_worker, ) - yield + + logger.info( + f"[{instance_id}] _lifespan_manager: already set, setting ContextVars " + f"for this context (docket={self._docket}, worker={self._worker})" + ) + + server_token = _current_server.set(weakref.ref(self)) + docket_token = _current_docket.set(self._docket) if self._docket else None + worker_token = _current_worker.set(self._worker) if self._worker else None + try: + yield + finally: + _current_server.reset(server_token) + if docket_token is not None: + _current_docket.reset(docket_token) + if worker_token is not None: + _current_worker.reset(worker_token) return logger.info(