Skip to content

feat(mcp): make token_exchange (OBO) production-ready - discovery threading + audit hardening + RFC 9728 challenge - #31622

Merged
tin-berri merged 14 commits into
litellm_internal_stagingfrom
litellm_mcp_v2_obo_list_threading
Jul 4, 2026
Merged

feat(mcp): make token_exchange (OBO) production-ready - discovery threading + audit hardening + RFC 9728 challenge#31622
tin-berri merged 14 commits into
litellm_internal_stagingfrom
litellm_mcp_v2_obo_list_threading

Conversation

@tin-berri

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

Copy link
Copy Markdown
Contributor

Relevant issues

Stacked on #31526 (the token_exchange / OBO arm). Base is that branch so the diff is just this work; it retargets to litellm_internal_staging after #31526 merges

Linear ticket

N/A

What this is

Makes token_exchange (OBO) production-ready on top of #31526. It began as the discovery fix (an OBO server's tools could not be listed through the aggregator) and then folded in a set of hardening fixes found by an adversarial audit of every OBO code path, credential shape, IdP failure mode, and third-party MCP client interaction. Each fix below is a separate commit with its own regression tests

1. Discovery threading (the original fix)

With #31526, OBO tool calls work, but an OBO server's tools could not be discovered. The tools/list path never threaded the caller's token, so every list hit the no-subject branch of the resolver. v1 masked this with a no-subject client_credentials fallback (discovery quietly used a service token); #31526 dropped that fallback per the v2 rule against falling through to a weaker source, so listing had no credential and the tools never appeared. Since an MCP client lists before it calls, the tools were invisible

The fix gives discovery the caller's own token: _get_tools_from_server extracts the inbound subject_token via the existing _extract_bearer_token and passes it into _create_mcp_client, gated on auth_type == oauth2_token_exchange so the bearer never leaks into other modes; server.py forwards oauth2_headers at the list call site. The list path's graceful degradation (a failed per-server fetch returns an empty list, never crashing the catalog) is preserved

2. Hardening fixes from the audit

a. A caller header can no longer bypass the exchange. The _create_mcp_client guard that ignores a per-request x-mcp-* override only protected authorization_code; on a token_exchange server the override dropped the v2 spec, skipped the RFC 8693 exchange, and forwarded the caller's raw header upstream. The guard now also covers token_exchange, so the exchange always runs and a caller cannot substitute an arbitrary upstream credential to defeat the audience-scoping

b. OBO now works for prompts and resources, not just tools. prompts/list, prompts/get, resources/list, resources/read, and resource-templates/list never threaded the subject token, so those operations failed closed (empty or 401) on an OBO server. They now thread the caller's bearer like the tools paths, via a shared _obo_subject_token helper gated on the mode

c. The resolver-owned credential is authoritative. A guardrail such as MCPJWTSigner, static_headers, or any injected Authorization could shadow the exchanged token, so the upstream received the injected JWT instead of the minted token and rejected it (no warning fired). For token_exchange and authorization_code, _create_mcp_client now drops the conflicting header and keeps the resolved token. No change for none / passthrough / static modes, where an injected Authorization still wins. This makes the signer-plus-OBO combination coherent instead of silently broken; the two are alternative identity-propagation strategies (LiteLLM-issued vs IdP-exchanged) that should never both reach one upstream

d. The OpenAPI path no longer leaks the raw subject token. The OpenAPI / local _request_extra_headers forwarder gated its strip on has_client_credentials only, so an OpenAPI-backed token_exchange server with extra_headers: [Authorization] forwarded the raw subject token upstream and never exchanged. It now uses the centralized _should_strip_caller_authorization, matching the managed paths

e. RFC 9728 challenge on unauthorized. OBO returned an opaque 401 Bearer error="invalid_request" with no discovery info, and any IdP exchange failure collapsed to a retryable 503. An OBO server now behaves like a standards-compliant OAuth resource server:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="/.well-known/oauth-protected-resource/mcp/<server>",
                  error="invalid_token",
                  error_description="Missing or invalid subject token; authenticate with the IdP and retry"

