diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 8f676df04cd..b2b3c1a3a66 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -2290,11 +2290,9 @@ def run_ollama_serve(): with open(os.devnull, "w") as devnull: subprocess.Popen(command, stdout=devnull, stderr=devnull) except Exception as e: - verbose_proxy_logger.debug( - f""" + verbose_proxy_logger.debug(f""" LiteLLM Warning: proxy started with `ollama` model\n`ollama serve` failed with Exception{e}. \nEnsure you run `ollama serve` - """ - ) + """) def _get_process_rss_mb() -> Optional[float]: @@ -14526,3 +14524,27 @@ async def dynamic_mcp_route(mcp_server_name: str, request: Request): app.mount(path=BASE_MCP_ROUTE, app=mcp_app) app.include_router(mcp_rest_endpoints_router) app.include_router(mcp_discoverable_endpoints_router) + + +# Rewrite /mcp -> /mcp/ internally to avoid 307 redirect from Starlette's +# mounted sub-application routing. Many MCP clients (e.g. Claude Code) send +# POST /mcp and do not follow redirects for POST, which drops the request body. +# Using a pure ASGI middleware (not BaseHTTPMiddleware) keeps SSE streaming intact. +class _MCPTrailingSlashMiddleware: + """Transparently append '/' to the MCP mount path so Starlette serves it + directly instead of responding with 307 Temporary Redirect.""" + + def __init__(self, app: Any) -> None: + self.app = app + + async def __call__(self, scope: Any, receive: Any, send: Any) -> None: + if scope["type"] == "http" and scope.get("path") == BASE_MCP_ROUTE: + scope = dict( + scope, + path=BASE_MCP_ROUTE + "/", + raw_path=(scope.get("raw_path") or BASE_MCP_ROUTE.encode()) + b"/", + ) + await self.app(scope, receive, send) + + +app.add_middleware(_MCPTrailingSlashMiddleware) diff --git a/tests/mcp_tests/test_mcp_trailing_slash_middleware.py b/tests/mcp_tests/test_mcp_trailing_slash_middleware.py new file mode 100644 index 00000000000..44fcb8607a8 --- /dev/null +++ b/tests/mcp_tests/test_mcp_trailing_slash_middleware.py @@ -0,0 +1,126 @@ +""" +Unit tests for _MCPTrailingSlashMiddleware. + +Validates that the middleware rewrites scope["path"] (and scope["raw_path"]) +from BASE_MCP_ROUTE to BASE_MCP_ROUTE + "/" for HTTP scopes, and passes +everything else through unchanged. +""" + +import asyncio +from typing import Optional + +# The middleware is defined at module scope in proxy_server.py alongside heavy +# imports we don't want here. Re-implement the same class locally to test the +# logic in isolation (it's only ~10 lines) while keeping the test dependency-free. + +BASE_MCP_ROUTE = "/mcp" + + +class _MCPTrailingSlashMiddleware: + """Mirror of the production middleware for isolated testing.""" + + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + if scope["type"] == "http" and scope.get("path") == BASE_MCP_ROUTE: + scope = dict( + scope, + path=BASE_MCP_ROUTE + "/", + raw_path=(scope.get("raw_path") or BASE_MCP_ROUTE.encode()) + b"/", + ) + await self.app(scope, receive, send) + + +# ── helpers ────────────────────────────────────────────────────────────── + + +class _Recorder: + """Dummy ASGI app that records the scope it was called with.""" + + def __init__(self): + self.scopes: list = [] + + async def __call__(self, scope, receive, send): + self.scopes.append(scope) + + +def _make_http_scope(path: str, raw_path: Optional[bytes] = None) -> dict: + scope: dict = {"type": "http", "path": path} + if raw_path is not None: + scope["raw_path"] = raw_path + return scope + + +# ── tests ──────────────────────────────────────────────────────────────── + + +def test_rewrites_exact_mcp_path(): + """POST /mcp should be rewritten to /mcp/.""" + recorder = _Recorder() + mw = _MCPTrailingSlashMiddleware(recorder) + + scope = _make_http_scope("/mcp", b"/mcp") + asyncio.run(mw(scope, None, None)) + + assert len(recorder.scopes) == 1 + assert recorder.scopes[0]["path"] == "/mcp/" + assert recorder.scopes[0]["raw_path"] == b"/mcp/" + + +def test_no_rewrite_for_mcp_subpath(): + """/mcp/foo should NOT be rewritten.""" + recorder = _Recorder() + mw = _MCPTrailingSlashMiddleware(recorder) + + scope = _make_http_scope("/mcp/foo", b"/mcp/foo") + asyncio.run(mw(scope, None, None)) + + assert recorder.scopes[0]["path"] == "/mcp/foo" + assert recorder.scopes[0]["raw_path"] == b"/mcp/foo" + + +def test_no_rewrite_for_other_paths(): + """/health should pass through unchanged.""" + recorder = _Recorder() + mw = _MCPTrailingSlashMiddleware(recorder) + + scope = _make_http_scope("/health", b"/health") + asyncio.run(mw(scope, None, None)) + + assert recorder.scopes[0]["path"] == "/health" + + +def test_no_rewrite_for_non_http_scope(): + """WebSocket or lifespan scopes should pass through unchanged.""" + recorder = _Recorder() + mw = _MCPTrailingSlashMiddleware(recorder) + + scope = {"type": "websocket", "path": "/mcp"} + asyncio.run(mw(scope, None, None)) + + assert recorder.scopes[0]["path"] == "/mcp" + + +def test_raw_path_fallback_when_absent(): + """If raw_path is missing from scope, middleware should still work.""" + recorder = _Recorder() + mw = _MCPTrailingSlashMiddleware(recorder) + + scope = _make_http_scope("/mcp") # no raw_path + asyncio.run(mw(scope, None, None)) + + assert recorder.scopes[0]["path"] == "/mcp/" + assert recorder.scopes[0]["raw_path"] == b"/mcp/" + + +def test_already_trailing_slash_no_double(): + """/mcp/ should NOT be rewritten (exact match only).""" + recorder = _Recorder() + mw = _MCPTrailingSlashMiddleware(recorder) + + scope = _make_http_scope("/mcp/", b"/mcp/") + asyncio.run(mw(scope, None, None)) + + assert recorder.scopes[0]["path"] == "/mcp/" + assert recorder.scopes[0]["raw_path"] == b"/mcp/"