Repository navigation
Description
Summary
OpenCode refreshes a remote MCP server's OAuth token from several requests at once: one per location (project directory or worktree), and sometimes twice within a single location. Servers that rotate refresh tokens (Grafana Cloud MCP and Buildkite MCP here) accept only one of those refreshes. The loser gets invalid_grant, OpenCode invalidates and deletes the shared credential, and the server shows "needs authentication" in every location until the user signs in again.
Environment
- opencode version: v2.2.0
- OS: Linux 6.12.63 NixOS x86_64 (remote VM; desktop app on macOS connects to it)
- Terminal: Rex (TERM_PROGRAM=rex, TERM=xterm-256color, COLORTERM=truecolor)
- Shell: bash
- Install/channel: latest
- Active plugins: none
Reproduction
- Configure a remote MCP server with OAuth whose authorization server rotates refresh tokens, e.g.
https://mcp.grafana.com/mcp/<stack>.grafana.netorhttps://mcp.buildkite.com/mcp. - Sign in via
/mcps. - Keep sessions open in two or more locations (for example the main checkout and a git worktree) so each location has its own connection.
- Wait for the access token to expire (about 1 hour for both servers).
Expected Behavior
One refresh per credential at a time, shared by every location and connection that uses it. Concurrent callers wait for that refresh and reuse its result. A rejected refresh should not delete a credential that another caller just refreshed successfully.
Actual Behavior
Two or three refresh requests start within a few milliseconds of each other for the same credential. With rotating refresh tokens at most one can win. The rest get invalid_grant, after which OpenCode logs mcp oauth credential invalidated and credential removed for the shared credential.
Grafana (both concurrent refreshes from the same location, 2 ms apart, both rejected):
11:04:38.777Z INFO mcp oauth request started server=grafana directory=~/src/project-worktrees/feature-a operation=refresh
11:04:38.779Z INFO mcp oauth request started server=grafana directory=~/src/project-worktrees/feature-a operation=refresh
11:04:38.913Z WARN mcp oauth request rejected status=400 error=invalid_grant message="invalid, expired, or already rotated refresh token"
11:04:38.914Z WARN mcp oauth request rejected status=400 error=invalid_grant message="invalid, expired, or already rotated refresh token"
11:04:38.915Z WARN mcp oauth credential invalidated credentialID=cred_18215478f0013LIIpyQrwL0pme scope=tokens
11:04:38.923Z INFO credential removed credentialID=cred_18215478f0013LIIpyQrwL0pme active=true replacementID=null
Buildkite (three refreshes from two locations; the earlier hourly refresh at 08:06:43 had also run twice, 3 ms apart, and both "succeeded", which likely left a rotated-out refresh token stored):
08:06:43.035Z INFO mcp oauth request started server=buildkite directory=~/src/project
08:06:43.230Z INFO mcp oauth request started server=buildkite directory=~/src/project
08:06:43.230Z INFO mcp oauth request succeeded
08:06:43.233Z INFO mcp oauth request succeeded
...
11:04:39.176Z INFO mcp oauth request started server=buildkite directory=~/src/project-worktrees/feature-a operation=refresh
11:04:39.609Z INFO mcp oauth request started server=buildkite directory=~/src/project-worktrees/feature-a operation=refresh
11:04:39.613Z INFO mcp oauth request started server=buildkite directory=~/src/project operation=refresh
11:04:39.613Z WARN mcp oauth request rejected status=400 error=invalid_grant message="Invalid or expired refresh token" (x3)
11:04:39.614Z WARN mcp oauth credential invalidated credentialID=cred_1835631e0001MOwYCZ9akOB0mm scope=tokens (x3)
11:04:39.617Z INFO credential removed credentialID=cred_1835631e0001MOwYCZ9akOB0mm active=true replacementID=null
Additional Context
- Frequency: Buildkite lost its credential this way twice in two days (2026-10-28 21:49 and 2026-10-30 11:04 UTC); Grafana once (2026-10-30 11:04). Same-ms double refreshes that happen to succeed show up roughly every hour.
- Both servers rotate refresh tokens on every use, as OAuth 2.1 recommends for public clients, so this probably affects any rotating provider, not just these two.
- Local stdio servers show the same shape outside OpenCode's OAuth code: a wrapper that refreshes an Argo CD SSO token at startup ran once per location and invalidated the single-use refresh token the same way.
- Workaround: where the server offers it, use a non-expiring API token with
oauth: false(Buildkite'shttps://mcp.buildkite.com/direct). Grafana Cloud MCP is OAuth-only, so it has no workaround. - Suggested fix: single-flight refresh per credential ID across all locations and connections, re-read the stored credential after acquiring the refresh lock, and on
invalid_grantcheck whether the stored credential changed since the request started before invalidating it.
Activity
opencodejr commentedon Oct 30, 2026
Thanks for the detailed report and logs. I could reproduce this on the latest
dev(65c0d83), using a test against OpenCode's MCP OAuth code rather than the real Grafana or Buildkite servers. That code is unchanged since v2.2.0.The test sets up a local token server that accepts only the newest refresh token, and two connections in different locations that share one stored credential with an expired access token:
r1 -> r2).r1from storage and sends it again.invalid_grant, OpenCode logsmcp oauth credential invalidated, and the shared credential is removed. The first location was fine, but the server now needs authentication everywhere.Concurrent refreshes only share one request while that request is still running. Once the response arrives, the shared request is dropped before the new tokens are saved, so a connection that reads the stored credential in that gap gets the old token. The check that avoids deleting a credential another connection just refreshed doesn't help here, because the new tokens haven't been saved yet. Nothing coordinates refreshes between separate processes either.
Related:
The script below reproduces it (run it with
bun testinpackages/coreof a dev checkout).Reproduction script (mcp-oauth-race.test.ts)
Run it:
bun mcp-oauth-race.test.tsAntonio-Faure commentedon Oct 31, 2026
Additional analysis from reading
packages/core/src/mcp/oauth.ts:senddeduplicates only in-flight refreshes (l.31-55) and removes the entry as soon as the response arrives, beforesaveTokens(l.306-316) persists the rotated token.invalidate(l.292-298) sees the row still holding the old token and removes the shared credential before the winner'supdateruns.saveTokensso a late write cannot overwrite a newer rotation; do not remove the credential oninvalid_grant.saveTokens, assert one network refresh andrefresh === "r2"afterwards.The cross-process race remains because this path does not use
Credential.lock(credential.ts:46-50,98-114), so that would be a separate follow-up.ggeoffreyginer-hub commentedon Nov 4, 2026
Independent confirmation on macOS / stable channel, with a third rotating-refresh-token provider (Noota,
https://mcp.noota.io/mcp).mcp-auth.jsonshared by several long-running sessions opened in different working directories.failed to get resourceserrors 1-47 ms apart within the same run, then the credential is gone (.tokensremoved from~/.local/share/opencode/mcp-auth.json) and every session shows "needs authentication". Recurs every 2-5 days of normal multi-session use; full browser re-auth required each time (logs since Sep 19).global event disconnected/global event connected), the next resource fetch fails withUnauthorizedand the credential is purged, even though the stored access token was still within its stated 4 h validity window at that moment.The provider's engineering team confirmed the following to us in writing, which directly supports the fix proposed above:
400 invalid_grant); the refreshed tokens obtained by the winning request remain fully valid.invalid_grant, the client should re-read the freshly stored tokens and retry with the new refresh token; a single-flight refresh with a shared lock on the token storage is the recommended durable design.Happy to provide anonymized logs or help reproduce against the Noota MCP if useful.
Antonio-Faure commentedon Nov 4, 2026
Thanks for the independent confirmation and for getting the provider's statement in writing.
This matches the mechanism in
packages/core/src/mcp/oauth.ts: the in-flight map only shares a refresh while the request is running, and the row is removed before the winningsaveTokenshas persisted the rotated token. A second process that read the old token can therefore replay it, getinvalid_grant, and — because the stored row still holds the old token at that point — delete the shared credential.The durable fix is the one in #60944: run the refresh under
Credential.lock(in-process sharing plus theflockfile lock), re-read the row after acquiring the lock, skip the refresh if another connection already replaced the rejected access token, and save the rotated tokens under the same lock. That is the single-flight plus shared-storage lock the provider recommends.One practical blocker: #60944 is currently 152 commits behind
devand GitHub reportsmergeable_state: dirty, so it needs a rebase before it can land.If you can share the anonymized logs — timestamps of the two
failed to get resourceserrors, whether the.tokensentry was removed, and theinvalid_grantresponse body — that would let us verify the fix against Noota as well.ggeoffreyginer-hub commentedon Nov 4, 2026
Thanks for the quick analysis, and for pointing to #60944: the
Credential.lock+ re-read-after-acquire design matches exactly what the provider recommends (single-flight refresh plus shared-storage lock).Here is the anonymized evidence you asked for.
1. Timestamps of the failed resource fetches (opencode 1.18.32, macOS; UTC, from
~/.local/share/opencode/log/opencode.log; both lines of each pair belong to the same run):The 2026-11-04 00:53/00:55 UTC events were each preceded by
global event disconnectedthenglobal event connected(00:53:14.424 -> 00:53:17.783, and 00:55:25.781 -> 00:55:31.882).2. Whether the
.tokensentry was removed: yes.jq '.noota.tokens.expiresAt' ~/.local/share/opencode/mcp-auth.jsonreturned null, i.e. thetokensobject was gone from the stored entry, while the entry itself (clientInfo,serverUrl) was still present.tokensis back (accessToken,expiresAt,refreshToken).3.
invalid_grantresponse body: not available from this installation.1.18.32 logs only the resource-fetch level errors (
Unauthorized,fetch failed,Request timed out) and does not log the refresh request/response. Grepping the full log forinvalid_grantreturns nothing.Happy to run a build with #60944 (once it is rebased) against the Noota MCP with debug logging, to capture the response body and verify the fix against Noota as well.
Antonio-Faure commentedon Nov 4, 2026
Thanks — this is exactly the evidence needed.
The
.tokensremoval whileclientInfoandserverUrlremain is the currentinvalidateCredentialspath: it deletes the credential when the stored refresh token still equals the one this connection presented. The same-millisecond / 1 ms / 47 ms pairs, and the 00:53 and 00:55 UTC events right afterglobal event disconnected/connected, match the mid-session 401 path, not just connect-time. No need to chase theinvalid_grantbody; the stored-token disappearance plus the provider's statement is conclusive.#60944 is the right fix but is currently 152 commits behind
devand GitHub reports it asdirty. I flagged that on the PR. Once it is rebased, your offer to run a Noota build with debug logging would be the best verification; I will reply here when a rebased branch is available.