Skip to content

fix(mcp): stop persisting the DCR client onto true_passthrough and oauth_delegate server rows - #32735

Merged
tin-berri merged 2 commits into
litellm_internal_stagingfrom
litellm_mcp_no_dcr_persist_for_passthrough
Jul 10, 2026
Merged

fix(mcp): stop persisting the DCR client onto true_passthrough and oauth_delegate server rows#32735
tin-berri merged 2 commits into
litellm_internal_stagingfrom
litellm_mcp_no_dcr_persist_for_passthrough

Conversation

@tin-berri

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

Copy link
Copy Markdown
Contributor

Relevant issues

Linear ticket

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 received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review (Greptile reviews automatically once the PR is opened; only comment @greptileai to re-request a review after pushing changes)

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 real Postgres, running real Dynamic Client Registration against Linear's authorization server (https://mcp.linear.app); no mocks.

Before (bug present), captured at base commit 2a12707. Create a true_passthrough server, then run the admin Authorize register call against it:

$ curl -s -X POST http://localhost:4000/v1/mcp/server \
    -H 'x-litellm-api-key: sk-1234' -H 'Content-Type: application/json' \
    -d '{"server_name":"linear_pt_repro","alias":"linear_pt_repro","url":"https://mcp.linear.app/mcp","transport":"http","auth_type":"true_passthrough"}'
{"server_id": "702d4d1b-931a-41a0-a2b3-db618093ed6f", "auth_type": "true_passthrough", "oauth2_flow": null, ...}

$ curl -s -X POST 'http://localhost:4000/v1/mcp/server/oauth/702d4d1b-931a-41a0-a2b3-db618093ed6f/register' \
    -H 'x-litellm-api-key: sk-1234' -H 'Content-Type: application/json' \
    -d '{"client_name":"LiteLLM Admin UI","grant_types":["authorization_code","refresh_token"],"response_types":["code"],"token_endpoint_auth_method":"none"}'
{"client_id": "6I--QP8L8TiO5R1-...", "redirect_uris": ["http://localhost:4000/callback"], "token_endpoint_auth_method": "none"}

$ psql -d litellm_dcr_repro -x -c "SELECT auth_type, oauth2_flow, updated_by, credentials ? 'client_id' AS client_id_persisted FROM \"LiteLLM_MCPServerTable\" WHERE server_id='702d4d1b-931a-41a0-a2b3-db618093ed6f';"
auth_type           | true_passthrough
oauth2_flow         | authorization_code      <-- stamped onto a pass-through server
updated_by          | mcp_oauth_dcr           <-- gateway wrote to the row
client_id_persisted | t                       <-- Linear-minted OAuth client persisted server-side

After (fix applied), captured at 35d7231. Identical flow against a fresh true_passthrough server:

$ curl -s -X POST 'http://localhost:4000/v1/mcp/server/oauth/3f1af13d-c80a-4ac0-a648-3c69d149adc4/register' \
    -H 'x-litellm-api-key: sk-1234' -H 'Content-Type: application/json' \
    -d '{"client_name":"LiteLLM Admin UI","grant_types":["authorization_code","refresh_token"],"response_types":["code"],"token_endpoint_auth_method":"none"}'
register response client_id present: True     <-- browser still receives the upstream registration

$ psql -d litellm_dcr_repro -c "SELECT alias, oauth2_flow, updated_by, credentials::text FROM \"LiteLLM_MCPServerTable\" ORDER BY created_at;"
 linear_pt_repro  | authorization_code | mcp_oauth_dcr   | {"client_id": "..."}   <- before, at 2a12707372
 linear_pt_verify |                    | default_user_id | {}                     <- after, at 35d723197e: row untouched

Control (fix applied, same commit 35d7231). A genuine oauth2 server still persists, so silent refresh keeps working for the interactive mode:

$ curl -s -X POST 'http://localhost:4000/v1/mcp/server/oauth/9f68b009-efca-42a5-b744-afb1d1a35a74/register' ... (same register body)
$ psql -d litellm_dcr_repro -x -c "SELECT alias, auth_type, oauth2_flow, updated_by, credentials ? 'client_id' AS client_id_persisted FROM \"LiteLLM_MCPServerTable\" WHERE server_id='9f68b009-efca-42a5-b744-afb1d1a35a74';"
alias               | linear_oauth2_control
auth_type           | oauth2
oauth2_flow         | authorization_code
updated_by          | mcp_oauth_dcr
client_id_persisted | t

Type

🐛 Bug Fix

Changes

The admin dashboard's browser-only Authorize flow calls POST /server/oauth/{server_id}/register, which forwards persist_credentials=_user_is_full_admin(...) into register_client_with_server. _persist_dcr_client_registration had no auth_type gate, so for a full admin it wrote the upstream-minted DCR client_id onto the server row and stamped oauth2_flow="authorization_code" (with updated_by="mcp_oauth_dcr") even for true_passthrough and oauth_delegate servers. Those modes promise the gateway holds no server-side OAuth state (the caller owns the upstream token; _raise_if_not_oauth2's docstring documents exactly this), so the stamp corrupts the row: a pass-through server acquires a gateway-held OAuth client identity, reuses it across later authorizes, and reads as half-oauth2 to anything that inspects oauth2_flow

The fix is an early return in _persist_dcr_client_registration for is_true_passthrough / is_oauth_delegate, returning a new "skipped" result. The registration response is still relayed to the browser, so the Authorize UX is unchanged; the gateway just no longer records the client. Placing the gate inside the persist function (rather than at the management call site) covers any future caller that passes persist_credentials=True. The _raise_if_not_oauth2 docstring is updated to state where the invariant is enforced

Regression tests mirror the existing test_token_exchange_does_not_persist_for_client_forwarded_modes pattern: a parametrized pair asserting no update_mcp_server call for true_passthrough / oauth_delegate (both fail without the fix), plus an oauth2 discriminator asserting persistence still happens so the negative tests cannot pass vacuously

A second commit (1e0cee5) pins the broader contract this bug sat inside: credentials bind to the server entry they were established for and never travel between server_ids that share an upstream URL. Three seams get an isolation test, one per credential kind: the v2 resolver's authorization_code arm plus its has_user_token discovery check (a token stored for server A resolves for A and is unauthorized for same-URL server B), the DCR persist/reuse path (a fresh server mints and persists its own client even when a same-URL sibling row already holds one), and the M2M client_credentials cache (identical config under two server_ids yields two tokens, two IdP POSTs). Each was mutation-checked by temporarily re-keying the corresponding lookup to the URL; every mutation fails exactly its test


Note

Medium Risk
Touches MCP OAuth registration persistence and documents credential scoping; behavior change is narrow but affects how pass-through server rows are interpreted and how admin Authorize interacts with DCR.

Overview
Stops the admin Authorize DCR path from writing OAuth client state onto true_passthrough / oauth_delegate server rows. _persist_dcr_client_registration now returns "skipped" for those modes, so persist_credentials=True no longer stamps oauth2_flow and client_id on rows that are supposed to keep no gateway-held OAuth identity. The upstream registration response is still returned to the browser unchanged.

Adds regression coverage that pass-through modes never call update_mcp_server, that real oauth2 servers still persist, and that DCR mint/reuse stays keyed by server_id when two servers share the same upstream URL.

Pins isolation by server_id (not URL) with new tests: per-user authorization_code tokens and has_user_token, DCR persist on the correct row, and M2M client_credentials token cache entries do not leak across distinct server_ids with identical config.

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

@greptile-apps

greptile-apps Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes _persist_dcr_client_registration in discoverable_endpoints.py to skip writing the DCR client_id (and oauth2_flow) onto the DB row for true_passthrough and oauth_delegate servers. Before this fix, the admin Authorize flow would corrupt these server rows — stamping gateway-held OAuth client state onto servers whose contract is that the gateway holds nothing. The upstream DCR response is still relayed to the browser unchanged.

  • Production fix: A two-line early return placed inside the persist function covers any present or future caller regardless of how persist_credentials is set at the call site. The new \"skipped\" literal is added to DcrRegistrationPersistenceResult.
  • Regression tests: Parametrized tests assert no DB write for both passthrough modes; a discriminator test confirms oauth2 servers still persist. A third test verifies DCR client identity is never inherited by a server row sharing the same upstream URL.
  • Isolation contract tests: Three credential seams are mutation-tested — the authorization code resolver, the DCR reuse/persist path, and the M2M token cache — each keyed by server_id, never by upstream URL.

Confidence Score: 5/5

Safe to merge. The change is a two-line early return in an isolated helper; it cannot affect the OAuth2 persist path, and the discriminator test guarantees persistence still works for oauth2 servers.

The production change is minimal and correctly scoped — one early return inside _persist_dcr_client_registration, backed by three levels of regression test. The PR description includes live Postgres proof-of-fix. No unrelated code paths are touched.

No files require special attention.

Important Files Changed

Filename Overview
litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py Adds early-return guard in _persist_dcr_client_registration for true_passthrough/oauth_delegate auth types, updates _raise_if_not_oauth2 docstring to document where the invariant is now enforced.
tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py Adds parametrized regression tests for both passthrough modes asserting no DB write occurs, a discriminator test verifying oauth2 still persists, and a server-id isolation test verifying DCR clients are never adopted from sibling rows.
tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_resolver.py Adds test verifying both resolve_credentials and has_user_token are keyed by server_id rather than upstream URL.
tests/test_litellm/proxy/_experimental/mcp_server/test_oauth2_token_cache.py Adds test verifying the M2M token cache is keyed by server_id so two servers with identical client_credentials config but different IDs each mint their own token.

Reviews (3): Last reviewed commit: "test(mcp): pin credential isolation acro..." | Re-trigger Greptile

@codecov

codecov Bot commented Jul 10, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@tin-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@codspeed-hq

codspeed-hq Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_mcp_no_dcr_persist_for_passthrough (a786ba9) with litellm_internal_staging (bf02a4a)

Open in CodSpeed

@tin-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@tin-berri

Copy link
Copy Markdown
Contributor Author

@greptileai rereview

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

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

Reviewed by Cursor Bugbot for commit a786ba9. Configure here.

@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 11aeeea into litellm_internal_staging Jul 10, 2026
132 checks passed
@tin-berri
tin-berri deleted the litellm_mcp_no_dcr_persist_for_passthrough branch July 10, 2026 18:03
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