anthropics / anthropics/claude-code
MCP OAuth credentials persisted with expiresAt: 0 and no refreshToken, forcing re-auth every session (mcp.grafana.com, mcp.slack.com)
- Dominant language
- Python
- Stars
- 145k
- Forks
- 23.1k
- PR merge metrics
- PR metrics pending
Description
## 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)
Contributor guide
No contributing guide indexed for this repository
Research direction
Start with the mcpOAuth entries in ~/.claude/.credentials.json and trace the OAuth persistence path used by `claude mcp add --transport http`. Reproduce with the Grafana or Slack server, then verify that a response without usable `expires_in` omits `expiresAt`, issued refresh tokens are stored, and a new session refreshes without browser authentication.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- authentication, cli
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 52/100