Skip to content

fix(mcp): re-register DCR client when proxy origin no longer matches its registered redirect_uri - #32527

Merged
tin-berri merged 2 commits into
litellm_internal_stagingfrom
litellm_gh32473_dcr_redirect_uri
Jul 11, 2026
Merged

fix(mcp): re-register DCR client when proxy origin no longer matches its registered redirect_uri#32527
tin-berri merged 2 commits into
litellm_internal_stagingfrom
litellm_gh32473_dcr_redirect_uri

Conversation

@tin-berri

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

Copy link
Copy Markdown
Contributor

Behavior after this PR

1. Existing MCP server (DCR client persisted before this PR, so the row has no recorded redirect_uris)

  • Reused exactly as today, even if the proxy origin has changed: a missing recording is treated as a match, so upgrading never re-mints client_ids or orphans users' refresh tokens
  • A server already stranded by a past origin change stays on its old client (there is no recording to detect the mismatch against); recovery for those rows needs one manual admin step, see "Rollout ordering and recovery" below

2. Freshly created server (first connect happens on this build)

  • DCR runs as before; the persisted credentials now additionally record the redirect_uris the client was registered with
  • No behavior change while the proxy origin stays the same

3. Recorded server after the proxy origin changes (the case this PR fixes)

  • The next admin register call detects that the recording does not cover the current {origin}/callback, registers a replacement client upstream and persists it, logging a warning that users of this server must re-authenticate once
  • Authorize works again at the new origin instead of failing forever with "The redirect_uri parameter is invalid"

4. Admin-configured client_id, and non-admin or public register callers

  • Configured clients are never re-registered (they have no recording, so they always match); non-persisting callers keep today's reuse behavior even when the recording mismatches

Rollout ordering and recovery

This PR and #32921 fix two different triggers of the same IdP-side rejection. This PR covers the callback the proxy sends changing out from under a registered client: renaming the host, moving TLS termination, or setting PROXY_BASE_URL for the first time. #32921 covers the proxy sending a non-canonical https://host:443/callback that literal-matching IdPs reject against a canonically registered URI

Land this PR before #32921. To a literal-matching IdP, the port strip in #32921 is itself an origin change: every DCR client registered under the :443 form stops matching the callback the proxy sends afterwards. With this PR in first, rows registered from then on carry a recording, so that transition reads as a positive mismatch and heals on the next admin register. In the reverse order those clients would strand with no recording and no self-heal path

Two populations still need one manual admin step: rows stranded before this PR shipped, and unrecorded rows that cross the #32921 transition on deployments whose ingress sends X-Forwarded-Port: 443 without PROXY_BASE_URL set. Neither has a recording, so the mismatch check deliberately stays silent for them. The admin can delete and recreate the server, or more gently edit it, switch the auth type away from oauth2, save, then switch back and save again; update_mcp_server replaces the credentials blob whenever the auth type changes, so the stale client_id is dropped while the server_id, access groups and remaining settings survive. The next admin register then mints a fresh client under the current origin and records the binding. Users of that server re-authenticate once either way, since their refresh tokens were bound to the replaced client

Setting PROXY_BASE_URL stays the recommended configuration: it pins the resolved origin, which both avoids the :443 reconstruction that #32921 patches and prevents per-request origin variance from tripping the mismatch check after this PR

Relevant issues

Closes #32473 (reported by @katzdave)

Related: #32921 (non-canonical :443 in redirect_uri); land this PR first, see the rollout section above

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)

Screenshots / Proof of Fix

