chromedevtools--chrome-devtools-mcp
6e56c028cf
## Summary validatePath() in McpContext returned immediately, with no restriction at all, whenever roots() returned undefined. roots() only returns undefined when the connecting MCP client never negotiates the optional roots capability during initialize, which any minimal client can trigger simply by omitting it from its declared capabilities. Since roots() already always appends the OS temp directory to whatever explicit roots are configured, this change makes it return that same default (temp directory only) instead of undefined when no roots have been set. This removes the early return in validatePath() entirely, so path validation now runs unconditionally rather than being conditional on whether the connecting client happened to negotiate a capability it was never required to declare per the MCP spec. Any filePath-accepting tool (take_screenshot, saveFile, and the performance/Lighthouse export tools that route through the same check) had its only path-traversal guard silently disabled for the lifetime of a connection whenever the client omitted the optional roots capability. Since this server is designed to let an LLM drive a browser, and browsed page content is not trusted input, this meant a client that simply doesn't implement roots (a plausible, non-adversarial default for lightweight or custom MCP clients) removed the only boundary preventing the connected agent from writing to any path the process can reach. Added a test that exercises the actual default state of roots (never calling setRoots()) directly, since the existing tests always call setRoots(), even with an empty array, before validating. Verified locally with a minimal MCP client that declares no capabilities: before this change, take_screenshot with a filePath outside any root wrote a real file to an arbitrary path with no error; after this change, the same call is rejected with the existing Access denied error. Also verified that a client that does declare roots is unaffected, and that writes to the OS temp directory continue to succeed with no roots negotiated, matching prior behavior for that path.
431 行
12 KiB
TypeScript
431 行
12 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[];
|
|
blockedUrlPattern?: string[];
|
|
allowedUrlPattern?: 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,
|
|
blocklist: options.blockedUrlPattern,
|
|
allowlist: options.allowedUrlPattern,
|
|
};
|
|
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[];
|
|
blockedUrlPattern?: string[];
|
|
allowedUrlPattern?: string[];
|
|
allowUnrestrictedPaths?: boolean;
|
|
} = {},
|
|
args: Partial<ParsedArguments> = {},
|
|
) {
|
|
await withBrowser(async browser => {
|
|
TextSnapshot.resetCounter();
|
|
const response = new McpResponse(args as ParsedArguments);
|
|
if (context) {
|
|
context.dispose();
|
|
}
|
|
context = await McpContext.from(
|
|
browser,
|
|
logger('test'),
|
|
{
|
|
experimentalDevToolsDebugging: false,
|
|
performanceCrux: options.performanceCrux ?? true,
|
|
allowList: options.allowedUrlPattern,
|
|
blocklist: options.blockedUrlPattern,
|
|
allowUnrestrictedPaths: options.allowUnrestrictedPaths ?? false,
|
|
},
|
|
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[] {
|
|
// Puppeteer returns a fresh copy on every call (HTTPRequest returns
|
|
// `this._redirectChain.slice()`); mirror that so formatters can't share
|
|
// and accidentally mutate the same array across calls.
|
|
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');
|
|
|
|
// Stabilize URL-encoded file paths
|
|
const fileUriRegEx = /file%3A%2F%2F%2F[^)\n]+/g;
|
|
output = output.replaceAll(fileUriRegEx, '<file-path>');
|
|
|
|
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], {
|
|
env: process.env,
|
|
});
|
|
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',
|
|
);
|
|
}
|
|
|
|
export async function waitExecutionFor(
|
|
func: () => Promise<void>,
|
|
timeout: number,
|
|
) {
|
|
const start = Date.now();
|
|
while (Date.now() - start < 10000) {
|
|
try {
|
|
await func();
|
|
return;
|
|
} catch {
|
|
await new Promise(resolve => setTimeout(resolve, 100)); // wait and retry
|
|
}
|
|
}
|
|
|
|
throw new Error(`Timeout of ${timeout} reached.`);
|
|
}
|