Five open issues, one bug: your MCP OAuth refresh token is being replayed after rotation A developer analyzing five open issues across OpenAI Codex, Anthropic's Claude Code, Google's Gemini CLI and NousResearch's Hermes Agent found they share a single root cause: MCP OAuth clients replay refresh tokens that servers have already rotated, and swallow the resulting invalid_grant error instead of persisting revocation and prompting re-authentication. The writeup traces the bug to a bare except block that returns a stale access token as valid, and to concurrent processes racing on a shared .credentials.json file, and proposes assertions maintainers can test against. I read five open issues across Codex, Claude Code, Gemini CLI and Hermes Agent in one sitting. They look like five different bugs. They are one bug wearing five costumes: the client keeps using a refresh token the server already rotated, and never tells you. Here is the pattern, the code that causes it, and the check that catches it before your users do. From the issues themselves: | Project | Symptom as reported | |---|---| | openai/codex 39054 22 comments | "a rejected refresh token stays usable, so Codex retries it forever and never surfaces re-authentication" | | openai/codex 48507 | "concurrent processes race refresh-token rotation in .credentials.json ; next startup hits invalid grant " | | anthropics/claude-code 89862 | " refresh token request omits scope , so the refreshed token comes back with the wrong scope and the server 401s" | | google-gemini/gemini-cli | "concurrent MCP OAuth refreshes race, and any refresh failure..." | | NousResearch/hermes-agent | "background agents replay a dead MCP OAuth refresh token" | Every one of these is the same three lines of logic missing: 400 invalid grant . OAuth 2.1 rotated refresh tokens are single-use by design. Once the authorization server hands you a new one, the old one is dead. A client that treats "I have a refresh token" as "I can refresh" will spin forever against a corpse. except that hides the death This is the shape I keep finding. It parses as defensive. It is the opposite: python def get access token : creds = load credentials if creds "access token" and not expired creds "access token" : return creds "access token" try: new = refresh creds "refresh token" save credentials new return new "access token" except HTTPError as e: if e.code == 401: pass fall through to return whatever we have raise return creds "access token" <-- the dead one, returned as if alive Two failures live in that pass : The rejection is not recorded. Nothing marks the refresh token as revoked. The next call loads the same record from disk, sees a non-empty refresh token , and repeats the identical doomed request. A stale token is returned as valid. Falling through to creds "access token" hands the caller a token that the resource server has already rejected, and the surface error becomes a generic 401 on the tool call — not "your auth is dead, re-login". The user sees a broken tool, not an auth problem. The two issues that say "never surfaces re-authentication" are exactly this line. The second failure mode needs no server at all. It needs two processes. Codex 48507 documents it cleanly: several TUI sessions plus an app-server daemon share one .credentials.json . Stripe access tokens live 1h, Supabase 24h. At T0+24h two processes refresh at once: php proc A: read refresh token=R1 - POST /token - gets R2 - writes {R2} proc B: read refresh token=R1 - POST /token - 400 invalid grant proc B did nothing wrong. It read a valid file and used a token that was valid a millisecond earlier. The server is correct to reject it: R1 was single-use and R1 is gone. But proc B — running the bare- except code above — now concludes the grant is broken and demands a full re-login. Every reboot. All servers at once. That is the reported symptom, and it is not a server bug. If you maintain an MCP client, these are the assertions worth having. They are cheap and each one maps to a real reported failure. 1. The rejection must be persisted, not swallowed. python def refresh access creds : try: new = post token endpoint creds, grant="refresh token" except OAuthError as e: if e.error == "invalid grant": mark revoked creds "server url" persist, do not pass raise ReauthRequired creds "server url" raise return new The test: force invalid grant twice, assert the second call never hits the network and the error is ReauthRequired , not a timeout or a 401 on an unrelated tool. 2. Rotation must be atomic across processes. Writing the file is not the race; read-modify-write is. A file lock around the refresh, plus a re-read inside the lock, collapses the race: with file lock CRED PATH : creds = load credentials re-read AFTER the lock if creds "access token" and not expired creds "access token" : return creds "access token" someone else already refreshed return refresh access creds The test: spawn two processes against a stub server that rotates on every call. With the re-read, both succeed and only one token request is made. Without it, one gets invalid grant . 3. scope must be sent on refresh. RFC 6749 §6 makes scope optional — omit it and the server should replay the original grant. Real servers do not. Claude Code 89862 has the table: php interactive login sends scope - scp=mcp.access - 200 client refresh omits scope - scp={guid} - 401 manual refresh sends scope - scp=mcp.access - 200 Same refresh token, same endpoint. The only difference is the parameter. Against Entra this fails reliably; other authorization servers fail less predictably, which is worse, because it presents as "auth breaks every hour or so". Send the original scope: payload = {"grant type": "refresh token", "refresh token": token} if original scope: payload "scope" = original scope do not rely on the server's memory The failure is not loud. A revoked refresh token produces a 401 on a tool call three layers away from the auth code. Your logs show a failed tool, not a dead grant. Users retry, which multiplies the attempts, which makes it look like a flaky server. The tell is the retry count on a token that was rejected once. If invalid grant appears more than once for the same server, refresh token pair in your logs, the client is replaying. That is the number to alert on. I turned these three checks into a skill people can drop into an agent workflow: mcp-oauth-debug — reproduces each failure against a stub authorization server, then asserts the three behaviors above. It exists because I hit the invalid grant loop in my own agent stack and spent longer than I want to admit blaming the server. If you are debugging one of the five issues above, the stub-server repro is the fastest way to know whether your client replays a dead token: rotate on every call and count the requests. The reproducible checks are packaged as a paid skill — mcp-oauth-debug https://skills.seu.pet/p/mcp-oauth-debug?utm source=devto&utm medium=article&utm campaign=mcp-oauth-refresh&utm content=pt-skill R$ 47, Stripe, instant delivery . The article above is the full diagnosis; the pack is the stub server and the assertions. Written while reading open issues on 2026-11-15. Issue numbers and quotes are from the public trackers on that date; line numbers and code inside each project may have moved since.