Rig: a strict stub IdP plus MCP resource server on localhost:9500 (RFC 9728/8414 discovery, RFC 7591 /register that records each client's redirect_uris, /authorize that returns the standard 400 on a redirect mismatch exactly like a strict production IdP) and the proxy on localhost:4100 backed by Postgres. The origin change is simulated by restarting the proxy with a different PROXY_BASE_URL, which is the production trigger described in the issue

Before the fix: the server is permanently stranded

Proxy started with PROXY_BASE_URL=http://localhost:4100, MCP server created pointing at the stub, then the admin register endpoint mints and persists the DCR client

$ curl -sS -X POST http://localhost:4100/v1/mcp/server/oauth/stub-dcr-gh32473/register \
    -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" \
    -d '{"client_name":"litellm-gateway","grant_types":["authorization_code","refresh_token"],"response_types":["code"],"token_endpoint_auth_method":"none"}'
{"client_id":"client-1","redirect_uris":["http://localhost:4100/callback"],"token_endpoint_auth_method":"none"}

$ curl -sS http://localhost:9500/registrations
{"client-1":{"client_id":"client-1","client_name":"litellm-gateway","redirect_uris":["http://localhost:4100/callback"]}}

Authorize works at this origin (the IdP 302s back with a code). Now the proxy is restarted with PROXY_BASE_URL=https://litellm.example.com. Register becomes a silent no-op (the persisted client short-circuits it, nothing reaches the IdP) and authorize builds the poison pair of old client and new callback

$ curl -sS -X POST http://localhost:4100/v1/mcp/server/oauth/stub-dcr-gh32473/register ... (same body)
{"client_id":"stub-dcr-gh32473","client_secret":"dummy","redirect_uris":["https://litellm.example.com/callback"]}

$ echo "$LOC"    # Location captured from GET /v1/mcp/server/oauth/stub-dcr-gh32473/authorize
http://localhost:9500/authorize?client_id=client-1&redirect_uri=https%3A%2F%2Flitellm.example.com%2Fcallback&...

$ curl -sS -i "$LOC"
HTTP/1.1 400 Bad Request
{"error":"invalid_request","error_description":"The redirect_uri parameter is invalid"}

The stub logs AUTHORIZE REJECTED client-1: redirect_uri='https://litellm.example.com/callback' not in registered ['http://localhost:4100/callback']. Every subsequent connect fails the same way and nothing ever re-registers

After the fix: grandfathering holds for pre-existing rows

On the fixed build, the same register call against the row persisted by the old code (which has no redirect_uris recording) still reuses the existing client and sends nothing to the IdP, so upgrading does not re-mint client_ids or orphan refresh tokens for existing installs

$ curl -sS -X POST http://localhost:4100/v1/mcp/server/oauth/stub-dcr-gh32473/register ... (same body)
{"client_id":"stub-dcr-gh32473","client_secret":"dummy","redirect_uris":["https://litellm.example.com/callback"]}

$ curl -sS http://localhost:9500/registrations
{"client-1":{...,"redirect_uris":["http://localhost:4100/callback"]}}    # still only client-1

After the fix: a recorded row self-heals on origin change

Server recreated on the fixed build at origin A; the persisted blob now records the binding (client_id stays encrypted, the recording is not a secret)

$ psql ... "select credentials from \"LiteLLM_MCPServerTable\" where server_id='stub-dcr-gh32473'"
{'client_id': '<encrypted>', 'client_secret': None, 'redirect_uris': ['http://localhost:4100/callback'], 'token_endpoint_auth_method': None}

Proxy restarted with PROXY_BASE_URL=https://litellm.example.com, then the same admin register call detects the positive mismatch and re-registers instead of reusing

$ curl -sS -X POST http://localhost:4100/v1/mcp/server/oauth/stub-dcr-gh32473/register ... (same body)
{"client_id":"client-2","redirect_uris":["https://litellm.example.com/callback"],"token_endpoint_auth_method":"none"}

$ curl -sS http://localhost:9500/registrations
{"client-1":{...,"redirect_uris":["http://localhost:4100/callback"]},
 "client-2":{...,"redirect_uris":["https://litellm.example.com/callback"]}}

$ echo "$LOC"    # authorize Location now carries the replacement client
http://localhost:9500/authorize?client_id=client-2&redirect_uri=https%3A%2F%2Flitellm.example.com%2Fcallback&...

$ curl -sS -i "$LOC"
HTTP/1.1 302 Found

The DB blob after healing records the new binding (redirect_uris: ['https://litellm.example.com/callback']) and the proxy logs an operator-visible warning: "persisted DCR client for server_id=stub-dcr-gh32473 is registered with redirect_uris=['http://localhost:4100/callback'] which do not include the current callback https://litellm.example.com/callback (proxy origin changed); registering a replacement client. Users previously signed in to this server will need to re-authenticate."

Type

🐛 Bug Fix

Changes

Root cause: the DCR persist introduced in #31912 stores client_id, client_secret and token_endpoint_auth_method on the server row but discards the redirect_uris the client was registered with. Every later connect short-circuits registration (either on the hydrated client_id or through _reuse_persisted_dcr_client_if_available), while authorize_with_server re-derives {current_origin}/callback on every request. Once the resolved origin changes, the IdP rejects the mismatched pair forever; the rejection happens on the IdP side so nothing surfaces in proxy logs, and the only recovery was deleting and recreating the server

The fix records redirect_uris in the persisted credentials at DCR time and treats a positive mismatch with the current callback as a stale client on the admin register path (persist_credentials=True), falling through to a fresh registration that overwrites the persisted identity. A missing recording is treated as a match, which grandfathers both rows persisted before this field existed and admin-configured clients; this is deliberate, since re-minting a client_id orphans every user's refresh tokens for that server (the invariant test_register_client_reuses_existing_client_id_without_re_dcr guards). Public register routes and non-admin callers keep today's behavior exactly, so recovery is an admin re-running the connect flow rather than any caller being able to churn upstream clients

Because update_mcp_server merges credential blobs, the persist now writes client_secret and token_endpoint_auth_method explicitly as None when the registration response omits them; otherwise a re-registered public client would inherit the previous client's encrypted secret and auth method through the merge. redirect_uris records the callback the gateway sent rather than the IdP's echo so a normalizing IdP cannot cause a re-register loop. When re-registration happens the proxy logs a warning naming both bindings, which also covers the issue's request to surface the stranded state

Regression tests cover the four contract points: positive mismatch re-registers and persists the replacement (fails on unfixed code), a missing recording is grandfathered, a matching recording keeps the client, and the non-admin path reuses even on mismatch. The existing persist test now also pins the recorded redirect_uris


Note

Medium Risk
Touches MCP OAuth DCR persistence and client reuse; wrong mismatch logic could re-mint client_ids and force re-auth, but grandfathering and scoped admin-only re-register limit blast radius.

Overview
Fixes MCP OAuth servers getting permanently stranded after the proxy’s public origin changes: persisted DCR clients were reused while authorize always used the new {origin}/callback, so strict IdPs rejected the pair with invalid redirect_uri.

Persisted credentials now store redirect_uris at registration time (MCPCredentials / _PersistedDcrCredentials). On the admin register path (persist_credentials=True), a recorded binding that does not include the current callback is treated as stale: registration is not short-circuited on the in-memory client_id, reuse is skipped, upstream DCR runs again, and the row is overwritten with the new client plus explicit None for client_secret / token_endpoint_auth_method so merged credential blobs do not leak the old secret.

Missing redirect_uris (pre-upgrade rows and admin-configured clients) is grandfathered—no automatic re-mint. Public / non-persisting register callers still reuse persisted clients even on mismatch, avoiding orphan upstream clients without persistence.

Operator warning when re-registration happens; tests cover mismatch re-register, grandfathering, match reuse, and non-admin behavior.

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

@greptile-apps

greptile-apps Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes MCP OAuth DCR clients being permanently stranded when the proxy's public origin changes: the reused client stays bound to the old redirect_uri while the IdP rejects every authorize with a redirect mismatch. The fix records redirect_uris in the persisted credentials blob at registration time and re-registers on the admin path when a positive mismatch is detected.

  • _PersistedDcrCredentials / MCPCredentials: adds redirect_uris field; missing recording (pre-upgrade rows, admin-configured clients) is treated as a match to avoid churning client IDs on upgrade.
  • Re-registration path (register_client_with_server with persist_credentials=True): when mcp_server.client_id is hydrated but the recorded redirect is stale, the stale-client short-circuit is bypassed, a fresh DCR call is made, and the new credentials (with explicit None for client_secret and token_endpoint_auth_method) are persisted so the DB merge cannot inherit the previous client's secret.
  • Four regression tests cover: positive mismatch re-registers and persists replacement, missing recording is grandfathered, matching recording keeps client, non-admin path reuses even on mismatch.

Confidence Score: 5/5

Safe to merge; the change is scoped to the admin DCR persist path with deliberate backward-compatible grandfathering for pre-existing rows.

The stale-redirect detection logic is correct and well-guarded: missing redirect_uris in the stored blob is treated as a match, so upgrading never re-mints client IDs for existing installs. Explicit None writes for client_secret and token_endpoint_auth_method correctly override old values through the DB merge. The non-admin path is unchanged. Four regression tests pin each contract point. No data-loss or auth-boundary issues found.

No files require special attention.

Important Files Changed

Filename Overview
litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py Adds stale-redirect detection (_redirect_uri_not_registered, _persisted_dcr_redirect_uri_is_stale), threads current_redirect_uri through reuse checks and persist, records redirect_uris with explicit None-clearing for client_secret/token_endpoint_auth_method. Logic is correct; DB merge semantics verified to handle explicit None values properly.
litellm/types/mcp.py Adds redirect_uris: Optional[List[str]] field to MCPCredentials TypedDict with clear documentation; correctly marked optional and not a secret.
tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py Adds four well-targeted regression tests covering all four contract points: positive mismatch re-registers, missing recording grandfathered, matching recording reuses, non-admin path reuses on mismatch. Also pins redirect_uris assertion on the existing persist test.

Reviews (3): Last reviewed commit: "fix(mcp): emit one operator warning per ..." | Re-trigger Greptile

Comment thread litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py
@codecov

codecov Bot commented Jul 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.15385% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
..._experimental/mcp_server/discoverable_endpoints.py 95.83% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@tin-berri

Copy link
Copy Markdown
Contributor Author

@greptileai addressed the duplicate-warning concern: the reuse-skip log in _reuse_persisted_dcr_client_if_available is now debug level, so a re-registration event emits exactly one operator warning (the staleness probe's message naming both bindings and the re-auth impact)

@greptile-apps

greptile-apps Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Confirmed — the implementation matches what you described. In the re-registration path, _persisted_dcr_redirect_uri_is_stale emits exactly one verbose_logger.warning with the full operator-facing message (naming both bindings and the re-auth impact), and the subsequent skip log in _reuse_persisted_dcr_client_if_available is verbose_logger.debug. No duplicate operator-visible warning.

The debug message in _reuse_persisted_dcr_client_if_available also includes a clear cross-reference pointing to _persisted_dcr_redirect_uri_is_stale, which is good for future debuggability. This looks correct.

@tin-berri

Copy link
Copy Markdown
Contributor Author

@greptileai rereview

@tin-berri

Copy link
Copy Markdown
Contributor Author

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!

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

Reviewed by Cursor Bugbot for commit 1afc1c7. Configure here.

@codspeed-hq

codspeed-hq Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_gh32473_dcr_redirect_uri (2254ffb) with litellm_internal_staging (a4199d3)

Open in CodSpeed

…its registered redirect_uri

A dynamically registered (RFC 7591) OAuth client persisted onto the MCP server row is bound to the redirect_uri it was first registered with, but that binding was never recorded. After the proxy's public origin changed, every authorize paired the reused client with the new callback and the IdP rejected it permanently.

The DCR persist now records redirect_uris alongside the client identity. The admin register path treats a positive mismatch between the recording and the current callback as stale and re-registers a replacement client; rows without a recording (pre-existing installs and admin-configured clients) are grandfathered so upgrades never re-mint client_ids or orphan refresh tokens. The persist also writes client_secret and token_endpoint_auth_method explicitly as None when absent so the credential blob merge cannot pair a re-registered public client with the previous client's secret. Public register routes and non-admin callers keep existing behavior.

Closes #32473
The stale-redirect path logged three warnings for a single re-registration: the staleness probe plus the reuse skip in both register_client_with_server and the persist race guard. The reuse-skip message is a mechanical consequence of the probe's decision, so it now logs at debug; the actionable warning that names both bindings and the re-authentication impact is emitted once by _persisted_dcr_redirect_uri_is_stale
@tin-berri
tin-berri force-pushed the litellm_gh32473_dcr_redirect_uri branch from 1afc1c7 to 2254ffb Compare July 11, 2026 17:13
@tin-berri

Copy link
Copy Markdown
Contributor Author

@greptileai rereview

@tin-berri

Copy link
Copy Markdown
Contributor Author

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!

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

Reviewed by Cursor Bugbot for commit 2254ffb. 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 2631ce7 into litellm_internal_staging Jul 11, 2026
131 of 132 checks passed
@tin-berri
tin-berri deleted the litellm_gh32473_dcr_redirect_uri branch July 11, 2026 18:25
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.

[Bug]: MCP DCR client reuse permanently strands a server when the proxy origin changes (redirect_uri never re-registered)

2 participants