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)

Open
#95,113 0 comments 0 reactions 0 assignees View on GitHub
area:auth area:mcp bug has repro platform:linux
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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.