[Bug] MCP servers silently unavailable when tokenless credential stub blocks Keychain refresh
Bug Description
An mcpOAuth entry in ~/.claude/.credentials.json that has the key structure but empty
accessToken/refreshToken and no expiresAt (the code calls this a "tokenless stub") is treated
as "this server needs authentication," even when the macOS Keychain holds a perfectly valid,
unexpired token for that server.
Startup then writes a needs-auth marker to ~/.claude/mcp-needs-auth-cache.json and skips the
connection. Skipping the connection is what prevents reaching the code that would refresh the
token or clear the stub. The state is therefore self-sealing and never expires on its own.
Net effect: MCP servers are silently unavailable in every new session, indefinitely, while every
ordinary diagnostic reports healthy. Re-authenticating does not survive a restart.
## Environment
- Claude Code 2.1.224 (also reproduced on 2.1.223), Homebrew cask, /opt/homebrew/bin/claude
- macOS 26.6 (25G72), arm64
- Affected servers: two remote HTTP MCP servers (https://mcp.notion.com/mcp, and a plugin-sourced
https://mcp.slack.com/mcp)
- Persisted from ~Aug 1 to Aug 17 2026 across multiple version upgrades and several repair attempts
## Symptoms, and why they mislead
Every routine check reports green:
- claude mcp list → ✔ Connected for both servers. It bypasses the needs-auth cache and reads the
Keychain, so it reports on credentials, not on what a session will actually load.
- The Keychain entries are valid and unexpired.
- /mcp re-auth works — but only for the live process; the state is back on next launch.
The only places the failure surfaces:
- the startup banner ("N MCP servers need authentication")
- headless runs logging Skipping connection (cached needs-auth)
- the session's tool list containing only mcp__<server>__authenticate /
mcp__<server>__complete_authentication with the real tools absent
Because a session that connected before the stub was written keeps working from its in-memory
tokens, a post-repair verification performed from that session looks fully healthy. The only valid
test is a fresh launch.
## Root cause
### The stub is a recognized state with dedicated repair code
From the 2.1.224 bundle (minified identifiers; Aa() is the credentials store, Am() builds the
<serverName>|<sha256-16> key):
``js
async function k1o(e, t) {
let r = Am(e, t), n = (await Aa().readAsync())?.mcpOAuth?.[r];
if (!n || n.accessToken || n.refreshToken) return; // has tokens → nothing to do
try {
await Aa().mutate((o) => {
let i = o.mcpOAuth?.[r];
if (!i || i.accessToken || i.refreshToken) return o;
let s = { ...o.mcpOAuth };
return delete s[r], { ...o, mcpOAuth: s }; // delete the stub
});
} catch (o) { bt(e, clear tokenless stub failed: ${ue(o)}); }
}
`
So a tokenless stub is understood to be a transient artifact that should be deleted on sight. The
defect is that when it is *not* cleared, it is indistinguishable from a genuine needs-auth state.
### The needs-auth cache suppresses the only path that repairs it
`js
function v_t() { return path.join(Tn(), "mcp-needs-auth-cache.json") }
function E_t() { if (!mun) mun = read(v_t()).then(Gt).catch(() => ({})); return mun }
function Tyr() { mun = null }
function vyr() { mun = null; delete(v_t()).catch(() => {}) }
var mun = null;
`
mun is a module-level memo: within a process the cache is read once and never re-read. Combined
with the connection-skip path:
`js
if (S.type !== "claudeai-proxy" && S.pluginSource === void 0)
bt(b, "Skipping connection (cached needs-auth)");
n({ client: { name: b, type: "needs-auth", config: S }, tools: R9e(b, S), commands: [] });
return;
`
...the loop closes: stub → needs-auth verdict → cache stamped → connection skipped → token never
refreshed and k1o never reached → stub persists → repeat on every launch. Clearing the cache alone
does not help, because the stub regenerates the marker on the next start.
### Observed timeline (single morning, mtime + ps elimination)
| time | event |
|---|---|
| 09:24:11 | manual repair: .credentials.json → {}, cache deleted, claude mcp login notion |
| 09:24:27 | session A starts, reads {}, falls through to Keychain → **MCP works** (verified with live tool calls) |
| 09:29:05 | .credentials.json rewritten with **blanked** entries for both servers. Session A was the only claude` process alive |
| 09:37 | cache still absent, Keychain healthy, live calls still succeed — appears fixed |
| 09:51:55 | next session starts, reads the blanked file, marks both servers needs-auth, stamps the…
Note: Content was truncated.
This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