MCP OAuth: access token never persisted — server silently reverts to unauthenticated after restart
MCP OAuth: access token is never persisted — server works until restart, then silently reverts to unauthenticated
Environment
- Claude Code 2.1.222, Node v26.6.0, macOS 26.6 (arm64)
CLAUDE_CONFIG_DIR=~/.local/claude(launchd-injected)- Credential store: macOS Keychain, generic-password
Claude Code-credentials-<hash> - Also reproduced while running under the
nono0.71.0 sandbox, but the sandbox is not implicated — see "Ruled out" below.
Summary
Completing the OAuth flow for a remote ("type": "http") MCP server authenticates the running process but does not persist the access token. The Keychain entry is created and populated with the client-registration fields only. After a restart the server reports unauthenticated again, with no error surfaced anywhere.
Expected
After a successful OAuth flow, the server's entry under mcpOAuth contains a non-empty accessToken, and the server remains authenticated across restarts.
Actual
The entry is created with clientId, redirectUri, expiresAt, serverUrl, serverName, discoveryState — but accessToken is the empty string. refreshToken is absent.
In my store, 19 of 20 MCP servers were in this half-finished state:
plugin:linear:linear|638130d5… accessToken len=0 refresh=no
<16 further entries elided> accessToken len=0 refresh=no
plugin:sentry:sentry|800cb29a… accessToken len=57 refresh=yes ← the only working one
Exactly one server (Sentry) had a real token, and Sentry was the only server whose tools were available.
Steps to reproduce
- Authenticate a remote MCP server via
/mcp(observed with Linear,https://mcp.linear.app/mcp). - Flow completes; the server's real tools appear;
/mcpreports it as connected. - Restart or resume the session.
- Server reports unauthenticated; only its
authenticate/complete_authenticationtools are exposed. - Inspect the Keychain — the entry exists,
accessTokenis"".
Diagnostic
security find-generic-password -s "Claude Code-credentials-<hash>" -w \
| python3 -c "import sys,json;[print(k, len(v.get('accessToken','') or '')) for k,v in json.load(sys.stdin)['mcpOAuth'].items()]"
What does work
Calling the server's authenticate tool from within the session that needs the token, opening the returned URL manually, and letting the localhost listener capture the callback does persist correctly (accessToken len=86, refreshToken present). So the exchange-and-persist path is functional; something about the /mcp path is not completing it.
Ruled out
- Not a sandbox issue. The Keychain is explicitly granted via
bypass_protection_paths; direct reads succeed. Outbound HTTPS to the MCP host is permitted. The localhost callback listener (127.0.0.1:3118) binds and accepts connections. - Not a config-dir issue. Same
CLAUDE_CONFIG_DIRin both sessions. - Not
~/.local/claude/.credentials.json. That file is vestigial — everyaccessTokenin it is empty, including Sentry's, which works. It appears to be read by nothing.
Impact
The failure is silent and misattributed. The user sees a successful browser authorisation and a "connected" status, then finds the server unauthenticated later with no indication why — and re-authenticating appears to work again each time, reinforcing the belief that it succeeded. Several hours were lost diagnosing this.
Suggested fixes
- Fail loudly if the token exchange does not produce a non-empty
accessToken, rather than persisting a partial entry. - Treat an entry with an empty
accessTokenas absent, so the UI doesn't report a half-written entry as connected.