Skip to content

feat(mcp): discover the OBO token endpoint via RFC 9728 -> RFC 8414 (no IdP guessing) - #31762

Merged
tin-berri merged 3 commits into
litellm_internal_stagingfrom
litellm_mcp_v2_obo_endpoint_discovery
Jul 4, 2026
Merged

feat(mcp): discover the OBO token endpoint via RFC 9728 -> RFC 8414 (no IdP guessing)#31762
tin-berri merged 3 commits into
litellm_internal_stagingfrom
litellm_mcp_v2_obo_endpoint_discovery

Conversation

@tin-berri

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

Copy link
Copy Markdown
Contributor

Relevant issues

Stacked on #31622 (the OBO production-readiness PR). Base is that branch, so this diff is just the discovery work; it retargets up the stack as the parent merges

Linear ticket

N/A

What this is

A follow-up from the OBO behavior-contract audit. Today an oauth2_token_exchange (OBO) server only works when an admin hand-configures token_exchange_endpoint/token_url. This adds the contract's deterministic token-endpoint discovery, reusing the chain the oauth2 flow already runs, while keeping the gateway from ever guessing the IdP.

Resolution order

Defined config first, then authoritative discovery, then fail closed. An explicitly configured token_exchange_endpoint/token_url always wins and skips any network round-trip. When none is configured, the gateway follows RFC 9728 (read the upstream's protected-resource metadata for its advertised authorization_servers) then RFC 8414 (read that authorization server's metadata for token_endpoint), reusing MCPServerManager._descovery_metadata (SSRF-guarded via async_safe_get). The two existing discovery gates (config load and DB build) now also fire for OBO behind a new _obo_needs_endpoint_discovery, only when the endpoint is unset. The discovered endpoint lands on token_url, which _token_exchange_spec already reads, so the resolver and exchanger are untouched. If discovery yields nothing, the endpoint stays unset and the parent PR's 412 fail-closed takes over.

No IdP guessing

Discovery for OBO is authoritative only. _descovery_metadata grows an allow_origin_fallback flag; it stays True for the browser oauth2 (authorization_code) flow, where a human sees the redirect, but is False for token_exchange. That disables the last-resort fallback that treats the resource server's own origin as its authorization server when nothing is advertised, so the gateway never POSTs a subject token to an endpoint it inferred rather than one explicitly configured or authoritatively advertised. _resolve_oauth2_flow returns None for any non-oauth2 auth type, so a discovered token_url on an OBO server is never mis-inferred as the M2M client_credentials flow.

Discovery runs once at config-load / DB-build time and the result is persisted onto the MCPServer record, so there is no per-request discovery latency.

Screenshots / Proof of Fix

Verified live against a real Keycloak IdP (the same setup used to prove the OBO arm), with two extra upstreams to exercise discovery: one that advertises its authorization server via RFC 9728 protected-resource metadata, and one that advertises no PRM but whose origin does serve RFC 8414 authorization-server metadata, so it is a guessable origin. Loopback was added to litellm_settings.user_url_allowed_hosts so the discovery fetches clear the SSRF guard.

Discovery success. obo_discovered is a token_exchange server with no token_exchange_endpoint configured. Its upstream advertises the IdP:

GET http://127.0.0.1:9100/.well-known/oauth-protected-resource
{ "resource": "http://127.0.0.1:9100/mcp", "authorization_servers": ["http://localhost:8080/realms/litellm"] }

The gateway reads that, follows RFC 8414 to Keycloak's metadata for the token_endpoint, and completes the exchange with no endpoint ever configured by hand. The upstream receives azp=litellm-exchange aud=upstream-api (the minted, audience-scoped token), proving the endpoint came from discovery.

No IdP guessing. obo_guessable points at an upstream that returns 404 for its PRM but serves a valid token endpoint at its own origin's .well-known/oauth-authorization-server, so guessing the origin would "work". The upstream's request log shows exactly what the gateway probed during config-load discovery:

GET /mcp
GET /.well-known/oauth-protected-resource/mcp      -> 404
GET /.well-known/oauth-protected-resource          -> 404

It probed the RFC 9728 PRM and stopped. It made zero requests to /.well-known/oauth-authorization-server, so it never guessed the origin as the IdP, and the endpoint stays unresolved. A call then fails closed: precondition required: token exchange endpoint is not configured for this server (412), with no token POSTed anywhere.

The full battery:

1. Discovery SUCCESS (obo_discovered, no configured endpoint)
   - exchange completed using the DISCOVERED endpoint                  PASS
   - upstream got the minted, audience-scoped token via discovery      PASS
2. NO IdP GUESS (obo_guessable: no PRM, but origin is a guessable AS)
   - RFC 9728 PRM was probed and returned no authorization server      PASS
   - the guessable origin AS metadata was NEVER fetched (0)            PASS
   - a call fails closed (412 precondition, nothing exchanged)         PASS

RESULT: 5 passed, 0 failed

