claude-code - 💡(How to fix) Fix [FEATURE] Persist MCP OAuth client registrations and refresh tokens across CLI launches [2 comments, 2 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
anthropics/claude-code#58607Fetched 2026-05-14 03:43:54
View on GitHub
Comments
2
Participants
2
Timeline
11
Reactions
0
Author
Timeline (top)
labeled ×4commented ×2closed ×1cross-referenced ×1

Root Cause

  • Disable OAuth on the server side. For self-hosted MCP on a trusted LAN, setting auth_enabled: false and 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_id to 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.

Fix Action

Fix / Workaround

  • Disable OAuth on the server side. For self-hosted MCP on a trusted LAN, setting auth_enabled: false and 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_id to 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.

Code Example

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
RAW_BUFFERClick to expand / collapse

Preflight Checklist

  • I have searched existing requests and this feature hasn't been requested yet
  • 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):

  1. The client_id and client_secret returned from DCR.
  2. The refresh token from the most recent authorization-code or refresh-token exchange.
  3. 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: false and 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_id to 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:

  1. I run a self-hosted MCP server on my home LAN. It implements OAuth 2.0 via the official mcp Python SDK's OAuthAuthorizationServerProvider. Access tokens are issued with a 30-day TTL and a sliding window that extends on every successful verification.
  2. I add the server via claude mcp add --transport sse ha-ops http://10.0.0.150:8901/sse.
  3. Claude Code opens a browser, completes DCR + authorization-code flow, server issues an access token and refresh token bound to client_id A. I work happily.
  4. I quit Claude Code.
  5. 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_id A) and the auth prompt opens again. The server now has two client_id entries on file; the old token under client_id A is still valid for 29 more days but orphaned and unreachable.
  6. 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.jsonmcpServers block holds the URL + transport only; no client_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-credentialsclaudeAiOauth key 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 — headersHelper not re-invoked when access token expires on long-lived HTTP transport. Same family of "long-lived transport doesn't refresh credentials cleanly."
  • #58130 — /mcp reconnect should refresh both local and remote (cloud routine) connectors.

Environment:

  • Claude Code CLI on macOS (Darwin 25.4.0).
  • MCP server: Python mcp SDK with OAuthAuthorizationServerProvider, SSE transport.
  • 30-day sliding access-token TTL on the server, 30-day refresh-token TTL.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING