mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-03 07:23:33 +00:00
fix(mcp): answer bare OPTIONS on MCP routes without running LiteLLM auth (#49497)
Every MCP route (/mcp, /<server>/mcp, /toolset/<name>/mcp, /mcp/proxy) ran LiteLLM auth before looking at the HTTP method, so a bare OPTIONS probe got the RFC 9728 401 OAuth challenge and clients that probe with OPTIONS looped on re-auth. The Streamable HTTP handler now returns 204 with Allow: GET, POST, DELETE, OPTIONS before auth runs. GET, POST and DELETE are unchanged. Resolves LIT-10231
This commit is contained in:
parent
81969e4afc
commit
acd084ab38
2 changed files with 36 additions and 1 deletions
|
|
@ -21,7 +21,7 @@ import httpx
|
|||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import ConfigDict, TypeAdapter, ValidationError
|
||||
from starlette.requests import Request as StarletteRequest
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.responses import JSONResponse, Response
|
||||
from starlette.routing import Route
|
||||
from starlette.types import Message, Receive, Scope, Send
|
||||
|
||||
|
|
@ -2489,6 +2489,9 @@ if MCP_AVAILABLE:
|
|||
},
|
||||
)(scope, receive, send)
|
||||
return
|
||||
if scope.get("method") == "OPTIONS":
|
||||
await Response(status_code=204, headers={"Allow": "GET, POST, DELETE, OPTIONS"})(scope, receive, send)
|
||||
return
|
||||
path: Final = _scope_path(scope)
|
||||
peek: Final = _LazyPeekedBody(receive) if scope.get("method") == "POST" else None
|
||||
if peek is not None:
|
||||
|
|
|
|||
|
|
@ -3319,3 +3319,35 @@ async def test_mcp_routing_truncated_json_body_is_not_stashed_for_auth():
|
|||
|
||||
assert await scope[MCP_PEEKED_BODY_SCOPE_KEY]() == b"{}"
|
||||
assert await _read_request_body(_admission_request(scope)) == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("path", ["/mcp", "/atlassian/mcp"])
|
||||
async def test_bare_options_skips_litellm_auth(path: str):
|
||||
"""A bare OPTIONS probe must not run LiteLLM auth, so it never triggers the 401 OAuth challenge."""
|
||||
from litellm.proxy.mcp_server import server as mcp_server
|
||||
|
||||
scope: Scope = {
|
||||
"type": "http",
|
||||
"method": "OPTIONS",
|
||||
"path": path,
|
||||
"headers": [],
|
||||
"query_string": b"",
|
||||
"server": ("localhost", 8000),
|
||||
"scheme": "http",
|
||||
}
|
||||
receive = AsyncMock(return_value={"type": "http.request", "body": b"", "more_body": False})
|
||||
sent: list[dict] = []
|
||||
|
||||
async def send(message: dict) -> None:
|
||||
sent.append(message)
|
||||
|
||||
auth = AsyncMock(side_effect=AssertionError("LiteLLM auth ran on OPTIONS"))
|
||||
with patch.object(mcp_server, "extract_mcp_auth_context", auth):
|
||||
await mcp_server.handle_streamable_http_mcp(scope, receive, send)
|
||||
|
||||
auth.assert_not_awaited()
|
||||
start = next(m for m in sent if m["type"] == "http.response.start")
|
||||
assert start["status"] == 204
|
||||
headers = {k.decode().lower(): v.decode() for k, v in start["headers"]}
|
||||
assert headers["allow"] == "GET, POST, DELETE, OPTIONS"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue