import { randomUUID } from "node:crypto"; import fs from "node:fs/promises"; 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 type { ChromeMcpSnapshotNode } from "./chrome-mcp.snapshot.js"; import type { BrowserTab } from "./client.js"; import { BrowserProfileUnavailableError, BrowserTabNotFoundError } from "./errors.js"; type ChromeMcpStructuredPage = { id: number; url?: string; selected?: boolean; }; type ChromeMcpToolResult = { structuredContent?: Record; content?: Array>; isError?: boolean; }; type ChromeMcpSession = { client: Client; transport: StdioClientTransport; ready: Promise; }; type ChromeMcpSessionFactory = (profileName: string) => Promise; 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", ]; const sessions = new Map(); const pendingSessions = new Map>(); let sessionFactory: ChromeMcpSessionFactory | null = null; function asRecord(value: unknown): Record | null { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : null; } function asPages(value: unknown): ChromeMcpStructuredPage[] { if (!Array.isArray(value)) { return []; } const out: ChromeMcpStructuredPage[] = []; for (const entry of value) { const record = asRecord(entry); if (!record || typeof record.id !== "number") { continue; } out.push({ id: record.id, url: typeof record.url === "string" ? record.url : undefined, selected: record.selected === true, }); } return out; } function parsePageId(targetId: string): number { const parsed = Number.parseInt(targetId.trim(), 10); if (!Number.isFinite(parsed)) { throw new BrowserTabNotFoundError(); } return parsed; } function toBrowserTabs(pages: ChromeMcpStructuredPage[]): BrowserTab[] { return pages.map((page) => ({ targetId: String(page.id), title: "", url: page.url ?? "", type: "page", })); } function extractStructuredContent(result: ChromeMcpToolResult): Record { return asRecord(result.structuredContent) ?? {}; } function extractTextContent(result: ChromeMcpToolResult): string[] { const content = Array.isArray(result.content) ? result.content : []; return content .map((entry) => { const record = asRecord(entry); return record && typeof record.text === "string" ? record.text : ""; }) .filter(Boolean); } function extractTextPages(result: ChromeMcpToolResult): ChromeMcpStructuredPage[] { const pages: ChromeMcpStructuredPage[] = []; for (const block of extractTextContent(result)) { for (const line of block.split(/\r?\n/)) { const match = line.match(/^\s*(\d+):\s+(.+?)(?:\s+\[(selected)\])?\s*$/i); if (!match) { continue; } pages.push({ id: Number.parseInt(match[1] ?? "", 10), url: match[2]?.trim() || undefined, selected: Boolean(match[3]), }); } } return pages; } function extractStructuredPages(result: ChromeMcpToolResult): ChromeMcpStructuredPage[] { const structured = asPages(extractStructuredContent(result).pages); return structured.length > 0 ? structured : extractTextPages(result); } function extractSnapshot(result: ChromeMcpToolResult): ChromeMcpSnapshotNode { const structured = extractStructuredContent(result); const snapshot = asRecord(structured.snapshot); if (!snapshot) { throw new Error("Chrome MCP snapshot response was missing structured snapshot data."); } return snapshot as unknown as ChromeMcpSnapshotNode; } function extractJsonBlock(text: string): unknown { const match = text.match(/```json\s*([\s\S]*?)\s*```/i); const raw = match?.[1]?.trim() || text.trim(); return raw ? JSON.parse(raw) : null; } function extractMessageText(result: ChromeMcpToolResult): string { const message = extractStructuredContent(result).message; if (typeof message === "string" && message.trim()) { return message; } const blocks = extractTextContent(result); return blocks.find((block) => block.trim()) ?? ""; } function extractToolErrorMessage(result: ChromeMcpToolResult, name: string): string { const message = extractMessageText(result).trim(); return message || `Chrome MCP tool "${name}" failed.`; } function extractJsonMessage(result: ChromeMcpToolResult): unknown { const candidates = [extractMessageText(result), ...extractTextContent(result)].filter((text) => text.trim(), ); let lastError: unknown; for (const candidate of candidates) { try { return extractJsonBlock(candidate); } catch (err) { lastError = err; } } if (lastError) { throw lastError; } return null; } async function createRealSession(profileName: string): Promise { const transport = new StdioClientTransport({ command: DEFAULT_CHROME_MCP_COMMAND, args: DEFAULT_CHROME_MCP_ARGS, stderr: "pipe", }); const client = new Client( { name: "openclaw-browser", version: "0.0.0", }, {}, ); const ready = (async () => { try { await client.connect(transport); const tools = await client.listTools(); if (!tools.tools.some((tool) => tool.name === "list_pages")) { throw new Error("Chrome MCP server did not expose the expected navigation tools."); } } catch (err) { await client.close().catch(() => {}); 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. ` + `Details: ${String(err)}`, ); } })(); return { client, transport, ready, }; } async function getSession(profileName: string): Promise { let session = sessions.get(profileName); if (session && session.transport.pid === null) { sessions.delete(profileName); session = undefined; } if (!session) { let pending = pendingSessions.get(profileName); if (!pending) { pending = (async () => { const created = await (sessionFactory ?? createRealSession)(profileName); sessions.set(profileName, created); return created; })(); pendingSessions.set(profileName, pending); } try { session = await pending; } finally { if (pendingSessions.get(profileName) === pending) { pendingSessions.delete(profileName); } } } try { await session.ready; return session; } catch (err) { const current = sessions.get(profileName); if (current?.transport === session.transport) { sessions.delete(profileName); } throw err; } } async function callTool( profileName: string, name: string, args: Record = {}, ): Promise { const session = await getSession(profileName); try { const result = (await session.client.callTool({ name, arguments: args, })) as ChromeMcpToolResult; if (result.isError) { throw new Error(extractToolErrorMessage(result, name)); } return result; } catch (err) { sessions.delete(profileName); await session.client.close().catch(() => {}); throw err; } } async function withTempFile(fn: (filePath: string) => Promise): Promise { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-chrome-mcp-")); const filePath = path.join(dir, randomUUID()); try { return await fn(filePath); } finally { await fs.rm(dir, { recursive: true, force: true }).catch(() => {}); } } async function findPageById(profileName: string, pageId: number): Promise { const pages = await listChromeMcpPages(profileName); const page = pages.find((entry) => entry.id === pageId); if (!page) { throw new BrowserTabNotFoundError(); } return page; } export async function ensureChromeMcpAvailable(profileName: string): Promise { await getSession(profileName); } export function getChromeMcpPid(profileName: string): number | null { return sessions.get(profileName)?.transport.pid ?? null; } export async function closeChromeMcpSession(profileName: string): Promise { pendingSessions.delete(profileName); const session = sessions.get(profileName); if (!session) { return false; } sessions.delete(profileName); await session.client.close().catch(() => {}); return true; } export async function stopAllChromeMcpSessions(): Promise { const names = [...sessions.keys()]; for (const name of names) { await closeChromeMcpSession(name).catch(() => {}); } } export async function listChromeMcpPages(profileName: string): Promise { const result = await callTool(profileName, "list_pages"); return extractStructuredPages(result); } export async function listChromeMcpTabs(profileName: string): Promise { return toBrowserTabs(await listChromeMcpPages(profileName)); } export async function openChromeMcpTab(profileName: string, url: string): Promise { const result = await callTool(profileName, "new_page", { url }); const pages = extractStructuredPages(result); const chosen = pages.find((page) => page.selected) ?? pages.at(-1); if (!chosen) { throw new Error("Chrome MCP did not return the created page."); } return { targetId: String(chosen.id), title: "", url: chosen.url ?? url, type: "page", }; } export async function focusChromeMcpTab(profileName: string, targetId: string): Promise { await callTool(profileName, "select_page", { pageId: parsePageId(targetId), bringToFront: true, }); } export async function closeChromeMcpTab(profileName: string, targetId: string): Promise { await callTool(profileName, "close_page", { pageId: parsePageId(targetId) }); } export async function navigateChromeMcpPage(params: { profileName: string; targetId: string; url: string; timeoutMs?: number; }): Promise<{ url: string }> { await callTool(params.profileName, "navigate_page", { pageId: parsePageId(params.targetId), type: "url", url: params.url, ...(typeof params.timeoutMs === "number" ? { timeout: params.timeoutMs } : {}), }); const page = await findPageById(params.profileName, parsePageId(params.targetId)); return { url: page.url ?? params.url }; } export async function takeChromeMcpSnapshot(params: { profileName: string; targetId: string; }): Promise { const result = await callTool(params.profileName, "take_snapshot", { pageId: parsePageId(params.targetId), }); return extractSnapshot(result); } export async function takeChromeMcpScreenshot(params: { profileName: string; targetId: string; uid?: string; fullPage?: boolean; format?: "png" | "jpeg"; }): Promise { return await withTempFile(async (filePath) => { await callTool(params.profileName, "take_screenshot", { pageId: parsePageId(params.targetId), filePath, format: params.format ?? "png", ...(params.uid ? { uid: params.uid } : {}), ...(params.fullPage ? { fullPage: true } : {}), }); return await fs.readFile(filePath); }); } export async function clickChromeMcpElement(params: { profileName: string; targetId: string; uid: string; doubleClick?: boolean; }): Promise { await callTool(params.profileName, "click", { pageId: parsePageId(params.targetId), uid: params.uid, ...(params.doubleClick ? { dblClick: true } : {}), }); } export async function fillChromeMcpElement(params: { profileName: string; targetId: string; uid: string; value: string; }): Promise { await callTool(params.profileName, "fill", { pageId: parsePageId(params.targetId), uid: params.uid, value: params.value, }); } export async function fillChromeMcpForm(params: { profileName: string; targetId: string; elements: Array<{ uid: string; value: string }>; }): Promise { await callTool(params.profileName, "fill_form", { pageId: parsePageId(params.targetId), elements: params.elements, }); } export async function hoverChromeMcpElement(params: { profileName: string; targetId: string; uid: string; }): Promise { await callTool(params.profileName, "hover", { pageId: parsePageId(params.targetId), uid: params.uid, }); } export async function dragChromeMcpElement(params: { profileName: string; targetId: string; fromUid: string; toUid: string; }): Promise { await callTool(params.profileName, "drag", { pageId: parsePageId(params.targetId), from_uid: params.fromUid, to_uid: params.toUid, }); } export async function uploadChromeMcpFile(params: { profileName: string; targetId: string; uid: string; filePath: string; }): Promise { await callTool(params.profileName, "upload_file", { pageId: parsePageId(params.targetId), uid: params.uid, filePath: params.filePath, }); } export async function pressChromeMcpKey(params: { profileName: string; targetId: string; key: string; }): Promise { await callTool(params.profileName, "press_key", { pageId: parsePageId(params.targetId), key: params.key, }); } export async function resizeChromeMcpPage(params: { profileName: string; targetId: string; width: number; height: number; }): Promise { await callTool(params.profileName, "resize_page", { pageId: parsePageId(params.targetId), width: params.width, height: params.height, }); } export async function handleChromeMcpDialog(params: { profileName: string; targetId: string; action: "accept" | "dismiss"; promptText?: string; }): Promise { await callTool(params.profileName, "handle_dialog", { pageId: parsePageId(params.targetId), action: params.action, ...(params.promptText ? { promptText: params.promptText } : {}), }); } export async function evaluateChromeMcpScript(params: { profileName: string; targetId: string; fn: string; args?: string[]; }): Promise { const result = await callTool(params.profileName, "evaluate_script", { pageId: parsePageId(params.targetId), function: params.fn, ...(params.args?.length ? { args: params.args } : {}), }); return extractJsonMessage(result); } export async function waitForChromeMcpText(params: { profileName: string; targetId: string; text: string[]; timeoutMs?: number; }): Promise { await callTool(params.profileName, "wait_for", { pageId: parsePageId(params.targetId), text: params.text, ...(typeof params.timeoutMs === "number" ? { timeout: params.timeoutMs } : {}), }); } export function setChromeMcpSessionFactoryForTest(factory: ChromeMcpSessionFactory | null): void { sessionFactory = factory; } export async function resetChromeMcpSessionsForTest(): Promise { sessionFactory = null; pendingSessions.clear(); await stopAllChromeMcpSessions(); }