This commit is contained in:
d 🔹 2026-04-29 23:17:28 +00:00 committed by GitHub
commit 18bba4abeb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 152 additions and 4 deletions

View file

@ -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)

View file

@ -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/"