Skip to content

fix(mcp): 401+WWW-Authenticate (not 500) for unauthenticated MCP bootstrap - #27489

Closed
michelligabriele wants to merge 2 commits into
litellm_internal_stagingfrom
litellm_fix_mcp_pkce_bootstrap_401
Closed

fix(mcp): 401+WWW-Authenticate (not 500) for unauthenticated MCP bootstrap#27489
michelligabriele wants to merge 2 commits into
litellm_internal_stagingfrom
litellm_fix_mcp_pkce_bootstrap_401

Conversation

@michelligabriele

Copy link
Copy Markdown
Contributor

Relevant issues

Linear ticket

Pre-Submission checklist

  • I have Added testing in the tests/test_litellm/ directory, Adding at least 1 test is a hard requirement - see details
  • My PR passes all unit tests on make test-unit
  • My PR's scope is as isolated as possible, it only solves 1 specific problem
  • I have requested a Greptile review by commenting @greptileai and received a Confidence Score of at least 4/5 before requesting a maintainer review

CI (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}/mcp for an OAuth2-configured MCP server with no Authorization header:

- HTTP/1.1 500 Internal Server Error
- {"error": "MCP request failed", "details": ""}
+ HTTP/1.1 401 Unauthorized
+ WWW-Authenticate: Bearer authorization_uri=https://<proxy>/.well-known/oauth-authorization-server/<server>
+ {"detail": "Unauthorized"}

The 401 challenge points the client at the .well-known discovery endpoint so it can fetch OAuth metadata and start PKCE. Previously the empty Authorization header was rejected by strict API-key validation as a ProxyException, the catch-all coerced it to 500, and the client never reached discovery.

Pre-check skip cases (no-op, defer to existing logic)

Condition Behavior
Path doesn't resolve to a named MCP server (/mcp without a server segment) no-op
Resolved server's auth_type != oauth2 no-op
Request carries any Authorization header (Bearer or otherwise) no-op

Test output

$ uv run pytest tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_stale_session.py -v -k "oauth_bootstrap"

tests/.../test_mcp_stale_session.py::test_oauth_bootstrap_returns_401_without_mocking_extract_mcp_auth_context PASSED  [ 25%]
tests/.../test_mcp_stale_session.py::test_oauth_bootstrap_skips_for_non_oauth_server PASSED                            [ 50%]
tests/.../test_mcp_stale_session.py::test_oauth_bootstrap_skips_when_authorization_header_present PASSED               [ 75%]
tests/.../test_mcp_stale_session.py::test_oauth_bootstrap_skips_when_path_does_not_resolve_to_named_server PASSED      [100%]

======================= 4 passed =======================

Full file (12 existing + 4 new = 16 tests): all passing.

======================= 16 passed in 1.90s =======================

tests/code_coverage_tests/ensure_async_clients_test.py and ruff check clean on changed files.

Type

🐛 Bug Fix

Changes

Returns 401 Unauthorized with a WWW-Authenticate challenge (instead of 500 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-known and PKCE never bootstraps.

Root cause

For a POST /mcp/{server}/mcp request with no Authorization header against an OAuth2-configured MCP server:

  1. handle_streamable_http_mcp dispatches to extract_mcp_auth_context.
  2. The empty api_key reaches strict API-key validation, which raises Exception("Malformed API Key passed in. Ensure Key has 'Bearer ' prefix."), re-wrapped as ProxyException.
  3. ProxyException extends Exception, not HTTPException, so the catch-all in the handler returns 500 {"error": "MCP request failed", "details": ""}.
  4. The existing 401 challenge gate (added in fix(mcp): restore PKCE-triggering 401 when no stored per-user token exists #26032) sits after extract_mcp_auth_context and never runs for the cold-start case.

Fix

A new helper _maybe_raise_oauth_bootstrap_challenge(scope, path) runs at the top of both handle_streamable_http_mcp and handle_sse_mcp, before extract_mcp_auth_context. It raises HTTPException(401, ..., headers={"www-authenticate": ...}) only when:

  1. The path resolves to a named MCP server via _get_mcp_servers_in_path.
  2. The resolved server has auth_type == MCPAuth.oauth2.
  3. The request carries no Authorization header (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

  • The existing 401 challenge gate (per-user-OAuth-token + stored-token check) is left in place. The new pre-check fires first and only for the cold-start case. Once the client responds with Authorization: Bearer sk-..., the existing logic correctly handles per-user-OAuth-token gating.
  • The 500 catch-all is left in place for genuinely unexpected errors. After this change it is unreachable for the auth-bootstrap case but continues to serve its original purpose.
  • Non-OAuth (api_key, etc.) MCP servers are unaffected; the pre-check skips them entirely.
  • Multi-server paths (/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 in global_mcp_server_manager, drives handle_streamable_http_mcp with no Authorization header against /mcp/{server}, asserts 401 + WWW-Authenticate pointing at the right .well-known endpoint. Deliberately does NOT mock extract_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 to extract_mcp_auth_context.
  • test_oauth_bootstrap_skips_when_path_does_not_resolve_to_named_server — root /mcp path falls through unchanged.
  • test_oauth_bootstrap_skips_for_non_oauth_serverauth_type=api_key server is not gated.

All 16 tests in the file pass (12 existing + 4 new); ruff clean; tests/code_coverage_tests/ensure_async_clients_test.py clean.

@codecov

codecov Bot commented May 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.90909% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
litellm/proxy/_experimental/mcp_server/server.py 90.90% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

@greptile-apps

greptile-apps Bot commented May 8, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes the cold-start OAuth bootstrap flow for MCP servers: unauthenticated requests to an OAuth2-configured server now receive 401 Unauthorized with a WWW-Authenticate challenge pointing at the .well-known discovery endpoint, instead of the previous 500 Internal Server Error caused by a ProxyException being caught by the generic error handler.

  • A new _maybe_raise_oauth_bootstrap_challenge helper is inserted at the top of both handle_streamable_http_mcp and handle_sse_mcp, before extract_mcp_auth_context is called. It checks for an Authorization header (no-op if present), resolves the path to a named server, and raises HTTPException(401) only for OAuth2-typed servers.
  • Both handlers now carry an explicit except HTTPException: raise guard ahead of the bare except Exception catch-all, ensuring the new 401 and any other HTTP-typed exceptions are never coerced into 500 responses.
  • Five new unit tests cover the main regression path (StreamableHTTP and SSE), the three no-op skip conditions, and all tests are mock-only with no real network calls.

Confidence Score: 5/5

Safe 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 except HTTPException: raise) has been addressed in this version, and a dedicated test verifies it. Non-OAuth and authenticated paths are unaffected.

No files require special attention.

Important Files Changed

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

@michelligabriele

Copy link
Copy Markdown
Contributor Author

@greptile please review again

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

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.

@github-actions github-actions Bot added the stale label Aug 7, 2026
@github-actions github-actions Bot closed this Aug 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants