Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 21 additions & 6 deletions mcp_cloud/http_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
29 changes: 25 additions & 4 deletions mcp_cloud/tests/test_http_server_routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down