You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Concurrent refreshes of the same OAuth token share one network request to avoid token-rotation races. In packages/core/src/mcp/oauth.ts, the sharing wrapper only clones the first caller's response. As a result, a follower's AbortSignal is ignored, while aborting the first caller also rejects every follower.
A cancelled MCP operation can therefore remain pending, and a healthy caller can fail because another caller cancelled.
Plugins
None found in config.
OpenCode version
2.1.4 and current dev source
Steps to reproduce
Use a loopback token endpoint that waits before returning. Start two refresh requests with the same URL, client ID, refresh token, and different abort signals. Only one request reaches the endpoint.
Abort only the second caller, then release the endpoint.
Expected: the second caller rejects; the first receives the response.
Actual: both succeed.
Repeat, aborting only the first caller.
Expected: the first caller rejects; the second receives the response.
Thanks for the clear report. I reproduced this from source on the latest dev (08ac673). The MCP OAuth code is unchanged since v2.1.4.
I made two concurrent refresh requests through McpOAuth.loggedFetch with the same URL, client ID and refresh token. Each had its own AbortController, and the loopback token endpoint held its response until released. The endpoint was hit only once, as intended. Cancellation is not handled per caller:
Aborting only the second caller: both callers resolved with 200, so the cancelled caller still received the token.
Aborting only the first caller: both callers rejected with AbortError, so the caller that wasn't cancelled failed too.
The cause is that send in packages/core/src/mcp/oauth.ts passes only the first caller's init.signal to the shared request. Later callers just get a clone of that shared promise. The script below reproduces both cases.
I can take this issue if it is not already assigned.
I confirmed the current dev implementation still passes only the first caller's refresh signal to the shared request: aborting a follower does nothing, while aborting the first caller rejects every follower. I have a focused two-file fix ready in a worktree that keeps one underlying refresh request, removes its signal from the shared transport, and races each caller's returned clone against that caller's own AbortSignal.
The regression covers both abort directions with one loopback token endpoint. cd packages/core && bun run script/test.ts test/mcp-oauth.test.ts --rerun-each 3 passed (69 tests per run), bun typecheck passed, and git diff --check passed. The change is limited to packages/core/src/mcp/oauth.ts and its test.
This is separate from the cross-location rotating-token issue in #60612. Please confirm that this caller-scoped cancellation fix fits the intended boundary before I open a PR.
Follow-up after #62827, at dev 12a661ea40 (Bun 1.4.4, MCP SDK 2.3.1):
The direct loggedFetch reproducer still shows the signal coupling, but SDK refreshAuthorization/executeTokenRequest do not pass a caller init.signal into the token fetch.
I checked the real McpClient.connect path with the token endpoint held open. Interrupting the caller settles before its response is released; the shared refresh then persists the rotated tokens. A second SDK refresh with the old token receives the saved result, with one token request. Unmodified dev and a caller-signal patch behave identically, consistent with #59114's withdrawal.
I'm withdrawing my earlier proposal to fix this only inside loggedFetch; it needs a failing reproduction through the affected MCP entry point first. Loopback fixtures only; no live providers. AI-assisted investigation.
Activity
opencode-agent commentedon Oct 26, 2026
Thanks for the clear report. I reproduced this from source on the latest
dev(08ac673). The MCP OAuth code is unchanged since v2.1.4.I made two concurrent refresh requests through
McpOAuth.loggedFetchwith the same URL, client ID and refresh token. Each had its ownAbortController, and the loopback token endpoint held its response until released. The endpoint was hit only once, as intended. Cancellation is not handled per caller:AbortError, so the caller that wasn't cancelled failed too.The cause is that
sendinpackages/core/src/mcp/oauth.tspasses only the first caller'sinit.signalto the shared request. Later callers just get a clone of that shared promise. The script below reproduces both cases.Reproduction script (repro.ts)
Run it:
bun repro.tsr266-tech commentedon Nov 1, 2026
I can take this issue if it is not already assigned.
I confirmed the current
devimplementation still passes only the first caller's refresh signal to the shared request: aborting a follower does nothing, while aborting the first caller rejects every follower. I have a focused two-file fix ready in a worktree that keeps one underlying refresh request, removes its signal from the shared transport, and races each caller's returned clone against that caller's ownAbortSignal.The regression covers both abort directions with one loopback token endpoint.
cd packages/core && bun run script/test.ts test/mcp-oauth.test.ts --rerun-each 3passed (69 tests per run),bun typecheckpassed, andgit diff --checkpassed. The change is limited topackages/core/src/mcp/oauth.tsand its test.This is separate from the cross-location rotating-token issue in #60612. Please confirm that this caller-scoped cancellation fix fits the intended boundary before I open a PR.
r266-tech commentedon Nov 9, 2026
Follow-up after #62827, at
dev 12a661ea40(Bun 1.4.4, MCP SDK 2.3.1):The direct
loggedFetchreproducer still shows the signal coupling, but SDKrefreshAuthorization/executeTokenRequestdo not pass a callerinit.signalinto the token fetch.I checked the real
McpClient.connectpath with the token endpoint held open. Interrupting the caller settles before its response is released; the shared refresh then persists the rotated tokens. A second SDK refresh with the old token receives the saved result, with one token request. Unmodifieddevand a caller-signal patch behave identically, consistent with #59114's withdrawal.I'm withdrawing my earlier proposal to fix this only inside
loggedFetch; it needs a failing reproduction through the affected MCP entry point first. Loopback fixtures only; no live providers. AI-assisted investigation.