




























@@ -5,11 +5,14 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
55import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
66import { normalizeOptionalString, readStringValue } from "openclaw/plugin-sdk/text-runtime";
77import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
8+import { createSubsystemLogger } from "../logging/subsystem.js";
89import { asRecord } from "../record-shared.js";
910import type { ChromeMcpSnapshotNode } from "./chrome-mcp.snapshot.js";
1011import type { BrowserTab } from "./client.types.js";
1112import { BrowserProfileUnavailableError, BrowserTabNotFoundError } from "./errors.js";
121314+const log = createSubsystemLogger("browser").child("chrome-mcp");
15+1316type ChromeMcpStructuredPage = {
1417id: number;
1518url?: string;
@@ -44,6 +47,10 @@ const DEFAULT_CHROME_MCP_ARGS = [
4447];
4548const CHROME_MCP_NEW_PAGE_TIMEOUT_MS = 5_000;
4649const CHROME_MCP_NAVIGATE_TIMEOUT_MS = 20_000;
50+const CHROME_MCP_HANDSHAKE_TIMEOUT_MS = 30_000;
51+const CHROME_MCP_STDERR_MAX_BYTES = 8 * 1024;
52+const STALE_SELECTED_PAGE_ERROR =
53+"The selected page has been closed. Call list_pages to see open pages.";
47544855const sessions = new Map<string, ChromeMcpSession>();
4956const pendingSessions = new Map<string, Promise<ChromeMcpSession>>();
@@ -151,6 +158,10 @@ function extractToolErrorMessage(result: ChromeMcpToolResult, name: string): str
151158return message || `Chrome MCP tool "${name}" failed.`;
152159}
153160161+function shouldReconnectForToolError(name: string, message: string): boolean {
162+return name === "list_pages" && message.includes(STALE_SELECTED_PAGE_ERROR);
163+}
164+154165function extractJsonMessage(result: ChromeMcpToolResult): unknown {
155166const candidates = [extractMessageText(result), ...extractTextContent(result)].filter((text) =>
156167text.trim(),
@@ -218,6 +229,52 @@ export function buildChromeMcpArgs(userDataDir?: string): string[] {
218229 : [...DEFAULT_CHROME_MCP_ARGS];
219230}
220231232+function drainStderr(transport: StdioClientTransport): () => string {
233+const stream = transport.stderr;
234+if (!stream) {
235+return () => "";
236+}
237+const chunks: Buffer[] = [];
238+let totalBytes = 0;
239+stream.on("data", (chunk: Buffer | string) => {
240+const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
241+const capped =
242+buffer.length > CHROME_MCP_STDERR_MAX_BYTES
243+ ? buffer.subarray(buffer.length - CHROME_MCP_STDERR_MAX_BYTES)
244+ : buffer;
245+chunks.push(capped);
246+totalBytes += capped.length;
247+while (totalBytes > CHROME_MCP_STDERR_MAX_BYTES && chunks.length > 1) {
248+const dropped = chunks.shift();
249+if (dropped) {
250+totalBytes -= dropped.length;
251+}
252+}
253+});
254+stream.on("error", () => {});
255+return () => Buffer.concat(chunks).toString("utf8").trim().slice(-CHROME_MCP_STDERR_MAX_BYTES);
256+}
257+258+async function withChromeMcpHandshakeTimeout<T>(task: Promise<T>): Promise<T> {
259+let timer: ReturnType<typeof setTimeout> | undefined;
260+try {
261+return await Promise.race([
262+task,
263+new Promise<never>((_, reject) => {
264+timer = setTimeout(
265+() => reject(new Error("Chrome MCP handshake timed out")),
266+CHROME_MCP_HANDSHAKE_TIMEOUT_MS,
267+);
268+timer.unref?.();
269+}),
270+]);
271+} finally {
272+if (timer) {
273+clearTimeout(timer);
274+}
275+}
276+}
277+221278async function createRealSession(
222279profileName: string,
223280userDataDir?: string,
@@ -235,25 +292,38 @@ async function createRealSession(
235292{},
236293);
237294295+let getStderr = () => "";
238296const ready = (async () => {
239297try {
240-await client.connect(transport);
241-const tools = await client.listTools();
242-if (!tools.tools.some((tool) => tool.name === "list_pages")) {
243-throw new Error("Chrome MCP server did not expose the expected navigation tools.");
244-}
298+await withChromeMcpHandshakeTimeout(
299+(async () => {
300+await client.connect(transport);
301+getStderr = drainStderr(transport);
302+const tools = await client.listTools();
303+if (!tools.tools.some((tool) => tool.name === "list_pages")) {
304+throw new Error("Chrome MCP server did not expose the expected navigation tools.");
305+}
306+})(),
307+);
245308} catch (err) {
246309await client.close().catch(() => {});
310+const stderr = getStderr();
311+if (stderr) {
312+log.warn(
313+`Chrome MCP attach failed for profile "${profileName}". Subprocess stderr:\n${stderr}`,
314+);
315+}
247316const targetLabel = userDataDir
248317 ? `the configured Chromium user data dir (${userDataDir})`
249318 : "Google Chrome's default profile";
250319throw new BrowserProfileUnavailableError(
251320`Chrome MCP existing-session attach failed for profile "${profileName}". ` +
252321`Make sure ${targetLabel} is running locally with remote debugging enabled. ` +
253-`Details: ${String(err)}`,
322+`Details: ${err instanceof Error ? err.message : String(err)}`,
254323);
255324}
256325})();
326+ready.catch(() => {});
257327258328return {
259329 client,
@@ -313,72 +383,87 @@ async function callTool(
313383opts?: { timeoutMs?: number; signal?: AbortSignal },
314384): Promise<ChromeMcpToolResult> {
315385const cacheKey = buildChromeMcpSessionCacheKey(profileName, userDataDir);
316-const session = await getSession(profileName, userDataDir);
317386const timeoutMs = opts?.timeoutMs;
318387const signal = opts?.signal;
319388if (signal?.aborted) {
320389throw signal.reason ?? new Error("aborted");
321390}
322391323-const rawCall = session.client.callTool({
324- name,
325-arguments: args,
326-}) as Promise<ChromeMcpToolResult>;
327-328-let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
329-let abortListener: (() => void) | undefined;
330-const racers: Array<Promise<ChromeMcpToolResult> | Promise<never>> = [rawCall];
331-332-if (timeoutMs !== undefined && timeoutMs > 0) {
333-racers.push(
334-new Promise<never>((_, reject) => {
335-timeoutHandle = setTimeout(() => {
336-reject(
337-new Error(
338-`Chrome MCP "${name}" timed out after ${timeoutMs}ms. Session reset for reconnect.`,
339-),
340-);
341-}, timeoutMs);
342-}),
343-);
344-}
345-346-if (signal) {
347-racers.push(
348-new Promise<never>((_, reject) => {
349-abortListener = () => reject(signal.reason ?? new Error("aborted"));
350-signal.addEventListener("abort", abortListener, { once: true });
351-}),
352-);
353-}
392+for (let attempt = 0; attempt < 2; attempt += 1) {
393+const session = await getSession(profileName, userDataDir);
394+const rawCall = session.client.callTool({
395+ name,
396+arguments: args,
397+}) as Promise<ChromeMcpToolResult>;
398+399+let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
400+let abortListener: (() => void) | undefined;
401+const racers: Array<Promise<ChromeMcpToolResult> | Promise<never>> = [rawCall];
402+403+if (timeoutMs !== undefined && timeoutMs > 0) {
404+racers.push(
405+new Promise<never>((_, reject) => {
406+timeoutHandle = setTimeout(() => {
407+reject(
408+new Error(
409+`Chrome MCP "${name}" timed out after ${timeoutMs}ms. Session reset for reconnect.`,
410+),
411+);
412+}, timeoutMs);
413+}),
414+);
415+}
354416355-let result: ChromeMcpToolResult;
356-try {
357-result = racers.length === 1 ? await rawCall : await Promise.race(racers);
358-} catch (err) {
359-void rawCall.catch(() => {});
360-// Transport/connection error, timeout, or abort: tear down session so it reconnects.
361-// Transport-identity check prevents clobbering a replacement session created concurrently.
362-const cur = sessions.get(cacheKey);
363-if (cur?.transport === session.transport) {
364-sessions.delete(cacheKey);
365-await session.client.close().catch(() => {});
417+if (signal) {
418+racers.push(
419+new Promise<never>((_, reject) => {
420+abortListener = () => reject(signal.reason ?? new Error("aborted"));
421+signal.addEventListener("abort", abortListener, { once: true });
422+}),
423+);
366424}
367-throw err;
368-} finally {
369-if (timeoutHandle !== undefined) {
370-clearTimeout(timeoutHandle);
425+426+let result: ChromeMcpToolResult;
427+try {
428+result = racers.length === 1 ? await rawCall : await Promise.race(racers);
429+} catch (err) {
430+void rawCall.catch(() => {});
431+// Transport/connection error, timeout, or abort: tear down session so it reconnects.
432+// Transport-identity check prevents clobbering a replacement session created concurrently.
433+const cur = sessions.get(cacheKey);
434+if (cur?.transport === session.transport) {
435+sessions.delete(cacheKey);
436+await session.client.close().catch(() => {});
437+}
438+throw err;
439+} finally {
440+if (timeoutHandle !== undefined) {
441+clearTimeout(timeoutHandle);
442+}
443+if (signal && abortListener) {
444+signal.removeEventListener("abort", abortListener);
445+}
371446}
372-if (signal && abortListener) {
373-signal.removeEventListener("abort", abortListener);
447+// Tool-level errors (element not found, script error, etc.) don't indicate a
448+// broken connection. A stale selected-page error does poison the Chrome MCP
449+// session, so reconnect and retry that one once.
450+if (result.isError) {
451+const message = extractToolErrorMessage(result, name);
452+if (shouldReconnectForToolError(name, message)) {
453+const cur = sessions.get(cacheKey);
454+if (cur?.transport === session.transport) {
455+sessions.delete(cacheKey);
456+await session.client.close().catch(() => {});
457+}
458+if (attempt === 0) {
459+continue;
460+}
461+}
462+throw new Error(message);
374463}
464+return result;
375465}
376-// Tool-level errors (element not found, script error, etc.) don't indicate a
377-// broken connection — don't tear down the session for these.
378-if (result.isError) {
379-throw new Error(extractToolErrorMessage(result, name));
380-}
381-return result;
466+throw new Error(`Chrome MCP tool "${name}" failed after reconnect.`);
382467}
383468384469async function withTempFile<T>(fn: (filePath: string) => Promise<T>): Promise<T> {
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。