From 8a43e213b4d0d1341d724b60d95b9276f2c02d95 Mon Sep 17 00:00:00 2001 From: voidborne-d Date: Sun, 15 Mar 2026 21:11:53 +0000 Subject: [PATCH 1/5] fix(proxy): avoid 307 redirect on POST /mcp (fixes #23688) Starlette's Router redirects /mcp -> /mcp/ with 307 Temporary Redirect when the MCP sub-app is mounted at /mcp. Many HTTP clients (and MCP clients like Claude Code) do not follow redirects for POST requests, causing the request body to be dropped and the connection to fail. Add a lightweight pure-ASGI middleware that internally rewrites /mcp to /mcp/ before routing, so the mounted sub-app handles the request directly. Using a raw ASGI middleware (instead of BaseHTTPMiddleware) preserves SSE streaming used by the MCP transport layer. --- litellm/proxy/proxy_server.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 9c29927c5cb..7f8b94d0b8f 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -13399,3 +13399,23 @@ 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: # noqa: F811 + 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 + "/") + await self.app(scope, receive, send) + + +app.add_middleware(_MCPTrailingSlashMiddleware) From 0c895e87f204e4147a08463a3c44fcef61b9cd3e Mon Sep 17 00:00:00 2001 From: d Date: Sun, 15 Mar 2026 21:53:22 +0000 Subject: [PATCH 2/5] fix: also update raw_path in MCP trailing-slash middleware + add unit tests Address review feedback from Greptile bot: - Update scope['raw_path'] alongside scope['path'] to avoid stale values in downstream middleware/loggers. - Add 6 unit tests covering: exact path rewrite, subpath passthrough, non-MCP paths, non-HTTP scope types, missing raw_path fallback, and no double-slash on already-trailed path. --- litellm/proxy/proxy_server.py | 6 +- .../test_mcp_trailing_slash_middleware.py | 132 ++++++++++++++++++ 2 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 tests/mcp_tests/test_mcp_trailing_slash_middleware.py diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 7f8b94d0b8f..a92ffcbc75a 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -13414,7 +13414,11 @@ class _MCPTrailingSlashMiddleware: 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 + "/") + 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) 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..28989d37f14 --- /dev/null +++ b/tests/mcp_tests/test_mcp_trailing_slash_middleware.py @@ -0,0 +1,132 @@ +""" +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 + +import pytest + + +# 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: bytes | None = None) -> dict: + scope: dict = {"type": "http", "path": path} + if raw_path is not None: + scope["raw_path"] = raw_path + return scope + + +def _run(coro): + return asyncio.get_event_loop().run_until_complete(coro) + + +# ── 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") + _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") + _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") + _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"} + _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 + _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/") + _run(mw(scope, None, None)) + + assert recorder.scopes[0]["path"] == "/mcp/" + assert recorder.scopes[0]["raw_path"] == b"/mcp/" From 4dfb55773bb30adacab1036b7d6047bb3ed52077 Mon Sep 17 00:00:00 2001 From: voidborne-d Date: Mon, 16 Mar 2026 02:08:00 +0000 Subject: [PATCH 3/5] fix: use asyncio.run() and remove unused pytest import in tests Address review feedback: - Replace deprecated asyncio.get_event_loop().run_until_complete() with asyncio.run() - Remove unused pytest import --- tests/mcp_tests/test_mcp_trailing_slash_middleware.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/mcp_tests/test_mcp_trailing_slash_middleware.py b/tests/mcp_tests/test_mcp_trailing_slash_middleware.py index 28989d37f14..edcbe6b9ccf 100644 --- a/tests/mcp_tests/test_mcp_trailing_slash_middleware.py +++ b/tests/mcp_tests/test_mcp_trailing_slash_middleware.py @@ -8,8 +8,6 @@ everything else through unchanged. import asyncio -import pytest - # 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 @@ -55,7 +53,7 @@ def _make_http_scope(path: str, raw_path: bytes | None = None) -> dict: def _run(coro): - return asyncio.get_event_loop().run_until_complete(coro) + return asyncio.run(coro) # ── tests ──────────────────────────────────────────────────────────────── From 86f00a1254455b630ac0204cc4321f7009d86daf Mon Sep 17 00:00:00 2001 From: voidborne-d Date: Mon, 16 Mar 2026 03:52:47 +0000 Subject: [PATCH 4/5] fix: address review comments on test file and middleware - Remove spurious `# noqa: F811` on __init__ (no redefinition here) - Replace `bytes | None` with `Optional[bytes]` for Python 3.9 compat - Use `asyncio.run()` instead of deprecated `get_event_loop().run_until_complete()` - Remove unused `pytest` import --- litellm/proxy/proxy_server.py | 2 +- .../test_mcp_trailing_slash_middleware.py | 19 ++++++++----------- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index a92ffcbc75a..f9003278d0f 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -13409,7 +13409,7 @@ 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: # noqa: F811 + def __init__(self, app: Any) -> None: self.app = app async def __call__(self, scope: Any, receive: Any, send: Any) -> None: diff --git a/tests/mcp_tests/test_mcp_trailing_slash_middleware.py b/tests/mcp_tests/test_mcp_trailing_slash_middleware.py index edcbe6b9ccf..76f646ed40b 100644 --- a/tests/mcp_tests/test_mcp_trailing_slash_middleware.py +++ b/tests/mcp_tests/test_mcp_trailing_slash_middleware.py @@ -7,6 +7,7 @@ everything else through unchanged. """ import asyncio +from typing import Optional # The middleware is defined at module scope in proxy_server.py alongside heavy @@ -45,17 +46,13 @@ class _Recorder: self.scopes.append(scope) -def _make_http_scope(path: str, raw_path: bytes | None = None) -> dict: +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 -def _run(coro): - return asyncio.run(coro) - - # ── tests ──────────────────────────────────────────────────────────────── @@ -65,7 +62,7 @@ def test_rewrites_exact_mcp_path(): mw = _MCPTrailingSlashMiddleware(recorder) scope = _make_http_scope("/mcp", b"/mcp") - _run(mw(scope, None, None)) + asyncio.run(mw(scope, None, None)) assert len(recorder.scopes) == 1 assert recorder.scopes[0]["path"] == "/mcp/" @@ -78,7 +75,7 @@ def test_no_rewrite_for_mcp_subpath(): mw = _MCPTrailingSlashMiddleware(recorder) scope = _make_http_scope("/mcp/foo", b"/mcp/foo") - _run(mw(scope, None, None)) + asyncio.run(mw(scope, None, None)) assert recorder.scopes[0]["path"] == "/mcp/foo" assert recorder.scopes[0]["raw_path"] == b"/mcp/foo" @@ -90,7 +87,7 @@ def test_no_rewrite_for_other_paths(): mw = _MCPTrailingSlashMiddleware(recorder) scope = _make_http_scope("/health", b"/health") - _run(mw(scope, None, None)) + asyncio.run(mw(scope, None, None)) assert recorder.scopes[0]["path"] == "/health" @@ -101,7 +98,7 @@ def test_no_rewrite_for_non_http_scope(): mw = _MCPTrailingSlashMiddleware(recorder) scope = {"type": "websocket", "path": "/mcp"} - _run(mw(scope, None, None)) + asyncio.run(mw(scope, None, None)) assert recorder.scopes[0]["path"] == "/mcp" @@ -112,7 +109,7 @@ def test_raw_path_fallback_when_absent(): mw = _MCPTrailingSlashMiddleware(recorder) scope = _make_http_scope("/mcp") # no raw_path - _run(mw(scope, None, None)) + asyncio.run(mw(scope, None, None)) assert recorder.scopes[0]["path"] == "/mcp/" assert recorder.scopes[0]["raw_path"] == b"/mcp/" @@ -124,7 +121,7 @@ def test_already_trailing_slash_no_double(): mw = _MCPTrailingSlashMiddleware(recorder) scope = _make_http_scope("/mcp/", b"/mcp/") - _run(mw(scope, None, None)) + asyncio.run(mw(scope, None, None)) assert recorder.scopes[0]["path"] == "/mcp/" assert recorder.scopes[0]["raw_path"] == b"/mcp/" From 2f1a8b6e28ba64927c7b8f9f2855d70a768db088 Mon Sep 17 00:00:00 2001 From: voidborne-d Date: Mon, 16 Mar 2026 06:53:03 +0000 Subject: [PATCH 5/5] style: run black on changed files to fix lint CI --- litellm/proxy/proxy_server.py | 64 +++++++++---------- .../test_mcp_trailing_slash_middleware.py | 1 - 2 files changed, 31 insertions(+), 34 deletions(-) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index f9003278d0f..ac68f49c949 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -638,9 +638,9 @@ except ImportError: server_root_path = get_server_root_path() _license_check = LicenseCheck() premium_user: bool = _license_check.is_premium() -premium_user_data: Optional[ - "EnterpriseLicenseData" -] = _license_check.airgapped_license_data +premium_user_data: Optional["EnterpriseLicenseData"] = ( + _license_check.airgapped_license_data +) global_max_parallel_request_retries_env: Optional[str] = os.getenv( "LITELLM_GLOBAL_MAX_PARALLEL_REQUEST_RETRIES" ) @@ -1523,9 +1523,9 @@ master_key: Optional[str] = None config_agents: Optional[List[AgentConfig]] = None otel_logging = False prisma_client: Optional[PrismaClient] = None -shared_aiohttp_session: Optional[ - "ClientSession" -] = None # Global shared session for connection reuse +shared_aiohttp_session: Optional["ClientSession"] = ( + None # Global shared session for connection reuse +) user_api_key_cache = DualCache( default_in_memory_ttl=UserAPIKeyCacheTTLEnum.in_memory_cache_ttl.value ) @@ -1533,13 +1533,13 @@ model_max_budget_limiter = _PROXY_VirtualKeyModelMaxBudgetLimiter( dual_cache=user_api_key_cache ) litellm.logging_callback_manager.add_litellm_callback(model_max_budget_limiter) -redis_usage_cache: Optional[ - RedisCache -] = None # redis cache used for tracking spend, tpm/rpm limits +redis_usage_cache: Optional[RedisCache] = ( + None # redis cache used for tracking spend, tpm/rpm limits +) polling_via_cache_enabled: Union[Literal["all"], List[str], bool] = False -native_background_mode: List[ - str -] = [] # Models that should use native provider background mode instead of polling +native_background_mode: List[str] = ( + [] +) # Models that should use native provider background mode instead of polling polling_cache_ttl: int = 3600 # Default 1 hour TTL for polling cache user_custom_auth = None user_custom_key_generate = None @@ -1898,9 +1898,9 @@ async def update_cache( # noqa: PLR0915 _id = "team_id:{}".format(team_id) try: # Fetch the existing cost for the given user - existing_spend_obj: Optional[ - LiteLLM_TeamTable - ] = await user_api_key_cache.async_get_cache(key=_id) + existing_spend_obj: Optional[LiteLLM_TeamTable] = ( + await user_api_key_cache.async_get_cache(key=_id) + ) if existing_spend_obj is None: # do nothing if team not in api key cache return @@ -2021,11 +2021,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]: @@ -3303,7 +3301,7 @@ class ProxyConfig: async_only_mode=True # only init async clients ), ignore_invalid_deployments=True, # don't raise an error if a deployment is invalid - ) # type:ignore + ) # type: ignore if redis_usage_cache is not None and router.cache.redis_cache is None: router._update_redis_cache(cache=redis_usage_cache) @@ -4952,10 +4950,10 @@ class ProxyConfig: ) try: - guardrails_in_db: List[ - Guardrail - ] = await GuardrailRegistry.get_all_guardrails_from_db( - prisma_client=prisma_client + guardrails_in_db: List[Guardrail] = ( + await GuardrailRegistry.get_all_guardrails_from_db( + prisma_client=prisma_client + ) ) verbose_proxy_logger.debug( "guardrails from the DB %s", str(guardrails_in_db) @@ -5337,9 +5335,9 @@ async def initialize( # noqa: PLR0915 user_api_base = api_base dynamic_config[user_model]["api_base"] = api_base if api_version: - os.environ[ - "AZURE_API_VERSION" - ] = api_version # set this for azure - litellm can read this from the env + os.environ["AZURE_API_VERSION"] = ( + api_version # set this for azure - litellm can read this from the env + ) if max_tokens: # model-specific param dynamic_config[user_model]["max_tokens"] = max_tokens if temperature: # model-specific param @@ -5676,9 +5674,9 @@ class ProxyStartupEvent: """ from litellm.secret_managers.main import str_to_bool - _use_redis_transaction_buffer: Optional[ - Union[bool, str] - ] = general_settings.get("use_redis_transaction_buffer", False) + _use_redis_transaction_buffer: Optional[Union[bool, str]] = ( + general_settings.get("use_redis_transaction_buffer", False) + ) if isinstance(_use_redis_transaction_buffer, str): _use_redis_transaction_buffer = str_to_bool(_use_redis_transaction_buffer) @@ -12114,9 +12112,9 @@ async def get_config_list( hasattr(sub_field_info, "description") and sub_field_info.description is not None ): - nested_fields[ - idx - ].field_description = sub_field_info.description + nested_fields[idx].field_description = ( + sub_field_info.description + ) idx += 1 _stored_in_db = None diff --git a/tests/mcp_tests/test_mcp_trailing_slash_middleware.py b/tests/mcp_tests/test_mcp_trailing_slash_middleware.py index 76f646ed40b..44fcb8607a8 100644 --- a/tests/mcp_tests/test_mcp_trailing_slash_middleware.py +++ b/tests/mcp_tests/test_mcp_trailing_slash_middleware.py @@ -9,7 +9,6 @@ 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.