Skip to content

feat(mcp)!: extend keyless gateway OAuth flow to per-server MCP URL paths - #34856

Merged
tin-berri merged 1 commit into
litellm_internal_stagingfrom
litellm_lit4864_perserver_keyless_oauth
Jul 31, 2026
Merged

feat(mcp)!: extend keyless gateway OAuth flow to per-server MCP URL paths#34856
tin-berri merged 1 commit into
litellm_internal_stagingfrom
litellm_lit4864_perserver_keyless_oauth

Conversation

@tin-berri

@tin-berri tin-berri commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • Keyless OAuth (gateway as AS) only worked on aggregate /mcp/
  • Per-server paths 401 with "LiteLLM Virtual Key expected. Received=gho_****"
  • Per-server 401s carried no WWW-Authenticate, so clients could not recover

How it solves it:

  • Session-bearer admission now fires at any MCP scope
  • Per-server 401s challenge with per-server resource metadata
  • Per-server PRM for gateway-managed oauth2 advertises the gateway AS

Relevant issues

  • Extends the keyless gateway DCR flow (session bearers) from the aggregate /mcp/ scope to the per-server URL spellings /mcp/{server} and /{server}/mcp
  • Adds the missing RFC 9728 WWW-Authenticate challenge on per-server 401s for gateway-managed oauth2 targets, in the URL spelling the request used
  • Client-forwarded modes (true_passthrough, oauth_delegate, dcr_bridge, delegate_auth_to_upstream, OBO), unknown names, and CSV multi-target paths keep their existing behavior byte for byte

Linear ticket

Resolves LIT-4864

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 CI/CD checks (e.g., lint, format, unit tests)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review (Greptile reviews automatically once the PR is opened; only comment @greptileai to re-request a review after pushing changes)

Screenshots / Proof of Fix

Live proxy on localhost:4864, real Postgres, config with three servers: deepwiki (real public upstream, auth none, allow_all_keys), github_oauth (auth_type: oauth2, oauth2_flow: authorization_code, allow_all_keys), locked_down (no grants). The session bearer in every step below was minted through the real keyless flow with curl only: POST /login -> POST /register (DCR) -> GET /authorize (S256 PKCE) -> POST /authorize/complete -> POST /token

Before, at the merge base 2a7885aee7 (unfixed):

$ curl -s http://localhost:4864/.well-known/oauth-protected-resource/mcp/github_oauth
{"authorization_servers":["http://localhost:4864/github_oauth"],"resource":"http://localhost:4864/mcp/github_oauth",...}

$ curl -si -X POST http://localhost:4864/mcp/github_oauth -d '<initialize>'          # anonymous
HTTP/1.1 401 Unauthorized                                                            # no www-authenticate at all
{"detail":"Authentication Error, Malformed API Key passed in. Ensure Key has `Bearer ` prefix."}

$ curl -si -X POST http://localhost:4864/mcp/github_oauth -H 'authorization: Bearer gho_fake...' -d '<initialize>'
HTTP/1.1 401 Unauthorized                                                            # the customer log, exactly
{"detail":"LiteLLM Virtual Key expected. Received=gho_****cdef, expected to start with 'sk-'."}

$ curl -si -X POST http://localhost:4864/mcp/ -H "authorization: Bearer llm_session_..." -d '<initialize>'
HTTP/1.1 200 OK                                                                      # aggregate works

$ curl -si -X POST http://localhost:4864/mcp/deepwiki -H "authorization: Bearer llm_session_..." -d '<initialize>'
HTTP/1.1 401 Unauthorized                                                            # same bearer, per-server refused
{"detail":"LiteLLM Virtual Key expected. Received=llm_****8z0Y, expected to start with 'sk-'."}

After, at 6be1b5e291 (this PR), same rig and the same session bearer:

$ curl -s http://localhost:4864/.well-known/oauth-protected-resource/mcp/github_oauth
{"authorization_servers":["http://localhost:4864/mcp"],"resource":"http://localhost:4864/mcp/github_oauth",...}

$ curl -si -X POST http://localhost:4864/mcp/github_oauth -d '<initialize>'          # anonymous
HTTP/1.1 401 Unauthorized
www-authenticate: Bearer resource_metadata="http://localhost:4864/.well-known/oauth-protected-resource/mcp/github_oauth"

$ curl -si -X POST http://localhost:4864/mcp/github_oauth -H 'authorization: Bearer gho_fake...' -d '<initialize>'
HTTP/1.1 401 Unauthorized                                                            # stale relayed token now recovers
www-authenticate: Bearer error="invalid_token", resource_metadata="http://localhost:4864/.well-known/oauth-protected-resource/mcp/github_oauth"

