From 65d3711a117fb9a8eaf3bb1699fd1dedd54e02af Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 27 May 2026 12:59:29 -0400 Subject: [PATCH] fix(mcp): start FastMCP session manager via Quart serving lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After fixing the /mcp path forwarding in 1fd303a, requests now reach FastMCP — but its StreamableHTTPSessionManager raises: RuntimeError: Task group is not initialized. Make sure to use run(). The session manager owns a task group that must be running before it can handle requests. In a stand-alone Starlette app this happens via the `lifespan` parameter (lifespan = session_manager.run). Hosted inside Quart, my dispatch wrapper only forwards HTTP events, not lifespan, so the manager never got its startup signal. Fix: hook session_manager.run() (an async context manager) into Quart's @app.before_serving and @app.after_serving so the task group is alive across the serving window. The CI integration test was hitting the same crash because it drives app.asgi_app raw without going through Quart's serving lifecycle — @before_serving never fires. Updated the test to manually enter session_manager.run() around the request. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/fabledassistant/mcp/server.py | 20 ++++++++++++++++++- tests/test_mcp_endpoint.py | 33 ++++++++++++++++++------------- 2 files changed, 38 insertions(+), 15 deletions(-) diff --git a/src/fabledassistant/mcp/server.py b/src/fabledassistant/mcp/server.py index 6d42f35..453cd17 100644 --- a/src/fabledassistant/mcp/server.py +++ b/src/fabledassistant/mcp/server.py @@ -36,7 +36,13 @@ def mount_mcp(app: Quart) -> None: A small ASGI middleware between Quart and the FastMCP sub-app validates the Bearer token against the api_keys table. Authenticated requests have their user_id attached to the ASGI scope under "scribe_user_id" for tool handlers - to read in later phases. + to read. + + FastMCP's streamable_http session manager owns a task group that must be + running before it can serve requests. In a stand-alone Starlette deployment + that would happen via the Starlette `lifespan` parameter. Since we're hosted + inside Quart, we hook the session manager's `run()` async context manager + into Quart's serving lifecycle (before_serving / after_serving). """ from fabledassistant.mcp.auth import resolve_bearer_to_user_id @@ -44,6 +50,18 @@ def mount_mcp(app: Quart) -> None: mcp_asgi = mcp.streamable_http_app() app.mcp_instance = mcp + @app.before_serving + async def _start_mcp_session() -> None: + cm = mcp.session_manager.run() + await cm.__aenter__() + app._mcp_session_cm = cm + + @app.after_serving + async def _stop_mcp_session() -> None: + cm = getattr(app, "_mcp_session_cm", None) + if cm is not None: + await cm.__aexit__(None, None, None) + async def auth_wrapped(scope, receive, send): if scope["type"] != "http": return await mcp_asgi(scope, receive, send) diff --git a/tests/test_mcp_endpoint.py b/tests/test_mcp_endpoint.py index d799e42..8999620 100644 --- a/tests/test_mcp_endpoint.py +++ b/tests/test_mcp_endpoint.py @@ -88,7 +88,11 @@ async def test_mcp_endpoint_valid_token_passes_auth(): """With a valid Bearer, the request must successfully reach FastMCP's initialize handler. Asserting `!= 401` is too weak: it lets a 404 from a path-mismatch (the original bug) through. FastMCP responds 200 to a - well-formed initialize handshake.""" + well-formed initialize handshake. + + FastMCP's session manager normally starts via Quart's @before_serving + hook in production. This raw-ASGI test doesn't go through Quart's + serving lifecycle, so we manually enter the session manager.""" fake_key = MagicMock() fake_key.user_id = 7 fake_key.scope = "write" @@ -101,19 +105,20 @@ async def test_mcp_endpoint_valid_token_passes_auth(): "clientInfo": {"name": "test", "version": "0"}, }, }).encode() - with patch( - "fabledassistant.mcp.auth.lookup_key", - AsyncMock(return_value=fake_key), - ): - status, _ = await _send_request( - app, "POST", "/mcp", - headers={ - "Authorization": "Bearer fmcp_valid", - "Content-Type": "application/json", - "Accept": "application/json, text/event-stream", - }, - body=initialize_body, - ) + async with app.mcp_instance.session_manager.run(): + with patch( + "fabledassistant.mcp.auth.lookup_key", + AsyncMock(return_value=fake_key), + ): + status, _ = await _send_request( + app, "POST", "/mcp", + headers={ + "Authorization": "Bearer fmcp_valid", + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + }, + body=initialize_body, + ) assert status == 200, f"expected 200 from FastMCP initialize, got {status}"