Skip to content

fix(mcp): challenge delegate-auth OAuth servers with upstream resource_metadata - #31250

Closed
tin-berri wants to merge 1 commit into
litellm_internal_stagingfrom
litellm_mcp_delegate_oauth_challenge
Closed

fix(mcp): challenge delegate-auth OAuth servers with upstream resource_metadata#31250
tin-berri wants to merge 1 commit into
litellm_internal_stagingfrom
litellm_mcp_delegate_oauth_challenge

Conversation

@tin-berri

Copy link
Copy Markdown
Contributor

Relevant issues

Regression introduced by #30124 (rolled up in #30202); keeps the original #29770 fix intact

Linear ticket

N/A; reported from Claude Desktop DCR ("OAuth probe timeout after 10000ms" on a delegate-auth MCP server)

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have added meaningful tests
  • 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

An oauth2 MCP server with delegate_auth_to_upstream: true (interactive, not client_credentials) where the user has not signed in yet. Config used for the run

mcp_servers:
  notion_delegate:
    url: https://mcp.notion.com/mcp
    transport: http
    auth_type: oauth2
    delegate_auth_to_upstream: true

Before (bug): unauthenticated initialize connects with no challenge

$ curl -sS -D - -o /dev/null -X POST http://localhost:4010/notion_delegate/mcp \
    -H 'Accept: application/json, text/event-stream' \
    -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'

HTTP/1.1 200 OK
content-type: text/event-stream
mcp-session-id: 477e622a56214c8ca4a64b37c3cb8332