$ <initialize + notifications/initialized + tools/list> http://localhost:4864/mcp/deepwiki
tools (3): ['deepwiki-ask_question', 'deepwiki-read_wiki_contents', 'deepwiki-read_wiki_structure']

$ <same> http://localhost:4864/deepwiki/mcp                                          # legacy spelling
tools (3): ['deepwiki-ask_question', 'deepwiki-read_wiki_contents', 'deepwiki-read_wiki_structure']

$ <same> http://localhost:4864/mcp/locked_down                                       # ungranted: fail closed
tools (0): []

$ curl -si -X POST http://localhost:4864/mcp/github_oauth -H "authorization: Bearer llm_session_..." -d '<initialize>'
HTTP/1.1 401 Unauthorized                                                            # no vaulted upstream token yet:
www-authenticate: Bearer resource_metadata="http://localhost:4864/.well-known/oauth-protected-resource/mcp/github_oauth"

$ curl -si -X POST http://localhost:4864/mcp/does_not_exist -d '<initialize>'        # unknown name: unchanged
HTTP/1.1 401 Unauthorized
{"detail":"Authentication Error, Malformed API Key passed in. Ensure Key has `Bearer ` prefix."}

Type

🆕 New Feature

Changes

  • New MCPServer.is_gateway_managed_oauth2 owner (auth_type == oauth2 and not delegate_auth_to_upstream); the challenge gate and the PRM flip both read it
  • user_api_key_auth_mcp.py: the session-bearer arm admits at any MCP scope (downstream grant resolution already intersects path and header targets fail closed); the gateway DCR challenge is scope-aware and fires for the aggregate scope, x-mcp-servers scoped requests, and a single gateway-managed oauth2 path target
  • discoverable_endpoints.py: explicitly named gateway-managed oauth2 PRM advertises {base}/mcp; the root-resolved unnamed shape and every other mode are unchanged
  • server.py preemptive 401: an admitted keyless subject with no stored user token is challenged with per-server resource_metadata (the gateway flow vaults via the authorize interlude) instead of the relay authorization_uri, whose token request cannot vault without a litellm key
  • get_passthrough_resource_metadata_url moved to oauth_utils.py (shared with the auth module) and now inserts the SERVER_ROOT_PATH segment exactly as the discovery routes register it

Things a reviewer will ask about: accepting the relayed upstream token itself at per-server ingress was considered and rejected; no gateway identity attaches to it (the relay token request carries no litellm credential, so nothing is vaulted and there is no user to admit), which is why it fails twice today. The challenge fires only for names whose per-server PRM already returns 200 publicly, so no new server-name enumeration surface is created. The keyed interactive flow keeps its authorization_uri challenge and the per-server relay endpoints, unchanged

Final Attestation

  • The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR

Note

High Risk
Touches MCP authentication, OAuth discovery, and 401 challenge behavior across admission and route handlers; incorrect scoping could mis-route clients into gateway sign-in or change behavior for delegated/passthrough modes.

Overview
Extends the keyless gateway DCR flow so it works on per-server MCP URLs (/mcp/{server}, /{server}/mcp) and x-mcp-servers-scoped calls, not only aggregate /mcp.

Gateway session bearer admission now runs at any MCP scope; grants still intersect with path/header targets so access cannot broaden. 401 handling is scope-aware: unauthenticated or failed auth on eligible scopes returns an RFC 9728 WWW-Authenticate challenge pointing at either aggregate or per-server protected-resource metadata (URL spelling matches the request). A new is_gateway_managed_oauth2 flag gates this to interactive/M2M oauth2 servers not using upstream-delegated auth; passthrough, delegate, OBO, and multi-target paths stay unchanged.

Discovery changes so explicitly named gateway-managed servers advertise {base}/mcp as the authorization server while keeping the per-server resource URL; root-resolved unnamed discovery is unchanged. Preemptive 401s for keyless admitted users without a vaulted upstream token now use resource_metadata (gateway sign-in) instead of the per-server relay authorization_uri.

Shared helpers get_passthrough_resource_metadata_url / get_passthrough_www_authenticate move to oauth_utils.py (with SERVER_ROOT_PATH alignment) and replace local server.py copies.

Reviewed by Cursor Bugbot for commit 6be1b5e. Bugbot is set up for automated code reviews on this repo. Configure here.

The keyless flow (gateway as authorization server, no virtual key) worked
only at the aggregate /mcp scope: the session-bearer admission arm was
gated on _is_aggregate_mcp_scope, the 401 fallback only challenged at
aggregate scope, and per-server protected-resource metadata for plain
oauth2 servers pointed clients at the per-server relay, whose flow
returns the raw upstream token that ingress can never accept keylessly
(401 "LiteLLM Virtual Key expected. Received=gho_****").

