fix(mcp): persist DCR client_id so interactive OAuth token refresh works - #31912
Conversation
Greptile SummaryThis PR persists the Dynamic Client Registration (RFC 7591)
Confidence Score: 5/5Safe to merge — the admin-only gate is correct, credentials are encrypted via the existing path, the conditional token_url spread prevents NULL overwrites, and the double-check pattern adequately mitigates concurrent-persist races. The persistence logic correctly gates writes behind PROXY_ADMIN, reuses the existing encrypt_credentials path, and the update_mcp_server merge semantics ensure new DCR credentials don't wipe unrelated existing fields. No auth bypass, data loss, or incorrect credential exposure was found in the changed paths. No files require special attention.
|
| Filename | Overview |
|---|---|
| litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py | Adds _DcrClientRegistration, _PersistedDcrCredentials, and helper functions for persisting and reusing DCR client registrations; introduces persist_credentials flag to register_client_with_server with correct gating logic |
| litellm/proxy/management_endpoints/mcp_management_endpoints.py | Minimal one-line change: passes persist_credentials=_user_is_full_admin(user_api_key_dict) to register_client_with_server, correctly limiting persistence to PROXY_ADMIN callers |
| tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py | Adds 7 new mock-based tests covering DCR persistence, token_url omission, reuse on stale registry, concurrent-persist race, public-route non-persistence, and admin vs non-admin gating; no real network calls |
| tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py | Strengthens existing admin test by adding persist_credentials=True assertion; adds new non-admin test verifying persist_credentials=False for INTERNAL_USER callers |
Reviews (9): Last reviewed commit: "fix: reuse persisted MCP DCR clients" | Re-trigger Greptile
This comment was marked as outdated.
This comment was marked as outdated.
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
PR overviewAll previously flagged issues have been addressed. No open security concerns remain on this pull request. Security reviewNo open security issues remain on this pull request. Fixed/addressed: 2 · PR risk: 0/10 |
|
@greptileai I pushed a fix for the token_url NULL-clobber finding: token_url is now only included in the update when present, so exclude_unset leaves the column untouched instead of overwriting it with NULL. Added a regression test (test_register_client_does_not_clobber_token_url_when_absent). |
462abc4 to
d56e0d1
Compare
|
@greptileai re-review please: addressed the token_url NULL-clobber (now conditional), and added register-once/reuse of an existing client_id so multi-user servers don't re-mint per authorize. New regression tests cover both. |
d56e0d1 to
5641933
Compare
|
@greptileai re-review: added a security fix for the finding that the unauthenticated root /register route persisted DCR results. Persistence is now gated to the authenticated management path (mcp_register passes persist_credentials=True); the public route registers and returns without writing to the server row. Regression tests cover both halves. |
5641933 to
680140e
Compare
|
@greptileai re-review please. Rebased onto latest litellm_internal_staging (clears a stale-base CI failure). Also dropped the cast on the persisted credentials dict; MCPCredentials already declares token_endpoint_auth_method as a Literal, so the dict is now typed directly. |
680140e to
f984d02
Compare
|
@greptileai re-review please. Addressed the finding that a non-admin with server access could trigger persistence: persist_credentials is now gated to a full PROXY_ADMIN (_user_is_full_admin), so user-side registration returns the DCR response without writing shared client credentials. Added a non-admin regression test asserting no persist. |
|
bugbot run |
|
|
Interactive authorization_code MCP servers register an OAuth client via Dynamic Client Registration (RFC 7591) during the authorize flow, but the minted client_id and the discovered token_url were returned to the caller and never written to the server row. The autonomous refresh_token grant reads client_id, client_secret and token_url off the server, so an expired access token could not be refreshed; the user was bounced back to re-authorize and tools/list returned zero tools Persist the DCR client_id (plus client_secret and token_endpoint_auth_method when the registration returns them) and the discovered token_url onto the server row, reusing the encrypt_credentials write that client_credentials and token exchange already use, then refresh the in-memory registry so the value is live at refresh time. Both the v1 refresher and the v2 AuthorizationCodeRefresher read those same fields, so egress needs no change
84ba218 to
efe3523
Compare
|
bugbot run |
|
@greptileai rereview pls |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for all 3 issues found in the latest run.
- ✅ Fixed: Non-admin skips DB client reuse
- Persisted DCR client reuse now runs before outbound registration regardless of persist_credentials, including non-admin registration calls.
- ✅ Fixed: Reuse reports success without refresh
- Reuse now applies the persisted client credentials to the request server before returning success, so skipped DCR still leaves usable credentials in memory.
- ✅ Fixed: DCR response after skipped persist
- When a concurrent persist wins after outbound DCR, the registration response now short-circuits to the shared dummy client instead of returning the losing upstream client_id.
You can send follow-ups to the cloud agent here.
|
bugbot run |
There was a problem hiding this comment.
✅ 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 2704b55. Configure here.
…client_oncreate Resolve conflicts in discoverable_endpoints.py and its test by taking the canonical version now on the base branch, which is #31912 squash-merged with its post-review refinements (client_secret and token_endpoint_auth_method persistence, the decrypt/apply helpers, and the DcrRegistrationPersistenceResult result). This branch's copies of those backend commits predate the merge and none of the on-create UI commits touch those files, so taking base keeps the merged behavior with no loss. The on-create UI change sits cleanly on top of the base's token endpoint auth method selector work; the net PR diff is only the four dashboard files
…rks (BerriAI#31912) * fix(mcp): persist DCR client_id so interactive OAuth token refresh works Interactive authorization_code MCP servers register an OAuth client via Dynamic Client Registration (RFC 7591) during the authorize flow, but the minted client_id and the discovered token_url were returned to the caller and never written to the server row. The autonomous refresh_token grant reads client_id, client_secret and token_url off the server, so an expired access token could not be refreshed; the user was bounced back to re-authorize and tools/list returned zero tools Persist the DCR client_id (plus client_secret and token_endpoint_auth_method when the registration returns them) and the discovered token_url onto the server row, reusing the encrypt_credentials write that client_credentials and token exchange already use, then refresh the in-memory registry so the value is live at refresh time. Both the v1 refresher and the v2 AuthorizationCodeRefresher read those same fields, so egress needs no change * fix: reuse persisted MCP DCR clients * fix: reuse persisted MCP DCR clients --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> (cherry picked from commit 15ff389)
Config.yaml-declared OAuth2 MCP servers using Dynamic Client Registration have no LiteLLM_MCPServerTable row, so the DCR persist path called update_mcp_server, which returns None for a missing row, then update_server(None), which dereferenced .approval_status and raised AttributeError. The exception was swallowed to a warning while /register still returned 200, so the minted client was never stored and every access-token expiry forced a full re-authorization Persist the acquired DCR client (client_id, client_secret, token_endpoint_auth_method, redirect_uris, encrypted at rest) in a dedicated LiteLLM_MCPServerOAuthClient store keyed by server_id when the server has no row, overlay it onto the in-memory config server so the refresh_token grant can authenticate within the process, and rehydrate it when the registry syncs from the database (which runs after the DB connects, unlike config load) so restarts and other pods pick it up. The DB-backed server path is unchanged, and guarding the None return removes the swallowed-crash footgun Resolves the config.yaml DCR persistence regression introduced in v1.92.0 by #31912
Config.yaml-declared OAuth2 MCP servers using Dynamic Client Registration have no LiteLLM_MCPServerTable row, so the DCR persist path called update_mcp_server, which returns None for a missing row, then update_server(None), which dereferenced .approval_status and raised AttributeError. The exception was swallowed to a warning while /register still returned 200, so the minted client was never stored and every access-token expiry forced a full re-authorization Persist the acquired DCR client (client_id, client_secret, token_endpoint_auth_method, redirect_uris, encrypted at rest) in a dedicated LiteLLM_MCPServerOAuthClient store keyed by server_id when the server has no row, overlay it onto the in-memory config server so the refresh_token grant can authenticate within the process, and rehydrate it when the registry syncs from the database (which runs after the DB connects, unlike config load) so restarts and other pods pick it up. The store is encrypted at rest and is re-encrypted by the master-key rotation path alongside the server rows, through a shared helper so the two sites cannot diverge. The DB-backed server path is unchanged, and guarding the None return removes the swallowed-crash footgun Resolves the config.yaml DCR persistence regression introduced in v1.92.0 by #31912
Config.yaml-declared OAuth2 MCP servers using Dynamic Client Registration have no LiteLLM_MCPServerTable row, so the DCR persist path called update_mcp_server, which returns None for a missing row, then update_server(None), which dereferenced .approval_status and raised AttributeError. The exception was swallowed to a warning while /register still returned 200, so the minted client was never stored and every access-token expiry forced a full re-authorization Persist the acquired DCR client (client_id, client_secret, token_endpoint_auth_method, redirect_uris, encrypted at rest) in a dedicated LiteLLM_MCPServerOAuthClient store keyed by server_id when the server has no row, overlay it onto the in-memory config server so the refresh_token grant can authenticate within the process, and rehydrate it when the registry syncs from the database (which runs after the DB connects, unlike config load) so restarts and other pods pick it up. The store is encrypted at rest and is re-encrypted by the master-key rotation path alongside the server rows, through a shared helper so the two sites cannot diverge. The DB-backed server path is unchanged, and guarding the None return removes the swallowed-crash footgun Resolves the config.yaml DCR persistence regression introduced in v1.92.0 by #31912
Config.yaml-declared OAuth2 MCP servers using Dynamic Client Registration have no LiteLLM_MCPServerTable row, so the DCR persist path called update_mcp_server, which returns None for a missing row, then update_server(None), which dereferenced .approval_status and raised AttributeError. The exception was swallowed to a warning while /register still returned 200, so the minted client was never stored and every access-token expiry forced a full re-authorization Persist the acquired DCR client (client_id, client_secret, token_endpoint_auth_method, redirect_uris, encrypted at rest) in a dedicated LiteLLM_MCPServerOAuthClient store keyed by server_id when the server has no row, overlay it onto the in-memory config server so the refresh_token grant can authenticate within the process, and rehydrate it when the registry syncs from the database (which runs after the DB connects, unlike config load) so restarts and other pods pick it up. The store is encrypted at rest and is re-encrypted by the master-key rotation path alongside the server rows, through a shared helper so the two sites cannot diverge. The DB-backed server path is unchanged, and guarding the None return removes the swallowed-crash footgun Resolves the config.yaml DCR persistence regression introduced in v1.92.0 by #31912
Config.yaml-declared OAuth2 MCP servers using Dynamic Client Registration have no LiteLLM_MCPServerTable row, so the DCR persist path called update_mcp_server, which returns None for a missing row, then update_server(None), which dereferenced .approval_status and raised AttributeError. The exception was swallowed to a warning while /register still returned 200, so the minted client was never stored and every access-token expiry forced a full re-authorization Persist the acquired DCR client (client_id, client_secret, token_endpoint_auth_method, redirect_uris, encrypted at rest) in a dedicated LiteLLM_MCPServerOAuthClient store keyed by server_id when the server has no row, overlay it onto the in-memory config server so the refresh_token grant can authenticate within the process, and rehydrate it when the registry syncs from the database (which runs after the DB connects, unlike config load) so restarts and other pods pick it up. The store is encrypted at rest and is re-encrypted by the master-key rotation path alongside the server rows, through a shared helper so the two sites cannot diverge. The DB-backed server path is unchanged, and guarding the None return removes the swallowed-crash footgun Resolves the config.yaml DCR persistence regression introduced in v1.92.0 by #31912
Config.yaml-declared OAuth2 MCP servers using Dynamic Client Registration have no LiteLLM_MCPServerTable row, so the DCR persist path called update_mcp_server, which returns None for a missing row, then update_server(None), which dereferenced .approval_status and raised AttributeError. The exception was swallowed to a warning while /register still returned 200, so the minted client was never stored and every access-token expiry forced a full re-authorization Persist the acquired DCR client (client_id, client_secret, token_endpoint_auth_method, redirect_uris, encrypted at rest) in a dedicated LiteLLM_MCPServerOAuthClient store keyed by server_id when the server has no row, overlay it onto the in-memory config server so the refresh_token grant can authenticate within the process, and rehydrate it when the registry syncs from the database (which runs after the DB connects, unlike config load) so restarts and other pods pick it up. The store is encrypted at rest and is re-encrypted by the master-key rotation path alongside the server rows, through a shared helper so the two sites cannot diverge. The DB-backed server path is unchanged, and guarding the None return removes the swallowed-crash footgun Resolves the config.yaml DCR persistence regression introduced in v1.92.0 by #31912
Relevant issues
Linear ticket
LIT-4154 https://linear.app/litellm-ai/issue/LIT-4154
This PR covers variant 1 (post-create authorize). Variant 2 (on-create "Authorize and Fetch") is tracked in the same ticket and handled in a stacked follow-up PR.
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
@greptileaiand received a Confidence Score of at least 4/5 before requesting a maintainer reviewScreenshots / Proof of Fix
An interactive
authorization_codeMCP server registers an OAuth client via Dynamic Client Registration (RFC 7591) during the authorize flow. The mintedclient_idis not re-derivable from discovery, so if it is not persisted the autonomousrefresh_tokengrant has no client identity and the upstream rejects the refresh. The user is silently forced to re-authorize andtools/listreturns zero tools.Live proxy on
localhost:4000backed by real Postgres, authorizing against a real upstream MCP server's OAuth (its token endpoint is hit for real).Before (this fix absent), the server row stores none of the client identity and the refresh is rejected:
After (this fix), authorizing the same server persists the client identity and the refresh succeeds:
The same server-side fields feed both refreshers (v1
refresh_user_oauth_tokenand the v2AuthorizationCodeRefresher), so once the row carriesclient_idandtoken_url, both authenticate with no egress-side change.Scope: this covers the flow where a server already exists in the DB and is authorized from the tools page. The separate "Authorize and Fetch" on-create flow runs DCR against a temporary in-memory server that has no DB row and creates the real server afterward, so it needs its own link between the authorize session and the create; that is a known follow-up and is intentionally out of scope here.
Type
Bug Fix
Changes
register_client_with_serverperforms the upstream DCR call and previously returned the registration response straight to the caller without persisting it. This adds_persist_dcr_client_registration, which validates the RFC 7591 response with a typed pydantic model and writesclient_id(plusclient_secretwhen the registration returns one, andtoken_endpoint_auth_methodonly when it isclient_secret_basic, since the token-endpoint auth already defaults toclient_secret_postwhen unset) together with the discoveredtoken_urlonto the server row.token_urlis included in the partial update only when the in-memory server already carries it, so the update never overwrites an existing value with NULL. It reuses the existingencrypt_credentialswrite thatclient_credentialsand token exchange already use, so the secrets are encrypted at rest the same way, and it refreshes the in-memory registry viaupdate_serverso the value is live at the next refresh rather than only after a reload. Persistence failures are logged and never raised, so an authorize still returns to the caller if the write fails.Regression tests in
test_discoverable_endpoints.pyassert the DCR response is persisted with the rightclient_id/client_secret/token_endpoint_auth_methodandtoken_url, and thattoken_urlis omitted from the update when the server has none; they fail on the pre-fix code and pass with the fix.Beyond persisting the client, the register guard now reuses an existing
client_idinstead of re-registering per authorize (it previously re-ran DCR unless bothclient_idandclient_secretwere present, so public clients minted a fresh client on every authorize). One client per server, shared across users, is the OAuth standard; re-minting would orphan earlier users' refresh tokens. Confidential and config-defined servers already reused their configured client. A regression test asserts a server that already has aclient_idis reused without an outbound DCR call.Persistence is gated to a full proxy admin.
register_client_with_serverwrites to the server row only whenmcp_registerpassespersist_credentials=True, which it does only for aPROXY_ADMINcaller (_user_is_full_admin); the unauthenticated root/registerroute and any non-admin management caller register and return without persisting. Establishing the one shared client per server is an admin action and users only authorize and reuse it, so a non-admin, even one with access to the server, cannot bind a caller-controlled client, whose secret it also receives, onto the shared row before the admin sets one up. The reuse guard additionally blocks overwriting an existing client. Regression tests assert the public route and a non-admin management caller do not persist while a full admin does.Config.yaml-defined servers are unaffected and are not the target of this change. They live in memory only (
config_mcp_servers), never inLiteLLM_MCPServerTable, and config is reload-authoritative, so theirclient_id/client_secretmust be declared in YAML;token_url/authorization_urlare discovered (RFC 9728 then 8414) so those are optional. A configoauth2server that omitsclient_idand leans on DCR will authorize once with an ephemeral client_id but cannot refresh, since nothing reload-safe holds that client_id. This persistence fix applies only to servers that have a DB row (UI-created), where the gateway can save the DCR-minted client identity.Note
Medium Risk
Changes OAuth client identity storage and registration guards on the proxy; persistence is admin-gated but mishandling could still affect shared refresh behavior across users.
Overview
Fixes MCP interactive OAuth so refresh_token grants can authenticate after access tokens expire, by persisting Dynamic Client Registration (RFC 7591) results on DB-backed MCP servers instead of discarding them after
register_client_with_server.Persistence: When
persist_credentials=True, the gateway validates the upstream DCR response, writes encryptedclient_id/ optionalclient_secret/token_endpoint_auth_method(andtoken_urlonly when already known) via the existingupdate_mcp_serverpath, and refreshes the in-memory registry. Failures are logged only; registration still returns to the caller.Reuse / no re-DCR: Registration now short-circuits if the server already has a
client_id(not only when both id and secret were set), and can load and apply credentials from the DB when the registry is stale—skipping outbound DCR and returning the dummy client response when appropriate. Concurrent admin persist can detect an existing row and return reused instead of overwriting.Who may persist: Only the authenticated management
mcp_registerpath passespersist_credentials=Truefor full proxy admins; the public/registerroute and non-admin callers register without writing shared server credentials.Reviewed by Cursor Bugbot for commit 2704b55. Bugbot is set up for automated code reviews on this repo. Configure here.