event: message
data: {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","serverInfo":{"name":"notion_delegate","version":"1.0.0"}}}

No www-authenticate header. The gateway answers initialize itself and never probes upstream, so the client (Claude Desktop) treats the server as not requiring OAuth; it shows "connected" with no tools and never opens the sign-in page, or it times out on the OAuth probe

After (fixed): unauthenticated initialize returns the upstream RFC 9728 challenge

$ curl -sS -D - -o /dev/null -X POST http://localhost:4010/notion_delegate/mcp \
    -H 'Accept: application/json, text/event-stream' \
    -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'

HTTP/1.1 401 Unauthorized
www-authenticate: Bearer resource_metadata="http://localhost:4010/.well-known/oauth-protected-resource/notion_delegate/mcp"

The challenge carries resource_metadata= (the upstream-delegation form), not LiteLLM's authorization_uri=. Following that chain lands the client on the upstream IdP, proxied by the gateway

$ curl -sS http://localhost:4010/.well-known/oauth-protected-resource/notion_delegate/mcp
{"authorization_servers":["http://localhost:4010/notion_delegate"],"resource":"http://localhost:4010/notion_delegate/mcp","scopes_supported":[]}

$ curl -sS http://localhost:4010/.well-known/oauth-authorization-server/notion_delegate
{"issuer":"http://localhost:4010",
 "authorization_endpoint":"http://localhost:4010/notion_delegate/authorize",
 "token_endpoint":"http://localhost:4010/notion_delegate/token",
 "registration_endpoint":"http://localhost:4010/notion_delegate/register"}

Claude Desktop now follows this to the upstream authorize page and signs in. An authenticated retry (token present) skips the preemptive check unchanged and reaches the session manager, where a token the upstream rejects still surfaces the upstream's own challenge via MCPUpstreamAuthError

Type

🐛 Bug Fix

Changes

_raise_preemptive_401_for_unauthenticated_servers skipped the challenge entirely for delegate_auth_to_upstream oauth2 servers with a bare continue (added in #30124 to avoid emitting the wrong authorization_uri= form). Because the gateway answers initialize locally and only contacts upstream on tools/list or tools/call, no challenge was ever produced for the request that matters, so MCP clients got no sign-in prompt

The continue is replaced with a preemptive 401 carrying the proxied resource_metadata= challenge built by _get_passthrough_www_authenticate, the same form pass-through servers and MCPUpstreamAuthError.to_http_exception already emit. This restores the upstream PKCE prompt without reintroducing the authorization_uri= form that #29770 removed. The fix lives in the shared helper, so both the streamable-HTTP and SSE handlers are covered

Tests in test_mcp_stale_session.py are updated to match: the former ..._reaches_session_manager test (which locked in the skipped challenge) becomes a regression test asserting the 401 + resource_metadata challenge and that the session manager is never reached; the ..._surfaces_upstream_challenge test now exercises the authenticated path, where a present-but-rejected token reaches the session manager and the upstream challenge is surfaced

…e_metadata

An oauth2 MCP server with delegate_auth_to_upstream=true never prompted the
user to sign in. On an unauthenticated initialize the gateway answered locally
(200, no tools) and emitted no WWW-Authenticate, so clients like Claude Desktop
either connected empty or hit "OAuth probe timeout after 10000ms".

#30124 added a bare `continue` in _raise_preemptive_401_for_unauthenticated_servers
to stop sending LiteLLM's gateway authorization_uri challenge for delegate-auth
servers, expecting the upstream to emit its own challenge. On initialize the
gateway never probes upstream, so no challenge ever reached the client.

Replace the `continue` with a preemptive 401 carrying the proxied
resource_metadata (RFC 9728) challenge, the same form passthrough servers and
MCPUpstreamAuthError already use. This keeps #29770 fixed (still no
authorization_uri) while restoring the upstream PKCE sign-in prompt.
@tin-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@greptile-apps

greptile-apps Bot commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a regression in the MCP delegate-auth OAuth flow where unauthenticated initialize requests to delegate_auth_to_upstream=True servers were silently passing through instead of returning a resource_metadata= challenge. The root cause was a bare continue added in #30124 that skipped the 401 entirely for delegate-auth servers; the fix replaces it with an early raise HTTPException(401) that carries the proxied RFC 9728 resource_metadata= challenge built by the shared _get_passthrough_www_authenticate helper.

  • server.py: In _raise_preemptive_401_for_unauthenticated_servers, the continue for delegate_auth_to_upstream=True servers is replaced with a preemptive 401 that includes the correct resource_metadata= challenge URL (routing through _get_passthrough_resource_metadata_url to pick the right well-known form based on the inbound path), covering both the streamable-HTTP and SSE handlers since they share this helper.
  • test_mcp_stale_session.py: The former ..._reaches_session_manager test is repurposed as a regression test asserting the 401+resource_metadata challenge is emitted and the session manager is never reached; the ..._surfaces_upstream_challenge test is updated to exercise the authenticated path (bearer token present → upstream rejects → upstream challenge surfaces), with session_manager_stateless correctly changed to session_manager_stateful since both tests now use initialize bodies.

Confidence Score: 5/5

Safe to merge — a tightly scoped two-file change that restores a missing 401 challenge for an edge-case auth path without touching any shared or unrelated infrastructure.

The production change is a single-branch swap: one continue becomes a raise HTTPException with the correct challenge header. The helper it calls (_get_passthrough_www_authenticate) is already exercised by the passthrough and MCPUpstreamAuthError paths, so the URL-building logic is well-tested. The two reworked tests correctly split the no-token path (session manager must not be reached) from the token-present path (upstream challenge surfaces), and the session_manager_statelesssession_manager_stateful switch matches how the router actually dispatches initialize bodies. No unrelated code is touched.

No files require special attention.

Important Files Changed

Filename Overview
litellm/proxy/_experimental/mcp_server/server.py Replaces the bare continue for delegate-auth servers in _raise_preemptive_401_for_unauthenticated_servers with a preemptive 401 carrying the correct resource_metadata= challenge; change is minimal and correctly scoped to the no-stored-token + delegate_auth branch
tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_stale_session.py Tests updated to reflect corrected behavior: no-token path now asserts 401+resource_metadata challenge (session manager never reached); authenticated path correctly uses session_manager_stateful with an initialize body matching actual routing logic

Reviews (1): Last reviewed commit: "fix(mcp): challenge delegate-auth OAuth ..." | Re-trigger Greptile

@greptile-apps

greptile-apps Bot commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Fixes a regression where delegate_auth_to_upstream OAuth2 MCP servers silently skipped the preemptive 401 challenge entirely, causing clients like Claude Desktop to see a connected-but-empty server and never open the upstream sign-in page. The fix replaces the bare continue with a proper 401 carrying the proxied resource_metadata= challenge form, consistent with how pass-through servers and MCPUpstreamAuthError already operate.

  • server.py: In _raise_preemptive_401_for_unauthenticated_servers, the continue for delegate_auth_to_upstream servers is replaced with _get_passthrough_www_authenticate + raise HTTPException(401), so unauthenticated requests are immediately challenged with the RFC 9728 resource_metadata= form pointing at the gateway's proxied well-known endpoint rather than the gateway's own authorization_uri=.
  • test_mcp_stale_session.py: The two affected tests are updated — the former "reaches session manager" test is converted into a regression guard that asserts no session-manager call and a correct resource_metadata= challenge; the _surfaces_upstream_challenge test is re-focused to the authenticated path (token present, rejected upstream) to verify the upstream challenge is surfaced correctly.

Confidence Score: 5/5

Safe to merge — the change is a one-site, 12-line replacement of a continue with a well-defined 401 path that reuses an existing, already-tested helper.

The server change is minimal and precisely targeted: it replaces a silent skip with a challenge that reuses _get_passthrough_www_authenticate, which is already exercised by the passthrough-OAuth and MCPUpstreamAuthError paths. The test updates correctly convert a test that was documenting buggy behavior into a regression guard for the fix, and add coverage for the authenticated (token-present-but-rejected) path.

No files require special attention.

Important Files Changed

Filename Overview
litellm/proxy/_experimental/mcp_server/server.py Replaces the bare continue for delegate_auth_to_upstream OAuth2 servers in _raise_preemptive_401_for_unauthenticated_servers with a proper 401 carrying a resource_metadata= challenge built by _get_passthrough_www_authenticate, matching the form already used by passthrough servers and MCPUpstreamAuthError. The change is minimal (12 lines), targets the exact regression site, and the shared helper ensures both streamable-HTTP and SSE paths are covered.
tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_stale_session.py Two tests updated to match the corrected behavior: (1) test_handle_streamable_http_mcp_delegated_server_without_token_returns_preemptive_resource_metadata_401 replaces the old test that locked in the buggy skip-to-session-manager path; it now asserts the 401 + resource_metadata= challenge and that the session manager is never reached. (2) test_handle_streamable_http_mcp_delegated_server_surfaces_upstream_challenge is re-focused to the authenticated path (token present but rejected by upstream), verifying the upstream challenge is surfaced. Both tests mock network IO and make no real network calls.

Reviews (2): Last reviewed commit: "fix(mcp): challenge delegate-auth OAuth ..." | Re-trigger Greptile

@codecov

codecov Bot commented Jun 24, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@tin-berri tin-berri closed this Jun 25, 2026
@tin-berri
tin-berri deleted the litellm_mcp_delegate_oauth_challenge branch June 25, 2026 00:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant