[BUG] MCP OAuth: rotated refresh token lost when the Keychain write fails, and Keychain read timeouts reported as no token (macOS, under load)
Preflight Checklist
- [x] I have searched existing issues. Related but distinct: #93537 (Keychain blob entries zeroed by concurrent processes), #85236 (per-process refresh lock; the 2.1.289 build does take a cross-process lock file), #91641 (shared refresh token vs single-use rotation).
- [x] This is a single bug report (one root cause: MCP OAuth credential-store reads and writes failing under load are treated as success-or-absent).
- [x] Latest version: 2.1.289 (logs below from 2.1.288).
What's Wrong?
On macOS with many concurrent claude sessions on a heavily loaded machine (load average 100-250, ~60 claude processes, user-scope HTTP MCP server with OAuth), two Keychain failures in the MCP OAuth provider sign users out of an MCP server that has valid credentials.
1. A successful refresh is lost when the Keychain write fails. The provider refreshes, the authorization server rotates the refresh token and returns a new one, then saveTokens fails (Token persist failed: storage write failed). Nothing retries the write, and storage keeps the old refresh token, which the server has already consumed. The next refresh, from this or any other session, presents that consumed token. A server with single-use rotation and reuse detection (as OAuth 2.1 / RFC 9700 recommends for public clients) treats it as token theft and revokes the whole token family, signing out every session.
02:53:16.124 Token expires in 187s, attempting proactive refresh
02:53:26.966 Acquiring refresh lock (attempt 1)
02:53:29.073 Token refresh successful
02:53:29.073 Saving tokens
02:53:29.073 Token expires in: 3600
02:53:29.098 Refresh lock held by another process, waiting (attempt 1/5)
02:53:43.037 Token refresh already in progress, reusing existing promise
02:53:47.169 Token persist failed: storage write failed <- rotated token never stored
02:55:05.265 Starting token refresh <- next refresh uses the stored, consumed token
02:58:42.279 Token expires in -139s, attempting proactive refresh
02:59:05.212 Token refresh failed with invalid_grant: invalid or expired refresh token
02:59:34.800 No valid tokens in storage, clearing stored tokens
02:59:39.831 Refresh lock was compromised
On the server, at 02:58:50 the old token's replay was refused with reuse detection and the family was revoked. This happened twice in one day (02:58 and 06:21 UTC) and once the day before.
2. A Keychain read that times out is reported as "no token". With valid tokens in the Keychain (saved at 15:41 and used successfully by three other sessions at 15:47 and 16:00), a session started at 16:01 logged No access token in storage three times and prompted for a full re-authentication. The security find-generic-password call has a 2 s timeout, and its failure path returns null (absent) rather than a transient error. Under load the read takes longer than 2 s (one in-lock storage read here took 4.7 s).
What Should Happen?
- When
saveTokensfails after a successful refresh, the provider should retry the write and keep the new tokens in memory for the process, so the next refresh does not fall back to a consumed refresh token. At minimum it should never present a refresh token it knows it has just rotated. - A Keychain read that times out or fails should be treated as transient: retry, or keep the last good in-memory credentials. It should not be treated as "no credentials", which forces an interactive re-auth while valid tokens exist.
- The 2 s Keychain timeout could be configurable, or adaptive under load.
Workaround we are deploying (server side)
Our server will treat a replay of a rotated token whose successor was never presented as a lost write, not theft, and issues 24 h access tokens (as Notion's MCP does) so refreshes are rarer. That cannot help case (2), which is session-local.
Environment
- Claude Code 2.1.288 / 2.1.289, native install
- macOS 26 (Darwin 25.5.0), 64 GB
- Remote HTTP MCP server, user scope, OAuth via CIMD client id, rotating refresh tokens with a 30 s reuse grace
- Logs from
~/Library/Caches/claude-cli-nodejs/<project>/mcp-logs-<server>/