fix(mcp): start FastMCP session manager via Quart serving lifecycle
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) <noreply@anthropic.com>
This commit is contained in:
@@ -36,7 +36,13 @@ def mount_mcp(app: Quart) -> None:
|
|||||||
A small ASGI middleware between Quart and the FastMCP sub-app validates the
|
A small ASGI middleware between Quart and the FastMCP sub-app validates the
|
||||||
Bearer token against the api_keys table. Authenticated requests have their
|
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
|
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
|
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()
|
mcp_asgi = mcp.streamable_http_app()
|
||||||
app.mcp_instance = mcp
|
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):
|
async def auth_wrapped(scope, receive, send):
|
||||||
if scope["type"] != "http":
|
if scope["type"] != "http":
|
||||||
return await mcp_asgi(scope, receive, send)
|
return await mcp_asgi(scope, receive, send)
|
||||||
|
|||||||
+19
-14
@@ -88,7 +88,11 @@ async def test_mcp_endpoint_valid_token_passes_auth():
|
|||||||
"""With a valid Bearer, the request must successfully reach FastMCP's
|
"""With a valid Bearer, the request must successfully reach FastMCP's
|
||||||
initialize handler. Asserting `!= 401` is too weak: it lets a 404 from
|
initialize handler. Asserting `!= 401` is too weak: it lets a 404 from
|
||||||
a path-mismatch (the original bug) through. FastMCP responds 200 to a
|
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 = MagicMock()
|
||||||
fake_key.user_id = 7
|
fake_key.user_id = 7
|
||||||
fake_key.scope = "write"
|
fake_key.scope = "write"
|
||||||
@@ -101,19 +105,20 @@ async def test_mcp_endpoint_valid_token_passes_auth():
|
|||||||
"clientInfo": {"name": "test", "version": "0"},
|
"clientInfo": {"name": "test", "version": "0"},
|
||||||
},
|
},
|
||||||
}).encode()
|
}).encode()
|
||||||
with patch(
|
async with app.mcp_instance.session_manager.run():
|
||||||
"fabledassistant.mcp.auth.lookup_key",
|
with patch(
|
||||||
AsyncMock(return_value=fake_key),
|
"fabledassistant.mcp.auth.lookup_key",
|
||||||
):
|
AsyncMock(return_value=fake_key),
|
||||||
status, _ = await _send_request(
|
):
|
||||||
app, "POST", "/mcp",
|
status, _ = await _send_request(
|
||||||
headers={
|
app, "POST", "/mcp",
|
||||||
"Authorization": "Bearer fmcp_valid",
|
headers={
|
||||||
"Content-Type": "application/json",
|
"Authorization": "Bearer fmcp_valid",
|
||||||
"Accept": "application/json, text/event-stream",
|
"Content-Type": "application/json",
|
||||||
},
|
"Accept": "application/json, text/event-stream",
|
||||||
body=initialize_body,
|
},
|
||||||
)
|
body=initialize_body,
|
||||||
|
)
|
||||||
assert status == 200, f"expected 200 from FastMCP initialize, got {status}"
|
assert status == 200, f"expected 200 from FastMCP initialize, got {status}"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user