The challenge is emitted preemptively at connect (in the per-server preemptive-401), because the OBO tools are not discoverable without a subject and a tool-call-time 401 is wrapped into a JSON-RPC error so the WWW-Authenticate would be lost. The protected-resource metadata for an OBO server advertises the JWT-auth issuer(s) (JWT_ISSUER / litellm_jwtauth.issuers) as authorization_servers, which is the same IdP that issues and validates the subject token. So a spec-compliant MCP client discovers the IdP, SSOs, and retries with a fresh subject token, which LiteLLM then exchanges. An IdP 4xx (subject rejected) is a non-retryable 401 (the challenge) so a caller with a dead token re-authenticates instead of looping; 5xx and transport failures stay a retryable 503

3. Pure challenge edge (follow-up refactor)

The two challenge builders in the outbound_credentials adapter read SERVER_ROOT_PATH ambiently via get_server_root_path(), a hidden environment read inside a module that is supposed to be a pure edge. That coupling made the preemptive-challenge test order-dependent under xdist (a sibling test sets SERVER_ROOT_PATH at import without cleanup, leaking the prefix into the challenge URL). The root path is now resolved at the imperative-shell call sites and injected keyword-only, so raise_user_oauth_challenge and raise_token_exchange_challenge are pure functions of their inputs; the duplicated resource_metadata path construction collapses into one oauth_protected_resource_path helper. The adapter tests pass root_path as a real value instead of monkeypatching the environment, and the stale-session preemptive test asserts structural invariants rather than the exact prefixed URL so a leaked env var can no longer break it.

Two adjacent cleanups rode along: _create_mcp_client dropped back under the strict complexity ceiling by extracting the v2 credential resolution into _resolve_v2_auth, and the OBO protected-resource-metadata branch (which had shipped without coverage) moved into _obo_protected_resource_response with five new tests covering the issuer branch end to end

4. Contract-audit follow-ups (cache identity, retry, logging)

A pass over the OBO behavior contract surfaced three more gaps that land here. The exchanged-token cache key now folds in the caller's tenant alongside the subject token and exchange config, so two tenants presenting the same opaque token can never collide on one cache entry; isolation is structural rather than a side effect of subject-token uniqueness. The tool-call path gained a single reactive retry: when an upstream rejects the injected token with a 401/403, the gateway invalidates the cached exchange, re-mints once through the IdP, and retries the call exactly once before surfacing the upstream error, so a token revoked or rotated upstream mid-TTL self-heals without an infinite loop. This is gated strictly to oauth2_token_exchange, so passthrough, authorization_code, client_credentials, api_key, and none keep their existing single-call behavior. The exchanger also now emits the v1-parity log lines it had dropped (an attempt line with server, endpoint, and audience, a success line, and a cache-hit line), while still never logging the form, subject token, secret, or minted token.

One contract item is deliberately left for later. The exchange does not capture or use an RFC 8693 refresh_token: every expiry re-runs the exchange rather than refreshing, matching v1 and avoiding speculative refresh plumbing for IdPs that mostly do not issue OBO refresh tokens. The cache plus single-flight already keep the IdP round-trips off the hot path, so re-exchange-on-expiry is the intended lifetime here. RFC 9728 to RFC 8414 discovery of the token endpoint (so the endpoint need not be configured) is tracked as a separate follow-up rather than bundled here

Token endpoint resolution is now strictly config-driven and fails closed. A token_exchange server uses only an explicitly configured token_exchange_endpoint/token_url; the gateway never guesses an IdP or falls back to a weaker source. An OBO server that carries client credentials but no endpoint is owned by the v2 arm rather than deferred to v1, so a missing endpoint returns a clean 412 (precondition) before any upstream or IdP call and the caller's subject token is never sent anywhere. The no-subject case keeps its 401 RFC 9728 challenge, a rejected subject stays 401, and an unreachable IdP stays 503.

5. List-time 401 no longer masked (contract follow-up)

The list path still had one masking gap. When the resolver raised its 401 challenge while building the client (a present-but-rejected subject token, or an authorization_code server with no stored token), _get_tools_from_server caught it in the broad except Exception: return [] and the server showed zero tools with no challenge, so a client could not tell "no tools" from "authenticate and retry". The preemptive connect-time challenge only covers the no-subject case; a subject the IdP rejects fails later, at exchange time, inside that try

The fix routes a v2 resolver auth failure through the same MCPUpstreamAuthError channel pass-through already uses. _get_tools_from_server now converts a 401/403 HTTPException into MCPUpstreamAuthError, preserving the WWW-Authenticate header, so a single-server route surfaces the challenge (the client re-authenticates) while the multi-server aggregator keeps absorbing it into an empty contribution for that one server. A non-auth error (412 no endpoint, 503 IdP down) stays absorbed as before, so one misconfigured server cannot blank the whole catalog. This is mode-agnostic, so authorization_code list discovery gets the same un-masking for free

Alongside it, the exchanger now logs a warning when it refuses a non-Bearer token_type from the IdP (the fail-closed check itself lands in #31526; this adds the observability line now that this branch carries the exchanger's logging)

Auth model recap

A caller can present credentials two ways. Canonical single-JWT: Authorization: Bearer <IdP JWT> with no x-litellm-api-key, where the same JWT authenticates to LiteLLM via JWT auth and is the subject that gets exchanged (requires JWT auth configured against that IdP). Two-header: x-litellm-api-key: Bearer <litellm-key> for LiteLLM auth plus Authorization: Bearer <subject> for the exchange. In both, the subject always comes from Authorization, never from x-litellm-api-key, and only the exchanged token reaches the upstream

Screenshots / Proof of Fix

Verified end to end against a real Keycloak IdP (standing in for Okta), not a mock exchanger. The setup is Keycloak 26.2 with standard RFC 8693 token exchange enabled (realm litellm, user alice, a confidential litellm-exchange client the gateway authenticates the exchange with, and upstream-api as the exchange audience), a dedicated Postgres, a mock upstream MCP server that logs every Authorization it receives, and the proxy from this branch run with --use_v2_migration_resolver, JWT auth enabled, and the enterprise license.

The flow proven is exactly the OBO contract: SSO with Keycloak to obtain an <IdP JWT>, send it to LiteLLM, LiteLLM exchanges it at Keycloak's token endpoint for an upstream-scoped access token, and that minted token (never the JWT) is what reaches the upstream. The subject JWT and the token the upstream actually received, decoded side by side:

subject  (from Keycloak): azp=mcp-subject     aud=litellm-exchange  jti=onrtro:9266...
upstream (forwarded)    : azp=litellm-exchange aud=upstream-api      jti=ntrtte:b034...

It is a different token, re-scoped to the upstream audience, and the raw subject string never appears in the upstream's request log.

A full battery covering both credential shapes and every fail-closed path:

1. SSO: mint alice's IdP JWT from Keycloak (password grant)        PASS
2. Two-header flow (x-litellm-api-key + Authorization=subject)
   - tools listed, tool call returned the upstream result          PASS
   - upstream got the EXCHANGED token (jti != subject)             PASS
   - exchanged token is audience-scoped to upstream-api            PASS
   - raw subject NEVER forwarded upstream                          PASS
3. Single-JWT flow (only Authorization=<IdP JWT>, no api-key)
   - JWT authenticated to LiteLLM AND the call succeeded           PASS
   - the same JWT was exchanged; upstream got the minted token     PASS
   - raw subject JWT NEVER forwarded upstream                      PASS
4. No subject -> 401 + RFC 9728 resource_metadata challenge        PASS
5. token_exchange server with no endpoint -> 412 precondition      PASS
6. Invalid subject -> IdP 4xx -> non-retryable 401 (not 503)       PASS
7. Repeated calls reuse one exchanged token (cache, one IdP hit)   PASS

RESULT: 15 passed, 0 failed

The no-subject challenge, verbatim:

POST /mcp/obo_demo   (x-litellm-api-key only, no Authorization)
HTTP/1.1 401 Unauthorized
www-authenticate: Bearer resource_metadata="/.well-known/oauth-protected-resource/mcp/obo_demo",
                  error="invalid_token", error_description="Missing or invalid subject token; authenticate with the IdP and retry"

The two fail-closed call paths surface in the MCP tool result: a token_exchange server with no configured endpoint returns precondition required: token exchange endpoint is not configured for this server (412, with no IdP guessing and no fallback to a weaker source), and an invalid subject that Keycloak rejects with a 4xx returns Unauthorized (a non-retryable 401, not a retryable 503). Each behavior above also ships with unit regression tests that fail on the pre-fix code

Type

New Feature

🐛 Bug Fix

Changes

See the numbered sections above. Touched: mcp_server_manager.py, server.py, discoverable_endpoints.py, and the outbound_credentials adapter / exchanger / provider, with tests across test_mcp_server_manager.py, test_adapter.py, test_token_exchanger.py, test_resolver.py, test_discoverable_endpoints.py, test_mcp_stale_session.py, and the experimental_mcp_client client tests

Deliberately not implemented

The single JWT (SSO) shape, where the caller's IdP JWT both authenticates to LiteLLM and serves as the exchange subject, is the intended mode. The two-header shape (LiteLLM api key plus a separate Authorization subject) remains reachable but is not advertised, and in that shape the gateway does not pre-validate the subject's signature or expiry before the exchange; the IdP is the authority that accepts or rejects it. Delegation via actor_token (RFC 8693 section 4.1) is intentionally absent; the arm implements impersonation only

@tin-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@greptile-apps

greptile-apps Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR makes the OBO / token_exchange MCP auth mode production-ready by threading the caller's subject token through discovery (tools/list, prompts, resources), hardening the credential resolution path against bypasses and injection conflicts, adding an RFC 9728 challenge for missing/rejected subject tokens, and improving the exchange's reliability contract (tenant-isolated cache keys, a single reactive retry on upstream 401, and proper IdP error classification).

  • Discovery threading: _get_tools_from_server now extracts and forwards the caller's bearer for oauth2_token_exchange servers only, unmasking the earlier "zero tools visible" bug; prompts, resources, and resource-templates get the same treatment via _obo_subject_token.
  • Credential hardening: _create_mcp_client now blocks x-mcp-* caller overrides for TokenExchangeConfig (not just AuthorizationCodeConfig), _resolve_v2_auth drops a conflicting Authorization from extra_headers so a signer/static-header cannot shadow the resolver-owned token, and the OpenAPI forwarder now uses the centralized _should_strip_caller_authorization instead of its own has_client_credentials guard.
  • Exchange robustness: IdP 4xx responses are classified into caller-fault (SubjectTokenRejected → 401 OBO challenge) vs gateway-fault (TokenExchangeClientError → 500), cache keys fold in tenant_id to prevent cross-tenant collisions, an invalidate path plus one reactive retry handle mid-TTL token rotation, and _ttl_seconds is corrected to never cache a token beyond its own remaining lifetime.

Confidence Score: 4/5

Safe to merge with the caveat that OBO tool calls bypass the per-server concurrency semaphore, which matters whenever max_concurrency is configured for a token_exchange server.

The OBO retry path (_obo_call_tool_with_retry) does not acquire the per-server concurrency semaphore that all non-OBO tool calls hold, so any max_concurrency limit configured on an OBO server is silently ignored for every OBO tool call. All other changes — credential hardening, challenge routing, cache isolation, IdP error classification, and the _ttl_seconds correction — look correct and are well-covered by the new tests.

litellm/proxy/_experimental/mcp_server/mcp_server_manager.py — the _obo_call_tool_with_retry method and its call site need the concurrency semaphore applied.

Important Files Changed

Filename Overview
litellm/proxy/_experimental/mcp_server/mcp_server_manager.py Large refactor for OBO discovery threading, retry-on-401, and challenge routing; the OBO retry path (_obo_call_tool_with_retry) does not acquire the per-server concurrency semaphore that the non-OBO path uses.
litellm/proxy/_experimental/mcp_server/outbound_credentials/token_exchanger.py Adds SubjectTokenRejected/TokenExchangeClientError exception types, tenant isolation in cache key, invalidate, logging, float expires_in handling, and a corrected _ttl_seconds that floors at the token's own remaining lifetime.
litellm/proxy/_experimental/mcp_server/outbound_credentials/adapter.py Extracts oauth_protected_resource_path helper, makes raise_user_oauth_challenge and new raise_token_exchange_challenge pure (root_path injected), and widens _token_exchange_spec to own servers with credentials but no endpoint.
litellm/proxy/_experimental/mcp_server/outbound_credentials/token_exchange_provider.py Adds SubjectTokenRejected / TokenExchangeClientError classification for IdP 4xx responses; gateway-fault OAuth error codes (invalid_client, etc.) map to misconfigured, caller fault maps to unauthorized.
litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py Adds _obo_protected_resource_response and _jwt_auth_issuers for OBO PRM, and fixes the _build_oauth_protected_resource_response order so OBO servers get their own metadata before falling through to the gateway default.
litellm/proxy/_experimental/mcp_server/server.py Forwards oauth2_headers to _get_tools_from_server, uses centralized _should_strip_caller_authorization for OpenAPI path, and adds preemptive RFC 9728 challenge for OBO servers with no subject token at connect time.
litellm/experimental_mcp_client/client.py Adds raise_on_error parameter to call_tool and extracts error_tool_result static helper to avoid code duplication.
tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py New tests for OBO discovery threading, 401 surfacing, token-thread mode gating, and retry behavior; existing tests refactored for readability only.
tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_token_exchanger.py New tests for IdP rejection → unauthorized, gateway fault → misconfigured, transport failure → 503, tenant isolation in cache, invalidate targeting, and logging of bad token_type.
tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_adapter.py Tests refactored to inject root_path directly instead of monkeypatching; new tests for oauth_protected_resource_path and raise_token_exchange_challenge.
tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py Mostly line-length reformatting of existing tests; no changes to assertions or mock behavior.
tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_stale_session.py Reformatted assertions plus a new test for the preemptive OBO challenge at connect time.
tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_resolver.py Adds invalidate_credentials tests and updates _FakeExchanger to track tenant_id in call records.
tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_token_exchange_provider.py New file with tests for IdP 4xx classification (SubjectTokenRejected vs TokenExchangeClientError vs None for 5xx).

Comments Outside Diff (1)

  1. litellm/proxy/_experimental/mcp_server/mcp_server_manager.py, line 3368-3392 (link)

    P1 OBO tool calls bypass the per-server concurrency semaphore

    The non-OBO branch wraps client.call_tool inside async with self._limit_outbound_concurrency(mcp_server), but _obo_call_tool_with_retry (both the initial call and the single retry) never acquires that semaphore. If an operator has set max_concurrency on an OBO server, concurrent OBO tool calls are not rate-limited and can flood the upstream, defeating the guard entirely.

Reviews (3): Last reviewed commit: "style(mcp): PEP 604 union in the OBO ret..." | Re-trigger Greptile

@greptile-apps

greptile-apps Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR threads caller OAuth context into MCP tool discovery. The main changes are:

  • Pass oauth2_headers from the MCP list-tools aggregator into per-server discovery.
  • Extract the inbound bearer token for oauth2_token_exchange servers and pass it as subject_token to MCP client creation.
  • Keep other auth modes from receiving the caller bearer token.
  • Add tests for token-exchange discovery, non-OBO no-leak behavior, and background refresh without headers.

Confidence Score: 5/5

The changes are narrow, scoped to OAuth token-exchange discovery, and covered by targeted tests for the new threading behavior and non-leak cases.

The implementation matches the described call-path behavior, preserves existing graceful degradation for listing, and includes tests for the relevant auth-mode boundaries.

T-Rex T-Rex Logs

What T-Rex did

  • Ran a focused runtime test exercising MCPServerManager._get_tools_from_server and observed the head state increased tool_count to 1 with tool_names including 'obo_tool' and the overall validation passed.
  • Validated that the head now supports oauth2_headers and that non-OBO scenarios completed with HTTP 200 OK while still capturing subject_token=[REDACTED].
  • Verified the new in-process contract boundary by exercising MCPServerManager._get_tools_from_server with optional oauth2_headers=None, observed a simulated failure returning [] with exit code 0, and noted that no HTTP endpoints were started for this scoped validation.

View all artifacts

T-Rex Ran code and verified through T-Rex

Reviews (1): Last reviewed commit: "feat(mcp): thread the caller token into ..." | Re-trigger Greptile

@codecov

codecov Bot commented Jun 29, 2026

Copy link
Copy Markdown

@tin-berri
tin-berri force-pushed the litellm_mcp_v2_obo_list_threading branch 2 times, most recently from 153fba7 to 915d562 Compare June 29, 2026 16:46
Comment thread litellm/proxy/_experimental/mcp_server/mcp_server_manager.py
@veria-ai

This comment was marked as outdated.

@tin-berri tin-berri changed the title feat(mcp): thread the caller token into tools/list discovery for token_exchange (OBO) feat(mcp): make token_exchange (OBO) production-ready - discovery threading + audit hardening + RFC 9728 challenge Jun 29, 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.

Is the veria concern legit?

@tin-berri
tin-berri force-pushed the litellm_mcp_v2_token_exchange branch from e9e752c to 967b2f5 Compare July 3, 2026 17:37
@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_token_exchange branch from 967b2f5 to 5bcf4aa Compare July 3, 2026 18:44
@tin-berri
tin-berri force-pushed the litellm_mcp_v2_obo_list_threading branch 2 times, most recently from 4c8729b to 2d4f870 Compare July 3, 2026 18:53
Comment thread litellm/proxy/_experimental/mcp_server/mcp_server_manager.py
@tin-berri
tin-berri force-pushed the litellm_mcp_v2_obo_list_threading branch from 2d4f870 to c5e32f5 Compare July 3, 2026 22:42
Base automatically changed from litellm_mcp_v2_token_exchange to litellm_internal_staging July 3, 2026 23:04
tin-berri added 9 commits July 3, 2026 16:07
…n_exchange

A token_exchange (OBO) server's tools could not be discovered through the aggregator: the list path
never threaded the caller's token, so every tools/list hit the no-subject branch. v1 masked this with
its client_credentials fallback (discovery used a service token); v2 dropped that fallback, so listing
had no credential and the OBO server's tools never appeared - and an MCP client lists before it calls.

Thread the inbound subject_token into the list path the same way the call path does, gated on
auth_type oauth2_token_exchange so the caller's bearer never leaks into other modes:
_get_tools_from_server takes an oauth2_headers param, extracts the token via _extract_bearer_token, and
passes it to _create_mcp_client; server.py forwards oauth2_headers at the list call site.
authorization_code (resolves off identity plus stored token), the static/config modes, and the
background registry refresh are unaffected, and the list path's existing graceful degradation
(catch -> empty list) is preserved.
…s_in, subject_token_type)

- _should_strip_caller_authorization returns True for oauth2_token_exchange, so the inbound subject
  token is never forwarded upstream raw - only the IdP-exchanged token is (matches authorization_code).
- _parse_expires_in accepts a JSON float / numeric-string expires_in, and _ttl_seconds caps the cache
  TTL at the token's real remaining lifetime so a short-lived exchanged token is never served stale.
- to_server_spec normalizes a falsy subject_token_type to the default URN, parity with v1.

The subject/key disambiguation (never exchange the LiteLLM key; Authorization: Bearer <litellm-key>
support for /mcp) is intentionally a separate cross-cutting PR off staging, not part of this OBO work.
…nto prompts/resources

The per-server x-mcp-* override guard in _create_mcp_client only kept the v2 spec
for authorization_code, so a caller-supplied header silently disabled the RFC 8693
exchange on a token_exchange server and forwarded the raw bearer upstream. Extend
the guard to token_exchange so the exchange always runs and the caller cannot
substitute an arbitrary upstream credential.

prompts/list+get, resources/list+read, and resource-templates/list never threaded
the OBO subject token, so those operations failed closed (401 / empty) on a
token_exchange server. Thread the caller's bearer as the subject for those paths
too, gated on the token_exchange mode via a shared _obo_subject_token helper.
…centralize OpenAPI strip

A guardrail (e.g. MCPJWTSigner), static_headers, or any other injected Authorization could
shadow the resolver-owned credential for token_exchange / authorization_code servers, so the
upstream would receive e.g. the signer's JWT instead of the exchanged token and reject it. In
_create_mcp_client the resolver-owned credential now wins: a conflicting header is dropped and
the minted/stored token reaches upstream. No behavior change for none/passthrough/static modes,
where an injected Authorization still wins as before.

The OpenAPI/local _request_extra_headers forwarder gated its Authorization strip on
has_client_credentials only, so an OpenAPI-backed token_exchange server with
extra_headers:[Authorization] forwarded the raw subject token upstream and never exchanged. It
now uses the centralized _should_strip_caller_authorization so it matches the managed paths.
OBO previously returned an opaque 401 (Bearer error="invalid_request") with no discovery
info, and any IdP exchange failure collapsed to a retryable 503. Now an OBO server behaves like
a standards-compliant OAuth resource server:

- A missing/rejected subject token returns the RFC 9728 / RFC 6750 challenge: 401 +
  WWW-Authenticate: Bearer resource_metadata="...", error="invalid_token", so a spec-compliant
  MCP client can discover the IdP, SSO, and retry with a fresh subject token.
- The protected-resource metadata for a token_exchange server advertises the JWT-auth issuer(s)
  (JWT_ISSUER / litellm_jwtauth.issuers) as authorization_servers -- the IdP that issues and
  validates the subject -- instead of the gateway.
- An IdP 4xx (subject rejected) is now a non-retryable 401 (the challenge) instead of a 503, so a
  caller with a dead token re-authenticates rather than looping; 5xx/transport stays retryable 503.
…t client can discover the IdP

A token_exchange server's tools are not discoverable without a subject token (list is lenient ->
empty), and a tool-call-time 401 is wrapped into a JSON-RPC error so the WWW-Authenticate header is
lost. So a cold-start client never saw the challenge and could not start discovery. Add a
token_exchange branch to the preemptive-401: a no-subject connect to an OBO server now returns
401 + WWW-Authenticate: Bearer resource_metadata=..., error="invalid_token" at the transport level,
so a spec-compliant client discovers the IdP (the PRM advertises the JWT-auth issuer), SSOs, and
retries with a subject token. Verified live on the per-server endpoint; the with-subject connect
still proceeds (no challenge).

(Also formats two lines from earlier commits in this stack.)
The adapter's raise_user_oauth_challenge and raise_token_exchange_challenge
reached into os.getenv("SERVER_ROOT_PATH") via get_server_root_path(), a
hidden ambient read in a module that is meant to be a pure edge. That coupling
made the preemptive-challenge test order-dependent under xdist: a sibling test
sets SERVER_ROOT_PATH at import without cleanup, leaking the prefix into the
challenge URL and failing the exact-match assertion.

