MCP OAuth credentials persisted with expiresAt: 0 and no refreshToken, forcing re-auth every session (mcp.grafana.com, mcp.slack.com)
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:
- Entries that omit
expiresAtkeep working, while entries that persist0always fail.0looks like a default written when the token response carries no usableexpires_in, rather than a value the provider sent. - 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, omitexpiresAt(the behaviour that already works fornewrelic/supabase/vercel) rather than writing0, which reads as permanently expired. - Persist
refresh_tokenwhen the provider issues one, and exercise therefresh_tokengrant when the provider advertises it.
Reproduction
claude mcp add --transport http grafana https://mcp.grafana.com/mcp- Authenticate via
/mcp(completes successfully) - Inspect
~/.claude/.credentials.json→ themcpOAuthentry hasexpiresAt: 0and norefreshToken - Start a new session →
claude mcp listreportsgrafana: ... ! Needs authentication
Not a duplicate
- #83834 — same shape (
expiresAtwritten as0) but forclaudeAiOauth, the main login. - #85262 — refresh dropping
expiresAt/scopes, alsoclaudeAiOauth.
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)
This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