项目文件夹

文件
T
Wolfgang Beyer e046c23563 chore: in-page tool output: stash DOM elements and preprocess for serialization (#1877)
Improvements for handling in-page tool responses. In order to
successfully pass an in-page tool response from the page context to the
MCP server, the response needs to be serializable. The code walks the
response object an performs the following changes:

- DOM elements are stashed onto the window object and replaced with an
ID. On the MCP server side this ID is used to map back to the
corresponding UID in the page snapshot generated from the accessibility
tree.
- Circular references are replaced with a string.
- Class instances (which can be complex or non-serializable) are
replaced with a string.
- Functions are replaced with a string.

If the in-page tool response contains DOM elements which are not part of
the page snapshot, a new snapshot is created add the missing elements
are added explicitly.
2026-04-22 14:51:09 +00:00

395 行
10 KiB
TypeScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import assert from 'node:assert';
import {spawn} from 'node:child_process';
import path from 'node:path';
import type {CallToolResult} from '@modelcontextprotocol/sdk/types.js';
import logger from 'debug';
import type {Browser} from 'puppeteer';
import puppeteer, {Locator} from 'puppeteer';
import type {
Frame,
HTTPRequest,
HTTPResponse,
LaunchOptions,
Page,
Target,
} from 'puppeteer-core';
import sinon from 'sinon';
import type {ParsedArguments} from '../src/bin/chrome-devtools-mcp-cli-options.js';
import {McpContext} from '../src/McpContext.js';
import {McpResponse} from '../src/McpResponse.js';
import {TextSnapshot} from '../src/TextSnapshot.js';
import {DevTools} from '../src/third_party/index.js';
import {stableIdSymbol} from '../src/utils/id.js';
export function assertNoServiceWorkerReported(targets: Target[], id: string) {
const target = targets.find(target => {
return target.url().includes(id) && target.type() === 'service_worker';
});
assert(target === undefined);
}
export function getTextContent(
content: CallToolResult['content'][number],
): string {
if (content.type === 'text') {
return content.text;
}
throw new Error(`Expected text content but got ${content.type}`);
}
export function getImageContent(content: CallToolResult['content'][number]): {
data: string;
mimeType: string;
} {
if (content.type === 'image') {
return {data: content.data, mimeType: content.mimeType};
}
throw new Error(`Expected image content but got ${content.type}`);
}
export function extractExtensionId(response: McpResponse) {
const responseLine = response.responseLines[0];
assert.ok(responseLine, 'Response should not be empty');
const match = responseLine.match(/Extension installed\. Id: (.+)/);
const extensionId = match ? match[1] : null;
assert.ok(extensionId, 'Response should contain a valid key');
return extensionId;
}
const browsers = new Map<string, Browser>();
let context: McpContext | undefined;
export async function withBrowser(
cb: (browser: Browser, page: Page) => Promise<void>,
options: {
debug?: boolean;
autoOpenDevTools?: boolean;
executablePath?: string;
args?: string[];
} = {},
) {
const launchOptions: LaunchOptions = {
executablePath:
options.executablePath ?? process.env.PUPPETEER_EXECUTABLE_PATH,
headless: !options.debug,
defaultViewport: null,
devtools: options.autoOpenDevTools ?? false,
pipe: true,
handleDevToolsAsPage: true,
args: [...(options.args || []), '--screen-info={3840x2160}'],
enableExtensions: true,
};
const key = JSON.stringify(launchOptions);
let browser = browsers.get(key);
if (!browser) {
browser = await puppeteer.launch(launchOptions);
browsers.set(key, browser);
}
const newPage = await browser.newPage();
// Close other pages.
await Promise.all(
(await browser.pages()).map(async page => {
if (page !== newPage) {
await page.close();
}
}),
);
await cb(browser, newPage);
}
export async function withMcpContext(
cb: (response: McpResponse, context: McpContext) => Promise<void>,
options: {
debug?: boolean;
autoOpenDevTools?: boolean;
performanceCrux?: boolean;
executablePath?: string;
args?: string[];
} = {},
args: ParsedArguments = {} as ParsedArguments,
) {
await withBrowser(async browser => {
TextSnapshot.resetCounter();
const response = new McpResponse(args);
if (context) {
context.dispose();
}
context = await McpContext.from(
browser,
logger('test'),
{
experimentalDevToolsDebugging: false,
performanceCrux: options.performanceCrux ?? true,
},
Locator,
);
response.setPage(context.getSelectedMcpPage());
await cb(response, context);
}, options);
}
export function getMockRequest(
options: {
url?: string;
method?: string;
response?: HTTPResponse;
failure?: HTTPRequest['failure'];
resourceType?: string;
hasPostData?: boolean;
postData?: string;
fetchPostData?: Promise<string>;
stableId?: number;
navigationRequest?: boolean;
frame?: Frame;
redirectChain?: HTTPRequest[];
headers?: Record<string, string>;
} = {},
): HTTPRequest {
return {
url() {
return options.url ?? 'http://example.com';
},
method() {
return options.method ?? 'GET';
},
fetchPostData() {
return options.fetchPostData ?? Promise.reject();
},
hasPostData() {
return options.hasPostData ?? false;
},
postData() {
return options.postData;
},
response() {
return options.response ?? null;
},
failure() {
return options.failure?.() ?? null;
},
resourceType() {
return options.resourceType ?? 'document';
},
headers(): Record<string, string> {
return (
options.headers ?? {
'content-size': '10',
}
);
},
redirectChain(): HTTPRequest[] {
return options.redirectChain ?? [];
},
isNavigationRequest() {
return options.navigationRequest ?? false;
},
frame() {
return options.frame ?? ({} as Frame);
},
[stableIdSymbol]: options.stableId ?? 1,
} as unknown as HTTPRequest;
}
export function getMockResponse(
options: {
status?: number;
headers?: Record<string, string>;
} = {},
): HTTPResponse {
return {
status() {
return options.status ?? 200;
},
headers(): Record<string, string> {
return options.headers ?? {};
},
} as unknown as HTTPResponse;
}
export function html(
strings: TemplateStringsArray,
...values: unknown[]
): string {
const bodyContent = strings.reduce((acc, str, i) => {
return acc + str + (values[i] || '');
}, '');
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My test page</title>
</head>
<body>
${bodyContent}
</body>
</html>`;
}
export function stabilizeStructuredContent(content: unknown): unknown {
if (typeof content === 'string') {
return stabilizeResponseOutput(content);
}
if (Array.isArray(content)) {
return content.map(item => stabilizeStructuredContent(item));
}
if (typeof content === 'object' && content !== null) {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(content)) {
if (key === 'snapshotFilePath' && typeof value === 'string') {
result[key] = '<file>';
} else {
result[key] = stabilizeStructuredContent(value);
}
}
return result;
}
return content;
}
export function stabilizeResponseOutput(text: unknown) {
if (typeof text !== 'string') {
throw new Error('Input must be string');
}
let output = text;
const dateRegEx = /.{3}, \d{2} .{3} \d{4} \d{2}:\d{2}:\d{2} [A-Z]{3}/g;
output = output.replaceAll(dateRegEx, '<long date>');
const localhostRegEx = /localhost:\d{5}/g;
output = output.replaceAll(localhostRegEx, 'localhost:<port>');
const userAgentRegEx = /user-agent:.*\n/g;
output = output.replaceAll(userAgentRegEx, 'user-agent:<user-agent>\n');
const chUaRegEx = /sec-ch-ua:"Chromium";v="\d{3}"/g;
output = output.replaceAll(chUaRegEx, 'sec-ch-ua:"Chromium";v="<version>"');
// sec-ch-ua-platform:"Linux"
const chUaPlatformRegEx = /sec-ch-ua-platform:"[a-zA-Z]*"/g;
output = output.replaceAll(chUaPlatformRegEx, 'sec-ch-ua-platform:"<os>"');
const savedSnapshot = /Saved snapshot to (.*)/g;
output = output.replaceAll(savedSnapshot, 'Saved snapshot to <file>');
const acceptLanguageRegEx = /accept-language:.*\n/g;
output = output.replaceAll(acceptLanguageRegEx, 'accept-language:<lang>\n');
return output;
}
export function getMockAggregatedIssue(): sinon.SinonStubbedInstance<DevTools.AggregatedIssue> {
const mockAggregatedIssue = sinon.createStubInstance(
DevTools.AggregatedIssue,
);
mockAggregatedIssue.getAllIssues.returns([]);
return mockAggregatedIssue;
}
export function mockListener() {
const listeners: Record<string, Array<(data: unknown) => void>> = {};
return {
on(eventName: string, listener: (data: unknown) => void) {
if (listeners[eventName]) {
listeners[eventName].push(listener);
} else {
listeners[eventName] = [listener];
}
},
off(_eventName: string, _listener: (data: unknown) => void) {
// no-op
},
emit(eventName: string, data: unknown) {
for (const listener of listeners[eventName] ?? []) {
listener(data);
}
},
};
}
export function getMockPage(): Page {
const mainFrame = {} as Frame;
const cdpSession = {
...mockListener(),
send: () => {
// no-op
},
target: () => ({_targetId: '<mock target ID>'}),
};
return {
mainFrame() {
return mainFrame;
},
...mockListener(),
// @ts-expect-error internal API.
_client() {
return cdpSession;
},
} satisfies Page;
}
export function getMockBrowser(): Browser {
const pages = [getMockPage()];
return {
pages() {
return Promise.resolve(pages);
},
...mockListener(),
} as Browser;
}
export const CLI_PATH = path.resolve('build/src/bin/chrome-devtools.js');
export async function runCli(
args: string[],
sessionId?: string,
): Promise<{status: number | null; stdout: string; stderr: string}> {
return new Promise((resolve, reject) => {
const finalArgs = [...args];
if (sessionId) {
finalArgs.push('--sessionId', sessionId);
}
const child = spawn('node', [CLI_PATH, ...finalArgs]);
let stdout = '';
let stderr = '';
child.stdout.on('data', chunk => {
stdout += chunk;
process.stdout.write(chunk);
});
child.stderr.on('data', chunk => {
stderr += chunk;
process.stderr.write(chunk);
});
child.on('close', status => resolve({status, stdout, stderr}));
child.on('error', reject);
});
}
export async function assertDaemonIsNotRunning(sessionId?: string) {
const result = await runCli(['status'], sessionId);
assert.strictEqual(
result.stdout,
'chrome-devtools-mcp daemon is not running.\n',
);
}
export async function assertDaemonIsRunning(sessionId?: string) {
const result = await runCli(['status'], sessionId);
assert.ok(
result.stdout.startsWith('chrome-devtools-mcp daemon is running.\n'),
'chrome-devtools-mcp daemon is not running',
);
}