Resolve the root path at the imperative-shell call sites and pass it in
keyword-only, so both challenge builders become pure functions of their inputs.
Extract the shared resource_metadata path construction into a single
oauth_protected_resource_path helper, collapsing the duplicated prefix/name
logic the two functions carried.

Also reduce _create_mcp_client below the strict complexity ceiling by extracting
the v2 credential resolution into _resolve_v2_auth, and extract the OBO
protected-resource-metadata branch into _obo_protected_resource_response (which
shipped without coverage) so discovery can be unit-tested directly.

Tests are now hermetic: the adapter tests pass root_path as a real input rather
than monkeypatching the environment, the stale-session preemptive test asserts
structural invariants instead of the exact prefixed URL, and five new tests
cover the OBO PRM issuer branch end to end.
…ity logs

From a pass over the OBO behavior contract. Three changes to the
token_exchange arm, none of which alters any other auth mode.

The exchanged-token cache key now folds in the caller's tenant alongside
the subject token and exchange config, so two tenants presenting the same
opaque token can never share a cache entry; cross-tenant isolation is
structural rather than incidental to subject-token uniqueness. tenant_id is
threaded from the resolver's Subject; it is keyword-only with an empty
default so the no-tenant case and the existing call sites are unchanged.

The tool-call path gains one reactive retry. When an upstream rejects the
injected token with a 401/403, the gateway invalidates the cached exchange,
re-mints once through the IdP by rebuilding the client, and retries the call
exactly once before surfacing the upstream error, so a token revoked or
rotated upstream mid-TTL self-heals without an infinite loop. It is gated
strictly to oauth2_token_exchange; passthrough, authorization_code,
client_credentials, api_key, and none keep their single-call behavior.
MCPClient.call_tool gains a raise_on_error flag (mirroring list_tools) so
the path can tell an upstream 401 apart from an ordinary tool error and
avoid re-running a non-idempotent tool on a non-auth failure.

The exchanger also emits the v1-parity log lines it had dropped (attempt
with server, endpoint and audience; success; cache hit), while never
logging the form, subject token, secret, or minted token.
…dpoint

A true token_exchange (OBO) server must use only an explicitly configured
token endpoint; it must never guess an IdP or silently fall back to a weaker
source. Previously an OBO server with client credentials but no
token_exchange_endpoint/token_url deferred to v1, which no-op'd and let the
request connect to the upstream with no credential (an upstream 401 rather
than a clear gateway error).

Now such a server is owned by the v2 arm: _token_exchange_spec builds the spec
even when the endpoint is absent, and the exchanger fails closed with a
precondition_required error that maps to HTTP 412 before any upstream or IdP
call, with the caller's subject token never sent anywhere. A missing
client_id/secret still maps to misconfigured (500); a present-but-rejected
subject still maps to 401; an unreachable IdP still maps to 503. The no-subject
case keeps its existing 401 RFC 9728 challenge.
@tin-berri
tin-berri force-pushed the litellm_mcp_v2_obo_list_threading branch from c5e32f5 to 67c7d32 Compare July 3, 2026 23:09
Comment thread litellm/proxy/_experimental/mcp_server/mcp_server_manager.py
@mateo-berri

Copy link
Copy Markdown
Contributor

@greptileai

@tin-berri
tin-berri requested a review from mateo-berri July 4, 2026 00:05

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

The non-OBO branch wraps client.call_tool inside async with self._limit_outbound_concurrency(mcp_server), but _obo_call_tool_with_retry (both the initial call and the single retry) never acquires that semaphore. If an operator has set max_concurrency on an OBO server, concurrent OBO tool calls are not rate-limited and can flood the upstream, defeating the guard entirely.

Is this a legit concern?

@tin-berri

Copy link
Copy Markdown
Contributor Author

The non-OBO branch wraps client.call_tool inside async with self._limit_outbound_concurrency(mcp_server), but _obo_call_tool_with_retry (both the initial call and the single retry) never acquires that semaphore. If an operator has set max_concurrency on an OBO server, concurrent OBO tool calls are not rate-limited and can flood the upstream, defeating the guard entirely.

Is this a legit concern?

no

@tin-berri
tin-berri requested a review from mateo-berri July 4, 2026 00:10

@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 0e56fc3 into litellm_internal_staging Jul 4, 2026
124 of 125 checks passed
@tin-berri
tin-berri deleted the litellm_mcp_v2_obo_list_threading branch July 4, 2026 00:12
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