Skip to content

feat(mcp): add entra_obo profile to the token_exchange (OBO) arm - #31983

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

feat(mcp): add entra_obo profile to the token_exchange (OBO) arm#31983
tin-berri merged 4 commits into
litellm_internal_stagingfrom
litellm_mcp_v2_entra_obo

Conversation

@tin-berri

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

Copy link
Copy Markdown
Contributor

Relevant issues

Stacked on #31762 (OBO token-endpoint discovery), the top of the OBO stack #31526 -> #31622 -> #31762. Base is litellm_mcp_v2_obo_endpoint_discovery, so the diff here is only the entra_obo work; it retargets up the stack as the parents merge

Linear ticket

Resolves LIT-4163

Pre-Submission checklist

  • I have added meaningful tests
  • My PR passes all CI/CD checks (lint, format, unit tests) locally
  • My PR's scope is as isolated as possible; it only adds the entra_obo profile
  • I have requested a Greptile review and received a Confidence Score of at least 4/5

What this is

The v2 token_exchange (OBO) arm speaks only RFC 8693 today. Microsoft Entra ID's On-Behalf-Of flow is not RFC 8693; it is the RFC 7523 jwt-bearer grant with a Microsoft requested_token_use=on_behalf_of extension, so an Entra upstream cannot mint tokens through the existing arm. This adds an entra_obo profile that switches the request form to Entra's dialect while reusing everything below the form

TokenExchangeConfig gains a profile: Literal["rfc8693", "entra_obo"] (default rfc8693, so existing servers are unchanged). The exchanger's form builder dispatches on it with an exhaustive match plus assert_never, the same pattern the package already uses for CredError and the auth-config union, so basedpyright proves the dialects stay in sync. Under entra_obo the caller's inbound token is sent as assertion (its aud must be the gateway's own client), the target resource is carried in scope as api://<app-id>/.default since Entra has no audience parameter, and requested_token_use=on_behalf_of is added; subject_token_type and audience are not sent. The cache key folds in the profile so a dialect flip re-exchanges rather than serving a token minted for the other form. The shared caching, single-flight, tenant keying, TTL, discovery, RFC 9728 challenge, and fail-closed contract are all untouched

Because the concrete exchanger now serves both dialects, Rfc8693TokenExchanger is renamed to OboTokenExchanger so the name is not a lie; the TokenExchanger protocol and the resolver arm are unchanged, so the rename is contained to the class and its two call sites

Fail-closed behavior follows the v2 no-silent-fallback rule. A missing endpoint is a 412 precondition, missing client credentials is a misconfigured 5xx, and an entra_obo server with no scope is misconfigured before any IdP call since Entra cannot resolve a target without one. An Entra 4xx such as AADSTS65001 (no admin consent) maps to a 401 challenge through the existing SubjectTokenRejected edge so the caller re-authenticates, while a 5xx or transport failure stays a retryable 503

Certificate client authentication (Entra's client_assertion case, RFC 7523 private_key_jwt) is out of scope here and slots into the same entra_obo branch as a later follow-up

Operator config for an Entra upstream:

mcp_servers:
  entra_downstream:
    url: https://.../mcp
    auth_type: oauth2_token_exchange
    token_exchange_profile: entra_obo
    token_url: https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/token
    client_id: <litellm-app-client-id>
    client_secret: os.environ/ENTRA_LITELLM_CLIENT_SECRET
    scopes: ["api://<target-api-app-id>/.default"]

Screenshots / Proof of Fix

A full end-to-end run against a real Entra tenant needs the reviewer's Azure app registrations (a gateway app with a client secret and an admin-consented permission to the downstream api:// app), so the automated proof below drives the real production egress exchanger (build_token_exchanger(), which POSTs through the real httpx _post_exchange_endpoint) against a local stand-in Entra token endpoint that records the exact form it receives. This exercises the real HTTP path and the real form builder, not mocked internals. The stand-in stands in for Entra the same way the parent PRs used Keycloak

SCENARIO 1 - entra_obo happy path: exact Entra OBO form + minted-token forward
form the gateway POSTed to the Entra token endpoint:
{
  "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
  "assertion": "alice-inbound-token-aud-litellm-gateway",
  "client_id": "litellm-gateway-client-id",
  "client_secret": "gateway-secret",
  "scope": "api://target-api/.default",
  "requested_token_use": "on_behalf_of"
}
caller token presented to LiteLLM : alice-inbound-token-aud-litellm-gateway
token the upstream will receive   : MINTED-token__aud=api://target-api__sub=alice
forwarded == caller token ?       : False   (True OBO: caller token never forwarded)
minted audience swapped to target?: True

SCENARIO 2 - Entra 4xx (AADSTS65001 no consent) fails closed as 401, not a retryable 503
Entra returned HTTP 400 (AADSTS65001); exchanger CredError tag = 'unauthorized'
maps to 401 challenge (not 503 loop)? : True

SCENARIO 3 - entra_obo without a scope fails closed as misconfigured, never POSTing
CredError tag = 'misconfigured'; HTTP calls made = 0
fail-closed before any IdP call? : True

SCENARIO 4 - contrast: the rfc8693 profile still sends the RFC 8693 form
form the gateway POSTed under rfc8693:
{
  "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
  "subject_token": "alice-inbound-token-aud-litellm-gateway-4",
  "subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
  "client_id": "cid",
  "client_secret": "sec",
  "audience": "https://target-api",
  "scope": "s1"
}

ALL SCENARIOS PASSED

To verify against a live Entra tenant on a running proxy, point token_url at your tenant, set client_id/client_secret to a gateway app that has admin-consented permission to the api://<target> app, present a user access token whose aud is the gateway app, and call a tool on the entra_obo server; the upstream receives the minted token with aud=api://<target> and the same user oid/sub, and a caller with no consent gets a 401 challenge rather than a 503 loop

The form builder, cache-key, and adapter mapping are pinned by mutation-grade unit tests in test_token_exchanger.py and test_adapter.py (the entra form asserts the exact dict and that subject_token/subject_token_type/audience are absent; the profile is verified to be part of the cache key by a count assertion that fails if it is dropped). The full outbound_credentials suite is green (210) and mcp_server_manager is green (214); ruff, ruff format, and basedpyright strict add zero new findings

Type

New Feature

Changes

TokenExchangeConfig gains profile; the exchanger form builder dispatches on it with an exhaustive match and is renamed OboTokenExchanger; the adapter maps token_exchange_profile from the server, normalizing an unknown value to rfc8693. The MCPServer model gains token_exchange_profile, and both the config-load and DB build sites thread it. MCPCredentials also gains token_exchange_profile so the management API (NewMCPServerRequest / UpdateMCPServerRequest) validates and persists it into the credentials blob rather than pydantic silently dropping the unknown key. No new auth_type and no DB migration, since the field rides in the existing credentials JSON blob

Conditional Access step-up challenge (follow-up commit)

Entra returns a 4xx with error=interaction_required and a claims blob when a Conditional Access policy demands step-up (MFA, compliant device, etc.); the client must replay those claims to Entra to satisfy the policy, then retry. The arm previously dropped the code and claims and emitted a static RFC 9728 challenge, so a CA-protected Entra upstream was unreachable through the gateway. This threads the step-up through end to end.

The provider reads the RFC 6749 error code and the claims string off the rejection body (the error_description is still never read; it can carry IdP internals), carries them on SubjectTokenRejected into CredError.unauthorized, and the challenge builder folds them into WWW-Authenticate: the machine error is embedded only when it is a plain OAuth token, guarding against header injection from a hostile IdP body, and the claims travel base64-encoded in a claims parameter, the convention MSAL-family clients decode. With neither field present the header is byte-identical to the prior static challenge, so the rfc8693 path is unchanged. The multi-server aggregate still absorbs a step-up 401 to an empty listing; only single-server routes surface it as a challenge.

Proof of Fix (live, real proxy egress)

Driven against a stand-in Entra token endpoint that records the exact form it receives and, on demand, returns Entra's CA rejection; the real egress exchanger (build_token_exchanger() -> real httpx _post_exchange_endpoint) POSTs to it, the same way the parent PRs used Keycloak.

Happy path: the gateway POSTs the exact Entra OBO form and forwards the minted token:

grant_type: urn:ietf:params:oauth:grant-type:jwt-bearer | requested_token_use: on_behalf_of | scope: api://target-api/.default
assertion == caller subject JWT: True | subject_token absent: True | audience absent: True
upstream got: AUTH=Bearer entra-minted-ae0f7a5a... (never the caller subject)

Conditional Access rejection: single-server connect returns 401 with the propagated code and the base64 claims, and no AADSTS/error_description leak:

HTTP status: 401
www-authenticate: Bearer resource_metadata="/.well-known/oauth-protected-resource/mcp/entra_demo", error="insufficient_claims", error_description="Step-up authentication required; satisfy the returned claims challenge with the IdP and retry", claims="eyJhY2Nlc3NfdG9rZW4iOnsiYWNycyI6eyJlc3NlbnRpYWwiOnRydWUsInZhbHVlIjoiYzEifX19"
decoded claims: {"access_token":{"acrs":{"essential":true,"value":"c1"}}}
AADSTS leak in headers/body: 0

Aggregate graceful degradation is preserved: /mcp with the CA-rejected server present still returns 200 and lists the healthy servers, rather than a blanket 401.

Unit coverage pins each hop: the provider extracts error+claims and never the description; a gateway-fault code still wins over a present claims blob; the exchanger carries both onto the unauthorized CredError; the challenge builder folds them in, rejects a non-token error code back to invalid_token, and stays byte-identical without them; the preflight surfaces them on the single-server 401 while the aggregate list still absorbs.

To verify against a live Entra tenant, point token_exchange_endpoint at your tenant, set client_id/client_secret to a gateway app admin-consented to the api://<target> app, present a user access token whose aud is the gateway app, and enable a Conditional Access policy on the target; the CA-blocked call returns a 401 whose claims the client replays to Entra to step up, then retries.

Field-by-field cross-check against Microsoft Learn, plus a real-Entra probe

The request form and the challenge were checked field-by-field against the Microsoft OBO doc (v2-oauth2-on-behalf-of-flow) and the claims-challenge format doc (claims-challenge). The request form matches verbatim (grant_type=jwt-bearer, assertion, scope=.../.default, requested_token_use=on_behalf_of, client auth in the body or Basic; no subject_token/audience). The claims value is base 64 encoded exactly as the spec's own example, and the directive name is claims. The one correction the cross-check surfaced is folded into this PR: a claims challenge must use error=insufficient_claims (not the raw token-endpoint code), which is what MSAL-family clients key on.

To validate the failure contract against the live server without a tenant, a bogus jwt-bearer OBO POST was sent to the real https://login.microsoftonline.com/common/oauth2/v2.0/token:

POST /common/oauth2/v2.0/token  (grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer, requested_token_use=on_behalf_of, assertion=<bogus>, scope=api://target-api/.default)
-> HTTP 400  {"error":"invalid_request","error_codes":[50027],"error_description":"AADSTS50027: JWT token is invalid or malformed...","trace_id":"...","correlation_id":"..."}

Entra recognized the grant (it reached assertion validation rather than returning unsupported_grant_type), and its error body carries exactly the fields the parser reads (error, error_codes, and claims on a CA rejection), while error_description and the trace IDs are the internals we deliberately never forward. This does not exercise the happy path (which needs app registrations to mint a token) and is not a substitute for a full real-tenant run; it confirms the wire dialect and error contract against the live endpoint.

Follow-up (out of scope here)

For strict non-MCP MSAL clients, also emit authorization_uri (the tenant /authorize endpoint) alongside the RFC 9728 resource_metadata in the challenge. MCP clients discover the IdP via resource_metadata, so it is not needed for this gateway's clients; it is a compatibility add for raw MSAL tooling.


Note

Medium Risk
Changes OAuth token exchange and 401 challenge behavior on a security-sensitive MCP egress path, though defaults stay RFC 8693 and behavior is fail-closed with broad unit coverage.

Overview
Extends the v2 MCP token exchange (OBO) arm with a configurable wire dialect via token_exchange_profile (rfc8693 default, entra_obo for Microsoft Entra). The profile is persisted on MCPServer, config YAML, DB credentials JSON, and management API MCPCredentials, and maps into TokenExchangeConfig.profile. Unknown profile values normalize to rfc8693.

Rfc8693TokenExchanger is renamed OboTokenExchanger and builds either the RFC 8693 token-exchange form or Entra’s RFC 7523 jwt-bearer form (assertion, required scope, requested_token_use=on_behalf_of). Cache keys include profile; entra_obo without scope fails as misconfigured before any IdP POST.

When Entra rejects exchange with a step-up claims blob (Conditional Access), the HTTP provider reads error + claims (not error_description), surfaces them on SubjectTokenRejected / CredError.unauthorized, and raise_token_exchange_challenge emits error=insufficient_claims with base64 claims in WWW-Authenticate (unchanged static challenge when no claims). Preflight and credential resolution pass claims through to that challenge.

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

Summary by CodeRabbit

  • New Features

    • Added support for multiple token-exchange profiles, including standard token exchange and Microsoft Entra on-behalf-of.
    • Token-exchange challenges can now include claim details for step-up authentication flows.
  • Bug Fixes

    • Improved handling of authentication errors so claim-based challenges are returned correctly.
    • Preserved token-exchange profile settings when loading and saving server configuration.
  • Tests

    • Expanded coverage for token-exchange profiles, challenge headers, caching behavior, and step-up authentication responses.

@tin-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@greptile-apps

greptile-apps Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds an entra_obo profile to the token-exchange (OBO) arm so the gateway can act as a client to Microsoft Entra's On-Behalf-Of endpoint, which speaks RFC 7523 jwt-bearer rather than RFC 8693. It also threads Entra Conditional Access step-up claims blobs end-to-end into the WWW-Authenticate challenge.

  • TokenExchangeConfig gains a profile: Literal[\"rfc8693\", \"entra_obo\"] field; OboTokenExchanger (renamed from Rfc8693TokenExchanger) dispatches on it with an exhaustive match + assert_never, and the cache key folds in the profile so a dialect flip forces a fresh exchange instead of serving a stale token.
  • Entra Conditional Access step-up rejections (interaction_required + claims blob) are extracted from the IdP 4xx body, carried through SubjectTokenRejectedCredError.of_unauthorized(claims=...)raise_token_exchange_challenge(claims=...), where the claims are base64-encoded and emitted as a claims parameter in WWW-Authenticate alongside error=\"insufficient_claims\", exactly as MSAL-family clients expect.
  • MCPCredentials, MCPServer, and both load paths (YAML + DB credentials blob) all thread token_exchange_profile through so the profile survives the management-API round-trip.

Confidence Score: 5/5

Safe to merge; the entra_obo dialect is additive, rfc8693 behavior is unchanged, and the Conditional Access claims flow is well-guarded against header injection.

The profile dispatch is exhaustive (match + assert_never), the entra_obo form correctly omits subject_token/subject_token_type/audience, profile is part of the cache key so a dialect flip forces a fresh exchange, and claims from the IdP body are base64-encoded before they reach the WWW-Authenticate header. All load paths (YAML, DB, management API) thread token_exchange_profile correctly. Tests are mock-only, cover the exact form dict, cache-key isolation, and the step-up claims end-to-end path.

No files require special attention.

Important Files Changed

Filename Overview
litellm/proxy/_experimental/mcp_server/outbound_credentials/token_exchanger.py Core OBO exchanger renamed and extended with exhaustive profile dispatch, entra_obo precondition check, and claims threading through SubjectTokenRejected; cache key correctly includes profile.
litellm/proxy/_experimental/mcp_server/outbound_credentials/adapter.py Profile normalization (unknown → rfc8693), raise_token_exchange_challenge extended with claims base64-encoding and correct insufficient_claims error; header injection prevented by fixed alphabet.
litellm/proxy/_experimental/mcp_server/outbound_credentials/token_exchange_provider.py _oauth_error_fields extended to return (error, claims) tuple; SubjectTokenRejected now carries claims from IdP rejection body; gateway-fault errors correctly take precedence over claims.
litellm/proxy/_experimental/mcp_server/outbound_credentials/types.py Unauthorized gains claims field; CredError.of_unauthorized gains claims parameter; TokenExchangeConfig gains profile field with rfc8693 default; all backward-compatible.
litellm/types/mcp.py MCPCredentials gains token_exchange_profile: Optional[str] so the management API can persist the entra_obo profile without Pydantic silently dropping it.
litellm/proxy/_experimental/mcp_server/mcp_server_manager.py YAML and DB credential paths both thread token_exchange_profile; preflight and egress challenge paths forward claims from err.unauthorized.claims.
tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_token_exchanger.py Comprehensive mock-only tests: entra_obo form assertion (exact dict + absence of rfc8693 fields), scope precondition, profile as part of cache key, step-up claims threading; all Rfc8693TokenExchanger references updated to OboTokenExchanger.
tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_adapter.py Tests for profile mapping, unknown profile normalization, static challenge byte-identity, and insufficient_claims challenge format; all mock-only.
tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_token_exchange_provider.py Tests for step-up error+claims threading, gateway-fault priority over claims, and None claims on plain rejection; mock-only.
tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py DB round-trip test for entra_obo profile, preflight challenge with step-up claims, and aggregate list absorbing a CA-challenged server; all mock-only.
tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py Tests that NewMCPServerRequest and UpdateMCPServerRequest preserve token_exchange_profile in the credentials dict through pydantic validation.

Reviews (4): Last reviewed commit: "fix(mcp): use error=insufficient_claims ..." | Re-trigger Greptile

@codecov

codecov Bot commented Jul 2, 2026

Copy link
Copy Markdown

Codecov Report

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

Files with missing lines Patch % Lines
...er/outbound_credentials/token_exchange_provider.py 90.90% 1 Missing ⚠️
...mcp_server/outbound_credentials/token_exchanger.py 95.23% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@greptile-apps

This comment was marked as outdated.

Comment thread litellm/proxy/_experimental/mcp_server/mcp_server_manager.py
@tin-berri
tin-berri force-pushed the litellm_mcp_v2_entra_obo branch from dfe69f5 to 8b56f66 Compare July 3, 2026 17:19
@tin-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@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_endpoint_discovery branch 2 times, most recently from fcdd52d to c64b9c7 Compare July 3, 2026 18:53
@tin-berri
tin-berri force-pushed the litellm_mcp_v2_obo_endpoint_discovery branch 4 times, most recently from 329c4b6 to 9c72739 Compare July 4, 2026 00:14
Base automatically changed from litellm_mcp_v2_obo_endpoint_discovery to litellm_internal_staging July 4, 2026 01:57
Microsoft Entra On-Behalf-Of uses the RFC 7523 jwt-bearer grant rather than RFC 8693, so the existing token_exchange arm cannot mint tokens against Entra. This adds an entra_obo profile on TokenExchangeConfig that switches the request form to Entra's jwt-bearer OBO dialect (the inbound token as assertion, the target resource carried in scope, and requested_token_use=on_behalf_of), while reusing the shared caching, single-flight, TTL, and fail-closed machinery. The exchanger is renamed from Rfc8693TokenExchanger to OboTokenExchanger since it now serves both dialects

The profile is threaded through the config-load path, the DB credentials blob, and the MCPCredentials request schema, so an operator can select it from YAML or the management API. It rides in the existing credentials JSON blob, so there is no new auth_type and no DB migration

Resolves LIT-4163
@tin-berri
tin-berri force-pushed the litellm_mcp_v2_entra_obo branch from 8b56f66 to 2176477 Compare July 4, 2026 02:08
…n the OBO 401

An entra_obo exchange that the IdP rejects for Conditional Access returns a 4xx with
error=interaction_required and a claims blob the client must satisfy to step up. The arm
dropped both and emitted a static RFC 9728 challenge, so a CA-protected Entra upstream was
unreachable through the gateway. The provider now reads the RFC 6749 error code and the claims
string off the rejection body (error_description is still never carried; it can leak IdP
internals), threads them through SubjectTokenRejected -> CredError.unauthorized, and the
challenge builder folds them into WWW-Authenticate: the machine error only when it is a plain
OAuth token (guards against header injection from a hostile body) and the claims base64-encoded
in a claims parameter, the convention MSAL-family clients decode. With neither field the header
is byte-identical to the static challenge. The multi-server aggregate still absorbs a
step-up 401 to an empty listing; only single-server routes surface it
Per Microsoft's claims-challenge format, a WWW-Authenticate carrying a claims challenge must set
error=insufficient_claims (the value MSAL-family clients key on to recognize the challenge and
replay the claims), not the raw token-endpoint code. The challenge now sets insufficient_claims
whenever a claims blob is present and keeps invalid_token otherwise, with a step-up-accurate
error_description in the claims case. The presence of claims now drives the error value, so the
raw oauth_error no longer needs threading from the provider through CredError to the edge; that
plumbing is removed (the provider still reads the error code for its gateway-fault classification).
Both the error value and the base64 claims are fixed-alphabet, so nothing from the IdP body reaches
the header unescaped. Cross-checked field-by-field against Microsoft Learn; a bogus jwt-bearer OBO
POST to the real login.microsoftonline.com/common endpoint confirmed Entra recognizes the grant and
returns the error shape the parser reads. Follow-up: also emit authorization_uri alongside
resource_metadata for strict non-MCP MSAL clients (RFC 9728 resource_metadata already serves MCP)
@mateo-berri

Copy link
Copy Markdown
Contributor

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 4, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@mateo-berri

Copy link
Copy Markdown
Contributor

bugbot run

@mateo-berri

mateo-berri commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

note @tin-berri I'm just testing out coderabbit and comparing it with bugbot. Don't consider the coderabbit/bugbot comments blocking

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Want reviews to match your repository better? Bugbot Learning can learn team-specific rules from PR activity. A team admin can enable Learning in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 5df7203. Configure here.

# IdP will reject.
return Error(
CredError.of_misconfigured("entra_obo token exchange requires a scope (e.g. api://<app-id>/.default)")
)

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.

Whitespace scopes bypass entra guard

Medium Severity

For entra_obo, the preflight uses not config.scopes, so a non-empty scopes tuple that only contains blank strings (e.g. from YAML scopes: [""]) skips the misconfigured path and still POSTs to Entra with an empty or whitespace scope, instead of failing closed before any IdP call as documented.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5df7203. Configure here.

@coderabbitai

coderabbitai Bot commented Jul 4, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b8e2fbf8-bc77-4e12-8052-2cd95025317b

📥 Commits

Reviewing files that changed from the base of the PR and between c737789 and 5df7203.

📒 Files selected for processing (12)
  • litellm/proxy/_experimental/mcp_server/mcp_server_manager.py
  • litellm/proxy/_experimental/mcp_server/outbound_credentials/adapter.py
  • litellm/proxy/_experimental/mcp_server/outbound_credentials/token_exchange_provider.py
  • litellm/proxy/_experimental/mcp_server/outbound_credentials/token_exchanger.py
  • litellm/proxy/_experimental/mcp_server/outbound_credentials/types.py
  • litellm/types/mcp.py
  • litellm/types/mcp_server/mcp_server_manager.py
  • tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_adapter.py
  • tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_token_exchange_provider.py
  • tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_token_exchanger.py
  • tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py
  • tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py

📝 Walkthrough

Walkthrough

This PR adds multi-profile support (rfc8693, entra_obo) to MCP OAuth2 token exchange. Rfc8693TokenExchanger is renamed to OboTokenExchanger with Entra JWT-bearer grant support, cache keys now include profile, and IdP step-up claims propagate through unauthorized errors into WWW-Authenticate challenges.

Changes

Entra OBO Token Exchange Profile Support

Layer / File(s) Summary
Type and schema additions
litellm/types/mcp.py, litellm/types/mcp_server/mcp_server_manager.py, litellm/proxy/_experimental/mcp_server/outbound_credentials/types.py
Adds token_exchange_profile field (default "rfc8693") to MCPCredentials/MCPServer/TokenExchangeConfig, and claims field to Unauthorized/CredError.of_unauthorized.
OboTokenExchanger core
litellm/proxy/_experimental/mcp_server/outbound_credentials/token_exchanger.py
Renames Rfc8693TokenExchanger to OboTokenExchanger, adds _entra_obo_form (RFC 7523 jwt-bearer grant), profile-aware cache keys, entra_obo scope validation, and claims propagation on subject-token rejection.
Provider wiring and error parsing
litellm/proxy/_experimental/mcp_server/outbound_credentials/token_exchange_provider.py
Composition root now builds OboTokenExchanger; error parsing extracts both error code and claims from token endpoint 4xx responses, raising SubjectTokenRejected with claims.
Adapter spec building and challenge headers
litellm/proxy/_experimental/mcp_server/outbound_credentials/adapter.py
_token_exchange_spec normalizes profile; raise_token_exchange_challenge accepts optional claims to emit insufficient_claims WWW-Authenticate headers with base64-encoded claims.
Server manager defaults and forwarding
litellm/proxy/_experimental/mcp_server/mcp_server_manager.py
Defaults token_exchange_profile when building servers from config/DB; forwards err.unauthorized.claims into challenge calls in _resolve_v2_auth and preflight_token_exchange.
Tests
tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/*, tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py, tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py
Adds/updates tests for profile mapping, Entra OBO form generation, cache key separation, claims propagation, DB round-trip, and request model handling.

Estimated code review effort: 3 (Moderate) | ~30 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant MCPServerManager
  participant Adapter
  participant OboTokenExchanger
  participant IdP

  Client->>MCPServerManager: request tool call
  MCPServerManager->>Adapter: resolve credentials (v2)
  Adapter->>OboTokenExchanger: exchange(config, profile)
  OboTokenExchanger->>IdP: POST token endpoint (rfc8693/entra_obo form)
  IdP-->>OboTokenExchanger: 4xx + claims (step-up required)
  OboTokenExchanger-->>Adapter: CredError.of_unauthorized(claims)
  Adapter->>Adapter: raise_token_exchange_challenge(claims)
  Adapter-->>Client: 401 WWW-Authenticate insufficient_claims
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed It clearly summarizes the new entra_obo profile for the MCP token_exchange flow.
Description check ✅ Passed It covers the required sections and includes detailed scope, ticket, checklist, tests, proof, type, and changes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch litellm_mcp_v2_entra_obo

Comment @coderabbitai help to get the list of available commands.

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

Nonblocking but it would be a lot of assurance if you could check if the bugbot issue is legit or nbd

…ils closed

The DB-build path already runs YAML/DB scopes through _extract_scopes (which drops blanks), but the
config-load path read server_config["scopes"] raw. A YAML `scopes: [""]` therefore reached the
exchanger as a ("",) tuple: non-empty, so the entra_obo `not config.scopes` precondition skipped its
fail-closed misconfigured path and the form builder POSTed an empty scope to the IdP instead of
failing before any network call. Config-load now filters blanks the same way, so an all-blank list
normalizes to None and the precondition fails closed. Regression test loads a blank-scope entra_obo
server and asserts the exchange returns misconfigured without POSTing
@tin-berri
tin-berri merged commit 2e38da6 into litellm_internal_staging Jul 4, 2026
124 checks passed
@tin-berri
tin-berri deleted the litellm_mcp_v2_entra_obo branch July 4, 2026 23:48
EkkoG pushed a commit to EkkoG/litellm that referenced this pull request Jul 7, 2026
…riAI#31983)

* feat(mcp): add entra_obo profile to the token_exchange (OBO) arm

Microsoft Entra On-Behalf-Of uses the RFC 7523 jwt-bearer grant rather than RFC 8693, so the existing token_exchange arm cannot mint tokens against Entra. This adds an entra_obo profile on TokenExchangeConfig that switches the request form to Entra's jwt-bearer OBO dialect (the inbound token as assertion, the target resource carried in scope, and requested_token_use=on_behalf_of), while reusing the shared caching, single-flight, TTL, and fail-closed machinery. The exchanger is renamed from Rfc8693TokenExchanger to OboTokenExchanger since it now serves both dialects

The profile is threaded through the config-load path, the DB credentials blob, and the MCPCredentials request schema, so an operator can select it from YAML or the management API. It rides in the existing credentials JSON blob, so there is no new auth_type and no DB migration

Resolves LIT-4163

* feat(mcp): propagate the Entra Conditional Access step-up challenge on the OBO 401

An entra_obo exchange that the IdP rejects for Conditional Access returns a 4xx with
error=interaction_required and a claims blob the client must satisfy to step up. The arm
dropped both and emitted a static RFC 9728 challenge, so a CA-protected Entra upstream was
unreachable through the gateway. The provider now reads the RFC 6749 error code and the claims
string off the rejection body (error_description is still never carried; it can leak IdP
internals), threads them through SubjectTokenRejected -> CredError.unauthorized, and the
challenge builder folds them into WWW-Authenticate: the machine error only when it is a plain
OAuth token (guards against header injection from a hostile body) and the claims base64-encoded
in a claims parameter, the convention MSAL-family clients decode. With neither field the header
is byte-identical to the static challenge. The multi-server aggregate still absorbs a
step-up 401 to an empty listing; only single-server routes surface it

* fix(mcp): use error=insufficient_claims for the Entra step-up challenge

Per Microsoft's claims-challenge format, a WWW-Authenticate carrying a claims challenge must set
error=insufficient_claims (the value MSAL-family clients key on to recognize the challenge and
replay the claims), not the raw token-endpoint code. The challenge now sets insufficient_claims
whenever a claims blob is present and keeps invalid_token otherwise, with a step-up-accurate
error_description in the claims case. The presence of claims now drives the error value, so the
raw oauth_error no longer needs threading from the provider through CredError to the edge; that
plumbing is removed (the provider still reads the error code for its gateway-fault classification).
Both the error value and the base64 claims are fixed-alphabet, so nothing from the IdP body reaches
the header unescaped. Cross-checked field-by-field against Microsoft Learn; a bogus jwt-bearer OBO
POST to the real login.microsoftonline.com/common endpoint confirmed Entra recognizes the grant and
returns the error shape the parser reads. Follow-up: also emit authorization_uri alongside
resource_metadata for strict non-MCP MSAL clients (RFC 9728 resource_metadata already serves MCP)

* fix(mcp): filter blank scopes on the config-load path so entra_obo fails closed

The DB-build path already runs YAML/DB scopes through _extract_scopes (which drops blanks), but the
config-load path read server_config["scopes"] raw. A YAML `scopes: [""]` therefore reached the
exchanger as a ("",) tuple: non-empty, so the entra_obo `not config.scopes` precondition skipped its
fail-closed misconfigured path and the form builder POSTed an empty scope to the IdP instead of
failing before any network call. Config-load now filters blanks the same way, so an all-blank list
normalizes to None and the precondition fails closed. Regression test loads a blank-scope entra_obo
server and asserts the exchange returns misconfigured without POSTing
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