Skip to content

ACP mode: expired MCP OAuth access token opens the browser authorization page instead of using the stored refresh token #5229

Open

Description

@julianmesa-gitkraken

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) and copilot -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

  1. Configure a remote OAuth MCP server in ~/.copilot/mcp-config.json:
    { "mcpServers": { "sentry": { "type": "http", "url": "https://mcp.sentry.dev/mcp" } } }
  2. Sign in once from the interactive CLI so an access token and refresh token are stored.
  3. Wait until the access token expires (about an hour for Sentry). Don't run the interactive CLI in between.
  4. Start copilot --acp over stdio and send initialize then session/new (cwd set to any temp dir, mcpServers: []).
  5. The browser opens the Sentry authorization page. Log: opened the MCP OAuth authorization page.
  6. 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 until OAuth callback timeout after 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions