Repository navigation
ACP mode: expired MCP OAuth access token opens the browser authorization page instead of using the stored refresh token #5229
Description
Describe the bug
In ACP mode (copilot --acp), when a remote HTTP MCP server answers 401 because the stored access token has expired, the CLI goes straight to [acp::mcp_oauth] opened the MCP OAuth authorization page. It never tries a refresh_token grant, even though a refresh token is stored and works.
The same config, same Keychain entry and same machine, a minute apart:
copilot(interactive) andcopilot -p: 401, then silent refresh, server connected in ~200 ms, no browser.copilot --acp: 401, then the authorization page opens. Nothing in the log shows a refresh attempt.
ACP does read the stored token: right after the interactive CLI refreshed and persisted it, an ACP session connected to the server without a browser. It just never renews an expired one.
Server used: Sentry remote MCP (https://mcp.sentry.dev/mcp), dynamic client registration (clientId https://github.com/copilot/cli/client-metadata.json), token in the macOS Keychain (service copilot-mcp-oauth).
Affected version
- GitHub Copilot CLI 1.0.102 (latest stable; 1.0.103-0 prerelease release notes mention nothing related)
- Same pattern in my logs going back to 2026-10-19 (1.0.100 and 1.0.101)
- macOS 27.0.1 (26A434), arm64
Steps to reproduce
- Configure a remote OAuth MCP server in
~/.copilot/mcp-config.json:{ "mcpServers": { "sentry": { "type": "http", "url": "https://mcp.sentry.dev/mcp" } } } - Sign in once from the interactive CLI so an access token and refresh token are stored.
- Wait until the access token expires (about an hour for Sentry). Don't run the interactive CLI in between.
- Start
copilot --acpover stdio and sendinitializethensession/new(cwdset to any temp dir,mcpServers: []). - The browser opens the Sentry authorization page. Log:
opened the MCP OAuth authorization page. - Close the ACP process, then run
copilot -p "reply with ok"from the same directory. The server connects after a 401 with no browser.
Minimal driver used for step 4:
import { spawn } from 'node:child_process';
const cwd = process.argv[2];
const child = spawn('copilot', ['--acp'], { cwd, stdio: ['pipe', 'pipe', 'inherit'] });
const send = (id, method, params) => child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
let buf = '';
child.stdout.on('data', d => {
buf += d; let i;
while ((i = buf.indexOf('\n')) >= 0) {
const m = JSON.parse(buf.slice(0, i)); buf = buf.slice(i + 1);
if (m.id === 1) send(2, 'session/new', { cwd, mcpServers: [] });
}
});
send(1, 'initialize', { protocolVersion: 1, clientCapabilities: {} });
setTimeout(() => child.kill('SIGTERM'), 45000);Expected behavior
On a 401 for a server with a stored refresh token, ACP does what the interactive CLI and -p already do: silently POST grant_type=refresh_token, persist the new tokens, reconnect. Only fall back to the authorization page if the refresh fails (invalid_grant, revoked, no refresh token).
Actual behavior
ACP, expired token (2026-10-22, live repro)
11:45:55.601Z [INFO] Starting ACP server (stdio)
11:45:56.976Z [ERROR] [rust:rmcp::transport::worker] worker quit with fatal: Transport channel closed, when Client(OAuthChallenge { www_authenticate_header: "******"OAuth\", resource_metadata=\"https://mcp.sentry.dev/.well-known/oauth-protected-resource/mcp\"", response: McpOAuthHttpResponse { status_code: 401, header_count: 10, has_body: true } })
11:45:57.315Z [INFO] [rust:acp::mcp_oauth] opened the MCP OAuth authorization page {"server_name":"sentry"}
11:45:57.315Z [WARNING] [rust:mcp_engine::agent_host] HTTP 401 challenge (WWW-Authenticate: ******"OAuth", resource_metadata="https://mcp.sentry.dev/.well-known/oauth-protected-resource/mcp") {"server":"sentry","error":"MCP OAuth authorization failed"}
11:45:57.414Z [ERROR] [rust:rmcp::transport::worker] worker quit with fatal: Transport channel closed, when Client(OAuthChallenge { ... status_code: 401 ... })
11:45:57.458Z [INFO] [rust:acp::mcp_oauth] opened the MCP OAuth authorization page {"server_name":"sentry"}
11:46:39.891Z [WARNING] [rust:mcp_client::session_authorizer] MCP OAuth login did not complete {"server_name":"sentry","error":"MCP OAuth was cancelled because its original requester or configuration changed."}
Two authorization pages per session/new, one per challenge. The ACP client got no session/update or request about auth; the CLI opened the browser on its own.
-p, same token, 60 s later
11:46:50.753Z [ERROR] [rust:rmcp::transport::worker] worker quit with fatal: Transport channel closed, when Client(OAuthChallenge { ... status_code: 401 ... })
11:46:50.955Z [INFO] [rust:rmcp::service] Service initialized as client {"peer_info":"Some(ServerPeerInfo { ... name: \"Sentry MCP\" ... })"}
No browser, connected 200 ms after the 401.
Interactive CLI, earlier the same day
07:54:03.670Z [ERROR] [rust:rmcp::transport::worker] worker quit with fatal: Transport channel closed, when Client(OAuthChallenge { ... status_code: 401 ... })
07:54:03.761Z [ERROR] Refreshing authentication for sentry...
07:54:04.340Z [ERROR] Successfully authenticated with sentry
07:54:04.413Z [INFO] [rust:rmcp::service] Service initialized as client {"peer_info":"Some(ServerPeerInfo { ... name: \"Sentry MCP\" ... })"}
The Keychain item's modification date is 07:54:04Z, so this refresh was persisted. An ACP process that had already opened the page at 07:53:30 and again at 07:54:29 finally connected to Sentry at 07:54:34, using the token the interactive CLI had just stored.
Across all my logs
I went through every process log in ~/.copilot/logs (2026-10-19 to 2026-10-22) that has a Sentry OAuth challenge:
| Mode | Processes | With a refresh attempt | Opened the authorization page |
|---|---|---|---|
--acp |
32 | 0 | 30 (in the other 2 the client disconnected before the page opened) |
| interactive | 2 | 2 | 1, only after its refresh attempt failed (2026-10-21, Refreshing authentication then Opening browser for sentry authentication) |
No ACP log contains Refreshing authentication, Successfully authenticated or anything else showing a refresh attempt.
Impact
ACP clients (Kepler, editors) spawn short-lived copilot --acp processes: one per new chat, plus background ones for titles, model lists and similar. Once the access token expires, which for Sentry is about an hour, every one of them:
- pops one or two Sentry authorization tabs the user didn't ask for, at session start, before any Sentry tool is used;
- cancels the flow a moment later when the process exits (
MCP OAuth was cancelled because its original requester or configuration changed.), or leaves it hanging untilOAuth callback timeoutafter 5 minutes; - starts the session without the server's tools.
The only workaround is to open the interactive CLI now and then so it refreshes and persists the token for ACP to pick up.
Possibly related: -p refresh is not persisted
The silent refresh in -p runs never updated the stored token. After two -p runs at 11:46 and 11:50 that both refreshed, the Keychain item's modification date was still 07:54:04Z and nothing under ~/.copilot changed. So every -p run refreshes again from the stored refresh token. That works with Sentry, but against a server that rotates refresh tokens it would break the stored grant (same class of problem as #5079). It also means a -p refresh doesn't help ACP the way an interactive refresh does.
Related issues
- Remote MCP (OAuth): expired access token forces interactive re-auth instead of silent refresh_token grant #4203: generic "expired access token forces interactive re-auth instead of refresh_token grant" (1.0.71). On 1.0.102 the interactive CLI and
-prefresh fine for me; ACP still doesn't, so what's left of Remote MCP (OAuth): expired access token forces interactive re-auth instead of silent refresh_token grant #4203 may be this ACP path. - Regression (1.0.88 → 1.0.89): MCP OAuth authorization page opens on every ACP session instead of the challenge being resolved silently #4994: the authorization page opens on every ACP session since 1.0.89, with a valid token and no refresh token. Different trigger (an expired token with a working refresh token), same ACP handler that goes straight to the browser.
- ACP mode: MCP OAuth token is never persisted after successful sign-in — every new session re-opens the browser #5192: tokens from an ACP sign-in are never persisted. Here the token is persisted (by the interactive CLI) and ACP reads it, but never refreshes it.
Activity