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:
jesus-berri 2026-11-02 16:16:29 -08:00 • committed by GitHub
parent 81969e4afc
commit acd084ab38
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 36 additions and 1 deletions

View file

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

View file

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