Config-first precedence is unchanged: an OBO server with an explicit token_exchange_endpoint skips discovery entirely (proven on the parent PR, where the happy-path server used a configured endpoint and made no PRM fetch). Unit coverage pins the decision predicate, that config-load threads the discovered token_endpoint onto token_url with the origin fallback disabled for OBO, and that _descovery_metadata does not guess the origin when the fallback is off

Type

🆕 New Feature

Changes

mcp_server_manager.py: _obo_needs_endpoint_discovery, the two widened discovery gates, and the allow_origin_fallback flag on _descovery_metadata (False for OBO), with tests in test_mcp_server_manager.py

Two more contract paths were proven live after the follow-up landed. A server with a correct endpoint but a wrong client secret (a real unauthorized_client rejection from Keycloak) fails the connect with an opaque 500 and no challenge, so the caller is never sent into a pointless re-login loop for a gateway-side fault. Revoking the minted token at the upstream mid-session was also exercised: the upstream 401'd the cached token once, the gateway invalidated it, re-exchanged with the same subject, and the retried call succeeded under a new jti, invisible to the caller

Deliberately not implemented

Discovery extracts and debug-logs the authorization server's advertised issuer, grant_types_supported, and token_endpoint_auth_methods_supported but enforces nothing from them; some IdPs under-advertise token exchange support in grant_types_supported, so a metadata gate would wrongly reject working servers, and the exchange itself is the authoritative check. Only token_endpoint is threaded onto the server record. RFC 8707 resource is not part of discovery or the exchange request

@greptile-apps

greptile-apps Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds deterministic token-endpoint discovery (RFC 9728 → RFC 8414) for oauth2_token_exchange (OBO) MCP servers, so admins no longer need to hand-configure token_exchange_endpoint/token_url. It also surfaces exchange failures at the transport edge via a new preflight_token_exchange call on single-server routes, preventing a rejected subject token from silently producing an empty tool list.

  • Discovery without IdP guessing: _obo_needs_endpoint_discovery gates discovery to OBO servers with no configured endpoint; allow_origin_fallback=False blocks the last-resort origin-as-AS inference in _descovery_metadata so the gateway never POSTs a subject token to an inferred endpoint.
  • DB write-back: _persist_discovered_obo_token_url writes the discovered token_url back to the DB row on first discovery so subsequent build_mcp_server_from_table calls skip the network round-trip; the write is best-effort and the previous reviewer concern about re-running discovery on every rebuild is addressed.
  • Preflight exchange: preflight_token_exchange resolves credentials at the ASGI transport edge for single-server routes, mapping IdP rejections to the RFC 9728 401 challenge and gateway faults to their public HTTP status before the MCP session opens.

Confidence Score: 5/5

Safe to merge; the discovery and preflight logic is correct, previous concerns about DB persistence and test coverage of the DB path are both addressed, and all new tests are mock-only with no real network calls.

Discovery resolves exactly two authoritative sources (RFC 9728 PRM and RFC 8414 AS metadata) with no fallback to guessed origins for OBO, the write-back fires at most once per server and is best-effort, and the preflight correctly distinguishes IdP rejections (401 + WWW-Authenticate) from gateway faults (500). No issues found that would affect correctness at runtime.

No files require special attention.

Important Files Changed

Filename Overview
litellm/proxy/_experimental/mcp_server/mcp_server_manager.py Adds _obo_needs_endpoint_discovery, preflight_token_exchange, _persist_discovered_obo_token_url, and allow_origin_fallback flag; all logic is correct and the new method signatures are backward-compatible.
litellm/proxy/_experimental/mcp_server/server.py Adds preflight OBO exchange call inside _raise_preemptive_401_for_unauthenticated_servers for single-server routes; gating on len(mcp_servers or []) == 1 is correctly scoped.
tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py Adds 11 new mock-only tests covering discovery decision, config-load OBO path, DB-path OBO path, DB write-back, and preflight scenarios; the one signature update to an existing test stub adds an assertion rather than weakening coverage.

Reviews (2): Last reviewed commit: "fix(mcp): persist the discovered OBO tok..." | Re-trigger Greptile

Comment thread litellm/proxy/_experimental/mcp_server/mcp_server_manager.py
@codecov

codecov Bot commented Jun 30, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.30769% with 3 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...oxy/_experimental/mcp_server/mcp_server_manager.py 94.59% 2 Missing ⚠️
litellm/proxy/_experimental/mcp_server/server.py 50.00% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@tin-berri
tin-berri force-pushed the litellm_mcp_v2_obo_endpoint_discovery branch from 3dcb0c1 to 5662285 Compare June 30, 2026 22:22
@tin-berri tin-berri changed the title feat(mcp): discover the OBO token endpoint via RFC 9728 -> RFC 8414 when unset feat(mcp): discover the OBO token endpoint via RFC 9728 -> RFC 8414 (no IdP guessing) Jun 30, 2026

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

Why is greptile 3/5? Just reping me once you've gotten 5/5 greptile with "No files need special attention", no veria concerns, and no bugbot concerns on the last commit. Or if there are any false positives, a response to each of them as to why it's a false positive

