Skip to content

feat(mcp): let users select the entra_obo token_exchange profile in the UI and API - #32144

Merged
tin-berri merged 3 commits into
litellm_internal_stagingfrom
litellm_mcp_token_exchange_profile_ui
Jul 7, 2026
Merged

feat(mcp): let users select the entra_obo token_exchange profile in the UI and API#32144
tin-berri merged 3 commits into
litellm_internal_stagingfrom
litellm_mcp_token_exchange_profile_ui

Conversation

@tin-berri

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

Copy link
Copy Markdown
Contributor

Relevant issues

Stacked on #31772 (the token-exchange config UI/API PR); its base is that branch and it retargets down the stack as the parents merge. This completes the frontend/API parity for the entra_obo backend added in #31983 (LIT-4163): that PR taught the token-exchange arm Microsoft Entra's On-Behalf-Of dialect, but the profile could only be set through config.yaml

Linear ticket

Complements LIT-4163

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have added meaningful tests
  • My PR passes all CI/CD checks (e.g., lint, format, unit tests)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have requested a Greptile review by commenting @greptileai and received a Confidence Score of at least 4/5 before requesting a maintainer review

Delays in PR merge?

If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).

Screenshots / Proof of Fix

Live proxy on localhost:4000 backed by Postgres (master key sk-1234). Create an entra_obo server and an rfc8693 (default) server through the API, then read them back and inspect the DB column

curl -s -X POST http://localhost:4000/v1/mcp/server -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" \
  -d '{"server_name":"entra_demo","url":"https://graph.microsoft.com/mcp","transport":"http","auth_type":"oauth2_token_exchange","token_exchange_profile":"entra_obo","token_exchange_endpoint":"https://login.microsoftonline.com/tenant/oauth2/v2.0/token","credentials":{"client_id":"entra-app-id","client_secret":"entra-secret","scopes":["api://graph/.default"]}}'
# [201]

curl -s -X POST http://localhost:4000/v1/mcp/server -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" \
  -d '{"server_name":"rfc_demo","url":"https://up.example.com/mcp","transport":"http","auth_type":"oauth2_token_exchange","token_exchange_endpoint":"https://idp.example.com/oauth2/token","credentials":{"client_id":"cid","client_secret":"sec"}}'
# [201]

The DB column holds the selected dialect for the entra server and stays null for the default, and the read path fills in rfc8693 for the null case

SELECT server_name, auth_type, token_exchange_profile FROM "LiteLLM_MCPServerTable" WHERE server_name IN ('entra_demo','rfc_demo');
 server_name |       auth_type       | token_exchange_profile
-------------+-----------------------+------------------------
 entra_demo  | oauth2_token_exchange | entra_obo
 rfc_demo    | oauth2_token_exchange |

GET /v1/mcp/server
 entra_demo -> token_exchange_profile = 'entra_obo'
 rfc_demo   -> token_exchange_profile = 'rfc8693'

Dashboard: at http://localhost:4000/ui/?page=mcp-servers, Add New MCP Server, pick Streamable HTTP, choose auth type "OAuth Token Exchange (OBO)", then set Profile to "Microsoft Entra OBO". The Scope field becomes required (hinting api:///.default) and Audience/Subject Token Type hide since that dialect ignores them; fill Client ID/Secret and a scope, then create

Dashboard Profile selector (before / after)

Rendered from the real create-form components on the parent branch head ff6dc33291 (before) vs this PR's head de8debc750 (after), same steps each time: Add New MCP Server, Streamable HTTP, auth type "OAuth Token Exchange (OBO)"

Before (parent #31772): the token-exchange section has no dialect control, so a dashboard user can only ever create an rfc8693 server

before: token-exchange section with no Profile control

After: a Profile dropdown appears, defaulting to "RFC 8693 (standard)" with the standard Audience, Subject Token Type and optional Scopes fields intact

after: Profile dropdown defaulting to RFC 8693, standard fields intact

The dropdown offers the two wire dialects

after: Profile dropdown open showing RFC 8693 and Microsoft Entra OBO

After, Microsoft Entra OBO selected: Audience and Subject Token Type disappear (that dialect ignores them) and Scopes flips to required with the api://<app-id>/.default hint

after: Microsoft Entra OBO selected, Audience/Subject Token Type hidden, Scopes required

Type

🆕 New Feature

Changes

The token-exchange arm supports two wire dialects through token_exchange_profile: rfc8693 (the default standard token-exchange grant) and entra_obo (Microsoft Entra On-Behalf-Of, the RFC 7523 jwt-bearer grant with requested_token_use=on_behalf_of). The backend already reads it from config.yaml and the credentials blob, but the create/update REST API and the dashboard had no way to set it, so dashboard and API users could only ever create rfc8693 servers. This surfaces the selector, completing the parity the parent PR started for the other token-exchange fields

token_exchange_profile becomes a dedicated column on LiteLLM_MCPServerTable, mirroring the sibling fields exactly. It is added to NewMCPServerRequest/UpdateMCPServerRequest, read column-first in build_mcp_server_from_table with the credentials blob as a back-compat fallback and a default of rfc8693, and carried through both runtime-to-table builders so a registry round-trip preserves it. Although it is a non-secret dialect selector, it is scrubbed from non-admin and virtual-key responses like every other token-exchange setting: those views receive no token-exchange config at all, matching the uniformity rule #31772 settled on for subject_token_type

On the dashboard a Profile dropdown is added to the token-exchange section. Selecting Microsoft Entra OBO makes the scope required, since that dialect carries the target resource in the scope and the exchanger fails closed without one, and hides Audience and Subject Token Type since the Entra dialect ignores them

Tests cover the profile column read, the credentials-blob fallback, the rfc8693 default, the table round-trip, the REST create and partial-update write paths carrying token_exchange_profile=entra_obo, and the dashboard making the scope required when Entra OBO is selected

Storage contract: token-exchange settings (blob → columns)

token_exchange_profile follows the storage contract #31772 establishes for the other token-exchange settings: the dedicated column is authoritative, and the same key inside credentials is the legacy pre-column shape — still accepted, but lifted into the column on write and stripped from the stored blob (an explicit top-level value, including an explicit null, always wins). The column or blob read fallback therefore only serves untouched pre-column rows. A null column means the rfc8693 default, applied at the egress build site — the backend never persists the default on its own (REST creates that omit the field leave the column null). The dashboard, by contrast, persists the admin's visible selection, which may equal the default: the create form preselects "RFC 8693 (standard)", so dashboard creates stamp rfc8693 explicitly. Stamped-rfc8693 and null are behaviorally identical at every read site, and the write path must support persisting rfc8693 regardless, since editing an entra_obo server back to the standard dialect has to write something. The lift for token_exchange_profile is implemented in this PR's second commit, together with restricted-view scrubbing (both sanitizers) and auth-switch clearing (_AUTH_FLOW_SCOPED_FIELDS + the edit form's payload nulling).


Note

Medium Risk
Changes OAuth token-exchange configuration persistence and runtime spec building (dialect selection), but follows established column/lift patterns and is covered by broad tests including auth-switch and sanitization paths.

Overview
Adds token_exchange_profile as a first-class MCP server setting so operators can choose rfc8693 vs entra_obo through the REST API and dashboard, not only config.yaml or the legacy credentials blob.

A new LiteLLM_MCPServerTable.token_exchange_profile column (with migration) is wired through request/response models, DB prepare/lift/strip logic (same contract as other token-exchange columns), build_mcp_server_from_table (column → blob fallback → default rfc8693), and table round-trips. Auth-type switches clear the field; restricted API views scrub it like other token-exchange settings.

The dashboard Profile control on the token-exchange form defaults to RFC 8693; Microsoft Entra OBO hides Audience/Subject Token Type and requires scopes. Tests cover create/update, blob migration, config load, and UI validation.

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

Link to Devin session: https://app.devin.ai/sessions/f03da2725ec94d28b3facf766871b102

@tin-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@codecov

codecov Bot commented Jul 4, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@greptile-apps

greptile-apps Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR surfaces the token_exchange_profile dialect selector (rfc8693 vs entra_obo) across the full stack — a dedicated nullable DB column, REST create/update fields, column-first reads with blob and rfc8693 defaults, sanitizer updates, and a Profile dropdown in the token-exchange section of the dashboard form.

  • Backend: Migration adds token_exchange_profile TEXT with IF NOT EXISTS; db.py adds it to _AUTH_FLOW_SCOPED_FIELDS (cleared on auth-type switch) and _TOKEN_EXCHANGE_COLUMN_FIELDS (lifted from blob on write); build_mcp_server_from_table now reads column → blob → \"rfc8693\" default, consistent with the other token-exchange fields.
  • Frontend: A new Profile dropdown in TokenExchangeFormFields defaults to RFC 8693, conditionally hides Audience/Subject Token Type for Entra OBO, and makes Scopes required for that dialect; the edit form's auth-type-switch clearing payload now also nulls token_exchange_profile; tests cover both the required-scope gate and the default stamping.

Confidence Score: 4/5

The backend changes are additive and well-tested; the main risk is in the edit-mode scope validation in the dashboard form.

The Profile selector's scope validation uses required: true unconditionally when isEntraObo is active, with no isEditing guard. Because the server-side sanitizers redact the credentials blob before returning it to the UI, an admin editing any field on an existing entra_obo server (even a rename) cannot save: the credentials.scopes field arrives empty and immediately fails the required-field check, leaving the server uneditable through the dashboard until scopes are manually re-entered.

ui/litellm-dashboard/src/components/mcp_tools/TokenExchangeFormFields.tsx — the scope required rule has no edit-mode guard.

Important Files Changed

Filename Overview
litellm-proxy-extras/litellm_proxy_extras/migrations/20260703120000_add_token_exchange_profile_to_mcp_servers/migration.sql Adds nullable token_exchange_profile column to LiteLLM_MCPServerTable with IF NOT EXISTS guard — additive and safe.
litellm/proxy/_experimental/mcp_server/mcp_server_manager.py Adds column-first read (then blob fallback, then rfc8693 default) for token_exchange_profile in build_mcp_server_from_table, and carries it through both _build_mcp_server_table builders.
litellm/proxy/_experimental/mcp_server/db.py Adds token_exchange_profile to _AUTH_FLOW_SCOPED_FIELDS (cleared on auth-type switch) and _TOKEN_EXCHANGE_COLUMN_FIELDS (lifted out of blob on write) — consistent with sibling fields.
litellm/proxy/management_endpoints/mcp_management_endpoints.py Both non-admin and virtual-key sanitizers now also null out token_exchange_profile; changes are minimal and consistent.
ui/litellm-dashboard/src/components/mcp_tools/TokenExchangeFormFields.tsx Adds a Profile dropdown; Entra OBO selection hides Audience/Subject Token Type and makes Scopes required — but required: true on scopes has no isEditing guard, blocking edits to existing entra_obo servers.
tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py Adds four focused tests for column read, default, blob fallback, and round-trip for token_exchange_profile; also adds a config-loading test. All new tests, no existing assertions weakened.

Reviews (4): Last reviewed commit: "test(mcp): assert every token-exchange s..." | Re-trigger Greptile

@greptile-apps

This comment was marked as outdated.

@tin-berri
tin-berri force-pushed the litellm_mcp_token_exchange_ui branch 3 times, most recently from 149784a to 65a4708 Compare July 7, 2026 19:08
Base automatically changed from litellm_mcp_token_exchange_ui to litellm_internal_staging July 7, 2026 22:26
tin-berri and others added 2 commits July 7, 2026 15:28
…he UI and API

The backend token_exchange arm supports two wire dialects via token_exchange_profile
("rfc8693" default, or "entra_obo" for Microsoft Entra's On-Behalf-Of, the RFC 7523
jwt-bearer grant), but it could only be set through config.yaml. This surfaces it to the
create/update REST API and the dashboard so an admin can create an entra_obo server there,
completing the parity started in the parent PR for the other token-exchange fields.

token_exchange_profile becomes a dedicated column on LiteLLM_MCPServerTable, mirroring the
sibling fields: it is added to the request models, read column-first in
build_mcp_server_from_table with the credentials-blob as a back-compat fallback and a
default of rfc8693, and carried through both runtime-to-table builders so registry
round-trips preserve it. It is a non-secret dialect selector, so it is not scrubbed from
non-admin or virtual-key responses.

In the dashboard a Profile dropdown (RFC 8693 vs Microsoft Entra OBO) is added to the
token-exchange section. Entra OBO carries the target resource in the scope, so selecting it
makes the scope required and hints the api://<app-id>/.default form, while audience and
subject_token_type (which that dialect ignores) are hidden.
…oken_exchange_profile

token_exchange_profile gets the same storage contract as the other three
token-exchange settings: the column is authoritative, a blob copy is the legacy
shape — lifted into the column on every write and stripped from the stored
blob — and switching auth_type away from token exchange clears it
(_AUTH_FLOW_SCOPED_FIELDS). Both restricted-view sanitizers scrub it for
uniformity, and the edit form's auth-switch payload nulling includes it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@tin-berri
tin-berri force-pushed the litellm_mcp_token_exchange_profile_ui branch from 0a03da8 to de8debc Compare July 7, 2026 22:34
@codspeed-hq

codspeed-hq Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 30 untouched benchmarks


Comparing litellm_mcp_token_exchange_profile_ui (c77fffd) with litellm_internal_staging (9652509)

Open in CodSpeed

@tin-berri

Copy link
Copy Markdown
Contributor Author

@greptileai rereview

@tin-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

/>
}
name="token_exchange_profile"
{...(isEditing ? {} : { initialValue: "rfc8693" })}

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.

Create form writes default profile

Low Severity

The create flow sets initialValue: "rfc8693" on token_exchange_profile, so dashboard creates POST rfc8693 into the dedicated column. The PR storage contract says a null column means the rfc8693 default at egress only and that default must never be written to the database.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit de8debc. Configure here.

@tin-berri

Copy link
Copy Markdown
Contributor Author

Create form writes default profile (Low) — intentional; the storage-contract wording in the description was imprecise and has been fixed. The "never written to the DB" clause is about the backend: the egress default-application never persists anything, and REST creates omitting the field leave the column null. The dashboard persists the admin's visible selection — the create form preselects "RFC 8693 (standard)", so submitting stamps it explicitly, consistent with how the oauth2_flow selector persists shown choices. Stamped-rfc8693 and null are behaviorally identical at every read site (egress column or blob or "rfc8693", and GET fills rfc8693 for null — see the live proof above). Suppressing default-equal writes isn't coherent anyway: editing an entra_obo server back to the standard dialect must persist rfc8693, so the write path supports it regardless.

…nfig.yaml

Pins the config surface: token_exchange_endpoint, audience, subject_token_type
and token_exchange_profile load from top-level config keys onto the built
server and through to the resolver spec; omitted keys resolve to their
documented defaults (RFC 8693 subject token type, rfc8693 profile), and
token_exchange servers need no oauth2_flow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@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 only discrepancy is between the main PR description prose and the actual scrubbing implementation, which is a documentation error rather than a code defect.

The PR description's main prose claims token_exchange_profile is left visible in non-admin and virtual-key responses, but mcp_management_endpoints.py actually scrubs it in both sanitizers — worth clarifying which behavior is intended before the description is used as a reference.

Worth clarifying here?

@mateo-berri

Copy link
Copy Markdown
Contributor

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

1 issue from previous review remains unresolved.

Fix All in Cursor

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

Reviewed by Cursor Bugbot for commit c77fffd. Configure here.

@tin-berri

Copy link
Copy Markdown
Contributor Author

Description/code discrepancy on token_exchange_profile scrubbing — good catch; the code is what's intended and the prose was stale. The original commit deliberately left the profile visible (non-secret dialect selector); the follow-up lift commit changed that decision to the uniformity rule #31772 settled on for subject_token_type — restricted views receive no token-exchange config at all, sensitive or not — and scrubs it in both sanitizers with test assertions. The description's storage-contract section already said so; the older sentence in the Changes prose has now been corrected to match.

@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 db24027 into litellm_internal_staging Jul 7, 2026
133 checks passed
@tin-berri
tin-berri deleted the litellm_mcp_token_exchange_profile_ui branch July 7, 2026 23:39
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