Skip to content

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

Merged
tin-berri merged 1 commit into
litellm_internal_stagingfrom
litellm-mcp-delegate-regr
Jun 25, 2026
Merged

fix(mcp): challenge delegate-auth OAuth servers with upstream resource_metadata#31255
tin-berri merged 1 commit into
litellm_internal_stagingfrom
litellm-mcp-delegate-regr

Conversation

@tin-berri

@tin-berri tin-berri commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

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

Unauthorized passthrough servers when attempting connection via Third-Party client (eg: claude code) led to 200:ok instead of 401 to trigger authorization.

Relevant issues

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

Linear ticket

Resolves LIT-4019; 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

https://www.loom.com/share/6fe22e05870947d9881511d6fa20201f

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.
@greptile-apps

greptile-apps Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Fixes a regression where delegate_auth_to_upstream=True OAuth2 MCP servers silently skipped the preemptive 401 challenge, leaving clients like Claude Desktop with no sign-in prompt. The bare continue in _raise_preemptive_401_for_unauthenticated_servers is replaced with an HTTPException(401) carrying the resource_metadata= challenge form via the existing _get_passthrough_www_authenticate helper.

  • server.py: Delegate-auth servers without a stored token now raise an immediate 401 with Bearer resource_metadata="..." (RFC 9728 passthrough form) instead of silently continuing to the session manager, which never issues a challenge for initialize calls handled locally by the gateway.
  • test_mcp_stale_session.py: The old test that locked in the broken "reaches session manager" behaviour is rewritten as a regression test for the 401 + resource_metadata= challenge; the "surfaces upstream challenge" test is updated to exercise the authenticated path (token present but upstream-rejected), which is the only scenario where the session manager is legitimately reached for delegate-auth servers.

Confidence Score: 5/5

Safe to merge — the change is a focused, single-site fix in a shared auth helper with solid before/after test coverage and no impact on the non-delegate code path.

The diff is minimal: one continue replaced by a call to an already-battle-tested helper and an immediate HTTPException. The _get_passthrough_www_authenticate function is reused from the same file (already exercised by pass-through servers and MCPUpstreamAuthError.to_http_exception), so no new logic is introduced. Both affected test scenarios — no-token preemptive 401 and token-present upstream-error propagation — are covered by the updated tests. The scope field additions in the updated test (scheme, query_string, server) also correct a latent test gap that would have caused StarletteRequest to produce a malformed base URL.

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 a resource_metadata= challenge built by the existing _get_passthrough_www_authenticate helper — covering both streamable-HTTP and SSE handlers.
tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_stale_session.py Two tests updated: the former "reaches_session_manager" test now asserts the preemptive 401 + resource_metadata= challenge (the fixed behaviour); the "surfaces_upstream_challenge" test now exercises the authenticated path (token present but rejected) to verify the upstream error propagates.

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

@mateo-berri mateo-berri left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM; thanks!

@tin-berri
tin-berri enabled auto-merge (squash) June 25, 2026 03:50
@tin-berri
tin-berri merged commit 0f56038 into litellm_internal_staging Jun 25, 2026
199 checks passed
@tin-berri
tin-berri deleted the litellm-mcp-delegate-regr branch June 25, 2026 03:50
ishaan-berri pushed a commit to ishaan-berri/litellm that referenced this pull request Jun 25, 2026
…e_metadata (BerriAI#31255)

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

BerriAI#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 BerriAI#29770 fixed (still no
authorization_uri) while restoring the upstream PKCE sign-in prompt.
yuneng-berri pushed a commit that referenced this pull request Jun 27, 2026
…e_metadata (#31255)

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.
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.

2 participants