MCP OAuth credentials persisted with expiresAt: 0 and no refreshToken, forcing re-auth every session (mcp.grafana.com, mcp.slack.com)

Status Open
Reported on v2.1.274
Maintainer reply None cached
Activity 2 comments · opened Sep 17, 2026

Summary

Remote MCP servers authenticated over OAuth are persisted into ~/.claude/.credentials.json under mcpOAuth with expiresAt: 0 and no refreshToken. Since 0 is epoch (1970-01-01) it always evaluates as expired, and with no refresh token there is no recovery path — so the server reports ! Needs authentication and needs a manual browser sign-in at the start of every session.

The main Claude login (claudeAiOauth) is healthy; this is specific to the per-server mcpOAuth store.

Evidence — persisted entries (token values redacted; field names / expiry only)

| entry | expiresAt | refreshToken | behaviour |
|---|---|---|---|
| grafana (mcp.grafana.com) | 0 | absent | re-auth every session |
| plugin:slack:slack (mcp.slack.com) | 0 | absent | re-auth every session |
| atlassian (mcp.atlassian.com) | real future ts | present | works, refreshes silently |
| newrelic, supabase, vercel | field absent | absent | works, treated as non-expiring |

Two things stand out:

  1. Entries that omit expiresAt keep working, while entries that persist 0 always fail. 0 looks like a default written when the token response carries no usable expires_in, rather than a value the provider sent.
  2. Two unrelated providers (mcp.grafana.com, mcp.slack.com) produce the identical malformed shape, which points at the client's persistence path rather than anything provider-specific.

The grafana entry does persist clientId, issuer, scope, redirectUri etc. — only refreshToken is missing and expiresAt is zeroed.

The provider does support refresh tokens

$ curl -s https://mcp.grafana.com/.well-known/oauth-authorization-server
{
  "issuer": "https://mcp.grafana.com/mcp",
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "scopes_supported": ["grafana:read", "grafana:query", "grafana:write"],
  "response_types_supported": ["code"],
  "code_challenge_methods_supported": ["S256"]
}

refresh_token is advertised in grant_types_supported, yet no refresh token is persisted. (Note offline_access is not among the supported scopes, so requesting it is not the fix here — the server issues refresh tokens under the standard OAuth 2.1 / MCP flow.)

Expected behaviour

  • When the token response has no usable expires_in, omit expiresAt (the behaviour that already works for newrelic/supabase/vercel) rather than writing 0, which reads as permanently expired.
  • Persist refresh_token when the provider issues one, and exercise the refresh_token grant when the provider advertises it.

Reproduction

  1. claude mcp add --transport http grafana https://mcp.grafana.com/mcp
  2. Authenticate via /mcp (completes successfully)
  3. Inspect ~/.claude/.credentials.json → the mcpOAuth entry has expiresAt: 0 and no refreshToken
  4. Start a new session → claude mcp list reports grafana: ... ! Needs authentication

Not a duplicate

  • #83834 — same shape (expiresAt written as 0) but for claudeAiOauth, the main login.
  • #85262 — refresh dropping expiresAt/scopes, also claudeAiOauth.

Both concern the main login credential. This report is about the separate per-server mcpOAuth entries, with the main login working normally.

Environment

  • Claude Code 2.1.274
  • Linux
  • Servers: https://mcp.grafana.com/mcp (HTTP), https://mcp.slack.com/mcp (HTTP, via plugin)

View original on GitHub ↗

This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