From 4b02d4623d4ff55c88bb1841e0c8e0e426899058 Mon Sep 17 00:00:00 2001 From: Simon Strandgaard Date: Sat, 28 Feb 2026 23:49:54 +0100 Subject: [PATCH] =?UTF-8?q?Replace=20307=20redirect=20with=20ASGI=20path?= =?UTF-8?q?=20rewrite=20for=20/mcp=20=E2=86=92=20/mcp/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Smithery refuses to follow 307 redirects for POST requests, causing 502 errors during scanning. Replace the redirect route with a raw ASGI middleware (_NormalizeMcpPath) that silently rewrites /mcp to /mcp/ at the scope level before routing, so the mounted FastMCP app receives requests directly. Co-Authored-By: Claude Opus 4.6 --- mcp_cloud/http_server.py | 27 ++++++++++++++----- mcp_cloud/tests/test_http_server_routing.py | 29 ++++++++++++++++++--- 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/mcp_cloud/http_server.py b/mcp_cloud/http_server.py index c22fe0f7..8356bdcf 100644 --- a/mcp_cloud/http_server.py +++ b/mcp_cloud/http_server.py @@ -924,12 +924,22 @@ async def head_mcp_trailing_slash() -> Response: ) -@app.api_route("/mcp", methods=["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"]) -async def redirect_mcp_no_trailing_slash(request: Request) -> RedirectResponse: - """Normalize '/mcp' to '/mcp/' so streamable HTTP requests avoid 405 mismatches.""" - qs = request.url.query - target = f"/mcp/?{qs}" if qs else "/mcp/" - return RedirectResponse(url=target, status_code=307) +class _NormalizeMcpPath: + """ASGI middleware: rewrite ``/mcp`` → ``/mcp/`` at the scope level. + + Smithery (and possibly other registries) POST to ``/mcp`` but refuse to + follow 307 redirects. By rewriting the path *before* routing, the mounted + FastMCP sub-app receives the request directly — no HTTP redirect needed. + """ + + def __init__(self, app: Any) -> None: + self.app = app + + async def __call__(self, scope: dict, receive: Any, send: Any) -> None: + if scope["type"] == "http" and scope.get("path") == "/mcp": + scope = dict(scope) + scope["path"] = "/mcp/" + await self.app(scope, receive, send) @app.post("/mcp/tools/call", response_model=MCPToolCallResponse) @@ -987,6 +997,11 @@ async def list_tools(fastmcp_server: FastMCP = Depends(_get_fastmcp)) -> dict[st # REST endpoints with a 404 from the sub-app. app.mount("/mcp", fastmcp_http_app) +# Rewrite /mcp → /mcp/ at the ASGI level so clients that refuse to follow +# 307 redirects (e.g. Smithery) still reach the mounted FastMCP app. +# Added last so it becomes the outermost middleware (runs first). +app.add_middleware(_NormalizeMcpPath) + @app.get("/download/{plan_id}/{filename}") async def download_report( plan_id: str, diff --git a/mcp_cloud/tests/test_http_server_routing.py b/mcp_cloud/tests/test_http_server_routing.py index 8c4c2e22..678456d2 100644 --- a/mcp_cloud/tests/test_http_server_routing.py +++ b/mcp_cloud/tests/test_http_server_routing.py @@ -5,10 +5,31 @@ class TestHttpServerRouting(unittest.TestCase): - def test_mcp_no_trailing_slash_redirects_to_trailing_slash(self): - response = asyncio.run(http_server.redirect_mcp_no_trailing_slash()) - self.assertEqual(response.status_code, 307) - self.assertEqual(response.headers.get("location"), "/mcp/") + def test_normalize_mcp_path_rewrites_slash(self): + """_NormalizeMcpPath rewrites /mcp to /mcp/ at the ASGI scope level.""" + captured_path = None + + async def dummy_app(scope, receive, send): + nonlocal captured_path + captured_path = scope["path"] + + middleware = http_server._NormalizeMcpPath(dummy_app) + scope = {"type": "http", "path": "/mcp"} + asyncio.run(middleware(scope, None, None)) + self.assertEqual(captured_path, "/mcp/") + + def test_normalize_mcp_path_preserves_trailing_slash(self): + """_NormalizeMcpPath does not alter /mcp/ (already correct).""" + captured_path = None + + async def dummy_app(scope, receive, send): + nonlocal captured_path + captured_path = scope["path"] + + middleware = http_server._NormalizeMcpPath(dummy_app) + scope = {"type": "http", "path": "/mcp/"} + asyncio.run(middleware(scope, None, None)) + self.assertEqual(captured_path, "/mcp/") def test_options_mcp_returns_ok(self): response = asyncio.run(http_server.options_mcp())