From acd084ab3829757d17f094c0332c17e66cf73ecf Mon Sep 17 00:00:00 2001 From: jesus-berri Date: Mon, 2 Nov 2026 16:16:29 -0800 Subject: [PATCH] fix(mcp): answer bare OPTIONS on MCP routes without running LiteLLM auth (#49497) Every MCP route (/mcp, //mcp, /toolset//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 --- litellm/proxy/mcp_server/server.py | 5 ++- .../unit/proxy/mcp_server/test_mcp_server.py | 32 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/mcp_server/server.py b/litellm/proxy/mcp_server/server.py index aeb640a7c8b..bbbbd09e527 100644 --- a/litellm/proxy/mcp_server/server.py +++ b/litellm/proxy/mcp_server/server.py @@ -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: diff --git a/tests/unit/proxy/mcp_server/test_mcp_server.py b/tests/unit/proxy/mcp_server/test_mcp_server.py index 884c82760ea..456b1994e2d 100644 --- a/tests/unit/proxy/mcp_server/test_mcp_server.py +++ b/tests/unit/proxy/mcp_server/test_mcp_server.py @@ -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"