fix(mcp): don't strip /mcp prefix; FastMCP's handler is mounted there

The dispatch wrapper was rewriting scope['path'] from '/mcp' to '/'
before handing off to FastMCP. But FastMCP's streamable_http_app
mounts the JSON-RPC handler at '/mcp' (its default), so the rewritten
'/' had no matching route and FastMCP returned 404. Auth middleware
was correctly firing first (a no-auth request still gets 401), the
bug was only on the post-auth path.

Symptom: `claude mcp add ...` succeeds, registration shows in
`claude mcp list`, but connection fails because the initialize
handshake returns 404 instead of an MCP capabilities response.

Fix: pass the scope through unmodified. FastMCP's own routing matches
the '/mcp' path.

Also tightened the integration test that should have caught this —
it was asserting `status != 401`, which a 404 trivially passes. Now
asserts `== 200`, the actual expected response for initialize.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 12:55:47 -04:00
parent 0cc09f917d
commit 1fd303abe3
2 changed files with 11 additions and 7 deletions
+6 -4
View File
@@ -78,10 +78,12 @@ def mount_mcp(app: Quart) -> None:
if scope["type"] == "http": if scope["type"] == "http":
path = scope.get("path", "") path = scope.get("path", "")
if path == "/mcp" or path.startswith("/mcp/"): if path == "/mcp" or path.startswith("/mcp/"):
sub_scope = dict(scope) # Don't rewrite the path: FastMCP's streamable_http_app mounts
sub_scope["path"] = path[len("/mcp"):] or "/" # its handler at /mcp by default. If we strip the prefix to "/",
sub_scope["root_path"] = scope.get("root_path", "") + "/mcp" # FastMCP's internal routing returns 404 because there's no
return await auth_wrapped(sub_scope, receive, send) # handler at "/" — only at "/mcp". Pass the scope through
# untouched and let FastMCP's own routing match.
return await auth_wrapped(scope, receive, send)
return await original_asgi(scope, receive, send) return await original_asgi(scope, receive, send)
app.asgi_app = dispatch app.asgi_app = dispatch
+5 -3
View File
@@ -85,8 +85,10 @@ async def test_mcp_endpoint_invalid_token_returns_401():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mcp_endpoint_valid_token_passes_auth(): async def test_mcp_endpoint_valid_token_passes_auth():
"""With a valid Bearer, the request reaches FastMCP. Anything other than 401 """With a valid Bearer, the request must successfully reach FastMCP's
confirms auth passed and the request was handed off to the sub-app.""" 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."""
fake_key = MagicMock() fake_key = MagicMock()
fake_key.user_id = 7 fake_key.user_id = 7
fake_key.scope = "write" fake_key.scope = "write"
@@ -112,7 +114,7 @@ async def test_mcp_endpoint_valid_token_passes_auth():
}, },
body=initialize_body, body=initialize_body,
) )
assert status != 401 assert status == 200, f"expected 200 from FastMCP initialize, got {status}"
# Note: there's no explicit "non-/mcp paths bypass the middleware" test here # Note: there's no explicit "non-/mcp paths bypass the middleware" test here