diff --git a/src/browser/chrome-mcp.test.ts b/src/browser/chrome-mcp.test.ts index a77149d7a72..8cdb89120d1 100644 --- a/src/browser/chrome-mcp.test.ts +++ b/src/browser/chrome-mcp.test.ts @@ -1,5 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { loadConfig } from "../config/config.js"; import { + buildChromeMcpLaunchPlanForTest, evaluateChromeMcpScript, listChromeMcpTabs, openChromeMcpTab, @@ -7,6 +9,10 @@ import { setChromeMcpSessionFactoryForTest, } from "./chrome-mcp.js"; +vi.mock("../config/config.js", () => ({ + loadConfig: vi.fn(), +})); + type ToolCall = { name: string; arguments?: Record; @@ -79,6 +85,99 @@ function createFakeSession(): ChromeMcpSession { describe("chrome MCP page parsing", () => { beforeEach(async () => { await resetChromeMcpSessionsForTest(); + vi.mocked(loadConfig).mockReturnValue({ + browser: { + profiles: { + "chrome-live": { + driver: "existing-session", + attachOnly: true, + color: "#00AA00", + }, + }, + }, + }); + }); + + it("uses autoConnect for desktop existing-session profiles", () => { + const plan = buildChromeMcpLaunchPlanForTest("chrome-live"); + expect(plan.mode).toBe("autoConnect"); + expect(plan.args).toContain("--autoConnect"); + }); + + it("uses headless launch flags for headless existing-session profiles", () => { + vi.mocked(loadConfig).mockReturnValue({ + browser: { + headless: true, + noSandbox: true, + executablePath: "/usr/bin/google-chrome-stable", + extraArgs: ["--disable-dev-shm-usage"], + profiles: { + "chrome-live": { + driver: "existing-session", + attachOnly: true, + color: "#00AA00", + }, + }, + }, + }); + + const plan = buildChromeMcpLaunchPlanForTest("chrome-live"); + expect(plan.mode).toBe("headless"); + expect(plan.args).toEqual( + expect.arrayContaining([ + "--headless", + "--userDataDir", + expect.stringContaining("/browser/chrome-live/user-data"), + "--executablePath", + "/usr/bin/google-chrome-stable", + "--chromeArg", + "--no-sandbox", + "--chromeArg", + "--disable-setuid-sandbox", + "--chromeArg", + "--disable-dev-shm-usage", + ]), + ); + }); + + it("uses browserUrl for MCP profiles configured with an HTTP target", () => { + vi.mocked(loadConfig).mockReturnValue({ + browser: { + profiles: { + "chrome-live": { + driver: "existing-session", + attachOnly: true, + cdpUrl: "http://127.0.0.1:9222", + color: "#00AA00", + }, + }, + }, + }); + + const plan = buildChromeMcpLaunchPlanForTest("chrome-live"); + expect(plan.mode).toBe("browserUrl"); + expect(plan.args).toEqual(expect.arrayContaining(["--browserUrl", "http://127.0.0.1:9222"])); + }); + + it("uses wsEndpoint for MCP profiles configured with a WebSocket target", () => { + vi.mocked(loadConfig).mockReturnValue({ + browser: { + profiles: { + "chrome-live": { + driver: "existing-session", + attachOnly: true, + cdpUrl: "ws://127.0.0.1:9222/devtools/browser/abc", + color: "#00AA00", + }, + }, + }, + }); + + const plan = buildChromeMcpLaunchPlanForTest("chrome-live"); + expect(plan.mode).toBe("browserUrl"); + expect(plan.args).toEqual( + expect.arrayContaining(["--wsEndpoint", "ws://127.0.0.1:9222/devtools/browser/abc"]), + ); }); it("parses list_pages text responses when structuredContent is missing", async () => { diff --git a/src/browser/chrome-mcp.ts b/src/browser/chrome-mcp.ts index 25ae39b2293..b3b1b3ed3b0 100644 --- a/src/browser/chrome-mcp.ts +++ b/src/browser/chrome-mcp.ts @@ -4,8 +4,11 @@ import os from "node:os"; import path from "node:path"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { loadConfig } from "../config/config.js"; import type { ChromeMcpSnapshotNode } from "./chrome-mcp.snapshot.js"; +import { resolveOpenClawUserDataDir } from "./chrome.js"; import type { BrowserTab } from "./client.js"; +import { resolveBrowserConfig, resolveProfile } from "./config.js"; import { BrowserProfileUnavailableError, BrowserTabNotFoundError } from "./errors.js"; type ChromeMcpStructuredPage = { @@ -32,7 +35,6 @@ const DEFAULT_CHROME_MCP_COMMAND = "npx"; const DEFAULT_CHROME_MCP_ARGS = [ "-y", "chrome-devtools-mcp@latest", - "--autoConnect", // Direct chrome-devtools-mcp launches do not enable structuredContent by default. "--experimentalStructuredContent", "--experimental-page-id-routing", @@ -42,6 +44,50 @@ const sessions = new Map(); const pendingSessions = new Map>(); let sessionFactory: ChromeMcpSessionFactory | null = null; +type ChromeMcpLaunchPlan = { + args: string[]; + mode: "autoConnect" | "browserUrl" | "headless"; +}; + +function buildChromeMcpLaunchPlan(profileName: string): ChromeMcpLaunchPlan { + const cfg = loadConfig(); + const resolved = resolveBrowserConfig(cfg.browser, cfg); + const profile = resolveProfile(resolved, profileName); + if (!profile || profile.driver !== "existing-session") { + throw new BrowserProfileUnavailableError( + `Chrome MCP profile "${profileName}" is missing or is not driver=existing-session.`, + ); + } + + const args = [...DEFAULT_CHROME_MCP_ARGS]; + if (profile.mcpTargetUrl) { + const parsed = new URL(profile.mcpTargetUrl); + args.push( + parsed.protocol === "ws:" || parsed.protocol === "wss:" ? "--wsEndpoint" : "--browserUrl", + profile.mcpTargetUrl, + ); + return { args, mode: "browserUrl" }; + } + + if (!resolved.headless) { + args.push("--autoConnect"); + return { args, mode: "autoConnect" }; + } + + args.push("--headless"); + args.push("--userDataDir", resolveOpenClawUserDataDir(profile.name)); + if (resolved.executablePath) { + args.push("--executablePath", resolved.executablePath); + } + if (resolved.noSandbox) { + args.push("--chromeArg", "--no-sandbox", "--chromeArg", "--disable-setuid-sandbox"); + } + for (const arg of resolved.extraArgs) { + args.push("--chromeArg", arg); + } + return { args, mode: "headless" }; +} + function asRecord(value: unknown): Record | null { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) @@ -169,9 +215,10 @@ function extractJsonMessage(result: ChromeMcpToolResult): unknown { } async function createRealSession(profileName: string): Promise { + const launchPlan = buildChromeMcpLaunchPlan(profileName); const transport = new StdioClientTransport({ command: DEFAULT_CHROME_MCP_COMMAND, - args: DEFAULT_CHROME_MCP_ARGS, + args: launchPlan.args, stderr: "pipe", }); const client = new Client( @@ -191,9 +238,15 @@ async function createRealSession(profileName: string): Promise } } catch (err) { await client.close().catch(() => {}); + const hint = + launchPlan.mode === "autoConnect" + ? "Make sure Chrome is running, enable chrome://inspect/#remote-debugging, and approve the connection." + : launchPlan.mode === "browserUrl" + ? "Make sure the configured browserUrl/wsEndpoint is reachable and Chrome is running with remote debugging enabled." + : "Make sure a Chrome executable is available, and use browser.noSandbox=true on Linux containers/root setups when needed."; throw new BrowserProfileUnavailableError( `Chrome MCP existing-session attach failed for profile "${profileName}". ` + - `Make sure Chrome is running, enable chrome://inspect/#remote-debugging, and approve the connection. ` + + `${hint} ` + `Details: ${String(err)}`, ); } @@ -531,6 +584,10 @@ export async function waitForChromeMcpText(params: { }); } +export function buildChromeMcpLaunchPlanForTest(profileName: string): ChromeMcpLaunchPlan { + return buildChromeMcpLaunchPlan(profileName); +} + export function setChromeMcpSessionFactoryForTest(factory: ChromeMcpSessionFactory | null): void { sessionFactory = factory; }