Per-server spellings now join the same gateway flow for gateway-managed
oauth2 servers (auth_type oauth2 without delegate_auth_to_upstream, new
MCPServer.is_gateway_managed_oauth2 owner):

- the session-bearer arm admits at any MCP scope; downstream grant
  resolution already intersects the admitted subject's servers with the
  path or header targets fail-closed, so a narrower scope never broadens
- the 401 challenge is scope-aware: a single gateway-managed oauth2 path
  target gets the per-server resource_metadata in the spelling the
  request used, everything else gets the aggregate document; unknown
  names, CSV multi-target paths, and every client-forwarded or delegated
  mode keep their existing behavior
- per-server PRM for explicitly named gateway-managed oauth2 servers
  advertises the gateway AS ({base}/mcp); delegate, passthrough, bridge,
  OBO, and the root-resolved unnamed shape are byte-identical
- the preemptive 401 for an admitted keyless subject with no vaulted
  token challenges with resource_metadata (re-entering the gateway flow,
  whose authorize interlude vaults the upstream token) instead of the
  relay authorization_uri, which cannot vault without a litellm key

The per-server challenge URL builder moved from server.py to
oauth_utils.py (shared with the auth module) and now inserts the
SERVER_ROOT_PATH segment exactly as the discovery routes do.

Resolves LIT-4864
@greptile-apps

greptile-apps Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Extends keyless gateway OAuth support to per-server MCP routes.

  • Admits gateway session bearers across aggregate, header-scoped, and per-server MCP requests.
  • Adds scope-aware RFC 9728 challenges and protected-resource metadata URLs.
  • Advertises the gateway authorization server for explicitly named, gateway-managed OAuth2 servers.
  • Adds coverage for route spellings, delegated modes, stale sessions, root paths, and missing stored tokens.

Confidence Score: 5/5

The PR appears safe to merge, with no concrete changed-code failure identified.

The changed admission, discovery, and challenge paths consistently preserve target parsing, delegated-mode separation, route spelling, and fail-closed OAuth behavior.

Important Files Changed

Filename Overview
litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py Broadens session-bearer admission and emits scope-aware OAuth challenges while retaining fail-closed target parsing.
litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py Makes explicitly named gateway-managed OAuth2 resources advertise the aggregate gateway authorization server.
litellm/proxy/_experimental/mcp_server/oauth_utils.py Centralizes protected-resource challenge URL construction with root-path and legacy-route support.
litellm/proxy/_experimental/mcp_server/server.py Challenges admitted keyless subjects through per-server resource metadata when no stored upstream token exists.
litellm/types/mcp_server/mcp_server_manager.py Adds a shared predicate identifying OAuth2 servers whose credential custody is gateway-managed.

Reviews (1): Last reviewed commit: "feat(mcp): extend keyless gateway OAuth ..." | Re-trigger Greptile

@codecov

codecov Bot commented Jul 28, 2026

Copy link
Copy Markdown

Codecov Report

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

Files with missing lines Patch % Lines
...erimental/mcp_server/auth/user_api_key_auth_mcp.py 95.45% 1 Missing ⚠️
litellm/proxy/_experimental/mcp_server/server.py 83.33% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@tin-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot 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.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 6be1b5e. Configure here.

@codspeed-hq

codspeed-hq Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_lit4864_perserver_keyless_oauth (6be1b5e) with litellm_internal_staging (77ed122)1

Open in CodSpeed

Footnotes

  1. No successful run was found on litellm_internal_staging (bdf8f8c) during the generation of this report, so 77ed122 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

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

Change Risk (out of 10) Explanation
Per-server PRM advertises gateway AS 4 Spec clients doing PRM discovery re-route to gateway sign-in
Bad-key 401 on oauth2 paths gains challenge 3 Keyed clients with stale keys may auto-launch gateway sign-in
Root-path challenge URLs gain suffix 2 Passthrough challenge bytes change under SERVER_ROOT_PATH
Anonymous x-mcp-servers gets challenge 2 Clients now prompt sign-in where they errored
Session-shaped bearers intercepted everywhere 1 Previously always failed, so nothing working changes

Claude noted these potential backwards-incompatible changes. Of any of the higher risk backwards-incompatible changes, are any of these legit / should be addressed? Or are none of them legit?

@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!

Let's consider adding the breaking changes in the release notes

@mateo-berri mateo-berri changed the title feat(mcp): extend keyless gateway OAuth flow to per-server MCP URL paths feat(mcp)!: extend keyless gateway OAuth flow to per-server MCP URL paths Jul 31, 2026
@tin-berri
tin-berri merged commit 79d4962 into litellm_internal_staging Jul 31, 2026
85 checks passed
@tin-berri
tin-berri deleted the litellm_lit4864_perserver_keyless_oauth branch July 31, 2026 00:40
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