MCP OAuth: access token never persisted — server silently reverts to unauthenticated after restart

Status Open
Reported on v2.1.222
Maintainer reply None cached
Activity 0 comments · opened Aug 5, 2026

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 nono 0.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

  1. Authenticate a remote MCP server via /mcp (observed with Linear, https://mcp.linear.app/mcp).
  2. Flow completes; the server's real tools appear; /mcp reports it as connected.
  3. Restart or resume the session.
  4. Server reports unauthenticated; only its authenticate / complete_authentication tools are exposed.
  5. Inspect the Keychain — the entry exists, accessToken is "".

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_DIR in both sessions.
  • Not ~/.local/claude/.credentials.json. That file is vestigial — every accessToken in 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

  1. Fail loudly if the token exchange does not produce a non-empty accessToken, rather than persisting a partial entry.
  2. Treat an entry with an empty accessToken as absent, so the UI doesn't report a half-written entry as connected.

View original on GitHub ↗