Summary
When two or more tool calls targeting the same OAuth-protected, Streamable HTTP
MCP server are dispatched concurrently and the access token has expired,
each call independently triggers Refreshing authentication for <server>.
Every refresh creates a new rmcp::service instance, which cancels the
previously-created service. The tool call whose session was cancelled receives
"MCP transport closed before the tool responded", while only the last service
to complete initialization succeeds.
Affected version
GitHub Copilot CLI 1.0.79
Steps to reproduce
- Configure a remote Streamable HTTP MCP server with OAuth in
`~/.copilot/mcp-config.json`.
- In a single assistant turn, trigger two or more tool calls to that
server simultaneously (e.g. by making parallel tool calls that map to the
same server).
- Let the cached access token expire (or clear the tokens file) so a refresh
is required when the calls fire.
- Observe that one or more calls fail with:
MCP server '': Error: MCP request failed: MCP transport closed
before the tool responded
CLI logs (anonymised)
The following is extracted from
`~/.copilot/logs/process--.log`:
```
[ERROR] Refreshing authentication for ... ← triggered by call A
[ERROR] Successfully authenticated with
[rust:rmcp::service] task cancelled ← service for call A killed
[rust:rmcp::transport::streamable_http_client] delete session success
[rust:rmcp::service] serve finished {"quit_reason":"Cancelled"}
[rust:rmcp::service] Service initialized as client {...} ← new service, call A session ready
[ERROR] Refreshing authentication for ... ← triggered by call B (concurrent)
[ERROR] Successfully authenticated with
[rust:rmcp::service] task cancelled ← kills the session just created for call A
[rust:rmcp::transport::streamable_http_client] delete session success
[rust:rmcp::service] serve finished {"quit_reason":"Cancelled"}
[WARNING] Failed to refresh persisted MCP tools for ;
continuing with cached tools: Error: MCP server "" is not connected
[rust:rmcp::service] Service initialized as client {...} ← third init, only call B survives
```
Server-side evidence (anonymised)
MCP server logs confirm that the server successfully creates a new transport
session for the call whose service was cancelled, but never receives a
tool call on that session — because the client-side rmcp service was already
torn down:
```
{"level":"INFO","message":"Created new transport with session ID: ",
"correlation_id":""}
{"level":"INFO","message":"Created new transport with session ID: ",
"correlation_id":""}
{"level":"INFO","message":"Tool called via Streamable HTTP",
"correlation_id":"","tool":"","status":"called"}
{"level":"INFO","message":"Tool completed via Streamable HTTP",
"correlation_id":"","status":"completed","total":N}
```
Session <session-A> is created but never used; only <session-B> receives
tool calls.
Root cause (analysis)
The OAuth refresh path in the Rust MCP client has no mutex / debounce for
concurrent refreshes against the same server. Each concurrent call:
- Detects an expired (or missing) access token.
- Independently calls `Refreshing authentication for `.
- Tears down the current rmcp service (`task cancelled`) and spins up a new
one with the freshly-obtained token.
Because step 3 is not serialised, the second refresh cancels the session the
first refresh just created. The first call's promise resolves as
"transport closed" because its underlying rmcp service no longer exists.
This is related to but distinct from #3456 (refresh-token rotation/reuse
detection) and #4203 (interactive re-auth instead of silent grant): here the
token refresh itself succeeds silently — the failure is caused by the
uncoordinated service restarts that follow.
Expected behaviour
Concurrent tool calls to the same server should serialise (or coalesce) the
token-refresh + service-restart sequence so that exactly one refresh occurs and
all in-flight calls are queued on the resulting stable session.
Workaround
Dispatch tool calls to the affected server sequentially rather than in
parallel, ensuring each call completes its refresh + session initialisation
before the next one begins.
Summary
When two or more tool calls targeting the same OAuth-protected, Streamable HTTP
MCP server are dispatched concurrently and the access token has expired,
each call independently triggers
Refreshing authentication for <server>.Every refresh creates a new
rmcp::serviceinstance, which cancels thepreviously-created service. The tool call whose session was cancelled receives
"MCP transport closed before the tool responded", while only the last service
to complete initialization succeeds.
Affected version
GitHub Copilot CLI 1.0.79
Steps to reproduce
`~/.copilot/mcp-config.json`.
server simultaneously (e.g. by making parallel tool calls that map to the
same server).
is required when the calls fire.
CLI logs (anonymised)
The following is extracted from
`~/.copilot/logs/process--.log`:
```
[ERROR] Refreshing authentication for ... ← triggered by call A
[ERROR] Successfully authenticated with
[rust:rmcp::service] task cancelled ← service for call A killed
[rust:rmcp::transport::streamable_http_client] delete session success
[rust:rmcp::service] serve finished {"quit_reason":"Cancelled"}
[rust:rmcp::service] Service initialized as client {...} ← new service, call A session ready
[ERROR] Refreshing authentication for ... ← triggered by call B (concurrent)
[ERROR] Successfully authenticated with
[rust:rmcp::service] task cancelled ← kills the session just created for call A
[rust:rmcp::transport::streamable_http_client] delete session success
[rust:rmcp::service] serve finished {"quit_reason":"Cancelled"}
[WARNING] Failed to refresh persisted MCP tools for ;
continuing with cached tools: Error: MCP server "" is not connected
[rust:rmcp::service] Service initialized as client {...} ← third init, only call B survives
```
Server-side evidence (anonymised)
MCP server logs confirm that the server successfully creates a new transport
session for the call whose service was cancelled, but never receives a
tool call on that session — because the client-side rmcp service was already
torn down:
```
{"level":"INFO","message":"Created new transport with session ID: ",
"correlation_id":""}
{"level":"INFO","message":"Created new transport with session ID: ",
"correlation_id":""}
{"level":"INFO","message":"Tool called via Streamable HTTP",
"correlation_id":"","tool":"","status":"called"}
{"level":"INFO","message":"Tool completed via Streamable HTTP",
"correlation_id":"","status":"completed","total":N}
```
Session
<session-A>is created but never used; only<session-B>receivestool calls.
Root cause (analysis)
The OAuth refresh path in the Rust MCP client has no mutex / debounce for
concurrent refreshes against the same server. Each concurrent call:
one with the freshly-obtained token.
Because step 3 is not serialised, the second refresh cancels the session the
first refresh just created. The first call's promise resolves as
"transport closed" because its underlying rmcp service no longer exists.
This is related to but distinct from #3456 (refresh-token rotation/reuse
detection) and #4203 (interactive re-auth instead of silent grant): here the
token refresh itself succeeds silently — the failure is caused by the
uncoordinated service restarts that follow.
Expected behaviour
Concurrent tool calls to the same server should serialise (or coalesce) the
token-refresh + service-restart sequence so that exactly one refresh occurs and
all in-flight calls are queued on the resulting stable session.
Workaround
Dispatch tool calls to the affected server sequentially rather than in
parallel, ensuring each call completes its refresh + session initialisation
before the next one begins.