fix(mcp): 401+WWW-Authenticate (not 500) for unauthenticated MCP bootstrap - #27489
fix(mcp): 401+WWW-Authenticate (not 500) for unauthenticated MCP bootstrap#27489michelligabriele wants to merge 2 commits into
Conversation
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Greptile SummaryThis PR fixes the cold-start OAuth bootstrap flow for MCP servers: unauthenticated requests to an OAuth2-configured server now receive
Confidence Score: 5/5Safe to merge — the change is narrowly scoped to the unauthenticated cold-start path and is a no-op for every other request. The fix is isolated: it only fires before existing auth logic and only when no Authorization header is present for an OAuth2 server. Both handlers now correctly re-raise HTTPException before the catch-all. The previously flagged SSE regression (missing No files require special attention.
|
| Filename | Overview |
|---|---|
| litellm/proxy/_experimental/mcp_server/server.py | Adds _maybe_raise_oauth_bootstrap_challenge pre-check that fires 401 + WWW-Authenticate before auth validation in both StreamableHTTP and SSE handlers; adds except HTTPException: raise guard in handle_sse_mcp to prevent the new exception from being swallowed by the catch-all. |
| tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_stale_session.py | Adds 5 new tests covering the bootstrap 401 for StreamableHTTP and SSE handlers, skip-when-auth-present, skip-when-path-unresolved, and skip-for-non-oauth-server; all are mock-only with no real network calls. |
Reviews (2): Last reviewed commit: "fix(mcp): re-raise HTTPException in hand..." | Re-trigger Greptile
|
@greptile please review again |
|
This pull request has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. |
Relevant issues
Linear ticket
Pre-Submission checklist
tests/test_litellm/directory, Adding at least 1 test is a hard requirement - see detailsmake test-unit@greptileaiand received a Confidence Score of at least 4/5 before requesting a maintainer reviewCI (LiteLLM team)
Branch creation CI run
Link:
CI run for the last commit
Link:
Merge / cherry-pick CI run
Links:
Screenshots / Proof of Fix
Behavior contract (before → after)
POST /mcp/{server}/mcpfor an OAuth2-configured MCP server with noAuthorizationheader:The 401 challenge points the client at the
.well-knowndiscovery endpoint so it can fetch OAuth metadata and start PKCE. Previously the empty Authorization header was rejected by strict API-key validation as aProxyException, the catch-all coerced it to 500, and the client never reached discovery.Pre-check skip cases (no-op, defer to existing logic)
/mcpwithout a server segment)auth_type != oauth2Authorizationheader (Bearer or otherwise)Test output
Full file (12 existing + 4 new = 16 tests): all passing.
tests/code_coverage_tests/ensure_async_clients_test.pyandruff checkclean on changed files.Type
🐛 Bug Fix
Changes
Returns
401 Unauthorizedwith aWWW-Authenticatechallenge (instead of500 Internal Server Error) when an unauthenticated client cold-starts against an OAuth2-configured MCP server. Without the challenge, the client can't discover OAuth metadata via/.well-knownand PKCE never bootstraps.Root cause
For a
POST /mcp/{server}/mcprequest with noAuthorizationheader against an OAuth2-configured MCP server:handle_streamable_http_mcpdispatches toextract_mcp_auth_context.api_keyreaches strict API-key validation, which raisesException("Malformed API Key passed in. Ensure Key has 'Bearer ' prefix."), re-wrapped asProxyException.ProxyExceptionextendsException, notHTTPException, so the catch-all in the handler returns500 {"error": "MCP request failed", "details": ""}.extract_mcp_auth_contextand never runs for the cold-start case.Fix
A new helper
_maybe_raise_oauth_bootstrap_challenge(scope, path)runs at the top of bothhandle_streamable_http_mcpandhandle_sse_mcp, beforeextract_mcp_auth_context. It raisesHTTPException(401, ..., headers={"www-authenticate": ...})only when:_get_mcp_servers_in_path.auth_type == MCPAuth.oauth2.Authorizationheader (case-insensitive, byte- or str-keyed).Otherwise it is a no-op — requests that carry an Authorization header (Bearer + LiteLLM key or upstream OAuth bearer) flow into the existing auth pipeline unchanged, and non-OAuth servers / un-resolvable paths likewise reach the existing logic.
Backwards compatibility
Authorization: Bearer sk-..., the existing logic correctly handles per-user-OAuth-token gating.api_key, etc.) MCP servers are unaffected; the pre-check skips them entirely./mcp/server_a,server_b) raise on the first OAuth2-configured server found — same iteration semantics as the existing 401 gate.Tests
4 new tests in
tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_stale_session.py:test_oauth_bootstrap_returns_401_without_mocking_extract_mcp_auth_context— main regression. Registers a real OAuth2 server inglobal_mcp_server_manager, driveshandle_streamable_http_mcpwith no Authorization header against/mcp/{server}, asserts 401 +WWW-Authenticatepointing at the right.well-knownendpoint. Deliberately does NOT mockextract_mcp_auth_context(the existing 401-gate test in the same file does, which is exactly why it didn't catch this regression).test_oauth_bootstrap_skips_when_authorization_header_present— when an Authorization header exists, pre-check defers toextract_mcp_auth_context.test_oauth_bootstrap_skips_when_path_does_not_resolve_to_named_server— root/mcppath falls through unchanged.test_oauth_bootstrap_skips_for_non_oauth_server—auth_type=api_keyserver is not gated.All 16 tests in the file pass (12 existing + 4 new);
ruffclean;tests/code_coverage_tests/ensure_async_clients_test.pyclean.