[FEATURE] Persist MCP OAuth client registrations and refresh tokens across CLI launches
Preflight Checklist
- [x] I have searched existing requests and this feature hasn't been requested yet
- [x] This is a single feature request (not multiple features)
Problem Statement
For self-hosted MCP servers that implement OAuth 2.0 with Dynamic Client Registration (DCR), Claude Code performs a fresh DCR call followed by a full authorization-code exchange on every CLI launch. The previously issued client_id and refresh token from the prior session are discarded.
The user-visible symptom is "MCP authentication expired again" at every restart — even when the server's access tokens are still valid for weeks. Server-side, stale client registrations accumulate (one per launch), each bound to a different ephemeral http://localhost:<random-port>/callback redirect URI, with their issued access and refresh tokens orphaned but unrevoked.
This is distinct from the enterprise/Azure-AD case in #26675 (which asks to skip DCR entirely in favour of a pre-configured static client_id). The request here is for the inverse path: when DCR is available and used, cache the result so the next launch can reuse it instead of starting over.
Proposed Solution
Persist, keyed by MCP server URL (or by the issuer URL discovered during authorization-server metadata fetch):
- The
client_idandclient_secretreturned from DCR. - The refresh token from the most recent authorization-code or refresh-token exchange.
- Optionally the access token and its
expires_at, so the refresh-token exchange is only triggered when needed.
On launch, before initiating a new authorization flow, look these up and attempt a refresh-token exchange. Fall back to a full DCR + authorization-code flow only if:
- No record exists for this server URL, or
- The refresh token has expired or been revoked, or
- The authorization server rejects the registered client.
A related concern: pinning the loopback callback port (or registering a wildcard http://127.0.0.1 redirect URI per DCR spec §2 if the server supports it) would make the persisted DCR result actually reusable. Today the redirect URI itself changes per launch — so even if persistence were added, the stored client_id would only be usable from a CLI run that happened to land on the same ephemeral port.
Storage location is probably the macOS Keychain on Darwin (alongside the existing Claude Code-credentials entry), ~/.claude/.credentials.json on Linux, or DPAPI on Windows — whatever pattern Claude Code already uses for its claude.ai OAuth tokens.
Alternative Solutions
What I currently do or have considered:
- Disable OAuth on the server side. For self-hosted MCP on a trusted LAN, setting
auth_enabled: falseand relying on network-level trust is a viable workaround. Not acceptable for any server exposed beyond a tightly controlled boundary. - Accept once-per-launch re-auth. With a 30-day sliding access-token TTL on the server, a single session can run for a month without re-auth — but every CLI restart starts over.
- Pre-configured static credentials (#26675). Different mechanism but solves an overlapping problem. Useful for enterprise providers that don't support DCR; doesn't help self-hosted DCR servers because there's no
client_idto pre-configure ahead of the first registration. - Env-var bearer-token workaround (per #26675 comment from @shivarammysore). Pre-mint a token externally and inject via
headers.Authorization. Bypasses OAuth entirely; loses the refresh flow and the discovery/registration benefits of OAuth.
Priority
Medium - Would be very helpful
Feature Category
MCP server integration
Use Case Example
Concrete scenario:
- I run a self-hosted MCP server on my home LAN. It implements OAuth 2.0 via the official
mcpPython SDK'sOAuthAuthorizationServerProvider. Access tokens are issued with a 30-day TTL and a sliding window that extends on every successful verification. - I add the server via
claude mcp add --transport sse ha-ops http://10.0.0.150:8901/sse. - Claude Code opens a browser, completes DCR + authorization-code flow, server issues an access token and refresh token bound to
client_idA. I work happily. - I quit Claude Code.
- I relaunch Claude Code the next morning. First tool call hits 401 (because Claude Code is using a new ephemeral callback port and didn't persist
client_idA) and the auth prompt opens again. The server now has twoclient_identries on file; the old token underclient_idA is still valid for 29 more days but orphaned and unreachable. - Repeat daily.
With this feature: after step 4, Claude Code reuses client_id A on relaunch, refreshes the access token silently if needed, and the first tool call succeeds. No browser, no prompt, no orphaned registrations.
Evidence from the server side (haops_auth_status-equivalent introspection on the server):
clients: 3 (one per Claude Code launch, all named "Claude Code (ha-ops)")
- 4fd4f93c... → redirect: http://localhost:50836/callback
- 66d8073f... → redirect: http://localhost:62091/callback
- e5dc7735... → redirect: http://localhost:3118/callback
access_tokens: 2 active, both valid for ~29 more days, scoped to two of the three clients above
refresh_tokens: 2 active, both valid for ~29 more days
The tokens never actually expire in practice — they're stranded under stale client_ids.
Additional Context
What I checked locally for an existing persistence layer:
~/.claude.json—mcpServersblock holds the URL + transport only; noclient_id/ token fields per server.~/.claude/.credentials.json— claude.ai OAuth, no MCP entries.~/.claude/mcp-needs-auth-cache.json— only "needs auth" flags for the claude.ai-bundled MCP servers (Gmail, Drive, Calendar); nothing for user-added servers.- macOS Keychain
Claude Code-credentials—claudeAiOauthkey only, no MCP entries. ~/Library/Application Support/Claude/— desktop app config; no MCP token store.- Project session jsonl + tool-results only under
~/.claude/projects/<slug>/.
If a persistence layer for MCP OAuth state exists, it isn't being populated for user-added SSE servers.
Related issues:
- #26675 — "Support pre-configured OAuth client credentials without requiring Dynamic Client Registration." Adjacent angle on the same "stop forcing DCR on every launch" problem; that issue targets enterprise providers that don't support DCR. The fix proposed here covers the complementary case: servers that do support DCR but should not have it run every launch.
- #53267 —
headersHelpernot re-invoked when access token expires on long-lived HTTP transport. Same family of "long-lived transport doesn't refresh credentials cleanly." - #58130 —
/mcp reconnectshould refresh both local and remote (cloud routine) connectors.
Environment:
- Claude Code CLI on macOS (Darwin 25.4.0).
- MCP server: Python
mcpSDK withOAuthAuthorizationServerProvider, SSE transport. - 30-day sliding access-token TTL on the server, 30-day refresh-token TTL.
This issue has 3 comments on GitHub. Read the full discussion on GitHub ↗