litellm/tests/mcp_tests
tin-berri 0e56fc39e2
feat(mcp): make token_exchange (OBO) production-ready - discovery threading + audit hardening + RFC 9728 challenge (#31622)
* feat(mcp): thread the caller token into tools/list discovery for token_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.

* fix(mcp): harden token_exchange OBO from the audit (strip, TTL/expires_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.

* fix(mcp): stop caller header bypassing OBO exchange; thread subject into 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.

* fix(mcp): keep the OBO/authz_code resolver credential authoritative; 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.

* feat(mcp): RFC 9728 challenge for token_exchange (OBO) unauthorized

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.

* fix(mcp): emit the OBO RFC 9728 challenge preemptively so a no-subject 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.)

* refactor(mcp): inject root_path into the OBO/OAuth challenge edge

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.

* feat(mcp): OBO cache-key tenant isolation, reactive 401 retry, v1-parity 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.

* fix(mcp): fail closed with 412 when a token_exchange server has no endpoint

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.

* feat(mcp): log a refused non-Bearer token_type in the OBO exchange

* fix(mcp): surface OBO/authorization_code list-time 401 as a challenge instead of masking it

* feat(mcp): classify RFC 6749 gateway-fault token-exchange errors as 500, not a caller 401

* test(mcp): absorb fixture uses 500 now that 401/403 are challenge-class at list time

* style(mcp): PEP 604 union in the OBO retry signature to keep the UP007 budget flat
2026-07-03 17:12:25 -07:00
..
test_configs [Release Fix] (#22411) 2026-02-28 09:46:35 -08:00
conftest.py style: run black formatter on files from main merge 2026-04-17 13:02:59 -07:00
mcp_server.py test: const 2026-01-19 12:03:26 +09:00
test_aresponses_api_with_mcp.py style: run black formatter on files from main merge 2026-04-17 13:02:59 -07:00
test_mcp_auth_header_extraction.py style: run black formatter on files from main merge 2026-04-17 13:02:59 -07:00
test_mcp_auth_priority.py feat(mcp): graft v2 resolver onto _create_mcp_client (none + api_key static family) (#31058) 2026-06-24 14:53:33 -07:00
test_mcp_chat_completions.py style: run black formatter on files from main merge 2026-04-17 13:02:59 -07:00
test_mcp_client_unit.py style: run black formatter on files from main merge 2026-04-17 13:02:59 -07:00
test_mcp_guardrails.py style: run black formatter on files from main merge 2026-04-17 13:02:59 -07:00
test_mcp_hooks.py style: run black formatter on files from main merge 2026-04-17 13:02:59 -07:00
test_mcp_litellm_client.py style: run black formatter on files from main merge 2026-04-17 13:02:59 -07:00
test_mcp_logging.py style: run black formatter on files from main merge 2026-04-17 13:02:59 -07:00
test_mcp_server.py feat(mcp): make token_exchange (OBO) production-ready - discovery threading + audit hardening + RFC 9728 challenge (#31622) 2026-07-03 17:12:25 -07:00
test_oauth2_mcp_config.yaml [UI] M2M OAuth2 UI Flow (#20794) 2026-02-09 19:28:02 -08:00
test_openapi_spec_path_url.py style: run black formatter on files from main merge 2026-04-17 13:02:59 -07:00
test_per_user_oauth_cache.py [internal copy of #28008] Support MCP OAuth passthrough and issuer-scoped JWT auth (#28356) 2026-06-02 12:22:04 -07:00
test_proxy_mcp_e2e.py style: run black formatter on files from main merge 2026-04-17 13:02:59 -07:00
test_semantic_tool_filter_e2e.py style: run black formatter on files from main merge 2026-04-17 13:02:59 -07:00