diff --git a/packages/core/src/tools/mcp-client.test.ts b/packages/core/src/tools/mcp-client.test.ts index d25203a7c4c..ca375e74f3b 100644 --- a/packages/core/src/tools/mcp-client.test.ts +++ b/packages/core/src/tools/mcp-client.test.ts @@ -528,6 +528,34 @@ describe('mcp-client', () => { }); }); + it('skips OAuth without opening a browser in non-interactive mode on 401', async () => { + const { authenticate, connect, workspaceContext } = setupHttpOAuthRetry( + new Error( + 'HTTP 401 Unauthorized\nwww-authenticate: Bearer realm="example", resource_metadata="https://example.com/.well-known/oauth-protected-resource"', + ), + ); + + await expect( + connectToMcpServer( + 'http-server', + { httpUrl: 'http://test-server/mcp' }, + false, + workspaceContext, + undefined, + false, // non-interactive (`-p`) + ), + ).rejects.toThrow( + // Pin the non-interactive branch specifically: the dialog-instruction + // substring alone appears in many OAuth error paths. + /non-interactive mode/, + ); + + // The interactive OAuth flow (which opens a browser) must not run. + expect(authenticate).not.toHaveBeenCalled(); + // Only the initial connect attempt happens; no retry after OAuth. + expect(connect).toHaveBeenCalledTimes(1); + }); + it('falls back to base-url OAuth discovery when www-authenticate lacks resource metadata', async () => { const { authenticate, connect, discoverOAuthConfig, workspaceContext } = setupHttpOAuthRetry( diff --git a/packages/core/src/tools/mcp-client.ts b/packages/core/src/tools/mcp-client.ts index 18291e1d53a..2e97ad1b02e 100644 --- a/packages/core/src/tools/mcp-client.ts +++ b/packages/core/src/tools/mcp-client.ts @@ -901,6 +901,7 @@ export async function connectAndDiscover( debugMode, workspaceContext, sendSdkMcpMessage, + cliConfig.isInteractive(), ); mcpClient.onerror = (error) => { @@ -1291,6 +1292,7 @@ export function hasNetworkTransport(config: MCPServerConfig): boolean { * @param mcpServerName The name of the MCP server, used for logging and identification. * @param mcpServerConfig The configuration specifying how to connect to the server. * @param sendSdkMcpMessage Optional callback for SDK MCP servers to route messages via control plane. + * @param interactive When false (non-interactive/headless `-p` mode), skip OAuth flows that open a browser; the connection is rejected instead of blocking on a callback. * @returns A promise that resolves to a connected MCP `Client` instance. * @throws An error if the connection fails or the configuration is invalid. */ @@ -1300,6 +1302,10 @@ export async function connectToMcpServer( debugMode: boolean, workspaceContext: WorkspaceContext, sendSdkMcpMessage?: SendSdkMcpMessage, + // When false (non-interactive `-p` mode), never open a browser for MCP OAuth. + // A server that would require an interactive OAuth flow is skipped instead of + // blocking startup on a browser callback. + interactive: boolean = true, ): Promise { const mcpClient = new Client({ name: 'qwen-code-mcp-client', @@ -1439,6 +1445,18 @@ export async function connectToMcpServer( throw new Error(oauthMessage); } + // In non-interactive mode (`-p`) we must never open a browser for OAuth, + // as it would block startup on a callback the user can't complete. Skip + // this server instead — it surfaces via the normal failed-connection path. + if (!interactive) { + const oauthMessage = + `The MCP server '${mcpServerName}' requires OAuth authentication, ` + + `but Qwen Code is running in non-interactive mode. Skipping this server. ` + + getMcpOAuthDialogInstruction('authenticate', mcpServerName); + debugLogger.warn(oauthMessage); + throw new Error(oauthMessage); + } + // Try to extract www-authenticate header from the error let wwwAuthenticate = extractWWWAuthenticateHeader(errorString);