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}"