33Before FastMCP handed its lifespan to the SDK lowlevel Server (PR #4446), the
44SDK v1 lifespan was effectively session-scoped and FastMCP worked around it by
55driving its own ``_lifespan_manager`` beside the session manager. The SDK v2
6- ``StreamableHTTPSessionManager`` now enters ``app.lifespan(app)`` exactly once
7- for the manager's lifetime, so the user lifespan must fire once per process and
8- persist across multiple HTTP client sessions -- not once per session.
6+ ``StreamableHTTPSessionManager`` now enters ``app.lifespan(app)`` -- FastMCP's
7+ ``_lifespan_proxy`` -- exactly once for the manager's lifetime and reuses the
8+ yielded state for every session. The user lifespan must therefore fire once per
9+ process and persist across HTTP client sessions, not once per session.
10+
11+ The invariant that actually matters is "the session manager drives the lifespan
12+ exactly once, regardless of how many client sessions connect." A plain
13+ user-lifespan enter/exit counter cannot guard it: ``_lifespan_manager`` is
14+ ref-counted, and ``run_http_async`` opens an *outer* ``_lifespan_manager``
15+ around uvicorn. That outer entry holds the ref count at >= 1 for the whole
16+ server lifetime, so even if the session manager regressed to re-entering
17+ ``_lifespan_proxy`` once per session, the user lifespan would still be entered
18+ exactly once (the proxy's nested ``_lifespan_manager`` entries would all reuse
19+ the outer result). The user counter would stay ``1`` while the behavior it
20+ claims to guard was broken.
21+
22+ So this test spies on the session-manager entry point directly -- it counts how
23+ many times the SDK enters ``server._mcp_server.lifespan`` (the ``_lifespan_proxy``
24+ wrapper) -- and asserts that count is exactly one across sequential and
25+ overlapping sessions. A regression that moves ``app.lifespan(app)`` into the
26+ per-session code path makes this count grow with the session count and fails
27+ loudly. The user enter/exit counter is kept as a secondary check on the
28+ "entered once, exited once at shutdown" shape.
929"""
1030
1131from collections .abc import AsyncIterator
1232from contextlib import asynccontextmanager
1333from typing import Any
1434
35+ from mcp .server .lowlevel .server import Server as LowLevelServer
36+
1537from fastmcp import Client , FastMCP
1638from fastmcp .client .transports import StreamableHttpTransport
1739from fastmcp .utilities .tests import run_server_async
1840
1941
2042async def test_http_user_lifespan_fires_once_across_sessions ():
21- """The user lifespan must be entered exactly once for the server process,
22- even when several independent HTTP client sessions connect and disconnect.
43+ """The session manager must drive the user lifespan exactly once for the
44+ server process, even when several independent HTTP client sessions connect
45+ and disconnect.
2346 """
2447 enter_count = 0
2548 exit_count = 0
@@ -39,16 +62,34 @@ async def counting_lifespan(mcp: FastMCP) -> AsyncIterator[dict[str, Any]]:
3962 def ping () -> str :
4063 return "pong"
4164
65+ # Spy on the session manager's single entry point into FastMCP's lifespan.
66+ # `StreamableHTTPSessionManager.run()` calls `self.app.lifespan(self.app)`
67+ # exactly once and reuses the yielded state per session; `self.app` is
68+ # `server._mcp_server`, so `server._mcp_server.lifespan` is the
69+ # `_lifespan_proxy` wrapper. Counting entries here asserts the invariant
70+ # directly, independent of `_lifespan_manager`'s ref-count masking.
71+ proxy_enter_count = 0
72+ original_lifespan = server ._mcp_server .lifespan
73+
74+ @asynccontextmanager
75+ async def counting_proxy (app : LowLevelServer [Any ]) -> AsyncIterator [Any ]:
76+ nonlocal proxy_enter_count
77+ proxy_enter_count += 1
78+ async with original_lifespan (app ) as state :
79+ yield state
80+
81+ server ._mcp_server .lifespan = counting_proxy
82+
4283 async with run_server_async (server , transport = "http" ) as mcp_url :
4384 # `run_server_async` yields a URL that already includes the `/mcp` path.
4485 # Three separate, sequential client sessions against the same process.
4586 for _ in range (3 ):
4687 async with Client (StreamableHttpTransport (mcp_url )) as client :
4788 result = await client .call_tool ("ping" , {})
4889 assert result .data == "pong"
49- # The lifespan must not have exited when a session closed -- it is
50- # owned by the session manager for the whole process lifetime.
51- assert enter_count == 1
90+ # The session manager must not re-drive the lifespan when a session
91+ # closes -- it owns a single entry for the whole process lifetime.
92+ assert proxy_enter_count == 1
5293 assert exit_count == 0
5394
5495 # Overlapping sessions must also observe a single, still-open lifespan.
@@ -58,10 +99,12 @@ def ping() -> str:
5899 ):
59100 assert (await c1 .call_tool ("ping" , {})).data == "pong"
60101 assert (await c2 .call_tool ("ping" , {})).data == "pong"
61- assert enter_count == 1
102+ assert proxy_enter_count == 1
62103 assert exit_count == 0
63104
64- # After the server process task is torn down, the lifespan has exited once.
65- # (A spurious re-entry during teardown would re-exit, so this also guards
66- # that the lifespan was entered exactly once.)
105+ # The session manager entered the lifespan exactly once across every
106+ # session, and the user lifespan was entered once and exited once at
107+ # process shutdown.
108+ assert proxy_enter_count == 1
109+ assert enter_count == 1
67110 assert exit_count == 1
0 commit comments