mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-13 23:11:40 +00:00
Merge 2f1a8b6e28 into 3e1479c052
This commit is contained in:
commit
18bba4abeb
2 changed files with 152 additions and 4 deletions
|
|
@ -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)
|
||||
|
|
|
|||
126
tests/mcp_tests/test_mcp_trailing_slash_middleware.py
Normal file
126
tests/mcp_tests/test_mcp_trailing_slash_middleware.py
Normal 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/"
|
||||
Loading…
Add table
Reference in a new issue