@tin-berri
tin-berri force-pushed the litellm_mcp_v2_obo_list_threading branch from 25218ed to fe9f073 Compare July 3, 2026 17:37
@tin-berri
tin-berri force-pushed the litellm_mcp_v2_obo_endpoint_discovery branch from 5662285 to aea3a68 Compare July 3, 2026 17:37
@tin-berri
tin-berri force-pushed the litellm_mcp_v2_obo_list_threading branch from fe9f073 to 4c8729b Compare July 3, 2026 18:44
@tin-berri
tin-berri force-pushed the litellm_mcp_v2_obo_endpoint_discovery branch from aea3a68 to fcdd52d Compare July 3, 2026 18:44
@tin-berri
tin-berri force-pushed the litellm_mcp_v2_obo_list_threading branch from 4c8729b to 2d4f870 Compare July 3, 2026 18:53
@tin-berri
tin-berri force-pushed the litellm_mcp_v2_obo_endpoint_discovery branch from fcdd52d to c64b9c7 Compare July 3, 2026 18:53
@tin-berri
tin-berri force-pushed the litellm_mcp_v2_obo_list_threading branch from 2d4f870 to c5e32f5 Compare July 3, 2026 22:42
@tin-berri
tin-berri force-pushed the litellm_mcp_v2_obo_endpoint_discovery branch from 6eb44d3 to 679d65a Compare July 3, 2026 22:44
@tin-berri
tin-berri force-pushed the litellm_mcp_v2_obo_list_threading branch from c5e32f5 to 67c7d32 Compare July 3, 2026 23:09
@tin-berri
tin-berri force-pushed the litellm_mcp_v2_obo_endpoint_discovery branch 2 times, most recently from b910aec to 329c4b6 Compare July 3, 2026 23:19
Base automatically changed from litellm_mcp_v2_obo_list_threading to litellm_internal_staging July 4, 2026 00:12
@tin-berri
tin-berri requested a review from mateo-berri July 4, 2026 00:12
tin-berri added 2 commits July 3, 2026 17:13
…no IdP guessing)

An oauth2_token_exchange server can now have its token endpoint discovered the
same way the oauth2 (authorization_code) flow already does, instead of always
requiring token_exchange_endpoint/token_url to be configured by hand. The
existing _descovery_metadata chain (RFC 9728 protected-resource metadata ->
RFC 8414 authorization-server metadata -> token_endpoint, SSRF-guarded via
async_safe_get) is reused; both the config-load and DB-build paths gate on a new
_obo_needs_endpoint_discovery so discovery runs only when no endpoint is
configured, and an explicitly configured endpoint still wins and skips the
round-trip. The discovered token endpoint lands on token_url, which
_token_exchange_spec already reads, so no resolver change is needed.
_resolve_oauth2_flow returns None for any non-oauth2 auth_type, so a discovered
token_url on an OBO server is never mis-inferred as the M2M client_credentials
flow.

Discovery for OBO is authoritative only: the resolution order is explicitly
configured endpoint, then RFC 9728 -> RFC 8414 advertisement, then fail closed
(412, on the parent commit). The gateway never guesses the IdP. _descovery_metadata
grows an allow_origin_fallback flag, kept True for the browser oauth2 flow (a
human sees the redirect) but set False for token_exchange so the last-resort
guess that treats the resource server's own origin as its authorization server
is skipped; a subject token is never exchanged against an inferred endpoint.
…y tool list

A token_exchange server whose exchange fails with a subject present used to open the MCP
session anyway and mask the failure as an empty tools/list. Single-server routes now run
the exchange preemptively at the transport edge, where a rejected subject raises the RFC
9728 challenge and a gateway fault its public status; the multi-server aggregate keeps
absorbing per-server auth failures. The exchanger caches the preflight result, so the
session's list/call reuses it with no extra IdP round-trip. Discovery now also debug-logs
the authorization server's advertised issuer, grant types, and client auth methods
@tin-berri
tin-berri force-pushed the litellm_mcp_v2_obo_endpoint_discovery branch from 329c4b6 to 9c72739 Compare July 4, 2026 00:14
A DB-backed oauth2_token_exchange server with no configured endpoint had its token_url
resolved via RFC 9728 -> RFC 8414 only on the in-memory object returned from
build_mcp_server_from_table; the row kept token_url=None, so every rebuild re-ran discovery
and a transient upstream outage during a rebuild left the server with no endpoint until the
next successful discovery. Write the discovered token_url back onto the row so the guard sees
it on the next build. Best-effort and scoped to DB servers: config servers already persist
in-memory, and the write-back never fires from a user connect (only from add/update/reload,
all admin or system driven). Adds DB-path coverage for discovery firing when unset, skipping
when the credentials endpoint is configured, the write-back, and its negative guards
@tin-berri

Copy link
Copy Markdown
Contributor Author

@greptileai rereview

@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 merged commit c737789 into litellm_internal_staging Jul 4, 2026
124 of 125 checks passed
@tin-berri
tin-berri deleted the litellm_mcp_v2_obo_endpoint_discovery branch July 4, 2026 01:57
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