fix(mcp): persist per-user OAuth client_id so tokens auto-refresh - #31327
fix(mcp): persist per-user OAuth client_id so tokens auto-refresh#31327katzdave wants to merge 1 commit into
Conversation
Dynamically-registered MCP OAuth clients (e.g. Guru) mint a per-user client_id in the browser that is used for the token exchange and then discarded. At refresh time refresh_user_oauth_token read server.client_id, which is None for a registration-only server, so the refresh POST omitted client_id and the provider rejected it; the token died after about an hour and the user had to re-authorize. Persist client_id with the per-user credential across both store paths (the /token server-side store and the manual oauth-user-credential endpoint) and prefer it over the server config when refreshing. Also expose has_refresh_token on the credential status so API clients can tell whether a credential will auto-refresh.
Greptile SummaryThis PR fixes a token auto-refresh regression for MCP servers that use dynamic client registration (e.g. Guru): at refresh time the code was reading
Confidence Score: 4/5The change is narrowly scoped to MCP OAuth credential storage and refresh and does not touch the main LiteLLM request path or authentication layer. The core fix is correct and thoroughly tested with mock-only unit tests covering the full refresh cycle. The only noteworthy asymmetry is cred.get client_secret added alongside cred.get client_id in the refresh function — since client_secret is never persisted to the credential payload, that branch is permanently dead and could mislead future maintainers, but it has no runtime effect today. litellm/proxy/_experimental/mcp_server/db.py — specifically the client_secret resolution line in refresh_user_oauth_token.
|
| Filename | Overview |
|---|---|
| litellm/proxy/_experimental/mcp_server/db.py | Core fix: store_user_oauth_credential now persists client_id; refresh_user_oauth_token prefers the credential-stored client_id over server config. One asymmetry: cred.get("client_secret") is added alongside cred.get("client_id") but client_secret is never written to the credential payload, making that branch permanently dead. |
| litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py | Threads resolved_client_id through exchange_token_with_server to _store_per_user_token_server_side to store_user_oauth_credential. Correct for both dynamic (uses caller's client_id) and static (uses server config client_id) servers. |
| litellm/proxy/_types.py | Adds optional client_id to MCPOAuthUserCredentialRequest and has_refresh_token: bool = False to MCPOAuthUserCredentialStatus; both additions are backward-compatible. |
| litellm/proxy/management_endpoints/mcp_management_endpoints.py | Passes payload.client_id down to store_user_oauth_credential and populates has_refresh_token from the read-back stored credential. Consistent with the pattern already used for expires_at. |
| tests/test_litellm/proxy/_experimental/mcp_server/test_db_credentials.py | Adds four new tests: client_id round-trips, absence of empty key when omitted, credential-stored id preferred on refresh, server config fallback, and re-persistence across refresh cycles. All mock-only. |
| tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py | Two new tests verify client_id threads correctly from exchange_token_with_server into the DB layer. Mock-only, no network calls. |
| tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py | Two new tests verify client_id reaches store_user_oauth_credential and has_refresh_token is surfaced correctly on store and status responses. |
Reviews (1): Last reviewed commit: "fix(mcp): persist per-user OAuth client_..." | Re-trigger Greptile
| client_secret: Optional[str] = cred.get("client_secret") or getattr( | ||
| server, "client_secret", None | ||
| ) |
There was a problem hiding this comment.
cred.get("client_secret") is dead code — client_secret is never written to the credential payload in store_user_oauth_credential, so this branch always evaluates to None. The asymmetry with client_id is misleading: it suggests that client secrets can be read from stored credentials, which future maintainers may rely on incorrectly. Either add client_secret persistence to store_user_oauth_credential (with the same encrypted-storage treatment as client_id) or drop the cred.get(…) prefix.
| client_secret: Optional[str] = cred.get("client_secret") or getattr( | |
| server, "client_secret", None | |
| ) | |
| client_secret: Optional[str] = getattr(server, "client_secret", None) |
60a4c65 to
5db2fa5
Compare
Greptile SummaryFixes MCP OAuth token auto-refresh for dynamically-registered clients by persisting the per-user
Confidence Score: 4/5Safe to merge — changes are fully additive and backward-compatible, with well-targeted tests covering the new code paths. The fix is correct and well-tested. The only notable gap is that
|
| Filename | Overview |
|---|---|
| litellm/proxy/_experimental/mcp_server/db.py | Adds client_id parameter to store_user_oauth_credential and persists it in the payload; refreshes now prefer the per-user client_id from the stored credential over the server config. The client_secret read from cred is dead code since the store function never accepts it. |
| litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py | Threads client_id through _store_per_user_token_server_side to store_user_oauth_credential. Change is additive and preserves existing behavior for servers without a per-user client_id. |
| litellm/proxy/_types.py | Adds client_id field to MCPOAuthUserCredentialRequest and has_refresh_token to MCPOAuthUserCredentialStatus; both are additive with safe defaults. |
| litellm/proxy/management_endpoints/mcp_management_endpoints.py | Passes payload.client_id to the DB layer and populates has_refresh_token on both the store and status endpoints. Straightforward additions with no regressions. |
| tests/test_litellm/proxy/_experimental/mcp_server/test_db_credentials.py | Adds five focused unit tests covering round-trip persistence, omission when absent, fallback logic, and re-persistence across refresh. All mock-based, no real network calls. |
| tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py | Adds two tests verifying client_id propagates through exchange_token_with_server to _store_per_user_token_server_side to DB layer. Correct mocking, no real HTTP calls. |
| tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py | Adds two tests: one verifying client_id reaches the DB store, one verifying has_refresh_token is correctly surfaced for credentials with and without a refresh token. |
Reviews (2): Last reviewed commit: "fix(mcp): persist per-user OAuth client_..." | Re-trigger Greptile
| client_secret: Optional[str] = cred.get("client_secret") or getattr( | ||
| server, "client_secret", None | ||
| ) |
There was a problem hiding this comment.
client_secret is read from cred here, but store_user_oauth_credential never accepts or saves a client_secret parameter — so cred.get("client_secret") will always be None through any current code path. This creates a misleading symmetry with client_id: future maintainers may assume client_secret round-trips the same way client_id does, when it actually always falls back to the server config. If per-user client_secret persistence is intended for a future DCR use case, a matching client_secret parameter should also be added to store_user_oauth_credential now (or the dead half of the expression should be removed).
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Relevant issues
Relates to #31222 (per-user MCP OAuth credential management — the UI removal in #30178 that this PR's backend-only scope accounts for).
Companion PR: #31326 — use stored per-user OAuth token on tool calls.
Type
🐛 Bug Fix
Changes
Dynamically-registered MCP OAuth clients (e.g. Guru) mint a per-user
client_idduring browser-side dynamic client registration, use it for the token exchange, then discard it. At refresh timerefresh_user_oauth_tokenreadserver.client_id, which isNonefor a registration-only server, so the refresh POST omittedclient_idand the provider rejected it — the access token died after ~1h and the user had to re-authorize.This persists
client_idwith the per-user credential across both store paths (the server-side/tokenstore and the manualoauth-user-credentialendpoint) and prefers it over the server config when refreshing (same fallback forclient_secret). Also exposeshas_refresh_tokenon the credential status so API clients can tell whether a credential will auto-refresh.Backend-only: the original change also added a dashboard auto-refresh indicator, but the per-user MCP credential UI was removed upstream (#30178, tracked in #31222), so that hunk is intentionally omitted.
Tests
test_db_credentials.py—client_idpersisted, and preferred over server config on refreshtest_discoverable_endpoints.py—client_idthreaded through token exchange → server-side store → DB layertest_mcp_management_endpoints.py—client_idreachesstore_user_oauth_credential;has_refresh_tokensurfaced on statusAll passing locally (
uv run pyteston the three files above).Pre-Submission checklist