From e45036953d8e0ae409b4052fccded8ff02e51944 Mon Sep 17 00:00:00 2001 From: Nikolay Vitkov <34244704+Lightning00Blade@users.noreply.github.com> Date: Fri, 14 Aug 2026 10:35:24 +0000 Subject: [PATCH 01/72] fix: warn on unknown CLI args (#2577) --- src/ToolHandler.ts | 10 ++--- src/bin/chrome-devtools-mcp-cli-options.ts | 36 +++++++++++------- src/index.ts | 6 +-- tests/cli.test.ts | 43 ++++++---------------- 4 files changed, 41 insertions(+), 54 deletions(-) diff --git a/src/ToolHandler.ts b/src/ToolHandler.ts index 95048f9b..21844fcd 100644 --- a/src/ToolHandler.ts +++ b/src/ToolHandler.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type {parseArguments} from './bin/chrome-devtools-mcp-cli-options.js'; +import type {ParsedArguments} from './bin/chrome-devtools-mcp-cli-options.js'; import type {McpContext} from './McpContext.js'; import type {McpPage} from './McpPage.js'; import type {DataFormat} from './McpResponse.js'; @@ -46,7 +46,7 @@ function buildDisabledMessage( function getCategoryStatus( category: ToolCategory, - serverArgs: ReturnType, + serverArgs: ParsedArguments, ): {categoryFlag?: string; disabled: boolean} { const categoryFlag = buildFlag(category); @@ -70,7 +70,7 @@ function getCategoryStatus( function getConditionStatus( condition: string, - serverArgs: ReturnType, + serverArgs: ParsedArguments, ): {conditionFlag?: string; disabled: boolean} { if (condition && !serverArgs[condition]) { return {conditionFlag: condition, disabled: true}; @@ -81,7 +81,7 @@ function getConditionStatus( function getToolStatusInfo( tool: ToolDefinition | DefinedPageTool, - serverArgs: ReturnType, + serverArgs: ParsedArguments, ): {disabled: boolean; reason?: string} { const category = tool.annotations.category; const categoryCheck = getCategoryStatus(category, serverArgs); @@ -224,7 +224,7 @@ export class ToolHandler { constructor( private readonly tool: ToolDefinition | DefinedPageTool, - private readonly serverArgs: ReturnType, + private readonly serverArgs: ParsedArguments, private readonly getContext: () => Promise, private readonly toolMutex: Mutex, ) { diff --git a/src/bin/chrome-devtools-mcp-cli-options.ts b/src/bin/chrome-devtools-mcp-cli-options.ts index d972b1f2..5f8f8a0c 100644 --- a/src/bin/chrome-devtools-mcp-cli-options.ts +++ b/src/bin/chrome-devtools-mcp-cli-options.ts @@ -384,23 +384,14 @@ export function parser( argv = process.argv, env = process.env, ) { - // Preserve yargs' mixed camel/kebab-case expansion under strict validation. - const kebabCaseAliases: Record = {}; - for (const option of Object.keys(cliOptions)) { - const alias = option.replace( - /[A-Z]/g, - letter => `-${letter.toLowerCase()}`, - ); - if (alias !== option) { - kebabCaseAliases[option] = alias; - } - } - const yargsInstance = yargs(hideBin(argv)) .scriptName('npx chrome-devtools-mcp@latest') + .parserConfiguration({ + 'strip-aliased': true, + 'strip-dashed': true, + }) .options(cliOptions) - .alias(kebabCaseAliases) - .strictOptions() + .showHelpOnFail(false, 'Specify --help for available options') .middleware(args => { // We can't set default in the options else // Yargs will complain @@ -418,6 +409,23 @@ export function parser( ); args.usageStatistics = false; } + + const cliOptionsAllowedArgs = [ + ...Object.keys(cliOptions), + // Yargs populated with positional args + '_', + '$0', + ]; + + const unknownArgs = Object.keys(args).filter( + arg => !cliOptionsAllowedArgs.includes(arg), + ); + + if (unknownArgs.length > 0) { + console.error( + `Unknown arguments: ${unknownArgs.map(arg => `--${arg}`)}`, + ); + } }) .example([ [ diff --git a/src/index.ts b/src/index.ts index 2b625d62..08dcf450 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,7 +6,7 @@ import type fs from 'node:fs'; -import type {parseArguments} from './bin/chrome-devtools-mcp-cli-options.js'; +import {type ParsedArguments} from './bin/chrome-devtools-mcp-cli-options.js'; import type {Channel} from './browser.js'; import {ensureBrowserConnected, ensureBrowserLaunched} from './browser.js'; import {loadIssueDescriptions} from './devtools/issueDescriptions.js'; @@ -41,7 +41,7 @@ export {buildFlag} from './ToolHandler.js'; const ROOTS_REQUEST_TIMEOUT = 5_000; export async function createMcpServer( - serverArgs: ReturnType, + serverArgs: ParsedArguments, options: { logFile?: fs.WriteStream; }, @@ -230,7 +230,7 @@ export async function createMcpServer( return {server}; } -export const logDisclaimers = (args: ReturnType) => { +export const logDisclaimers = (args: ParsedArguments) => { console.error( `chrome-devtools-mcp exposes content of the browser instance to the MCP clients allowing them to inspect, debug, and modify any data in the browser or DevTools. diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 60749b6f..593e3d14 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -17,25 +17,15 @@ function parseArguments(argv: string[], env: NodeJS.ProcessEnv = {}) { describe('cli args parsing', () => { const defaultArgs = { - 'category-emulation': true, categoryEmulation: true, - 'category-performance': true, categoryPerformance: true, - 'category-network': true, categoryNetwork: true, - 'category-extensions': false, categoryExtensions: false, - 'category-experimental-third-party': false, categoryExperimentalThirdParty: false, - 'auto-connect': undefined, autoConnect: undefined, - 'performance-crux': true, performanceCrux: true, - 'usage-statistics': true, usageStatistics: true, - 'redact-network-headers': false, redactNetworkHeaders: false, - 'allow-unrestricted-paths': false, allowUnrestrictedPaths: false, }; @@ -57,17 +47,22 @@ describe('cli args parsing', () => { _: [], headless: false, $0: 'npx chrome-devtools-mcp@latest', - 'browser-url': 'http://localhost:3000', browserUrl: 'http://localhost:3000', - u: 'http://localhost:3000', }); }); it('rejects unknown options', async () => { - assert.throws( - () => parseArguments(['--browserURL', 'http://localhost:3000']), - /Unknown argument: browserURL/, - ); + let output = ''; + const originalError = console.error; + console.error = (msg: string) => { + output += msg; + }; + try { + parseArguments(['--browserURL', 'http://localhost:3000']); + assert.match(output, /Unknown arguments: --browserURL/); + } finally { + console.error = originalError; + } }); it('parses mixed-form option names', async () => { @@ -84,7 +79,6 @@ describe('cli args parsing', () => { headless: false, $0: 'npx chrome-devtools-mcp@latest', channel: 'stable', - 'user-data-dir': '/tmp/chrome-profile', userDataDir: '/tmp/chrome-profile', }); }); @@ -96,9 +90,7 @@ describe('cli args parsing', () => { _: [], headless: false, $0: 'npx chrome-devtools-mcp@latest', - 'browser-url': undefined, browserUrl: undefined, - u: undefined, channel: 'stable', }); }); @@ -110,8 +102,6 @@ describe('cli args parsing', () => { _: [], headless: false, $0: 'npx chrome-devtools-mcp@latest', - 'executable-path': '/tmp/test 123/chrome', - e: '/tmp/test 123/chrome', executablePath: '/tmp/test 123/chrome', }); }); @@ -142,7 +132,6 @@ describe('cli args parsing', () => { headless: false, $0: 'npx chrome-devtools-mcp@latest', channel: 'stable', - 'chrome-arg': ['--no-sandbox', '--disable-setuid-sandbox'], chromeArg: ['--no-sandbox', '--disable-setuid-sandbox'], }); }); @@ -158,10 +147,6 @@ describe('cli args parsing', () => { headless: false, $0: 'npx chrome-devtools-mcp@latest', channel: 'stable', - 'ignore-default-chrome-arg': [ - '--disable-extensions', - '--disable-cancel-all-touches', - ], ignoreDefaultChromeArg: [ '--disable-extensions', '--disable-cancel-all-touches', @@ -179,9 +164,7 @@ describe('cli args parsing', () => { _: [], headless: false, $0: 'npx chrome-devtools-mcp@latest', - 'ws-endpoint': 'ws://127.0.0.1:9222/devtools/browser/abc123', wsEndpoint: 'ws://127.0.0.1:9222/devtools/browser/abc123', - w: 'ws://127.0.0.1:9222/devtools/browser/abc123', }); }); @@ -195,9 +178,7 @@ describe('cli args parsing', () => { _: [], headless: false, $0: 'npx chrome-devtools-mcp@latest', - 'ws-endpoint': 'wss://example.com:9222/devtools/browser/abc123', wsEndpoint: 'wss://example.com:9222/devtools/browser/abc123', - w: 'wss://example.com:9222/devtools/browser/abc123', }); }); @@ -222,7 +203,6 @@ describe('cli args parsing', () => { headless: false, $0: 'npx chrome-devtools-mcp@latest', channel: 'stable', - 'category-emulation': false, categoryEmulation: false, }); }); @@ -234,7 +214,6 @@ describe('cli args parsing', () => { headless: false, $0: 'npx chrome-devtools-mcp@latest', channel: 'stable', - 'auto-connect': true, autoConnect: true, }); }); From 5d0283969c34f26faf9ef4c34be437d1343cd930 Mon Sep 17 00:00:00 2001 From: Nikolay Vitkov <34244704+Lightning00Blade@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:17:28 +0000 Subject: [PATCH 02/72] refactor: move config files out of bin (#2578) --- package.json | 2 +- scripts/generate-cli.ts | 13 +++++++++---- scripts/generate-docs.ts | 10 ++++++---- scripts/update_metrics.ts | 7 ++----- src/McpResponse.ts | 2 +- src/ToolHandler.ts | 2 +- src/bin/chrome-devtools-mcp-main.ts | 4 ++-- src/bin/chrome-devtools.ts | 16 ++++++++-------- .../cli-options.ts} | 6 +++++- .../mcp-options.ts} | 6 +++--- src/index.ts | 2 +- src/telemetry/flagUtils.ts | 4 ++-- src/tools/ToolDefinition.ts | 2 +- src/tools/tools.ts | 2 +- tests/McpResponse.test.ts | 2 +- tests/ToolHandler.test.ts | 2 +- tests/cli.test.ts | 2 +- tests/daemon/utils.test.ts | 2 +- tests/telemetry/flagUtils.test.ts | 10 +++++----- tests/tools/console.test.ts | 2 +- tests/tools/extensions.test.ts | 2 +- tests/tools/input.test.ts | 2 +- tests/tools/pages.test.ts | 2 +- tests/tools/screencast.test.ts | 2 +- tests/tools/screenshot.test.ts | 2 +- tests/tools/script.test.ts | 2 +- tests/utils.ts | 2 +- 27 files changed, 60 insertions(+), 52 deletions(-) rename src/{bin/chrome-devtools-cli-options.ts => config/cli-options.ts} (99%) rename src/{bin/chrome-devtools-mcp-cli-options.ts => config/mcp-options.ts} (99%) diff --git a/package.json b/package.json index 01f4b564..442adbf2 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "typecheck": "tsc --noEmit", "format": "eslint --cache --fix . && prettier --write --cache .", "check-format": "eslint --cache . && prettier --check --cache .;", - "gen": "npm run build && npm run docs:generate && npm run cli:generate && npm run update-metrics && npm run format", + "gen": "npm run build && npm run cli:generate && npm run docs:generate && npm run update-metrics && npm run format", "docs:generate": "node scripts/generate-docs.ts", "start": "npm run build && node build/src/bin/chrome-devtools-mcp.js", "start-debug": "NODE_DEBUG=mcp:* npm run build && node build/src/bin/chrome-devtools-mcp.js", diff --git a/scripts/generate-cli.ts b/scripts/generate-cli.ts index 4eb9f112..438ce821 100644 --- a/scripts/generate-cli.ts +++ b/scripts/generate-cli.ts @@ -10,7 +10,7 @@ import path from 'node:path'; import {Client} from '@modelcontextprotocol/sdk/client/index.js'; import {StdioClientTransport} from '@modelcontextprotocol/sdk/client/stdio.js'; -import {parseArguments} from '../build/src/bin/chrome-devtools-mcp-cli-options.js'; +import {parseArguments} from '../build/src/config/mcp-options.js'; import {buildFlag} from '../build/src/index.js'; import { labels, @@ -21,7 +21,7 @@ import {createTools} from '../build/src/tools/tools.js'; const OUTPUT_PATH = path.join( import.meta.dirname, - '../src/bin/chrome-devtools-cli-options.ts', + '../src/config/cli-options.ts', ); async function fetchTools() { @@ -108,7 +108,7 @@ function schemaToCLIOptions(schema: JsonSchema): CliOption[] { async function generateCli() { const tools = await fetchTools(); - const staticTools = createTools(parseArguments()); + const staticTools = createTools(parseArguments('0.0.0', [], {})); const toolNameToCategoryEnum = new Map(); const toolNameToConditions = new Map(); @@ -195,7 +195,12 @@ async function generateCli() { * SPDX-License-Identifier: Apache-2.0 */ -// NOTE: do not edit manually. Auto-generated by 'npm run cli:generate'. +/** + * @fileoverview + * WARNING: This file is auto-generated by 'npm run cli:generate'. + * Do not edit this file manually. + */ + export interface ArgDef { name: string; diff --git a/scripts/generate-docs.ts b/scripts/generate-docs.ts index 10bfdf33..949be885 100644 --- a/scripts/generate-docs.ts +++ b/scripts/generate-docs.ts @@ -8,9 +8,11 @@ import fs from 'node:fs'; import type {Tool} from '@modelcontextprotocol/sdk/types.js'; -import {cliOptions} from '../build/src/bin/chrome-devtools-mcp-cli-options.js'; -import type {ParsedArguments} from '../build/src/bin/chrome-devtools-mcp-cli-options.js'; -import {buildFlag} from '../build/src/index.js'; +import { + mcpOptions, + type ParsedArguments, +} from '../build/src/config/mcp-options.js'; +import {buildFlag} from '../build/src/ToolHandler.js'; import { ToolCategory, OFF_BY_DEFAULT_CATEGORIES, @@ -154,7 +156,7 @@ function updateReadmeWithToolsTOC(toolsTOC: string): void { function generateConfigOptionsMarkdown(): string { let markdown = ''; - for (const [optionName, optionConfig] of Object.entries(cliOptions)) { + for (const [optionName, optionConfig] of Object.entries(mcpOptions)) { // Skip hidden options if (optionConfig.hidden) { continue; diff --git a/scripts/update_metrics.ts b/scripts/update_metrics.ts index f6bb5ef0..75037831 100644 --- a/scripts/update_metrics.ts +++ b/scripts/update_metrics.ts @@ -7,10 +7,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { - cliOptions, - parseArguments, -} from '../build/src/bin/chrome-devtools-mcp-cli-options.js'; +import {mcpOptions, parseArguments} from '../build/src/config/mcp-options.js'; import {ErrorCode} from '../build/src/telemetry/errors.js'; import { getPossibleFlagMetrics, @@ -92,7 +89,7 @@ function writeFlagUsageMetrics() { } } - const newMetrics = getPossibleFlagMetrics(cliOptions); + const newMetrics = getPossibleFlagMetrics(mcpOptions); const mergedMetrics = applyToExisting( existingMetrics, newMetrics, diff --git a/src/McpResponse.ts b/src/McpResponse.ts index 7d03192a..7fff12e2 100644 --- a/src/McpResponse.ts +++ b/src/McpResponse.ts @@ -6,7 +6,7 @@ import type {WebMCPTool} from 'puppeteer-core'; -import type {ParsedArguments} from './bin/chrome-devtools-mcp-cli-options.js'; +import type {ParsedArguments} from './config/mcp-options.js'; import {ConsoleFormatter} from './formatters/ConsoleFormatter.js'; import { HeapSnapshotFormatter, diff --git a/src/ToolHandler.ts b/src/ToolHandler.ts index 21844fcd..4e36a9c0 100644 --- a/src/ToolHandler.ts +++ b/src/ToolHandler.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type {ParsedArguments} from './bin/chrome-devtools-mcp-cli-options.js'; +import type {ParsedArguments} from './config/mcp-options.js'; import type {McpContext} from './McpContext.js'; import type {McpPage} from './McpPage.js'; import type {DataFormat} from './McpResponse.js'; diff --git a/src/bin/chrome-devtools-mcp-main.ts b/src/bin/chrome-devtools-mcp-main.ts index 5710fe21..caaabadf 100644 --- a/src/bin/chrome-devtools-mcp-main.ts +++ b/src/bin/chrome-devtools-mcp-main.ts @@ -17,7 +17,7 @@ import {checkForUpdates} from '../utils/check-for-updates.js'; import {logger, saveLogsToFile} from '../utils/logger.js'; import {VERSION} from '../version.js'; -import {cliOptions, parseArguments} from './chrome-devtools-mcp-cli-options.js'; +import {mcpOptions, parseArguments} from '../config/mcp-options.js'; await checkForUpdates( 'Run `npm install chrome-devtools-mcp@latest` to update.', @@ -80,4 +80,4 @@ await server.connect(transport); logger?.('Chrome DevTools MCP Server connected'); logDisclaimers(args); void ClearcutLogger.get()?.logDailyActiveIfNeeded(); -void ClearcutLogger.get()?.logServerStart(computeFlagUsage(args, cliOptions)); +void ClearcutLogger.get()?.logServerStart(computeFlagUsage(args, mcpOptions)); diff --git a/src/bin/chrome-devtools.ts b/src/bin/chrome-devtools.ts index 812677cf..17e7a92d 100644 --- a/src/bin/chrome-devtools.ts +++ b/src/bin/chrome-devtools.ts @@ -30,8 +30,8 @@ import {hideBin, yargs, type CallToolResult} from '../third_party/index.js'; import {checkForUpdates} from '../utils/check-for-updates.js'; import {VERSION} from '../version.js'; -import {commands} from './chrome-devtools-cli-options.js'; -import {cliOptions, parseArguments} from './chrome-devtools-mcp-cli-options.js'; +import {commands} from '../config/cli-options.js'; +import {mcpOptions, parseArguments} from '../config/mcp-options.js'; await checkForUpdates( 'Run `npm install -g chrome-devtools-mcp@latest` and `chrome-devtools start` to update and restart the daemon.', @@ -46,8 +46,8 @@ async function start(args: string[], sessionId: string) { const defaultArgs = ['--viaCli', '--experimentalStructuredContent']; const startCliOptions = { - ...cliOptions, -} as Partial; + ...mcpOptions, +} as Partial; // Missing CLI serialization. delete startCliOptions.viewport; @@ -56,10 +56,10 @@ delete startCliOptions.viewport; delete startCliOptions.experimentalStructuredContent; delete startCliOptions.experimentalInteropTools; delete startCliOptions.experimentalPageIdRouting; -if (!('default' in cliOptions.headless)) { +if (!('default' in mcpOptions.headless)) { throw new Error('headless cli option unexpectedly does not have a default'); } -if ('default' in cliOptions.isolated) { +if ('default' in mcpOptions.isolated) { throw new Error('isolated cli option unexpectedly has a default'); } startCliOptions.headless!.default = true; @@ -149,7 +149,7 @@ y.command( if (argv.headless === undefined) { argv.headless = true; } - const args = serializeArgs(cliOptions, argv); + const args = serializeArgs(mcpOptions, argv); await start(args, argv.sessionId); process.exit(0); }, @@ -276,7 +276,7 @@ for (const [commandName, commandDef] of Object.entries(commands)) { : Promise.resolve(undefined); if (!isDaemonRunning(sessionId)) { - await start(serializeArgs(cliOptions, argv), sessionId); + await start(serializeArgs(mcpOptions, argv), sessionId); } const commandArgs: Record = {}; diff --git a/src/bin/chrome-devtools-cli-options.ts b/src/config/cli-options.ts similarity index 99% rename from src/bin/chrome-devtools-cli-options.ts rename to src/config/cli-options.ts index c0c649bd..bb2c056e 100644 --- a/src/bin/chrome-devtools-cli-options.ts +++ b/src/config/cli-options.ts @@ -4,7 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -// NOTE: do not edit manually. Auto-generated by 'npm run cli:generate'. +/** + * @fileoverview + * WARNING: This file is auto-generated by 'npm run cli:generate'. + * Do not edit this file manually. + */ export interface ArgDef { name: string; diff --git a/src/bin/chrome-devtools-mcp-cli-options.ts b/src/config/mcp-options.ts similarity index 99% rename from src/bin/chrome-devtools-mcp-cli-options.ts rename to src/config/mcp-options.ts index 5f8f8a0c..350787d6 100644 --- a/src/bin/chrome-devtools-mcp-cli-options.ts +++ b/src/config/mcp-options.ts @@ -7,7 +7,7 @@ import type {YargsOptions} from '../third_party/index.js'; import {yargs, hideBin} from '../third_party/index.js'; -export const cliOptions = { +export const mcpOptions = { autoConnect: { type: 'boolean', description: @@ -390,7 +390,7 @@ export function parser( 'strip-aliased': true, 'strip-dashed': true, }) - .options(cliOptions) + .options(mcpOptions) .showHelpOnFail(false, 'Specify --help for available options') .middleware(args => { // We can't set default in the options else @@ -411,7 +411,7 @@ export function parser( } const cliOptionsAllowedArgs = [ - ...Object.keys(cliOptions), + ...Object.keys(mcpOptions), // Yargs populated with positional args '_', '$0', diff --git a/src/index.ts b/src/index.ts index 08dcf450..84e8e2c7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,7 +6,7 @@ import type fs from 'node:fs'; -import {type ParsedArguments} from './bin/chrome-devtools-mcp-cli-options.js'; +import {type ParsedArguments} from './config/mcp-options.js'; import type {Channel} from './browser.js'; import {ensureBrowserConnected, ensureBrowserLaunched} from './browser.js'; import {loadIssueDescriptions} from './devtools/issueDescriptions.js'; diff --git a/src/telemetry/flagUtils.ts b/src/telemetry/flagUtils.ts index b4adb94e..39b641c4 100644 --- a/src/telemetry/flagUtils.ts +++ b/src/telemetry/flagUtils.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type {cliOptions} from '../bin/chrome-devtools-mcp-cli-options.js'; +import type {mcpOptions} from '../config/mcp-options.js'; import {DevTools} from '../third_party/index.js'; import {stripUnderscoreBeforeNumber} from './transformation.js'; @@ -12,7 +12,7 @@ import type {FlagUsage} from './types.js'; const {StringUtilities} = DevTools.Platform; -type CliOptions = typeof cliOptions; +type CliOptions = typeof mcpOptions; /** * For enums, log the value as uppercase. diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index 7b374d66..ed9b1fbd 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type {ParsedArguments} from '../bin/chrome-devtools-mcp-cli-options.js'; +import type {ParsedArguments} from '../config/mcp-options.js'; import type { HeapSnapshotAggregateData, HeapSnapshotClassDiff, diff --git a/src/tools/tools.ts b/src/tools/tools.ts index c5285612..9bae0ae5 100644 --- a/src/tools/tools.ts +++ b/src/tools/tools.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type {ParsedArguments} from '../bin/chrome-devtools-mcp-cli-options.js'; +import type {ParsedArguments} from '../config/mcp-options.js'; import * as consoleTools from './console.js'; import * as emulationTools from './emulation.js'; diff --git a/tests/McpResponse.test.ts b/tests/McpResponse.test.ts index 119af73c..2d2d04c1 100644 --- a/tests/McpResponse.test.ts +++ b/tests/McpResponse.test.ts @@ -12,7 +12,7 @@ import {describe, it} from 'node:test'; import sinon from 'sinon'; -import type {ParsedArguments} from '../src/bin/chrome-devtools-mcp-cli-options.js'; +import type {ParsedArguments} from '../src/config/mcp-options.js'; import type {McpContext} from '../src/McpContext.js'; import type {McpResponse} from '../src/McpResponse.js'; import type {Extension} from '../src/third_party/index.js'; diff --git a/tests/ToolHandler.test.ts b/tests/ToolHandler.test.ts index 03918a75..61a1f076 100644 --- a/tests/ToolHandler.test.ts +++ b/tests/ToolHandler.test.ts @@ -12,7 +12,7 @@ import {pathToFileURL} from 'node:url'; import sinon from 'sinon'; -import {parseArguments} from '../src/bin/chrome-devtools-mcp-cli-options.js'; +import {parseArguments} from '../src/config/mcp-options.js'; import {McpContext} from '../src/McpContext.js'; import {McpPage} from '../src/McpPage.js'; import {ClearcutLogger} from '../src/telemetry/ClearcutLogger.js'; diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 593e3d14..1b986a6e 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -7,7 +7,7 @@ import assert from 'node:assert'; import {describe, it} from 'node:test'; -import {parser} from '../src/bin/chrome-devtools-mcp-cli-options.js'; +import {parser} from '../src/config/mcp-options.js'; function parseArguments(argv: string[], env: NodeJS.ProcessEnv = {}) { return parser('0.0.0', ['node', 'main.js', ...argv], env) diff --git a/tests/daemon/utils.test.ts b/tests/daemon/utils.test.ts index b345c817..79ade0ed 100644 --- a/tests/daemon/utils.test.ts +++ b/tests/daemon/utils.test.ts @@ -13,7 +13,7 @@ import path from 'node:path'; import process from 'node:process'; import {afterEach, beforeEach, describe, it} from 'node:test'; -import type {ParsedArguments} from '../../src/bin/chrome-devtools-mcp-cli-options.js'; +import type {ParsedArguments} from '../../src/config/mcp-options.js'; import { serializeArgs, assertValidSessionId, diff --git a/tests/telemetry/flagUtils.test.ts b/tests/telemetry/flagUtils.test.ts index 603259d9..4e6af00b 100644 --- a/tests/telemetry/flagUtils.test.ts +++ b/tests/telemetry/flagUtils.test.ts @@ -7,7 +7,7 @@ import assert from 'node:assert/strict'; import {describe, it} from 'node:test'; -import type {cliOptions} from '../../src/bin/chrome-devtools-mcp-cli-options.js'; +import type {mcpOptions} from '../../src/config/mcp-options.js'; import { computeFlagUsage, getPossibleFlagMetrics, @@ -33,7 +33,7 @@ describe('computeFlagUsage', () => { description: 'A flag with a default value', default: false, }, - } as unknown as typeof cliOptions; + } as unknown as typeof mcpOptions; it('logs boolean flags directly with snake_case keys', () => { const args = {boolFlag: true}; @@ -113,7 +113,7 @@ describe('computeFlagUsage', () => { type: 'boolean' as const, description: 'A 3p flag', }, - } as unknown as typeof cliOptions; + } as unknown as typeof mcpOptions; const args = {experimental3pTool: true}; const usage = computeFlagUsage(args, mock3pOptions); assert.equal(usage.experimental3p_tool, true); @@ -136,7 +136,7 @@ describe('getPossibleFlagMetrics', () => { description: 'An enum flag', choices: ['a', 'b'], }, - } as unknown as typeof cliOptions; + } as unknown as typeof mcpOptions; it('returns all possible metrics for given options', () => { const metrics = getPossibleFlagMetrics(mockOptions); @@ -160,7 +160,7 @@ describe('getPossibleFlagMetrics', () => { type: 'boolean' as const, description: 'A 3p flag', }, - } as unknown as typeof cliOptions; + } as unknown as typeof mcpOptions; const metrics = getPossibleFlagMetrics(mock3pOptions); assert.deepEqual(metrics, [ diff --git a/tests/tools/console.test.ts b/tests/tools/console.test.ts index 21220ebf..ade614db 100644 --- a/tests/tools/console.test.ts +++ b/tests/tools/console.test.ts @@ -10,7 +10,7 @@ import {before, describe, it} from 'node:test'; import type {Dialog} from 'puppeteer-core'; -import type {ParsedArguments} from '../../src/bin/chrome-devtools-mcp-cli-options.js'; +import type {ParsedArguments} from '../../src/config/mcp-options.js'; import {loadIssueDescriptions} from '../../src/devtools/issueDescriptions.js'; import {McpResponse} from '../../src/McpResponse.js'; import {TextSnapshot} from '../../src/TextSnapshot.js'; diff --git a/tests/tools/extensions.test.ts b/tests/tools/extensions.test.ts index 6f57d8ad..d8769ee3 100644 --- a/tests/tools/extensions.test.ts +++ b/tests/tools/extensions.test.ts @@ -10,7 +10,7 @@ import {afterEach, describe, it} from 'node:test'; import sinon from 'sinon'; -import type {ParsedArguments} from '../../src/bin/chrome-devtools-mcp-cli-options.js'; +import type {ParsedArguments} from '../../src/config/mcp-options.js'; import {listConsoleMessages} from '../../src/tools/console.js'; import { installExtension, diff --git a/tests/tools/input.test.ts b/tests/tools/input.test.ts index 1c480cdc..7e46cd62 100644 --- a/tests/tools/input.test.ts +++ b/tests/tools/input.test.ts @@ -11,7 +11,7 @@ import {describe, it} from 'node:test'; import sinon from 'sinon'; -import type {ParsedArguments} from '../../src/bin/chrome-devtools-mcp-cli-options.js'; +import type {ParsedArguments} from '../../src/config/mcp-options.js'; import {McpResponse} from '../../src/McpResponse.js'; import {TextSnapshot} from '../../src/TextSnapshot.js'; import { diff --git a/tests/tools/pages.test.ts b/tests/tools/pages.test.ts index b8ba3ef2..a63702b4 100644 --- a/tests/tools/pages.test.ts +++ b/tests/tools/pages.test.ts @@ -11,7 +11,7 @@ import {afterEach, describe, it} from 'node:test'; import type {Dialog} from 'puppeteer-core'; import sinon from 'sinon'; -import type {ParsedArguments} from '../../src/bin/chrome-devtools-mcp-cli-options.js'; +import type {ParsedArguments} from '../../src/config/mcp-options.js'; import { listPages, newPage, diff --git a/tests/tools/screencast.test.ts b/tests/tools/screencast.test.ts index adf0ff2f..ce68639a 100644 --- a/tests/tools/screencast.test.ts +++ b/tests/tools/screencast.test.ts @@ -12,7 +12,7 @@ import {describe, it, afterEach} from 'node:test'; import sinon from 'sinon'; -import type {ParsedArguments} from '../../src/bin/chrome-devtools-mcp-cli-options.js'; +import type {ParsedArguments} from '../../src/config/mcp-options.js'; import {startScreencast, stopScreencast} from '../../src/tools/screencast.js'; import {withMcpContext} from '../utils.js'; diff --git a/tests/tools/screenshot.test.ts b/tests/tools/screenshot.test.ts index 327c073e..908d91f1 100644 --- a/tests/tools/screenshot.test.ts +++ b/tests/tools/screenshot.test.ts @@ -12,7 +12,7 @@ import {describe, it, afterEach} from 'node:test'; import sinon from 'sinon'; -import type {ParsedArguments} from '../../src/bin/chrome-devtools-mcp-cli-options.js'; +import type {ParsedArguments} from '../../src/config/mcp-options.js'; import {TextSnapshot} from '../../src/TextSnapshot.js'; import {screenshot} from '../../src/tools/screenshot.js'; import {screenshots} from '../snapshot.js'; diff --git a/tests/tools/script.test.ts b/tests/tools/script.test.ts index f5689293..d11bf2c4 100644 --- a/tests/tools/script.test.ts +++ b/tests/tools/script.test.ts @@ -10,7 +10,7 @@ import {describe, it} from 'node:test'; import sinon from 'sinon'; -import type {ParsedArguments} from '../../src/bin/chrome-devtools-mcp-cli-options.js'; +import type {ParsedArguments} from '../../src/config/mcp-options.js'; import {TextSnapshot} from '../../src/TextSnapshot.js'; import {installExtension} from '../../src/tools/extensions.js'; import {evaluateScript} from '../../src/tools/script.js'; diff --git a/tests/utils.ts b/tests/utils.ts index 381e69aa..ebe55ba7 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -22,7 +22,7 @@ import type { } from 'puppeteer-core'; import sinon from 'sinon'; -import type {ParsedArguments} from '../src/bin/chrome-devtools-mcp-cli-options.js'; +import type {ParsedArguments} from '../src/config/mcp-options.js'; import {McpContext} from '../src/McpContext.js'; import {McpResponse} from '../src/McpResponse.js'; import {TextSnapshot} from '../src/TextSnapshot.js'; From fce756ad199af2ee0ebdab08d568b14c137b8142 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Inf=C3=BChr?= Date: Sun, 16 Aug 2026 09:36:08 +0000 Subject: [PATCH 03/72] fix(clI): print the errror message alone instead of JSON in the CLI (#2580) Currently we print the JSON structure even when used in the CLI. --- src/daemon/client.ts | 9 +++++++++ tests/daemon/client.test.ts | 13 ++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/daemon/client.ts b/src/daemon/client.ts index 62624b0c..146e1fd5 100644 --- a/src/daemon/client.ts +++ b/src/daemon/client.ts @@ -224,6 +224,15 @@ export async function handleResponse( format: 'json' | 'md', ): Promise { if (response.isError) { + if (format === 'md') { + const chunks = []; + for (const content of response.content) { + if (content.type === 'text') { + chunks.push(content.text); + } + } + return chunks.join(' '); + } return JSON.stringify(response.content); } const chunks = []; diff --git a/tests/daemon/client.test.ts b/tests/daemon/client.test.ts index c995c9de..89461b92 100644 --- a/tests/daemon/client.test.ts +++ b/tests/daemon/client.test.ts @@ -126,13 +126,24 @@ describe('daemon client', () => { ); }); - it('handles error response when isError is true', async () => { + it('handles error response when isError is true with md format', async () => { const errorResponse = { isError: true, content: [{type: 'text' as const, text: 'Something went wrong'}], }; assert.strictEqual( await handleResponse(errorResponse, 'md'), + 'Something went wrong', + ); + }); + + it('handles error response when isError is true with json format', async () => { + const errorResponse = { + isError: true, + content: [{type: 'text' as const, text: 'Something went wrong'}], + }; + assert.strictEqual( + await handleResponse(errorResponse, 'json'), JSON.stringify(errorResponse.content), ); }); From 1c8ee68cd0668052c068b32580c0970004b95d75 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 07:54:48 +0000 Subject: [PATCH 04/72] chore(deps): bump third_party/devtools-frontend from `eaad249` to `9cf264b` (#2583) Bumps [third_party/devtools-frontend](https://github.com/ChromeDevTools/devtools-frontend) from `eaad249` to `9cf264b`.
Commits
  • 9cf264b Update DevTools DEPS (trusted)
  • 5600388 Update DevTools DEPS (trusted)
  • 129a8d9 Migrate emulated-css-media-feature-prefers-reduced-motion setting to SettingD...
  • c5cb2b0 Fix duplicate screen reader announcement for IssueCounter
  • 312ddfd AI: Streamline and structure debug logging
  • 7441098 Roll browser-protocol and CfT
  • 65f5704 AIv2: implement listStorageKeys and getStorageValues tools
  • 5c25861 AI: Redact screenshot base64 image data in formatEventForAI
  • 74d2aa5 AI: support initial context widgets for performance traces
  • 33dde33 AIv2: add getInsightDetails tool to performance skill
  • Additional commits viewable in compare view

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- third_party/devtools-frontend | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/third_party/devtools-frontend b/third_party/devtools-frontend index eaad2491..9cf264b2 160000 --- a/third_party/devtools-frontend +++ b/third_party/devtools-frontend @@ -1 +1 @@ -Subproject commit eaad2491924990cb310a4c753b2851e64d515f00 +Subproject commit 9cf264b26b39a9e8382f795a9084ddcd7a290937 From b7501682e43e80b8c8d4fbaa6b8fd6e3c205fba4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Inf=C3=BChr?= Date: Mon, 17 Aug 2026 09:08:46 +0000 Subject: [PATCH 05/72] fix: require .heapsnapshot (or .heaptimeline) as file extension (#2579) This makes it harder to pass wrong arguments to the CLI tools. --- src/processors/HeapSnapshotManager.ts | 11 +++++++++++ tests/processors/HeapSnapshotManager.test.ts | 9 +++++++++ 2 files changed, 20 insertions(+) diff --git a/src/processors/HeapSnapshotManager.ts b/src/processors/HeapSnapshotManager.ts index 02f0a20d..ebfa40de 100644 --- a/src/processors/HeapSnapshotManager.ts +++ b/src/processors/HeapSnapshotManager.ts @@ -49,6 +49,12 @@ export type HeapQueryOptions = export type HeapEdgesQueryOptions = DevTools.HeapSnapshotModel.HeapSnapshotModel.HeapEdgesQueryOptions; +const VALID_EXTENSIONS: readonly string[] = ['.heapsnapshot', '.heaptimeline']; + +function hasValidHeapSnapshotExtension(filePath: string): boolean { + return VALID_EXTENSIONS.some(ext => filePath.endsWith(ext)); +} + export class HeapSnapshotManager { #snapshotIdGenerator = createIdGenerator(); #snapshots = new Map< @@ -65,6 +71,11 @@ export class HeapSnapshotManager { async getSnapshot( filePath: string, ): Promise { + if (!hasValidHeapSnapshotExtension(filePath)) { + throw new Error( + `File ${filePath} must have a .heapsnapshot or .heaptimeline extension.`, + ); + } const absolutePath = path.resolve(filePath); const cached = this.#snapshots.get(absolutePath); if (cached) { diff --git a/tests/processors/HeapSnapshotManager.test.ts b/tests/processors/HeapSnapshotManager.test.ts index 65a2ffee..f86e76a4 100644 --- a/tests/processors/HeapSnapshotManager.test.ts +++ b/tests/processors/HeapSnapshotManager.test.ts @@ -17,6 +17,15 @@ describe('HeapSnapshotManager', () => { sinon.restore(); }); + it('rejects when a file without .heapsnapshot or .heaptimeline extension is passed', async () => { + const manager = new HeapSnapshotManager(); + + await assert.rejects( + manager.getSnapshot('tests/fixtures/snapshot_diffs.js'), + /must have a \.heapsnapshot or \.heaptimeline extension/, + ); + }); + it('disposes the worker when snapshot loading fails', async () => { const disposeSpy = sinon.spy( DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotWorkerProxy From 57adfa963ca6f97e4c167593d8010c2de71db6dd Mon Sep 17 00:00:00 2001 From: Alex Rudenko Date: Mon, 17 Aug 2026 10:19:41 +0000 Subject: [PATCH 06/72] refactor: make validatePath return resolved url (#2572) This changes the internal responsibility: validatePath is the only place that is expected to follow symlinks if it can. It then returns a resolved path for all consumers in the MCP server who are expected not to follow symlinks. This is to be combined with the followSymlinks=false setting in https://github.com/puppeteer/puppeteer/pull/15335 --- src/McpContext.ts | 58 +++++++----- src/ToolHandler.ts | 53 ++++++----- src/tools/extensions.ts | 2 +- tests/McpContext.test.ts | 94 ++++++++++++++++---- tests/ToolHandler.test.ts | 127 ++++++++++++++++++++++++--- tests/roots.test.ts | 24 +++-- tests/tools/lighthouse.test.ts | 4 +- tests/tools/memory.test.ts | 4 +- tests/tools/screenshot.test.ts | 4 +- tests/utils/files.test.ts | 155 +++++++++++++++++++-------------- 10 files changed, 376 insertions(+), 149 deletions(-) diff --git a/src/McpContext.ts b/src/McpContext.ts index 5578e6d4..7be2bbaa 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -225,21 +225,18 @@ export class McpContext implements Context { this.#roots = roots; } - async validatePath(filePath?: string): Promise { + /** + * Validates that the filePath is allowed according to the roots configuration. + * Tolerates if parts of the filePath do not exist yet but the file access to + * the resolved should only be allowed without following symlinks. + */ + async validatePath(filePath: string): Promise; + async validatePath(filePath?: undefined): Promise; + async validatePath(filePath?: string): Promise; + async validatePath(filePath?: string): Promise { if (filePath === undefined) { - return; - } - // If the client never negotiated roots and the operator has explicitly - // opted into unrestricted access via --allow-unrestricted-paths, restore - // the previous permissive behavior and skip validation. - if (this.#roots === undefined && this.#allowUnrestrictedPaths) { - return; + return undefined; } - // roots() always returns at least the temp directory, even if the - // connecting client never negotiated the optional `roots` capability. - // Path validation must not be skipped just because no workspace roots - // were configured. - const roots = this.roots(); let canonicalPath: string; @@ -255,6 +252,20 @@ export class McpContext implements Context { ); } + // If the client never negotiated roots and the operator has explicitly + // opted into unrestricted access via --allow-unrestricted-paths, restore + // the previous permissive behavior and skip validation. + if (this.#roots === undefined && this.#allowUnrestrictedPaths) { + // Canonical path might not exist yet so we fallback to + // path.resolve(filePath). Consumers should not follow symlinks. + return canonicalPath || path.resolve(filePath); + } + // roots() always returns at least the temp directory, even if the + // connecting client never negotiated the optional `roots` capability. + // Path validation must not be skipped just because no workspace roots + // were configured. + const roots = this.roots(); + let allowed = false; const resolvedRoots = await Promise.allSettled( roots.map(async root => { @@ -293,19 +304,20 @@ export class McpContext implements Context { `Access denied: path ${filePath} (canonical: ${canonicalPath}) is not within any of the configured workspace roots.`, ); } + + return canonicalPath || path.resolve(filePath); } async ensureExtension( filePath: string, extension: Extension, ): Promise<`${string}${Extension}`> { - const resolvedPath = path.resolve(filePath); - const currentExtension = path.extname(resolvedPath); - const outputPath: `${string}${Extension}` = `${resolvedPath.slice( + const resolved = await this.validatePath(filePath); + const currentExtension = path.extname(resolved); + const outputPath: `${string}${Extension}` = `${resolved.slice( 0, - resolvedPath.length - currentExtension.length, + resolved.length - currentExtension.length, )}${extension}`; - await this.validatePath(outputPath); return outputPath; } @@ -624,17 +636,17 @@ export class McpContext implements Context { filepath: string, data: Uint8Array, ): Promise { - await this.validatePath(filepath); + const resolved = await this.validatePath(filepath); try { - await fs.mkdir(path.dirname(filepath), {recursive: true}); + await fs.mkdir(path.dirname(resolved), {recursive: true}); // Open the file with flags to: // - O_WRONLY: Write-only // - O_CREAT: Create if it doesn't exist // - O_TRUNC: Truncate to zero length if it exists // - O_NOFOLLOW: DO NOT follow symlinks. // - 0o600: Permissions: read/write for owner, no permissions for others. - await fs.writeFile(filepath, data, { + await fs.writeFile(resolved, data, { flag: fs.constants.O_WRONLY | fs.constants.O_CREAT | @@ -866,8 +878,8 @@ export class McpContext implements Context { } case 'file:': { - await this.validatePath(fileURLToPath(url)); - return await fs.readFile(url, 'utf-8'); + const resolved = await this.validatePath(fileURLToPath(url)); + return await fs.readFile(resolved, 'utf-8'); } default: diff --git a/src/ToolHandler.ts b/src/ToolHandler.ts index 4e36a9c0..a8acc9ce 100644 --- a/src/ToolHandler.ts +++ b/src/ToolHandler.ts @@ -25,7 +25,7 @@ import type { import {pageIdSchema} from './tools/ToolDefinition.js'; import {logger} from './utils/logger.js'; import type {Mutex} from './third_party/index.js'; -import {fileURLToPath} from 'node:url'; +import {fileURLToPath, pathToFileURL} from 'node:url'; import {isLocalhost} from './utils/url.js'; export function buildFlag(category: ToolCategory) { @@ -151,14 +151,21 @@ function buildUnknownArgumentsMessage( return `Unknown ${unknownLabel} for tool "${toolName}": ${formatArgumentNames(unknownArgumentNames)}. ${expectedArguments} ${correction} and retry.`; } -function extractPaths(value: unknown): string[] { - if (typeof value === 'string') { - return [value]; - } - if (Array.isArray(value)) { - return value.filter(item => typeof item === 'string'); +async function validateAndResolvePathOrUrl( + filePathOrUrl: string, + context: McpContext, +): Promise { + try { + const url = new URL(filePathOrUrl); + if (url.protocol === 'file:') { + return pathToFileURL(await context.validatePath(fileURLToPath(url))).href; + } else if (['http:', 'https:', 'ws:', 'wss:'].includes(url.protocol)) { + return filePathOrUrl; + } + } catch { + // Suppress parsing errors for regular file paths. } - return []; + return await context.validatePath(filePathOrUrl); } function isLocalBrowser(context: McpContext): boolean { @@ -194,25 +201,25 @@ async function validateToolFiles( context: McpContext, ): Promise { const isLocal = isLocalBrowser(context); - const pathsOrUrlsToValidate: string[] = []; for (const [key, option] of Object.entries(tool.verifyFilesSchema)) { if (shouldValidateFile(option, isLocal)) { - pathsOrUrlsToValidate.push(...extractPaths(params[key])); - } - } - for (const filePathOrUrl of pathsOrUrlsToValidate) { - let filePath = filePathOrUrl; - try { - const url = new URL(filePathOrUrl); - if (url.protocol === 'file:') { - filePath = fileURLToPath(url); - } else if (['http:', 'https:', 'ws:', 'wss:'].includes(url.protocol)) { - continue; + const val = params[key]; + if (typeof val === 'string') { + params[key] = await validateAndResolvePathOrUrl(val, context); + } else if (Array.isArray(val)) { + const updated: unknown[] = []; + for (const item of val) { + if (typeof item === 'string') { + updated.push(await validateAndResolvePathOrUrl(item, context)); + } else { + throw new Error( + 'Unexpected non-string value as a file path or URL', + ); + } + } + params[key] = updated; } - } catch { - // Suppress parsing errors for regular file paths. } - await context.validatePath(filePath); } } diff --git a/src/tools/extensions.ts b/src/tools/extensions.ts index ffef1a03..e9489274 100644 --- a/src/tools/extensions.ts +++ b/src/tools/extensions.ts @@ -23,7 +23,7 @@ export const installExtension = defineTool({ }, blockedByDialog: false, verifyFilesSchema: { - path: true, + path: {local: true}, }, handler: async (request, response, context) => { const {path} = request.params; diff --git a/tests/McpContext.test.ts b/tests/McpContext.test.ts index 249b7d11..865df86b 100644 --- a/tests/McpContext.test.ts +++ b/tests/McpContext.test.ts @@ -20,6 +20,7 @@ import {McpPage} from '../src/McpPage.js'; import {TextSnapshot} from '../src/TextSnapshot.js'; import {type HTTPResponse} from '../src/third_party/index.js'; import type {TraceResult} from '../src/processors/PerformanceTrace.js'; +import {resolveCanonicalPath} from '../src/utils/files.js'; import { getMockRequest, @@ -418,8 +419,14 @@ describe('McpContext', () => { ]; context.setRoots(roots); // Valid path within root - await context.validatePath(path.join(workspacePath, 'test.txt')); - await context.validatePath(workspacePath); + const targetPath = path.join(workspacePath, 'test.txt'); + const resolved = await context.validatePath(targetPath); + assert.strictEqual(resolved, await resolveCanonicalPath(targetPath)); + const resolvedWorkspace = await context.validatePath(workspacePath); + assert.strictEqual( + resolvedWorkspace, + await resolveCanonicalPath(workspacePath), + ); // Invalid path outside root and outside temp dir const outsidePath = path.resolve(os.homedir(), 'outside-test.txt'); @@ -443,9 +450,9 @@ describe('McpContext', () => { ]; context.setRoots(roots); // Valid path within root with non-existent intermediate directories - await context.validatePath( - path.join(workspacePath, 'dir1', 'dir2', 'test.txt'), - ); + const targetPath = path.join(workspacePath, 'dir1', 'dir2', 'test.txt'); + const resolved = await context.validatePath(targetPath); + assert.strictEqual(resolved, await resolveCanonicalPath(targetPath)); } finally { await fs.rm(workspacePath, {recursive: true, force: true}); } @@ -456,19 +463,42 @@ describe('McpContext', () => { await withMcpContext( async (_response, context) => { context.setRoots(undefined); - await context.validatePath(path.resolve(os.homedir(), 'anywhere.txt')); + const targetPath = path.resolve(os.homedir(), 'anywhere.txt'); + const resolved = await context.validatePath(targetPath); + assert.strictEqual(resolved, await resolveCanonicalPath(targetPath)); }, {allowUnrestrictedPaths: true}, ); }); + it('validatePath returns undefined if filePath is undefined', async () => { + await withMcpContext(async (_response, context) => { + const resolved = await context.validatePath(undefined); + assert.strictEqual(resolved, undefined); + }); + }); + + it('validatePath returns resolved absolute path for relative paths', async () => { + await withMcpContext(async (_response, context) => { + const tmpDir = os.tmpdir(); + const relativeTmpPath = path.relative( + process.cwd(), + path.join(tmpDir, 'test.txt'), + ); + const resolved = await context.validatePath(relativeTmpPath); + assert.strictEqual(resolved, await resolveCanonicalPath(relativeTmpPath)); + }); + }); + it('validatePath denies paths outside tmpdir if roots are undefined and allowUnrestrictedPaths is not set', async () => { await withMcpContext(async (_response, context) => { // setRoots() never called — simulates a client that skips roots capability. const outsidePath = path.resolve(os.homedir(), 'anywhere.txt'); await assert.rejects(context.validatePath(outsidePath), /Access denied/); // Temp dir must still be reachable. - await context.validatePath(path.join(os.tmpdir(), 'test.txt')); + const tmpPath = path.join(os.tmpdir(), 'test.txt'); + const resolved = await context.validatePath(tmpPath); + assert.strictEqual(resolved, await resolveCanonicalPath(tmpPath)); }); }); @@ -476,7 +506,9 @@ describe('McpContext', () => { await withMcpContext(async (_response, context) => { context.setRoots([]); // Should allow temp dir - await context.validatePath(path.join(os.tmpdir(), 'test.txt')); + const tmpPath = path.join(os.tmpdir(), 'test.txt'); + const resolved = await context.validatePath(tmpPath); + assert.strictEqual(resolved, await resolveCanonicalPath(tmpPath)); // Should deny outside temp dir await assert.rejects( @@ -492,7 +524,35 @@ describe('McpContext', () => { return; } - it('saveFile refuses to write through a symlink to an existing file', async () => { + it('validatePath resolves symlinks and returns the canonical path', async () => { + await withMcpContext(async (_response, context) => { + const tmpDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'validate-symlink-test-'), + ); + try { + const targetDir = path.join(tmpDir, 'target'); + await fs.mkdir(targetDir); + const targetFile = path.join(targetDir, 'file.txt'); + await fs.writeFile(targetFile, 'hello'); + + const symlinkDir = path.join(tmpDir, 'symlink_dir'); + await fs.symlink(targetDir, symlinkDir, 'dir'); + + const canonicalTarget = await fs.realpath(targetDir); + context.setRoots([ + {uri: pathToFileURL(canonicalTarget).href, name: 'target'}, + ]); + + const filePathWithSymlink = path.join(symlinkDir, 'file.txt'); + const resolved = await context.validatePath(filePathWithSymlink); + assert.strictEqual(resolved, path.join(canonicalTarget, 'file.txt')); + } finally { + await fs.rm(tmpDir, {recursive: true, force: true}); + } + }); + }); + + it('saveFile allows writing to a symlinked file if it resolves to an allowed path', async () => { await withMcpContext(async (_response, context) => { const tmpDir = await fs.mkdtemp( path.join(os.tmpdir(), 'mcp-symlink-test-'), @@ -506,14 +566,12 @@ describe('McpContext', () => { const symlinkPath = path.join(tmpDir, 'symlink.txt'); await fs.symlink(targetPath, symlinkPath); - const data = new TextEncoder().encode('malicious content'); - await assert.rejects( - context.saveFile(data, symlinkPath, '.txt'), - /Could not write/, - ); + const data = new TextEncoder().encode('content'); + await context.saveFile(data, symlinkPath, '.txt'); + await context.saveFile(data, targetPath, '.txt'); const content = await fs.readFile(targetPath, 'utf-8'); - assert.strictEqual(content, 'original content'); + assert.strictEqual(content, 'content'); } finally { await fs.rm(tmpDir, {recursive: true, force: true}); } @@ -567,8 +625,10 @@ describe('McpContext', () => { const data = new TextEncoder().encode('allowed content'); const result = await context.saveFile(data, targetFilePath, '.txt'); - assert.strictEqual(result.filename, targetFilePath); - + assert.strictEqual( + result.filename, + await resolveCanonicalPath(targetFilePath), + ); const content = await fs.readFile( path.join(realDir, 'test.txt'), 'utf-8', diff --git a/tests/ToolHandler.test.ts b/tests/ToolHandler.test.ts index 61a1f076..42e4eb13 100644 --- a/tests/ToolHandler.test.ts +++ b/tests/ToolHandler.test.ts @@ -312,8 +312,9 @@ describe('ToolHandler', () => { assert.strictEqual(handlerCalled, false); }); - it('validates files specified in verifyFilesSchema', async () => { + it('validates files specified in verifyFilesSchema and rewrites input with validated paths/URLs', async () => { let handlerCalled = false; + let receivedParams: Record | undefined; const tool: ToolDefinition = { name: 'file_tool', description: 'A tool requiring file validation', @@ -330,15 +331,24 @@ describe('ToolHandler', () => { filePath: true, fileList: true, }, - handler: async () => { + handler: async request => { handlerCalled = true; + receivedParams = request.params; }, }; const mockContext = sinon.createStubInstance(McpContext); const mockProcess = sinon.createStubInstance(ChildProcess); mockContext.browser = getMockBrowser({process: mockProcess}); - mockContext.validatePath.resolves(); + mockContext.validatePath.callsFake(async p => { + if (!p) { + return undefined; + } + return path.resolve( + '/canonical', + path.relative(path.resolve('/workspace'), p), + ); + }); const toolMutex = new Mutex(); const serverArgs = parseArguments('1.0.0', ['node', 'script.js'], { @@ -378,6 +388,14 @@ describe('ToolHandler', () => { mockContext.validatePath.calledWith(testListFile2), true, ); + assert.deepStrictEqual(receivedParams, { + filePath: pathToFileURL(path.resolve('/canonical/url-file.txt')).href, + fileList: [ + path.resolve('/canonical/list1.txt'), + path.resolve('/canonical/list2.txt'), + 'https://example.com/remote.txt', + ], + }); }); it('returns error when file validation fails for verifyFilesSchema', async () => { @@ -434,6 +452,7 @@ describe('ToolHandler', () => { it('validates verifyFilesSchema when local: true and browser is running locally via process', async () => { let handlerCalled = false; + let receivedParams: Record | undefined; const tool: ToolDefinition = { name: 'upload_tool', description: 'A tool with local-only file verification', @@ -451,15 +470,17 @@ describe('ToolHandler', () => { remote: false, }, }, - handler: async () => { + handler: async request => { handlerCalled = true; + receivedParams = request.params; }, }; const mockContext = sinon.createStubInstance(McpContext); const mockProcess = sinon.createStubInstance(ChildProcess); mockContext.browser = getMockBrowser({process: mockProcess}); - mockContext.validatePath.resolves(); + const canonicalPath = path.resolve('/canonical/workspace/upload.png'); + mockContext.validatePath.resolves(canonicalPath); const toolMutex = new Mutex(); const serverArgs = parseArguments('1.0.0', ['node', 'script.js'], { @@ -481,10 +502,14 @@ describe('ToolHandler', () => { assert.strictEqual(result.isError, undefined); assert.strictEqual(handlerCalled, true); assert.strictEqual(mockContext.validatePath.calledOnceWith(testPath), true); + assert.deepStrictEqual(receivedParams, { + filePaths: [canonicalPath], + }); }); it('validates verifyFilesSchema when local: true and browser is connected to localhost wsEndpoint', async () => { let handlerCalled = false; + let receivedParams: Record | undefined; const tool: ToolDefinition = { name: 'install_pwa_tool', description: 'PWA tool with local-only file verification', @@ -501,8 +526,9 @@ describe('ToolHandler', () => { local: true, }, }, - handler: async () => { + handler: async request => { handlerCalled = true; + receivedParams = request.params; }, }; @@ -510,7 +536,8 @@ describe('ToolHandler', () => { mockContext.browser = getMockBrowser({ wsEndpoint: 'ws://127.0.0.1:9222/devtools/browser/test', }); - mockContext.validatePath.resolves(); + const canonicalBundlePath = path.resolve('/canonical/workspace/app.swbn'); + mockContext.validatePath.resolves(canonicalBundlePath); const toolMutex = new Mutex(); const serverArgs = parseArguments('1.0.0', ['node', 'script.js'], { @@ -536,10 +563,14 @@ describe('ToolHandler', () => { mockContext.validatePath.calledOnceWith(bundlePath), true, ); + assert.deepStrictEqual(receivedParams, { + installUrlOrBundleUrl: pathToFileURL(canonicalBundlePath).href, + }); }); it('skips local-only verifyFilesSchema when browser is remote', async () => { let handlerCalled = false; + let receivedParams: Record | undefined; const tool: ToolDefinition = { name: 'upload_tool', description: 'A tool with local-only file verification', @@ -557,8 +588,9 @@ describe('ToolHandler', () => { remote: false, }, }, - handler: async () => { + handler: async request => { handlerCalled = true; + receivedParams = request.params; }, }; @@ -586,6 +618,9 @@ describe('ToolHandler', () => { assert.strictEqual(result.isError, undefined); assert.strictEqual(handlerCalled, true); assert.strictEqual(mockContext.validatePath.called, false); + assert.deepStrictEqual(receivedParams, { + filePaths: ['/remote/server/path.txt'], + }); }); it('skips local-only verifyFilesSchema when browser has no process', async () => { @@ -685,6 +720,7 @@ describe('ToolHandler', () => { it('validates verifyFilesSchema with true but skips local: true on remote browser', async () => { let handlerCalled = false; + let receivedParams: Record | undefined; const tool: ToolDefinition = { name: 'hybrid_tool', description: 'A tool with both schema file verifications', @@ -704,8 +740,9 @@ describe('ToolHandler', () => { remote: false, }, }, - handler: async () => { + handler: async request => { handlerCalled = true; + receivedParams = request.params; }, }; @@ -713,7 +750,8 @@ describe('ToolHandler', () => { mockContext.browser = getMockBrowser({ wsEndpoint: 'ws://remote-host.com:9222/devtools/browser/test', }); - mockContext.validatePath.resolves(); + const canonicalOutputPath = path.resolve('/canonical/output.json'); + mockContext.validatePath.resolves(canonicalOutputPath); const toolMutex = new Mutex(); const serverArgs = parseArguments('1.0.0', ['node', 'script.js'], { @@ -739,6 +777,10 @@ describe('ToolHandler', () => { mockContext.validatePath.calledOnceWith(outputPath), true, ); + assert.deepStrictEqual(receivedParams, { + outputFile: canonicalOutputPath, + inputFile: '/remote/input.json', + }); }); it('returns error when file validation fails for local: true on local browser', async () => { @@ -860,4 +902,69 @@ describe('ToolHandler', () => { await localToolHandler.handle({remoteFile: remotePath}); assert.strictEqual(mockLocalContext.validatePath.called, false); }); + + it('rewrites file paths in params for page scoped tools', async () => { + let receivedParams: Record | undefined; + const tool: DefinedPageTool = { + name: 'page_file_tool', + description: 'A page scoped tool with file verification', + annotations: { + category: ToolCategory.DEBUGGING, + readOnlyHint: false, + }, + schema: { + filePath: zod.string(), + }, + blockedByDialog: false, + verifyFilesSchema: { + filePath: true, + }, + pageScoped: true, + handler: async request => { + receivedParams = request.params; + }, + }; + + const mockContext = sinon.createStubInstance(McpContext); + const mockProcess = sinon.createStubInstance(ChildProcess); + mockContext.browser = getMockBrowser({process: mockProcess}); + mockContext.getDevToolsData.resolves({}); + const mockPage = sinon.createStubInstance(McpPage); + mockPage.getDialog.returns(undefined); + sinon.stub(mockPage, 'networkConditions').get(() => undefined); + sinon.stub(mockPage, 'geolocation').get(() => undefined); + sinon.stub(mockPage, 'viewport').get(() => undefined); + sinon.stub(mockPage, 'userAgent').get(() => undefined); + sinon.stub(mockPage, 'colorScheme').get(() => undefined); + sinon.stub(mockPage, 'cpuThrottlingRate').get(() => 1); + mockContext.getSelectedMcpPage.returns(mockPage); + const canonicalFilePath = path.resolve('/canonical/output.png'); + mockContext.validatePath.resolves(canonicalFilePath); + + const toolMutex = new Mutex(); + const serverArgs = parseArguments('1.0.0', ['node', 'script.js'], { + CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true', + }); + + const toolHandler = new ToolHandler( + tool, + serverArgs, + async () => mockContext, + toolMutex, + ); + + const inputPath = path.resolve('/workspace/output.png'); + const result = await toolHandler.handle({ + filePath: inputPath, + }); + + assert.strictEqual(result.isError, undefined); + assert.strictEqual( + mockContext.validatePath.calledOnceWith(inputPath), + true, + ); + assert.deepStrictEqual(receivedParams, { + filePath: canonicalFilePath, + }); + }); }); diff --git a/tests/roots.test.ts b/tests/roots.test.ts index 0732d5a5..582fc603 100644 --- a/tests/roots.test.ts +++ b/tests/roots.test.ts @@ -11,6 +11,8 @@ import path from 'node:path'; import {describe, it} from 'node:test'; import {pathToFileURL} from 'node:url'; +import {resolveCanonicalPath} from '../src/utils/files.js'; + import {withMcpContext} from './utils.js'; describe('McpContext Roots', () => { @@ -18,8 +20,8 @@ describe('McpContext Roots', () => { await withMcpContext(async (_response, context) => { context.setRoots([]); const tmpPath = path.join(os.tmpdir(), 'test-file.txt'); - // This should not throw - await context.validatePath(tmpPath); + const resolved = await context.validatePath(tmpPath); + assert.strictEqual(resolved, await resolveCanonicalPath(tmpPath)); }); }); @@ -36,7 +38,8 @@ describe('McpContext Roots', () => { const tmpPath = path.join(os.tmpdir(), 'test-file.txt'); // The temp directory must remain reachable even with no negotiated // roots, matching the existing "empty roots" behavior above. - await context.validatePath(tmpPath); + const resolved = await context.validatePath(tmpPath); + assert.strictEqual(resolved, await resolveCanonicalPath(tmpPath)); }); }); @@ -51,11 +54,16 @@ describe('McpContext Roots', () => { context.setRoots([{uri: pathToFileURL(otherRoot).href, name: 'other'}]); const tmpPath = path.join(os.tmpdir(), 'test-file.txt'); - // This should not throw. - await context.validatePath(tmpPath); + const resolvedTmp = await context.validatePath(tmpPath); + assert.strictEqual(resolvedTmp, await resolveCanonicalPath(tmpPath)); // Other root should also be allowed. - await context.validatePath(path.join(otherRoot, 'file.txt')); + const otherFile = path.join(otherRoot, 'file.txt'); + const resolvedOther = await context.validatePath(otherFile); + assert.strictEqual( + resolvedOther, + await resolveCanonicalPath(otherFile), + ); // Outside should still be denied. Use a path that is definitely not a root or temp dir. const outsidePath = path.resolve( @@ -122,7 +130,9 @@ describe('McpContext Roots', () => { assert.strictEqual( resolvedPath, - path.join(workspacePath, testCase.expected), + await resolveCanonicalPath( + path.join(workspacePath, testCase.expected), + ), ); } } finally { diff --git a/tests/tools/lighthouse.test.ts b/tests/tools/lighthouse.test.ts index 305ba63b..272d6ec4 100644 --- a/tests/tools/lighthouse.test.ts +++ b/tests/tools/lighthouse.test.ts @@ -11,6 +11,7 @@ import path from 'node:path'; import {describe, it} from 'node:test'; import {lighthouseAudit} from '../../src/tools/lighthouse.js'; +import {resolveCanonicalPath} from '../../src/utils/files.js'; import {serverHooks} from '../server.js'; import {html, withMcpContext} from '../utils.js'; @@ -177,8 +178,9 @@ describe('lighthouse', () => { assert.equal(data.summary.mode, 'snapshot'); assert.equal(data.summary.device, 'mobile'); assert.ok(data.reports.length === 2); + const canonicalFolderPath = await resolveCanonicalPath(folderPath); for (const report of data.reports) { - assert.ok(report.startsWith(folderPath)); + assert.ok(report.startsWith(canonicalFolderPath)); } }); } finally { diff --git a/tests/tools/memory.test.ts b/tests/tools/memory.test.ts index 4da915ad..c9fdd2e1 100644 --- a/tests/tools/memory.test.ts +++ b/tests/tools/memory.test.ts @@ -26,6 +26,7 @@ import { getHeapSnapshotObjectDetails, } from '../../src/tools/memory.js'; import {stableIdSymbol} from '../../src/utils/id.js'; +import {resolveCanonicalPath} from '../../src/utils/files.js'; import {withMcpContext} from '../utils.js'; describe('memory', () => { @@ -39,9 +40,10 @@ describe('memory', () => { response, context, ); + const canonicalFilePath = await resolveCanonicalPath(filePath); assert.equal( response.responseLines.at(0), - `Heap snapshot saved to ${filePath}`, + `Heap snapshot saved to ${canonicalFilePath}`, ); assert.ok(existsSync(filePath)); } finally { diff --git a/tests/tools/screenshot.test.ts b/tests/tools/screenshot.test.ts index 908d91f1..2c6f52a7 100644 --- a/tests/tools/screenshot.test.ts +++ b/tests/tools/screenshot.test.ts @@ -15,6 +15,7 @@ import sinon from 'sinon'; import type {ParsedArguments} from '../../src/config/mcp-options.js'; import {TextSnapshot} from '../../src/TextSnapshot.js'; import {screenshot} from '../../src/tools/screenshot.js'; +import {resolveCanonicalPath} from '../../src/utils/files.js'; import {screenshots} from '../snapshot.js'; import {html, withMcpContext} from '../utils.js'; @@ -274,9 +275,10 @@ describe('screenshot', () => { response.responseLines.at(0), "Took a screenshot of the current page's viewport.", ); + const canonicalFilePath = await resolveCanonicalPath(filePath); assert.equal( response.responseLines.at(1), - `Saved screenshot to ${filePath}.`, + `Saved screenshot to ${canonicalFilePath}.`, ); const stats = await stat(filePath); diff --git a/tests/utils/files.test.ts b/tests/utils/files.test.ts index 2cfc5e40..032064bb 100644 --- a/tests/utils/files.test.ts +++ b/tests/utils/files.test.ts @@ -8,88 +8,113 @@ import assert from 'node:assert'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -import {describe, it} from 'node:test'; +import {afterEach, beforeEach, describe, it} from 'node:test'; import {resolveCanonicalPath} from '../../src/utils/files.js'; describe('resolveCanonicalPath', () => { - it('should resolve an existing standard file path', async () => { - const tmpDir = await fs.mkdtemp( + let tmpDir: string; + let canonicalTmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp( path.join(os.tmpdir(), 'resolve-canonical-test-'), ); - try { - const filePath = path.join(tmpDir, 'test.txt'); - await fs.writeFile(filePath, 'hello'); - - const resolved = await resolveCanonicalPath(filePath); - const canonicalTmpDir = await fs.realpath(tmpDir); - assert.strictEqual(resolved, path.join(canonicalTmpDir, 'test.txt')); - } finally { - await fs.rm(tmpDir, {recursive: true, force: true}); - } + canonicalTmpDir = await fs.realpath(tmpDir); + }); + + afterEach(async () => { + await fs.rm(tmpDir, {recursive: true, force: true}); + }); + + it('should resolve an existing standard file path', async () => { + const filePath = path.join(tmpDir, 'test.txt'); + await fs.writeFile(filePath, 'hello'); + + const resolved = await resolveCanonicalPath(filePath); + assert.strictEqual(resolved, path.join(canonicalTmpDir, 'test.txt')); }); it('should resolve a non-existent file whose parent directory exists', async () => { - const tmpDir = await fs.mkdtemp( - path.join(os.tmpdir(), 'resolve-canonical-test-'), + const filePath = path.join(tmpDir, 'non-existent.txt'); + + const resolved = await resolveCanonicalPath(filePath); + assert.strictEqual( + resolved, + path.join(canonicalTmpDir, 'non-existent.txt'), ); - try { - const filePath = path.join(tmpDir, 'non-existent.txt'); - - const resolved = await resolveCanonicalPath(filePath); - const canonicalTmpDir = await fs.realpath(tmpDir); - assert.strictEqual( - resolved, - path.join(canonicalTmpDir, 'non-existent.txt'), - ); - } finally { - await fs.rm(tmpDir, {recursive: true, force: true}); - } }); it('should resolve a non-existent deeply nested file whose parent directories do not exist', async () => { - const tmpDir = await fs.mkdtemp( - path.join(os.tmpdir(), 'resolve-canonical-test-'), + const filePath = path.join( + tmpDir, + 'nested1', + 'nested2', + 'non-existent.txt', + ); + + const resolved = await resolveCanonicalPath(filePath); + assert.strictEqual( + resolved, + path.join(canonicalTmpDir, 'nested1', 'nested2', 'non-existent.txt'), ); - try { - const filePath = path.join( - tmpDir, - 'nested1', - 'nested2', - 'non-existent.txt', - ); - - const resolved = await resolveCanonicalPath(filePath); - const canonicalTmpDir = await fs.realpath(tmpDir); - assert.strictEqual( - resolved, - path.join(canonicalTmpDir, 'nested1', 'nested2', 'non-existent.txt'), - ); - } finally { - await fs.rm(tmpDir, {recursive: true, force: true}); - } }); it('should resolve existing files with symlinks in path', async () => { - const tmpDir = await fs.mkdtemp( - path.join(os.tmpdir(), 'resolve-canonical-test-'), + const targetDir = path.join(tmpDir, 'target'); + await fs.mkdir(targetDir); + const targetFile = path.join(targetDir, 'file.txt'); + await fs.writeFile(targetFile, 'hello'); + + const symlinkDir = path.join(tmpDir, 'symlink_dir'); + await fs.symlink(targetDir, symlinkDir, 'dir'); + + const filePathWithSymlink = path.join(symlinkDir, 'file.txt'); + + const resolved = await resolveCanonicalPath(filePathWithSymlink); + const canonicalTargetDir = await fs.realpath(targetDir); + assert.strictEqual(resolved, path.join(canonicalTargetDir, 'file.txt')); + }); + + it('should resolve non-existent files with symlinks in path', async () => { + const targetDir = path.join(tmpDir, 'target'); + await fs.mkdir(targetDir); + + const symlinkDir = path.join(tmpDir, 'symlink_dir'); + await fs.symlink(targetDir, symlinkDir, 'dir'); + + const filePathWithSymlink = path.join(symlinkDir, 'non-existent.txt'); + + const resolved = await resolveCanonicalPath(filePathWithSymlink); + const canonicalTargetDir = await fs.realpath(targetDir); + assert.strictEqual( + resolved, + path.join(canonicalTargetDir, 'non-existent.txt'), + ); + }); + + it('should resolve dangling symlink at the end of path', async () => { + const nonExistentTarget = path.join(tmpDir, 'non-existent-target.txt'); + const danglingSymlink = path.join(tmpDir, 'dangling-symlink.txt'); + await fs.symlink(nonExistentTarget, danglingSymlink); + + const resolved = await resolveCanonicalPath(danglingSymlink); + assert.strictEqual( + resolved, + path.join(canonicalTmpDir, 'dangling-symlink.txt'), + ); + }); + + it('should resolve path with a dangling symlink directory in the middle', async () => { + const nonExistentTargetDir = path.join(tmpDir, 'non-existent-dir'); + const danglingSymlinkDir = path.join(tmpDir, 'dangling-dir'); + await fs.symlink(nonExistentTargetDir, danglingSymlinkDir, 'dir'); + + const filePath = path.join(danglingSymlinkDir, 'file.txt'); + const resolved = await resolveCanonicalPath(filePath); + assert.strictEqual( + resolved, + path.join(canonicalTmpDir, 'dangling-dir', 'file.txt'), ); - try { - const targetDir = path.join(tmpDir, 'target'); - await fs.mkdir(targetDir); - const targetFile = path.join(targetDir, 'file.txt'); - await fs.writeFile(targetFile, 'hello'); - - const symlinkDir = path.join(tmpDir, 'symlink_dir'); - await fs.symlink(targetDir, symlinkDir, 'dir'); - - const filePathWithSymlink = path.join(symlinkDir, 'file.txt'); - - const resolved = await resolveCanonicalPath(filePathWithSymlink); - const canonicalTargetDir = await fs.realpath(targetDir); - assert.strictEqual(resolved, path.join(canonicalTargetDir, 'file.txt')); - } finally { - await fs.rm(tmpDir, {recursive: true, force: true}); - } }); }); From 1c92ba091ce059533f009dafac5a92f452cfddf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Inf=C3=BChr?= Date: Mon, 17 Aug 2026 12:03:03 +0000 Subject: [PATCH 07/72] fix(cli): enable tools which require --memoryDebugging on the CLI (#2585) Currently tools that require --memoryDebugging cannot directly be invoked from the CLI. The server needs to be start explicitly with the --memoryDebugging flag first. This PR makes --memoryDebugging a default for the CLI. --- src/bin/chrome-devtools.ts | 42 ++++++++++++++++----------------- src/config/cli-options.ts | 14 ++++++++--- src/config/mcp-options.ts | 48 ++++++++++++++++++++++++++++++++++++-- tests/cli.test.ts | 11 +++++++++ 4 files changed, 88 insertions(+), 27 deletions(-) diff --git a/src/bin/chrome-devtools.ts b/src/bin/chrome-devtools.ts index 17e7a92d..ff2fd5c9 100644 --- a/src/bin/chrome-devtools.ts +++ b/src/bin/chrome-devtools.ts @@ -31,41 +31,39 @@ import {checkForUpdates} from '../utils/check-for-updates.js'; import {VERSION} from '../version.js'; import {commands} from '../config/cli-options.js'; -import {mcpOptions, parseArguments} from '../config/mcp-options.js'; +import { + mcpOptions, + parseArguments, + getMcpOptionsForViaCli, +} from '../config/mcp-options.js'; await checkForUpdates( 'Run `npm install -g chrome-devtools-mcp@latest` and `chrome-devtools start` to update and restart the daemon.', ); +const DEFAULT_CLI_ARGS = ['--viaCli']; + async function start(args: string[], sessionId: string) { - const combinedArgs = [...args, ...defaultArgs]; + const combinedArgs = [...DEFAULT_CLI_ARGS, ...args]; await startDaemon(combinedArgs, sessionId); logDisclaimers(parseArguments(VERSION, combinedArgs)); } -const defaultArgs = ['--viaCli', '--experimentalStructuredContent']; +function getCliOptions() { + const options: Partial = { + ...getMcpOptionsForViaCli(), + }; -const startCliOptions = { - ...mcpOptions, -} as Partial; + // Missing CLI serialization. + delete options.viewport; -// Missing CLI serialization. -delete startCliOptions.viewport; + // Change the defaults for the CLI. + delete options.experimentalStructuredContent; + delete options.experimentalInteropTools; + delete options.experimentalPageIdRouting; -// Change the defaults for the CLI. -delete startCliOptions.experimentalStructuredContent; -delete startCliOptions.experimentalInteropTools; -delete startCliOptions.experimentalPageIdRouting; -if (!('default' in mcpOptions.headless)) { - throw new Error('headless cli option unexpectedly does not have a default'); -} -if ('default' in mcpOptions.isolated) { - throw new Error('isolated cli option unexpectedly has a default'); + return options; } -startCliOptions.headless!.default = true; -startCliOptions.isolated!.description = - 'If specified, creates a temporary user-data-dir that is automatically cleaned up after the browser is closed. Defaults to true unless userDataDir is provided.'; -startCliOptions.categoryExtensions!.default = true; const y = yargs(hideBin(process.argv)) .locale('en') // Force English to ensure error string matching works in .fail, all custom messages we output are in English anyways @@ -132,7 +130,7 @@ y.command( 'Start or restart chrome-devtools-mcp', y => y - .options(startCliOptions) + .options(getCliOptions()) .example( '$0 start --browserUrl http://localhost:9222', 'Start the server connecting to an existing browser', diff --git a/src/config/cli-options.ts b/src/config/cli-options.ts index bb2c056e..39d167d7 100644 --- a/src/config/cli-options.ts +++ b/src/config/cli-options.ts @@ -223,7 +223,7 @@ export const commands: Commands = { }, evaluate_script: { description: - 'Evaluate a JavaScript function inside the currently selected page. Returns the response as JSON, so returned values have to be JSON-serializable.', + 'Evaluate a JavaScript function inside the currently selected page or service worker. Returns the response as JSON, so returned values have to be JSON-serializable.', category: 'Debugging', args: { function: { @@ -260,6 +260,13 @@ export const commands: Commands = { 'Whether to wait for the DOM to settle. Pass false if the script only reads data. Defaults to true.', required: false, }, + serviceWorkerId: { + name: 'serviceWorkerId', + type: 'string', + description: + "The optional service worker id to evaluate the script in. If provided, 'pageId' should be omitted. Note: 'args' (element UIDs) cannot be used when evaluating in a service worker.", + required: false, + }, }, }, execute_3p_developer_tool: { @@ -821,7 +828,7 @@ export const commands: Commands = { }, list_console_messages: { description: - 'List all console messages for the currently selected page since the last navigation.', + 'List all console messages for the currently selected page since the last navigation. This includes console messages originating from extensions content scripts.', category: 'Debugging', args: { pageSize: { @@ -913,7 +920,8 @@ export const commands: Commands = { }, }, list_pages: { - description: 'Get a list of pages open in the browser.', + description: + 'Get a list of pages including extension service workers open in the browser.', category: 'Navigation automation', args: {}, }, diff --git a/src/config/mcp-options.ts b/src/config/mcp-options.ts index 350787d6..b8efa7d7 100644 --- a/src/config/mcp-options.ts +++ b/src/config/mcp-options.ts @@ -163,11 +163,13 @@ export const mcpOptions = { }, memoryDebugging: { type: 'boolean', + default: false, describe: 'Whether to enable memory debugging tools.', alias: 'experimentalMemory', }, experimentalStructuredContent: { type: 'boolean', + default: false, describe: 'Whether to output structured formatted content.', }, experimentalToonFormat: { @@ -376,6 +378,45 @@ export const mcpOptions = { export type ParsedArguments = ReturnType; +export function getMcpOptionsForViaCli(): typeof mcpOptions { + if (!('default' in mcpOptions.headless)) { + throw new Error('headless cli option unexpectedly does not have a default'); + } + if (!('default' in mcpOptions.experimentalStructuredContent)) { + throw new Error( + 'experimentalStructuredContent cli option unexpectedly does not have a default', + ); + } + if ('default' in mcpOptions.isolated) { + throw new Error('isolated cli option unexpectedly has a default'); + } + + return { + ...mcpOptions, + headless: { + ...mcpOptions.headless, + default: true, + }, + memoryDebugging: { + ...mcpOptions.memoryDebugging, + default: true, + }, + categoryExtensions: { + ...mcpOptions.categoryExtensions, + default: true, + }, + experimentalStructuredContent: { + ...mcpOptions.experimentalStructuredContent, + default: true, + }, + isolated: { + ...mcpOptions.isolated, + description: + 'If specified, creates a temporary user-data-dir that is automatically cleaned up after the browser is closed. Defaults to true unless userDataDir is provided.', + }, + }; +} + /** * Exported only for testing to not trigger process exit. */ @@ -384,13 +425,16 @@ export function parser( argv = process.argv, env = process.env, ) { + const isViaCli = argv.includes('--viaCli') || argv.includes('--via-cli'); + const options = isViaCli ? getMcpOptionsForViaCli() : mcpOptions; + const yargsInstance = yargs(hideBin(argv)) .scriptName('npx chrome-devtools-mcp@latest') .parserConfiguration({ 'strip-aliased': true, 'strip-dashed': true, }) - .options(mcpOptions) + .options(options) .showHelpOnFail(false, 'Specify --help for available options') .middleware(args => { // We can't set default in the options else @@ -411,7 +455,7 @@ export function parser( } const cliOptionsAllowedArgs = [ - ...Object.keys(mcpOptions), + ...Object.keys(options), // Yargs populated with positional args '_', '$0', diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 1b986a6e..977f5c98 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -27,6 +27,8 @@ describe('cli args parsing', () => { usageStatistics: true, redactNetworkHeaders: false, allowUnrestrictedPaths: false, + memoryDebugging: false, + experimentalStructuredContent: false, }; it('parses with default args', async () => { @@ -40,6 +42,15 @@ describe('cli args parsing', () => { }); }); + it('parses with viaCli args', async () => { + const args = parseArguments(['--viaCli']); + assert.strictEqual(args.headless, true); + assert.strictEqual(args.memoryDebugging, true); + assert.strictEqual(args.categoryExtensions, true); + assert.strictEqual(args.experimentalStructuredContent, true); + assert.strictEqual(args.viaCli, true); + }); + it('parses with browser url', async () => { const args = parseArguments(['--browserUrl', 'http://localhost:3000']); assert.deepStrictEqual(args, { From fadbf41d96db84cea12e379592bc13b005c053b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Inf=C3=BChr?= Date: Mon, 17 Aug 2026 20:24:27 +0000 Subject: [PATCH 08/72] feat: Add query_heapsnapshot MCP tool (#2553) `query_heapsnapshot_objects` allows the agent to filter objects by properties like class name, self size, retained size, property name and/or detachedness. The resulting list of objects can be sorted as well. --- README.md | 3 +- docs/tool-reference.md | 24 ++++++- src/McpContext.ts | 8 +++ src/config/cli-options.ts | 82 ++++++++++++++++++++++++ src/processors/HeapSnapshotManager.ts | 10 ++- src/telemetry/tool_call_metrics.json | 53 ++++++++++++++++ src/tools/ToolDefinition.ts | 5 ++ src/tools/memory.ts | 77 +++++++++++++++++++++++ tests/tools/memory.test.js.snapshot | 63 +++++++++++++++++++ tests/tools/memory.test.ts | 91 +++++++++++++++++++++++++++ 10 files changed, 413 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 3ad97b12..c0ae2f34 100644 --- a/README.md +++ b/README.md @@ -557,7 +557,7 @@ If you run into any issues, checkout our [troubleshooting guide](./docs/troubles - [`take_snapshot`](docs/tool-reference.md#take_snapshot) - [`screencast_start`](docs/tool-reference.md#screencast_start) - [`screencast_stop`](docs/tool-reference.md#screencast_stop) -- **Memory** (12 tools) +- **Memory** (13 tools) - [`take_heapsnapshot`](docs/tool-reference.md#take_heapsnapshot) - [`close_heapsnapshot`](docs/tool-reference.md#close_heapsnapshot) - [`compare_heapsnapshots`](docs/tool-reference.md#compare_heapsnapshots) @@ -570,6 +570,7 @@ If you run into any issues, checkout our [troubleshooting guide](./docs/troubles - [`get_heapsnapshot_retainers`](docs/tool-reference.md#get_heapsnapshot_retainers) - [`get_heapsnapshot_retaining_paths`](docs/tool-reference.md#get_heapsnapshot_retaining_paths) - [`get_heapsnapshot_summary`](docs/tool-reference.md#get_heapsnapshot_summary) + - [`query_heapsnapshot_objects`](docs/tool-reference.md#query_heapsnapshot_objects) - **Extensions** (5 tools) - [`install_extension`](docs/tool-reference.md#install_extension) - [`list_extensions`](docs/tool-reference.md#list_extensions) diff --git a/docs/tool-reference.md b/docs/tool-reference.md index 3ccc6f83..ff32db7c 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -39,7 +39,7 @@ - [`take_snapshot`](#take_snapshot) - [`screencast_start`](#screencast_start) - [`screencast_stop`](#screencast_stop) -- **[Memory](#memory)** (12 tools) +- **[Memory](#memory)** (13 tools) - [`take_heapsnapshot`](#take_heapsnapshot) - [`close_heapsnapshot`](#close_heapsnapshot) - [`compare_heapsnapshots`](#compare_heapsnapshots) @@ -52,6 +52,7 @@ - [`get_heapsnapshot_retainers`](#get_heapsnapshot_retainers) - [`get_heapsnapshot_retaining_paths`](#get_heapsnapshot_retaining_paths) - [`get_heapsnapshot_summary`](#get_heapsnapshot_summary) + - [`query_heapsnapshot_objects`](#query_heapsnapshot_objects) - **[Extensions](#extensions)** (5 tools) - [`install_extension`](#install_extension) - [`list_extensions`](#list_extensions) @@ -600,6 +601,27 @@ in the DevTools Elements panel (if any). --- +### `query_heapsnapshot_objects` + +**Description:** Loads a memory heapsnapshot and queries objects matching specific filters (className, propertyName, nodeType, minRetainedSize, maxRetainedSize, minSelfSize, isDetached, sortBy). (requires flag: --memoryDebugging=true) + +**Parameters:** + +- **filePath** (string) **(required)**: A path to a .heapsnapshot file to read. +- **className** (string) _(optional)_: Optional regex or text matching object class name. +- **isDetached** (boolean) _(optional)_: Whether to filter for detached DOM nodes. +- **maxRetainedSize** (number) _(optional)_: Maximum retained size in bytes. +- **maxSelfSize** (number) _(optional)_: Maximum self size in bytes. +- **minRetainedSize** (number) _(optional)_: Minimum retained size in bytes. +- **minSelfSize** (number) _(optional)_: Minimum self size in bytes. +- **nodeType** (string) _(optional)_: Optional V8 node type filter (e.g. object, closure, string, array, code). +- **pageIdx** (number) _(optional)_: The page index for pagination. +- **pageSize** (number) _(optional)_: The page size for pagination. +- **propertyName** (string) _(optional)_: Optional property name filter for outgoing reference edges. +- **sortBy** (enum: "retainedSize", "selfSize", "id") _(optional)_: Sort order for results. Default is retainedSize. + +--- + ## Extensions > NOTE: The Extensions category is not active by default. Use the '--categoryExtensions' flag. diff --git a/src/McpContext.ts b/src/McpContext.ts index 7be2bbaa..e667a21b 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -17,6 +17,7 @@ import type { HeapSnapshotDetailedClassDiff, DuplicateStringGroup, HeapEdgesQueryOptions, + HeapQueryOptions, } from './processors/HeapSnapshotManager.js'; import {McpPage} from './McpPage.js'; import {type UncaughtError} from './collectors/PageCollector.js'; @@ -750,6 +751,13 @@ export class McpContext implements Context { return await this.#heapSnapshotManager.getDuplicateStrings(filePath); } + async queryHeapSnapshotObjects( + filePath: string, + options: HeapQueryOptions, + ): Promise { + return await this.#heapSnapshotManager.queryObjects(filePath, options); + } + async getHeapSnapshotStats( filePath: string, ): Promise { diff --git a/src/config/cli-options.ts b/src/config/cli-options.ts index 39d167d7..58ba94e9 100644 --- a/src/config/cli-options.ts +++ b/src/config/cli-options.ts @@ -1100,6 +1100,88 @@ export const commands: Commands = { }, }, }, + query_heapsnapshot_objects: { + description: + 'Loads a memory heapsnapshot and queries objects matching specific filters (className, propertyName, nodeType, minRetainedSize, maxRetainedSize, minSelfSize, isDetached, sortBy). (requires flag: --memoryDebugging=true)', + category: 'Memory', + args: { + filePath: { + name: 'filePath', + type: 'string', + description: 'A path to a .heapsnapshot file to read.', + required: true, + }, + className: { + name: 'className', + type: 'string', + description: 'Optional regex or text matching object class name.', + required: false, + }, + propertyName: { + name: 'propertyName', + type: 'string', + description: + 'Optional property name filter for outgoing reference edges.', + required: false, + }, + nodeType: { + name: 'nodeType', + type: 'string', + description: + 'Optional V8 node type filter (e.g. object, closure, string, array, code).', + required: false, + }, + minRetainedSize: { + name: 'minRetainedSize', + type: 'number', + description: 'Minimum retained size in bytes.', + required: false, + }, + maxRetainedSize: { + name: 'maxRetainedSize', + type: 'number', + description: 'Maximum retained size in bytes.', + required: false, + }, + minSelfSize: { + name: 'minSelfSize', + type: 'number', + description: 'Minimum self size in bytes.', + required: false, + }, + maxSelfSize: { + name: 'maxSelfSize', + type: 'number', + description: 'Maximum self size in bytes.', + required: false, + }, + isDetached: { + name: 'isDetached', + type: 'boolean', + description: 'Whether to filter for detached DOM nodes.', + required: false, + }, + sortBy: { + name: 'sortBy', + type: 'string', + description: 'Sort order for results. Default is retainedSize.', + required: false, + enum: ['retainedSize', 'selfSize', 'id'], + }, + pageIdx: { + name: 'pageIdx', + type: 'number', + description: 'The page index for pagination.', + required: false, + }, + pageSize: { + name: 'pageSize', + type: 'number', + description: 'The page size for pagination.', + required: false, + }, + }, + }, reload_extension: { description: 'Reloads an unpacked Chrome extension by its ID. (requires flag: --categoryExtensions=true)', diff --git a/src/processors/HeapSnapshotManager.ts b/src/processors/HeapSnapshotManager.ts index ebfa40de..170d66c4 100644 --- a/src/processors/HeapSnapshotManager.ts +++ b/src/processors/HeapSnapshotManager.ts @@ -48,7 +48,6 @@ export type HeapQueryOptions = export type HeapEdgesQueryOptions = DevTools.HeapSnapshotModel.HeapSnapshotModel.HeapEdgesQueryOptions; - const VALID_EXTENSIONS: readonly string[] = ['.heapsnapshot', '.heaptimeline']; function hasValidHeapSnapshotExtension(filePath: string): boolean { @@ -423,6 +422,15 @@ export class HeapSnapshotManager { return await snapshot.getDuplicateStrings(); } + async queryObjects( + filePath: string, + options: HeapQueryOptions, + ): Promise { + const snapshot = await this.getSnapshot(filePath); + const provider = snapshot.queryObjects(options); + return await provider.serializeItemsRange(0, Infinity); + } + hasSnapshots(): boolean { return this.#snapshots.size > 0; } diff --git a/src/telemetry/tool_call_metrics.json b/src/telemetry/tool_call_metrics.json index 3fe3bb49..4f84c149 100644 --- a/src/telemetry/tool_call_metrics.json +++ b/src/telemetry/tool_call_metrics.json @@ -978,5 +978,58 @@ "argType": "number" } ] + }, + { + "name": "query_heapsnapshot_objects", + "args": [ + { + "name": "file_path_length", + "argType": "number" + }, + { + "name": "class_name_length", + "argType": "number" + }, + { + "name": "property_name_length", + "argType": "number" + }, + { + "name": "node_type_length", + "argType": "number" + }, + { + "name": "min_retained_size", + "argType": "number" + }, + { + "name": "max_retained_size", + "argType": "number" + }, + { + "name": "min_self_size", + "argType": "number" + }, + { + "name": "max_self_size", + "argType": "number" + }, + { + "name": "is_detached", + "argType": "boolean" + }, + { + "name": "sort_by", + "argType": "string" + }, + { + "name": "page_idx", + "argType": "number" + }, + { + "name": "page_size", + "argType": "number" + } + ] } ] diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index ed9b1fbd..b8a80626 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -11,6 +11,7 @@ import type { HeapSnapshotDetailedClassDiff, DuplicateStringGroup, HeapEdgesQueryOptions, + HeapQueryOptions, } from '../processors/HeapSnapshotManager.js'; import type {McpPage} from '../McpPage.js'; import {zod} from '../third_party/index.js'; @@ -315,6 +316,10 @@ export type Context = Readonly<{ currentFilePath: string, classIndex: number, ): Promise; + queryHeapSnapshotObjects( + filePath: string, + options: HeapQueryOptions, + ): Promise; }>; /** diff --git a/src/tools/memory.ts b/src/tools/memory.ts index 8747b1c6..c6f34d1a 100644 --- a/src/tools/memory.ts +++ b/src/tools/memory.ts @@ -465,3 +465,80 @@ export const getHeapSnapshotObjectDetails = defineTool({ response.setHeapSnapshotObjectDetails(objectInfo); }, }); + +export const queryHeapSnapshotObjects = defineTool({ + name: 'query_heapsnapshot_objects', + description: + 'Loads a memory heapsnapshot and queries objects matching specific filters (className, propertyName, nodeType, minRetainedSize, maxRetainedSize, minSelfSize, isDetached, sortBy).', + annotations: { + category: ToolCategory.MEMORY, + readOnlyHint: true, + conditions: ['memoryDebugging'], + }, + blockedByDialog: false, + verifyFilesSchema: {filePath: true}, + schema: { + filePath: zod.string().describe('A path to a .heapsnapshot file to read.'), + className: zod + .string() + .optional() + .describe('Optional regex or text matching object class name.'), + propertyName: zod + .string() + .optional() + .describe('Optional property name filter for outgoing reference edges.'), + nodeType: zod + .string() + .optional() + .describe( + 'Optional V8 node type filter (e.g. object, closure, string, array, code).', + ), + minRetainedSize: zod + .number() + .optional() + .describe('Minimum retained size in bytes.'), + maxRetainedSize: zod + .number() + .optional() + .describe('Maximum retained size in bytes.'), + minSelfSize: zod + .number() + .optional() + .describe('Minimum self size in bytes.'), + maxSelfSize: zod + .number() + .optional() + .describe('Maximum self size in bytes.'), + isDetached: zod + .boolean() + .optional() + .describe('Whether to filter for detached DOM nodes.'), + sortBy: zod + .enum(['retainedSize', 'selfSize', 'id']) + .optional() + .describe('Sort order for results. Default is retainedSize.'), + pageIdx: zod.number().optional().describe('The page index for pagination.'), + pageSize: zod.number().optional().describe('The page size for pagination.'), + }, + handler: async (request, response, context) => { + const range = await context.queryHeapSnapshotObjects( + request.params.filePath, + { + className: request.params.className, + propertyName: request.params.propertyName, + nodeType: request.params.nodeType, + minRetainedSize: request.params.minRetainedSize, + maxRetainedSize: request.params.maxRetainedSize, + minSelfSize: request.params.minSelfSize, + maxSelfSize: request.params.maxSelfSize, + isDetached: request.params.isDetached, + sortBy: request.params.sortBy, + }, + ); + + response.setHeapSnapshotNodes(range, { + pageIdx: request.params.pageIdx, + pageSize: request.params.pageSize, + }); + }, +}); diff --git a/tests/tools/memory.test.js.snapshot b/tests/tools/memory.test.js.snapshot index 34169e01..931720af 100644 --- a/tests/tools/memory.test.js.snapshot +++ b/tests/tools/memory.test.js.snapshot @@ -449,3 +449,66 @@ Retained by context size: 3.5 kB (148 objects) Not retained by context size: 798 kB (11792 objects) Total size: 802 kB `; + +exports[`memory > query_heapsnapshot_objects > with className filter 1`] = ` +## Heap Snapshot Data +nodeId,nodeName,type,distance,selfSize,retainedSize +27635,Window (global*) / https://example.com,object,2,40.5 kB,51.0 kB +30887,Window (global*) / https://example.com,object,2,40.5 kB,50.9 kB +30967,Window (prototype) / https://example.com,object,4,26.6 kB,26.7 kB +42085,Window (prototype) / https://example.com,object,4,26.6 kB,26.7 kB +32995,Window (internal cache) / https://example.com,object,3,0.2 kB,10.1 kB +41995,Window (internal cache) / https://example.com,object,3,0.2 kB,10.1 kB +16321,Window / https://example.com,native,2,0.9 kB,1.9 kB +16329,Window,closure,3,0.1 kB,0.4 kB +16341,Window,closure,3,0.1 kB,0.4 kB +30975,Window (prototype) / https://example.com,object,3,0.0 kB,0.2 kB +Showing 1-10 of 34 (Page 1 of 4). +Next page: 1 +`; + +exports[`memory > query_heapsnapshot_objects > with default options 1`] = ` +## Heap Snapshot Data +nodeId,nodeName,type,distance,selfSize,retainedSize +1,,synthetic,100000000,0.0 kB,802 kB +7249,system / NativeContext / https://example.com,hidden,1,1.2 kB,350 kB +7199,system / NativeContext,hidden,1,1.2 kB,84.4 kB +7307,system / NativeContext,hidden,1,1.2 kB,84.4 kB +7195,system / NativeContext / https://example.com,hidden,1,1.2 kB,60.1 kB +27635,Window (global*) / https://example.com,object,2,40.5 kB,51.0 kB +30887,Window (global*) / https://example.com,object,2,40.5 kB,50.9 kB +3,(GC roots),synthetic,100000001,0.0 kB,45.6 kB +49547,,array,2,4.1 kB,41.0 kB +36241,,array,2,4.1 kB,41.0 kB +Showing 1-10 of 27466 (Page 1 of 2747). +Next page: 1 +`; + +exports[`memory > query_heapsnapshot_objects > with minRetainedSize filter 1`] = ` +## Heap Snapshot Data +nodeId,nodeName,type,distance,selfSize,retainedSize +1,,synthetic,100000000,0.0 kB,802 kB +7249,system / NativeContext / https://example.com,hidden,1,1.2 kB,350 kB +7199,system / NativeContext,hidden,1,1.2 kB,84.4 kB +7307,system / NativeContext,hidden,1,1.2 kB,84.4 kB +7195,system / NativeContext / https://example.com,hidden,1,1.2 kB,60.1 kB +27635,Window (global*) / https://example.com,object,2,40.5 kB,51.0 kB +30887,Window (global*) / https://example.com,object,2,40.5 kB,50.9 kB +3,(GC roots),synthetic,100000001,0.0 kB,45.6 kB +49547,,array,2,4.1 kB,41.0 kB +36241,,array,2,4.1 kB,41.0 kB +Showing 1-10 of 148 (Page 1 of 15). +Next page: 1 +`; + +exports[`memory > query_heapsnapshot_objects > with sortBy selfSize and pagination 1`] = ` +## Heap Snapshot Data +nodeId,nodeName,type,distance,selfSize,retainedSize +1,,synthetic,100000000,0.0 kB,802 kB +7249,system / NativeContext / https://example.com,hidden,1,1.2 kB,350 kB +7199,system / NativeContext,hidden,1,1.2 kB,84.4 kB +7307,system / NativeContext,hidden,1,1.2 kB,84.4 kB +7195,system / NativeContext / https://example.com,hidden,1,1.2 kB,60.1 kB +Showing 1-5 of 27466 (Page 1 of 5494). +Next page: 1 +`; diff --git a/tests/tools/memory.test.ts b/tests/tools/memory.test.ts index c9fdd2e1..4e40b582 100644 --- a/tests/tools/memory.test.ts +++ b/tests/tools/memory.test.ts @@ -24,6 +24,7 @@ import { compareHeapSnapshots, getHeapSnapshotDuplicateStrings, getHeapSnapshotObjectDetails, + queryHeapSnapshotObjects, } from '../../src/tools/memory.js'; import {stableIdSymbol} from '../../src/utils/id.js'; import {resolveCanonicalPath} from '../../src/utils/files.js'; @@ -650,4 +651,94 @@ describe('memory', () => { }); }); }); + + describe('query_heapsnapshot_objects', () => { + it('with default options', async t => { + await withMcpContext(async (response, context) => { + const filePath = join( + process.cwd(), + 'tests/fixtures/example.heapsnapshot', + ); + + await queryHeapSnapshotObjects.handler( + {params: {filePath, pageSize: 10}}, + response, + context, + ); + + const responseData = await response.handle(context); + const output = responseData.content + .map(c => (c.type === 'text' ? c.text : '')) + .join('\n'); + + t.assert.snapshot(output); + }); + }); + + it('with className filter', async t => { + await withMcpContext(async (response, context) => { + const filePath = join( + process.cwd(), + 'tests/fixtures/example.heapsnapshot', + ); + + await queryHeapSnapshotObjects.handler( + {params: {filePath, className: 'Window', pageSize: 10}}, + response, + context, + ); + + const responseData = await response.handle(context); + const output = responseData.content + .map(c => (c.type === 'text' ? c.text : '')) + .join('\n'); + + t.assert.snapshot(output); + }); + }); + + it('with minRetainedSize filter', async t => { + await withMcpContext(async (response, context) => { + const filePath = join( + process.cwd(), + 'tests/fixtures/example.heapsnapshot', + ); + + await queryHeapSnapshotObjects.handler( + {params: {filePath, minRetainedSize: 1000, pageSize: 10}}, + response, + context, + ); + + const responseData = await response.handle(context); + const output = responseData.content + .map(c => (c.type === 'text' ? c.text : '')) + .join('\n'); + + t.assert.snapshot(output); + }); + }); + + it('with sortBy selfSize and pagination', async t => { + await withMcpContext(async (response, context) => { + const filePath = join( + process.cwd(), + 'tests/fixtures/example.heapsnapshot', + ); + + await queryHeapSnapshotObjects.handler( + {params: {filePath, sortBy: 'selfSize', pageSize: 5, pageIdx: 0}}, + response, + context, + ); + + const responseData = await response.handle(context); + const output = responseData.content + .map(c => (c.type === 'text' ? c.text : '')) + .join('\n'); + + t.assert.snapshot(output); + }); + }); + }); }); From fd0b7e1d9ed97f4518c3750b28ffb3d149b7c049 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:25:50 +0000 Subject: [PATCH 09/72] chore(deps-dev): bump the dev-dependencies group across 1 directory with 3 updates (#2581) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the dev-dependencies group with 3 updates in the / directory: [@blackwell-systems/gcf](https://github.com/blackwell-systems/gcf-typescript), [@toon-format/toon](https://github.com/toon-format/toon) and [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node). Updates `@blackwell-systems/gcf` from 2.4.0 to 2.5.2
Release notes

Sourced from @​blackwell-systems/gcf's releases.

v2.5.2

  • Score rounding fix (SPEC 5, spec v3.5.1 errata). The graph node-line score now rounds half-to-even on the exact IEEE-754 double, matching the Go/Rust/Python/Swift/.NET reference. The previous Number.prototype.toFixed formatter rounded half-up, so it diverged at exact binary midpoints (0.125 -> 0.13 instead of 0.12, 0.625 -> 0.63 instead of 0.62) - a silent, non-interoperable wire difference. Pinned by the new graph-encode/004_score_midpoint_rounding conformance fixture. Encoding is unchanged for every non-midpoint score.

Part of spec v3.5.1.

v2.5.1

Decoders now reject a declared section count ([N]) that does not match the actual number of items, in both directions, per SPEC Section 13 (Count Validation). A declared count smaller than the items present was previously treated as a read limit and the surplus was dropped; it is now an error. This applies to the generic tabular, keyed-map, and root-array forms, the delta and full-set decoders, and the graph edge section.

Valid payloads are unaffected and encoding is unchanged: encoders always emit accurate counts, so this only changes how malformed or externally produced input is handled, rejecting it rather than silently truncating. New shared conformance fixtures pin the behavior in both directions.

v2.5.0: Keyed-tabular map encoding

Keyed-tabular map encoding (GCF spec v3.5.0).

Highlights

Keyed-tabular map encoding (SPEC §7.2a). A JSON object whose values are all objects forming a tabular set now encodes as a keyed table: the shared value fields are declared once in a header, with one key-prefixed row per member (## [N:]{key,...}). It is canonical by default, works in nested and streaming positions, and reuses generic delta with the map key as the identity, so an object-of-objects that recurs across an agent loop sends only what changed.

Also in this release

  • Negative zero canonicalizes to 0 for both integer and floating-point values (SPEC §2.3.1).
  • Canonical-output alignment across all six SDKs: object key ordering, graph header fields, and symbol ordering follow the specification and reference implementation exactly.

Compatibility

Additive. Existing payloads are unaffected. A pre-v3.5 decoder rejects the [N:] keyed-map marker, so decoders should be updated to read v3.5 output.

Quality

Verified by the shared cross-language conformance suite (byte-exact across all six SDKs), a re-encode idempotence check on the generic, graph, and delta profiles, and a differential cross-SDK fuzz in which the tree-sitter grammar parses every wire the SDKs emit.

Spec release: https://github.com/blackwell-systems/gcf/releases/tag/v3.5.0

Changelog

Sourced from @​blackwell-systems/gcf's changelog.

v2.5.2 (2026-08-09)

  • Score rounding fix (SPEC 5, spec v3.5.1 errata). The graph node-line score now rounds half-to-even on the exact IEEE-754 double, matching the Go/Rust/Python/Swift/.NET reference. The previous Number.prototype.toFixed formatter rounded half-up, so it diverged at exact binary midpoints (0.125 -> 0.13 instead of 0.12, 0.625 -> 0.63 instead of 0.62) - a silent, non-interoperable wire difference. Pinned by the new graph-encode/004_score_midpoint_rounding conformance fixture. Encoding is unchanged for every non-midpoint score.

v2.5.1 (2026-08-07)

  • Decoders now reject a declared [N] section count that does not match the actual item count, in both directions, per SPEC Section 13 (Count Validation). A declared count smaller than the rows or entries present was previously read as a limit and the surplus was dropped; it is now an error. Covers the generic tabular, keyed-map, and root-array forms, the delta and full-set decoders, and the graph ## edges [N] section. Valid payloads and encoding are unchanged.

v2.5.0 (2026-08-07)

Added

  • Keyed-tabular map encoding (SPEC 7.2a): a JSON object whose values are all objects forming a tabular set is encoded as a keyed table (## [N:]{key,...}) - the shared value fields are declared once, with one key-prefixed row per member. Canonical by default, supported in nested and streaming positions, and integrated with generic delta using the map key as the identity.

Changed

  • Negative zero is canonicalized to 0 for both integer and floating-point values (SPEC 2.3.1).
  • Canonical-output alignment across all six SDKs: object key ordering, graph header fields, and symbol ordering follow the specification and reference implementation exactly.

Testing

  • Conformance runners assert re-encode idempotence (encode(decode(x)) == x) for the generic, graph, and delta profiles; a differential cross-SDK fuzz was added to the verification suite.
Commits
  • 4a440ce release: v2.5.2 — score rounding half-to-even (spec v3.5.1 errata)
  • 715f5f9 fix: round graph score half-to-even to match the reference (SPEC 5)
  • 82aa33f docs: update version references (spec v3.5.0, v2.5.1 / Go v1.6.1 / Swift v2.6...
  • 929f849 fix: enforce declared [N] section counts in both directions (SPEC 13)
  • 53df904 docs: absolute README image/LICENSE URLs for guaranteed npm page rendering
  • 6b71173 chore: bump to 2.5.0
  • d7e849a conformance: assert graph + generic-delta re-encode idempotence
  • 46ad9a1 encode: canonicalize negative zero to 0 (SPEC 2.3.1)
  • 7b161ac decode: preserve object field order (Map-based reconstruction)
  • ae21ad2 generic: preserve first-observed object key order via order-preserving parse
  • Additional commits viewable in compare view

Updates `@toon-format/toon` from 4.1.0 to 4.1.1
Release notes

Sourced from @​toon-format/toon's releases.

v4.1.1

   🐞 Bug Fixes

    View changes on GitHub
Commits
  • a9e6d97 chore: release v4.1.1
  • 349941c chore: update @​toon-format/spec to v4.1.1
  • bfca235 docs: list the misplaced scalar line among the both-mode decode errors
  • bde6eae fix: reject a raw string whose comment marker follows a byte-order mark
  • 6e822b3 fix: treat a tab-indented hash line as content, not a comment
  • 1ab9e43 style: trim package and docs comments to intent and constraints
  • 0d9945b style: mark benchmark question sections with regions
  • c702deb style: trim benchmark comments to intent and constraints
  • 96c53e5 style: open shared boundary docs with a verb and name values as nouns
  • 05adaf3 build: update the lockfile after dropping the runtime dependencies
  • Additional commits viewable in compare view

Updates `@types/node` from 26.1.2 to 26.2.0
Commits

Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Nicholas Roscino --- package-lock.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 24a31a19..ab75f15a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -109,9 +109,9 @@ } }, "node_modules/@blackwell-systems/gcf": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@blackwell-systems/gcf/-/gcf-2.4.0.tgz", - "integrity": "sha512-lLEZCzNMYVk630iaVVPPhcQtmCjhPRETRlwk/JAptGE0SEL6ZMHfA9HZPornuMvvpGeyrimRjj9Yb2nYQNS1og==", + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/@blackwell-systems/gcf/-/gcf-2.5.2.tgz", + "integrity": "sha512-idARuwEDwa2LVSa1FinDt15StOC2VEY/LI4Tx9VaXAG5+YmA/jYubetKBF25a6odsN4+5RHNeij+3EpPL2EFUQ==", "dev": true, "license": "MIT", "bin": { @@ -1449,9 +1449,9 @@ } }, "node_modules/@toon-format/toon": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@toon-format/toon/-/toon-4.1.0.tgz", - "integrity": "sha512-dBB3pkEx9QYvHnHR6rtkaBAh+7x4W/oA5ONur4G0fh7Ow69PbPuM7OFxzNRABqyxC0t6SZ3RixiGbCuaFjPDAQ==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@toon-format/toon/-/toon-4.1.1.tgz", + "integrity": "sha512-SGCkS7IjVpwRmGPgnY8ENKpAf0EdAnZDOQkvFW0d2cgOpdn9FEFl7sTgryESyypXrWr0YajHGpwsAUX4zw9ZvA==", "dev": true, "license": "MIT" }, @@ -1512,9 +1512,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.1.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz", - "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==", + "version": "26.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", + "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", "dev": true, "license": "MIT", "dependencies": { From 6506425a46ead4dcb9e6831befc4501ef1a3beee Mon Sep 17 00:00:00 2001 From: yulunz <11618243+yulunz@users.noreply.github.com> Date: Wed, 19 Aug 2026 07:45:59 +0000 Subject: [PATCH 10/72] fix: do not append page url when the previously selected page is gone. (#2588) See https://github.com/ChromeDevTools/chrome-devtools-mcp/issues/2033#issuecomment-5328348778 the description of the issue. --- src/McpContext.ts | 15 +++++++++++++ src/ToolHandler.ts | 5 +---- tests/McpContext.test.ts | 47 +++++++++++++++++++++++++++++++++++++++ tests/ToolHandler.test.ts | 10 ++------- 4 files changed, 65 insertions(+), 12 deletions(-) diff --git a/src/McpContext.ts b/src/McpContext.ts index e667a21b..e1c0cab9 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -413,6 +413,21 @@ export class McpContext implements Context { return page; } + getSelectedMcpPageUrl(page?: McpPage): string | undefined { + let targetPage = page; + if (!targetPage) { + try { + targetPage = this.getSelectedMcpPage(); + } catch { + return undefined; + } + } + if (targetPage?.pptrPage?.isClosed() === false) { + return targetPage.pptrPage.url(); + } + return undefined; + } + async getDevToolsData(page?: McpPage): Promise { const targetPage = page ?? this.#selectedPage; if (!targetPage) { diff --git a/src/ToolHandler.ts b/src/ToolHandler.ts index a8acc9ce..d87e5e84 100644 --- a/src/ToolHandler.ts +++ b/src/ToolHandler.ts @@ -341,10 +341,7 @@ export class ToolHandler { response.setError(err); } devToolsData = await context.getDevToolsData(page); - const targetPage = page ?? context.getSelectedMcpPage(); - if (targetPage?.pptrPage?.isClosed() === false) { - pageUrl = targetPage.pptrPage.url(); - } + pageUrl = context.getSelectedMcpPageUrl(page); // Resolve data format: --experimentalDataFormat takes precedence, fall back to legacy --experimentalToonFormat let dataFormat: DataFormat = 'default'; if (this.serverArgs.experimentalDataFormat) { diff --git a/tests/McpContext.test.ts b/tests/McpContext.test.ts index 865df86b..26b2dd28 100644 --- a/tests/McpContext.test.ts +++ b/tests/McpContext.test.ts @@ -778,5 +778,52 @@ describe('McpContext', () => { }); }); }); + + describe('getSelectedMcpPageUrl', () => { + it('returns url from passed page when open', async () => { + await withMcpContext(async (_response, context) => { + const page = await context.newPage(); + const result = context.getSelectedMcpPageUrl(page); + assert.strictEqual(result, page.pptrPage.url()); + }); + }); + + it('returns undefined from passed page when closed', async () => { + await withMcpContext(async (_response, context) => { + const page = await context.newPage(); + await page.pptrPage.close(); + const result = context.getSelectedMcpPageUrl(page); + assert.strictEqual(result, undefined); + }); + }); + + it('returns url from selected page when no page passed', async () => { + await withMcpContext(async (_response, context) => { + const page = context.getSelectedMcpPage(); + const result = context.getSelectedMcpPageUrl(); + assert.strictEqual(result, page.pptrPage.url()); + }); + }); + + it('returns undefined when getSelectedMcpPage throws', async () => { + await withMcpContext(async (_response, context) => { + sinon + .stub(context, 'getSelectedMcpPage') + .throws(new Error('No page selected')); + const result = context.getSelectedMcpPageUrl(); + assert.strictEqual(result, undefined); + }); + }); + + it('returns undefined when selected page is closed and getSelectedMcpPage throws', async () => { + await withMcpContext(async (_response, context) => { + const page = context.getSelectedMcpPage(); + await page.pptrPage.close(); + assert.throws(() => context.getSelectedMcpPage()); + const result = context.getSelectedMcpPageUrl(); + assert.strictEqual(result, undefined); + }); + }); + }); }); }); diff --git a/tests/ToolHandler.test.ts b/tests/ToolHandler.test.ts index 42e4eb13..07492054 100644 --- a/tests/ToolHandler.test.ts +++ b/tests/ToolHandler.test.ts @@ -112,7 +112,7 @@ describe('ToolHandler', () => { const result = await toolHandler.handle({}); assert.strictEqual(mockContext.getDevToolsData.calledOnce, true); - assert.strictEqual(mockContext.getSelectedMcpPage.calledOnce, true); + assert.strictEqual(mockContext.getSelectedMcpPageUrl.calledOnce, true); assert.strictEqual(mockContext.getPageById.called, false); assert.strictEqual(handlerCalled, true); assert.strictEqual(result.isError, undefined); @@ -180,13 +180,7 @@ describe('ToolHandler', () => { mockContext.browser = getMockBrowser({process: mockProcess}); mockContext.getDevToolsData.resolves(testCase.devToolsData); if (testCase.pageUrl) { - const mockPage = { - pptrPage: { - isClosed: () => false, - url: () => testCase.pageUrl, - }, - } as unknown as McpPage; - mockContext.getSelectedMcpPage.returns(mockPage); + mockContext.getSelectedMcpPageUrl.returns(testCase.pageUrl); } const logSpy = sinon.spy(); From 9bd78c037472a939f292470baa56e6b1b73f5b88 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:16:56 +0000 Subject: [PATCH 11/72] chore(deps): bump third_party/devtools-frontend from `9cf264b` to `9e32095` (#2590) Bumps [third_party/devtools-frontend](https://github.com/ChromeDevTools/devtools-frontend) from `9cf264b` to `9e32095`.
Commits
  • 9e32095 Migrate jpeg-xl-format-disabled setting to SettingDescriptor
  • 3b38206 Fix Build.gn deps
  • 933872b Update What's new content for Chrome 152
  • 1949175 Gate safe-area emulation and cutouts behind feature flag
  • 66654ee [Connection Allowlist] Enforce for FedCM API - Add DevTools frontend issues
  • c321760 Fix laggy resizing in device mode view
  • bdbae4f [object_ui] Use ObjectPropertiesSectionWidget in ObjectPropertiesSection tests
  • 1298cb5 Roll puppeteer-core
  • 24bebc0 Instantiate SearchableView in the View of SourcesView
  • 0ca4d01 Migrate avif-format-disabled setting to SettingDescriptor
  • Additional commits viewable in compare view

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- third_party/devtools-frontend | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/third_party/devtools-frontend b/third_party/devtools-frontend index 9cf264b2..9e320957 160000 --- a/third_party/devtools-frontend +++ b/third_party/devtools-frontend @@ -1 +1 @@ -Subproject commit 9cf264b26b39a9e8382f795a9084ddcd7a290937 +Subproject commit 9e3209578a3ba27cd3af620392bd10027b59c0fe From 61e312f59314907bca32dfeb63cb002601a76cea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Inf=C3=BChr?= Date: Wed, 19 Aug 2026 13:47:13 +0000 Subject: [PATCH 12/72] feat: Use ranges with human readable sizes (#2589) Replace separate minimum and maximum heap-size filters with range strings. Values can use byte counts or units, such as `1024-2048`, `1MB-2MB`, `-1MiB`, and `1GiB-`. A bare value such as `10M` is treated as a minimum. The range syntax is used by `query_heapsnapshot_objects` for retained and self sizes and by `get_heapsnapshot_edges` for retained sizes. --- docs/tool-reference.md | 10 +- src/config/cli-options.ts | 41 ++++---- src/telemetry/tool_call_metrics.json | 27 +++++- src/tools/memory.ts | 43 ++++----- src/utils/bytes.ts | 134 +++++++++++++++++++++++++++ tests/tools/memory.test.js.snapshot | 43 +++++---- tests/tools/memory.test.ts | 39 +++++++- tests/utils/bytes.test.ts | 122 ++++++++++++++++++++++++ 8 files changed, 378 insertions(+), 81 deletions(-) create mode 100644 src/utils/bytes.ts create mode 100644 tests/utils/bytes.test.ts diff --git a/docs/tool-reference.md b/docs/tool-reference.md index ff32db7c..d6aa0f2b 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -546,9 +546,9 @@ in the DevTools Elements panel (if any). - **filePath** (string) **(required)**: A path to a .heapsnapshot file to read. - **nodeId** (number) **(required)**: The node ID to get outgoing edges for. - **excludePrimitives** (boolean) _(optional)_: Whether to exclude primitive target nodes. Default is true. -- **minRetainedSize** (number) _(optional)_: Minimum retained size in bytes for target nodes. - **pageIdx** (number) _(optional)_: The page index for pagination. - **pageSize** (number) _(optional)_: The page size for pagination. +- **retainedSize** (string) _(optional)_: Inclusive retained size range (e.g. "1MB-2MB", "-1MB", or "1MB-") for target nodes. A single value is treated as a minimum. Currently, only the lower bound is applied. - **sortBy** (enum: "retainedSize", "selfSize", "name") _(optional)_: Sort order for edges. Default is retainedSize. --- @@ -603,21 +603,19 @@ in the DevTools Elements panel (if any). ### `query_heapsnapshot_objects` -**Description:** Loads a memory heapsnapshot and queries objects matching specific filters (className, propertyName, nodeType, minRetainedSize, maxRetainedSize, minSelfSize, isDetached, sortBy). (requires flag: --memoryDebugging=true) +**Description:** Loads a memory heapsnapshot and queries objects matching specific filters (className, propertyName, nodeType, retainedSize, selfSize, isDetached, sortBy). (requires flag: --memoryDebugging=true) **Parameters:** - **filePath** (string) **(required)**: A path to a .heapsnapshot file to read. - **className** (string) _(optional)_: Optional regex or text matching object class name. - **isDetached** (boolean) _(optional)_: Whether to filter for detached DOM nodes. -- **maxRetainedSize** (number) _(optional)_: Maximum retained size in bytes. -- **maxSelfSize** (number) _(optional)_: Maximum self size in bytes. -- **minRetainedSize** (number) _(optional)_: Minimum retained size in bytes. -- **minSelfSize** (number) _(optional)_: Minimum self size in bytes. - **nodeType** (string) _(optional)_: Optional V8 node type filter (e.g. object, closure, string, array, code). - **pageIdx** (number) _(optional)_: The page index for pagination. - **pageSize** (number) _(optional)_: The page size for pagination. - **propertyName** (string) _(optional)_: Optional property name filter for outgoing reference edges. +- **retainedSize** (string) _(optional)_: Inclusive retained size range (e.g. "1MB-2MB", "-1MB", or "1MB-"). A single value is treated as a minimum. +- **selfSize** (string) _(optional)_: Inclusive self size range (e.g. "1MB-2MB", "-1MB", or "1MB-"). A single value is treated as a minimum. - **sortBy** (enum: "retainedSize", "selfSize", "id") _(optional)_: Sort order for results. Default is retainedSize. --- diff --git a/src/config/cli-options.ts b/src/config/cli-options.ts index 58ba94e9..cf97b0aa 100644 --- a/src/config/cli-options.ts +++ b/src/config/cli-options.ts @@ -518,10 +518,11 @@ export const commands: Commands = { required: false, enum: ['retainedSize', 'selfSize', 'name'], }, - minRetainedSize: { - name: 'minRetainedSize', - type: 'number', - description: 'Minimum retained size in bytes for target nodes.', + retainedSize: { + name: 'retainedSize', + type: 'string', + description: + 'Inclusive retained size range (e.g. "1MB-2MB", "-1MB", or "1MB-") for target nodes. A single value is treated as a minimum. Currently, only the lower bound is applied.', required: false, }, excludePrimitives: { @@ -1102,7 +1103,7 @@ export const commands: Commands = { }, query_heapsnapshot_objects: { description: - 'Loads a memory heapsnapshot and queries objects matching specific filters (className, propertyName, nodeType, minRetainedSize, maxRetainedSize, minSelfSize, isDetached, sortBy). (requires flag: --memoryDebugging=true)', + 'Loads a memory heapsnapshot and queries objects matching specific filters (className, propertyName, nodeType, retainedSize, selfSize, isDetached, sortBy). (requires flag: --memoryDebugging=true)', category: 'Memory', args: { filePath: { @@ -1131,28 +1132,18 @@ export const commands: Commands = { 'Optional V8 node type filter (e.g. object, closure, string, array, code).', required: false, }, - minRetainedSize: { - name: 'minRetainedSize', - type: 'number', - description: 'Minimum retained size in bytes.', - required: false, - }, - maxRetainedSize: { - name: 'maxRetainedSize', - type: 'number', - description: 'Maximum retained size in bytes.', - required: false, - }, - minSelfSize: { - name: 'minSelfSize', - type: 'number', - description: 'Minimum self size in bytes.', + retainedSize: { + name: 'retainedSize', + type: 'string', + description: + 'Inclusive retained size range (e.g. "1MB-2MB", "-1MB", or "1MB-"). A single value is treated as a minimum.', required: false, }, - maxSelfSize: { - name: 'maxSelfSize', - type: 'number', - description: 'Maximum self size in bytes.', + selfSize: { + name: 'selfSize', + type: 'string', + description: + 'Inclusive self size range (e.g. "1MB-2MB", "-1MB", or "1MB-"). A single value is treated as a minimum.', required: false, }, isDetached: { diff --git a/src/telemetry/tool_call_metrics.json b/src/telemetry/tool_call_metrics.json index 4f84c149..aa9bfe24 100644 --- a/src/telemetry/tool_call_metrics.json +++ b/src/telemetry/tool_call_metrics.json @@ -831,11 +831,16 @@ }, { "name": "min_retained_size", - "argType": "number" + "argType": "number", + "isDeprecated": true }, { "name": "exclude_primitives", "argType": "boolean" + }, + { + "name": "retained_size_length", + "argType": "number" } ] }, @@ -1000,19 +1005,23 @@ }, { "name": "min_retained_size", - "argType": "number" + "argType": "number", + "isDeprecated": true }, { "name": "max_retained_size", - "argType": "number" + "argType": "number", + "isDeprecated": true }, { "name": "min_self_size", - "argType": "number" + "argType": "number", + "isDeprecated": true }, { "name": "max_self_size", - "argType": "number" + "argType": "number", + "isDeprecated": true }, { "name": "is_detached", @@ -1029,6 +1038,14 @@ { "name": "page_size", "argType": "number" + }, + { + "name": "retained_size_length", + "argType": "number" + }, + { + "name": "self_size_length", + "argType": "number" } ] } diff --git a/src/tools/memory.ts b/src/tools/memory.ts index c6f34d1a..15670171 100644 --- a/src/tools/memory.ts +++ b/src/tools/memory.ts @@ -5,6 +5,7 @@ */ import {zod} from '../third_party/index.js'; +import {byteSizeRangeSchema} from '../utils/bytes.js'; import {ToolCategory} from './categories.js'; import {definePageTool, defineTool} from './ToolDefinition.js'; @@ -305,10 +306,9 @@ export const getHeapSnapshotEdges = defineTool({ .enum(['retainedSize', 'selfSize', 'name']) .optional() .describe('Sort order for edges. Default is retainedSize.'), - minRetainedSize: zod - .number() - .optional() - .describe('Minimum retained size in bytes for target nodes.'), + retainedSize: byteSizeRangeSchema( + 'Inclusive retained size range (e.g. "1MB-2MB", "-1MB", or "1MB-") for target nodes. A single value is treated as a minimum. Currently, only the lower bound is applied.', + ).optional(), excludePrimitives: zod .boolean() .optional() @@ -322,7 +322,8 @@ export const getHeapSnapshotEdges = defineTool({ request.params.nodeId, { sortBy: request.params.sortBy ?? 'retainedSize', - minRetainedSize: request.params.minRetainedSize, + // DevTools currently only supports a lower retained-size bound here. + minRetainedSize: request.params.retainedSize?.min, excludePrimitives: request.params.excludePrimitives ?? true, }, ); @@ -469,7 +470,7 @@ export const getHeapSnapshotObjectDetails = defineTool({ export const queryHeapSnapshotObjects = defineTool({ name: 'query_heapsnapshot_objects', description: - 'Loads a memory heapsnapshot and queries objects matching specific filters (className, propertyName, nodeType, minRetainedSize, maxRetainedSize, minSelfSize, isDetached, sortBy).', + 'Loads a memory heapsnapshot and queries objects matching specific filters (className, propertyName, nodeType, retainedSize, selfSize, isDetached, sortBy).', annotations: { category: ToolCategory.MEMORY, readOnlyHint: true, @@ -493,22 +494,12 @@ export const queryHeapSnapshotObjects = defineTool({ .describe( 'Optional V8 node type filter (e.g. object, closure, string, array, code).', ), - minRetainedSize: zod - .number() - .optional() - .describe('Minimum retained size in bytes.'), - maxRetainedSize: zod - .number() - .optional() - .describe('Maximum retained size in bytes.'), - minSelfSize: zod - .number() - .optional() - .describe('Minimum self size in bytes.'), - maxSelfSize: zod - .number() - .optional() - .describe('Maximum self size in bytes.'), + retainedSize: byteSizeRangeSchema( + 'Inclusive retained size range (e.g. "1MB-2MB", "-1MB", or "1MB-"). A single value is treated as a minimum.', + ).optional(), + selfSize: byteSizeRangeSchema( + 'Inclusive self size range (e.g. "1MB-2MB", "-1MB", or "1MB-"). A single value is treated as a minimum.', + ).optional(), isDetached: zod .boolean() .optional() @@ -527,10 +518,10 @@ export const queryHeapSnapshotObjects = defineTool({ className: request.params.className, propertyName: request.params.propertyName, nodeType: request.params.nodeType, - minRetainedSize: request.params.minRetainedSize, - maxRetainedSize: request.params.maxRetainedSize, - minSelfSize: request.params.minSelfSize, - maxSelfSize: request.params.maxSelfSize, + minRetainedSize: request.params.retainedSize?.min, + maxRetainedSize: request.params.retainedSize?.max, + minSelfSize: request.params.selfSize?.min, + maxSelfSize: request.params.selfSize?.max, isDetached: request.params.isDetached, sortBy: request.params.sortBy, }, diff --git a/src/utils/bytes.ts b/src/utils/bytes.ts new file mode 100644 index 00000000..4e5ad415 --- /dev/null +++ b/src/utils/bytes.ts @@ -0,0 +1,134 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {zod} from '../third_party/index.js'; + +const BYTE_UNITS: Readonly> = { + b: 1, + byte: 1, + bytes: 1, + k: 1000, + kb: 1000, + kib: 1024, + m: 1000 * 1000, + mb: 1000 * 1000, + mib: 1024 * 1024, + g: 1000 * 1000 * 1000, + gb: 1000 * 1000 * 1000, + gib: 1024 * 1024 * 1024, + t: 1000 * 1000 * 1000 * 1000, + tb: 1000 * 1000 * 1000 * 1000, + tib: 1024 * 1024 * 1024 * 1024, +}; + +/** + * Parses a byte size string (e.g. "1M", "1MB", "500KB", "1.5GB", "1024") into bytes. + */ +export function parseByteSize(value: string): number { + const trimmed = value.trim(); + if (trimmed === '') { + throw new Error(`Invalid byte size: "${value}"`); + } + + const match = trimmed.match(/^(\d+(?:\.\d+)?)\s*([a-zA-Z]+)?$/); + if (!match) { + throw new Error( + `Invalid byte size format: "${value}". Expected a number or format like "1024", "1M", "1MB", "1G", "1GB".`, + ); + } + + const numMatch = match[1]; + if (numMatch === undefined) { + throw new Error(`Invalid byte size: "${value}"`); + } + const num = Number(numMatch); + if (!Number.isFinite(num) || num < 0) { + throw new Error(`Invalid byte size: "${value}"`); + } + + const unitMatch = match[2]; + if (unitMatch === undefined) { + return Math.round(num); + } + + const unit = unitMatch.toLowerCase(); + const multiplier = BYTE_UNITS[unit]; + if (multiplier === undefined) { + throw new Error( + `Unknown unit "${unitMatch}" in "${value}". Supported units: B, KB, KiB, MB, MiB, GB, GiB, TB, TiB.`, + ); + } + + const bytes = Math.round(num * multiplier); + if (!Number.isFinite(bytes)) { + throw new Error(`Invalid byte size: "${value}"`); + } + return bytes; +} + +export interface ByteSizeRange { + min: number; + max?: number; +} + +/** + * Parses an inclusive byte-size range (e.g. "1MB", "1MB-2MB", "-1MB", "1MB-"). + */ +export function parseByteSizeRange(value: string): ByteSizeRange { + const trimmed = value.trim(); + if (!trimmed.includes('-')) { + return {min: parseByteSize(trimmed), max: undefined}; + } + + const parts = trimmed.split('-'); + if (parts.length !== 2) { + throw new Error( + `Invalid byte size range: "${value}". Expected a size or range like "1MB", "1MB-2MB", "-1MB", or "1MB-".`, + ); + } + + const minValue = parts[0]; + const maxValue = parts[1]; + if (minValue === undefined || maxValue === undefined) { + throw new Error(`Invalid byte size range: "${value}"`); + } + + const minText = minValue.trim(); + const maxText = maxValue.trim(); + if (minText === '' && maxText === '') { + throw new Error( + `Invalid byte size range: "${value}". At least one bound is required.`, + ); + } + + const min = minText === '' ? 0 : parseByteSize(minText); + const max = maxText === '' ? undefined : parseByteSize(maxText); + if (max !== undefined && min > max) { + throw new Error( + `Invalid byte size range: "${value}". The lower bound must not exceed the upper bound.`, + ); + } + + return {min, max}; +} + +export function byteSizeRangeSchema(description: string) { + return zod + .string() + .transform((value, context) => { + try { + return parseByteSizeRange(value); + } catch (error) { + context.addIssue({ + code: zod.ZodIssueCode.custom, + message: + error instanceof Error ? error.message : 'Invalid byte size range', + }); + return zod.NEVER; + } + }) + .describe(description); +} diff --git a/tests/tools/memory.test.js.snapshot b/tests/tools/memory.test.js.snapshot index 931720af..dd9295e4 100644 --- a/tests/tools/memory.test.js.snapshot +++ b/tests/tools/memory.test.js.snapshot @@ -302,6 +302,15 @@ Showing 1-2 of 56 (Page 1 of 28). Next page: 1 `; +exports[`memory > get_heapsnapshot_edges > with retainedSize range 1`] = ` +## Heap Snapshot Data +name,type,nodeId,nodeName,selfSize,retainedSize +map,internal,25577,system / Map,0.0 kB,0.7 kB +constructor,property,25575,String,0.1 kB,0.4 kB +__proto__,property,25329,{constructor, __defineGetter__, __defineSetter__, …, get __proto__, set __proto__, toLocaleString},0.1 kB,0.3 kB +Showing 1-3 of 3 (Page 1 of 1). +`; + exports[`memory > get_heapsnapshot_edges > with valid nodeId 1`] = ` ## Heap Snapshot Data name,type,nodeId,nodeName,selfSize,retainedSize @@ -450,6 +459,23 @@ Not retained by context size: 798 kB (11792 objects) Total size: 802 kB `; +exports[`memory > query_heapsnapshot_objects > with an unbounded retainedSize filter 1`] = ` +## Heap Snapshot Data +nodeId,nodeName,type,distance,selfSize,retainedSize +1,,synthetic,100000000,0.0 kB,802 kB +7249,system / NativeContext / https://example.com,hidden,1,1.2 kB,350 kB +7199,system / NativeContext,hidden,1,1.2 kB,84.4 kB +7307,system / NativeContext,hidden,1,1.2 kB,84.4 kB +7195,system / NativeContext / https://example.com,hidden,1,1.2 kB,60.1 kB +27635,Window (global*) / https://example.com,object,2,40.5 kB,51.0 kB +30887,Window (global*) / https://example.com,object,2,40.5 kB,50.9 kB +3,(GC roots),synthetic,100000001,0.0 kB,45.6 kB +49547,,array,2,4.1 kB,41.0 kB +36241,,array,2,4.1 kB,41.0 kB +Showing 1-10 of 148 (Page 1 of 15). +Next page: 1 +`; + exports[`memory > query_heapsnapshot_objects > with className filter 1`] = ` ## Heap Snapshot Data nodeId,nodeName,type,distance,selfSize,retainedSize @@ -484,23 +510,6 @@ Showing 1-10 of 27466 (Page 1 of 2747). Next page: 1 `; -exports[`memory > query_heapsnapshot_objects > with minRetainedSize filter 1`] = ` -## Heap Snapshot Data -nodeId,nodeName,type,distance,selfSize,retainedSize -1,,synthetic,100000000,0.0 kB,802 kB -7249,system / NativeContext / https://example.com,hidden,1,1.2 kB,350 kB -7199,system / NativeContext,hidden,1,1.2 kB,84.4 kB -7307,system / NativeContext,hidden,1,1.2 kB,84.4 kB -7195,system / NativeContext / https://example.com,hidden,1,1.2 kB,60.1 kB -27635,Window (global*) / https://example.com,object,2,40.5 kB,51.0 kB -30887,Window (global*) / https://example.com,object,2,40.5 kB,50.9 kB -3,(GC roots),synthetic,100000001,0.0 kB,45.6 kB -49547,,array,2,4.1 kB,41.0 kB -36241,,array,2,4.1 kB,41.0 kB -Showing 1-10 of 148 (Page 1 of 15). -Next page: 1 -`; - exports[`memory > query_heapsnapshot_objects > with sortBy selfSize and pagination 1`] = ` ## Heap Snapshot Data nodeId,nodeName,type,distance,selfSize,retainedSize diff --git a/tests/tools/memory.test.ts b/tests/tools/memory.test.ts index 4e40b582..0c226f77 100644 --- a/tests/tools/memory.test.ts +++ b/tests/tools/memory.test.ts @@ -26,6 +26,7 @@ import { getHeapSnapshotObjectDetails, queryHeapSnapshotObjects, } from '../../src/tools/memory.js'; +import {parseByteSizeRange} from '../../src/utils/bytes.js'; import {stableIdSymbol} from '../../src/utils/id.js'; import {resolveCanonicalPath} from '../../src/utils/files.js'; import {withMcpContext} from '../utils.js'; @@ -447,6 +448,34 @@ describe('memory', () => { t.assert.snapshot(output); }); }); + + it('with retainedSize range', async t => { + await withMcpContext(async (response, context) => { + const filePath = join( + process.cwd(), + 'tests/fixtures/example.heapsnapshot', + ); + + await getHeapSnapshotEdges.handler( + { + params: { + filePath, + nodeId: 25341, + retainedSize: parseByteSizeRange('100B-100B'), + }, + }, + response, + context, + ); + + const responseData = await response.handle(context); + const output = responseData.content + .map(c => (c.type === 'text' ? c.text : '')) + .join('\n'); + + t.assert.snapshot(output); + }); + }); }); describe('get_heapsnapshot_dominators', () => { @@ -697,7 +726,7 @@ describe('memory', () => { }); }); - it('with minRetainedSize filter', async t => { + it('with an unbounded retainedSize filter', async t => { await withMcpContext(async (response, context) => { const filePath = join( process.cwd(), @@ -705,7 +734,13 @@ describe('memory', () => { ); await queryHeapSnapshotObjects.handler( - {params: {filePath, minRetainedSize: 1000, pageSize: 10}}, + { + params: { + filePath, + retainedSize: parseByteSizeRange('1KB'), + pageSize: 10, + }, + }, response, context, ); diff --git a/tests/utils/bytes.test.ts b/tests/utils/bytes.test.ts new file mode 100644 index 00000000..1b938dab --- /dev/null +++ b/tests/utils/bytes.test.ts @@ -0,0 +1,122 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'node:assert'; +import {describe, it} from 'node:test'; + +import { + byteSizeRangeSchema, + parseByteSize, + parseByteSizeRange, +} from '../../src/utils/bytes.js'; + +describe('parseByteSize', () => { + it('should parse plain numeric strings', () => { + assert.strictEqual(parseByteSize('0'), 0); + assert.strictEqual(parseByteSize('1024'), 1024); + assert.strictEqual(parseByteSize(' 1048576 '), 1048576); + }); + + it('should parse bytes units', () => { + assert.strictEqual(parseByteSize('100B'), 100); + assert.strictEqual(parseByteSize('100b'), 100); + assert.strictEqual(parseByteSize('100 Bytes'), 100); + assert.strictEqual(parseByteSize('100 byte'), 100); + }); + + it('should parse kilobytes units', () => { + assert.strictEqual(parseByteSize('1K'), 1000); + assert.strictEqual(parseByteSize('1k'), 1000); + assert.strictEqual(parseByteSize('1KB'), 1000); + assert.strictEqual(parseByteSize('1kb'), 1000); + assert.strictEqual(parseByteSize('1KiB'), 1024); + assert.strictEqual(parseByteSize('1.5 KB'), 1500); + }); + + it('should parse megabytes units', () => { + assert.strictEqual(parseByteSize('1M'), 1000000); + assert.strictEqual(parseByteSize('1m'), 1000000); + assert.strictEqual(parseByteSize('1MB'), 1000000); + assert.strictEqual(parseByteSize('1mb'), 1000000); + assert.strictEqual(parseByteSize('1MiB'), 1048576); + assert.strictEqual(parseByteSize('2.5MB'), 2500000); + }); + + it('should parse gigabytes units', () => { + assert.strictEqual(parseByteSize('1G'), 1000000000); + assert.strictEqual(parseByteSize('1g'), 1000000000); + assert.strictEqual(parseByteSize('1GB'), 1000000000); + assert.strictEqual(parseByteSize('1gb'), 1000000000); + assert.strictEqual(parseByteSize('1GiB'), 1073741824); + assert.strictEqual(parseByteSize('0.5GB'), 500000000); + }); + + it('should parse terabytes units', () => { + assert.strictEqual(parseByteSize('1T'), 1000000000000); + assert.strictEqual(parseByteSize('1TB'), 1000000000000); + assert.strictEqual(parseByteSize('1TiB'), 1099511627776); + }); + + it('should throw for invalid inputs', () => { + assert.throws(() => parseByteSize(''), /Invalid byte size/); + assert.throws(() => parseByteSize(' '), /Invalid byte size/); + assert.throws(() => parseByteSize('abc'), /Invalid byte size/); + assert.throws(() => parseByteSize('10XYZ'), /Unknown unit/); + assert.throws(() => parseByteSize('-10MB'), /Invalid byte size/); + assert.throws(() => parseByteSize(`${'9'.repeat(300)}TB`), /Invalid byte/); + }); + + it('should parse inclusive byte-size ranges', () => { + assert.deepStrictEqual(parseByteSizeRange('10M'), { + min: 10 * 1000 * 1000, + max: undefined, + }); + assert.deepStrictEqual(parseByteSizeRange('1KB-2MiB'), { + min: 1000, + max: 2 * 1024 * 1024, + }); + assert.deepStrictEqual(parseByteSizeRange('-1MB'), { + min: 0, + max: 1000 * 1000, + }); + assert.deepStrictEqual(parseByteSizeRange('1MiB-'), { + min: 1024 * 1024, + max: undefined, + }); + assert.deepStrictEqual(parseByteSizeRange(' 1 KB - 2 MB '), { + min: 1000, + max: 2 * 1000 * 1000, + }); + }); + + it('should reject invalid byte-size ranges', () => { + assert.throws(() => parseByteSizeRange(''), /Invalid byte size/); + assert.throws(() => parseByteSizeRange('-'), /At least one bound/); + assert.throws(() => parseByteSizeRange('2MB-1MB'), /lower bound/); + assert.throws(() => parseByteSizeRange('1MB-2MB-3MB'), /Invalid byte/); + assert.throws(() => parseByteSizeRange('invalid-1MB'), /Invalid byte/); + }); +}); + +describe('byteSizeRangeSchema', () => { + it('parses valid byte-size range string', () => { + const schema = byteSizeRangeSchema('test'); + const result = schema.safeParse('1KB-2MB'); + assert.strictEqual(result.success, true); + if (result.success) { + assert.deepStrictEqual(result.data, { + min: 1000, + max: 2 * 1000 * 1000, + }); + } + }); + + it('fails for invalid byte-size range string', () => { + const schema = byteSizeRangeSchema('test'); + const result = schema.safeParse('2MB-1MB'); + assert.strictEqual(result.success, false); + }); +}); From dbe09424d3077c457b220291dbac47b2382f0bd2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:43:54 +0000 Subject: [PATCH 13/72] chore(deps-dev): bump the bundled group across 1 directory with 2 updates (#2582) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the bundled group with 2 updates in the / directory: [core-js](https://github.com/zloirock/core-js/tree/HEAD/packages/core-js) and [puppeteer](https://github.com/puppeteer/puppeteer). Updates `core-js` from 3.49.0 to 3.50.0
Release notes

Sourced from core-js's releases.

3.50.0 - 2026.08.05

  • Changes v3.49.0...v3.50.0 (138 commits)
  • Joint iteration proposal:
    • Built-ins:
      • Iterator.zip
      • Iterator.zipKeyed
    • Moved to stable ES, May 2026 TC39 meeting
    • Added es. namespace modules, /es/ and /stable/ namespace entries
  • Iterator chunking proposal:
    • Built-ins:
      • Iterator.prototype.chunks
      • Iterator.prototype.windows
    • Throw a TypeError instead of RangeError on non-integer number chunkSize / windowSize, following [tc39/proposal-iterator-chunking/#30](tc39/proposal-iterator-chunking#30)
    • Moved to stage 3, May 2026 TC39 meeting
    • Added /actual/ namespace entries, unconditional forced replacement changed to feature detection
  • Added Iterator includes stage 3 proposal:
    • Added built-in:
      • Iterator.prototype.includes
  • Added Iterator join stage 3 proposal:
    • Added built-in:
      • Iterator.prototype.join
  • Added Await dictionary of Promises stage 3 proposal:
    • Added built-ins:
      • Promise.allKeyed
      • Promise.allSettledKeyed
  • Throw a RangeError on finite unsafe integer limit in Iterator.prototype.{ drop, take }, following [tc39/ecma262/#3776](tc39/ecma262#3776)
  • Use PromiseResolve semantics in Promise.try, following [tc39/ecma262/#3883](tc39/ecma262#3883)
  • Added detection of missed Webkit ~ Safari < 26.2 Iterator.prototype.flatMap bug case, #1538
  • Deno 2.9+ replaces Object.prototype.__proto__ instead of removing it, so the feature detection updated
  • Fixed JSON.stringify polyfill with an array replacer - keys order now follows the replacer, inherited and non-enumerable properties are no longer ignored, #1539
  • Make URL / URLSearchParams parsing a little more correct (char sets, percent coding, etc)
  • Ensure opaque paths always roundtrip in URL polyfill (still without adding to feature detection), whatwg/url#844
  • Fix URL#toJSON when URL#toString is reassigned after core-js is imported
  • Fixed possible crash on some keys in Symbol.for
  • Some get-iterator / get-iterator-method fixes
  • Fixed String.prototype.{ match, search } polyfills conversion order
  • Added missed MAX_SAFE_INTEGER excess check in Array.from and { Map, Object }.groupBy polyfills
  • Improved the way of inner iterators cleaning in iterator helpers
  • Improved accuracy of Math.{ asinh, cbrt, log1p } polyfills with big and small values
  • Improved performance of Uint8Array base64 methods
  • Improved performance of escape
  • Slight performance improvement for engines with native Array.prototype.fill on ArrayBuffer constructor and %TypedArray%.prototype.fill
  • Clarify supported Node versions in package.json of some missed packages (just to satisfy publint)
  • Compat data improvements:

... (truncated)

Changelog

Sourced from core-js's changelog.

3.50.0 - 2026.08.05

  • Changes v3.49.0...v3.50.0 (138 commits)
  • Joint iteration proposal:
    • Built-ins:
      • Iterator.zip
      • Iterator.zipKeyed
    • Moved to stable ES, May 2026 TC39 meeting
    • Added es. namespace modules, /es/ and /stable/ namespace entries
  • Iterator chunking proposal:
    • Built-ins:
      • Iterator.prototype.chunks
      • Iterator.prototype.windows
    • Throw a TypeError instead of RangeError on non-integer number chunkSize / windowSize, following [tc39/proposal-iterator-chunking/#30](tc39/proposal-iterator-chunking#30)
    • Moved to stage 3, May 2026 TC39 meeting
    • Added /actual/ namespace entries, unconditional forced replacement changed to feature detection
  • Added Iterator includes stage 3 proposal:
    • Added built-in:
      • Iterator.prototype.includes
  • Added Iterator join stage 3 proposal:
    • Added built-in:
      • Iterator.prototype.join
  • Added Await dictionary of Promises stage 3 proposal:
    • Added built-ins:
      • Promise.allKeyed
      • Promise.allSettledKeyed
  • Throw a RangeError on finite unsafe integer limit in Iterator.prototype.{ drop, take }, following [tc39/ecma262/#3776](tc39/ecma262#3776)
  • Use PromiseResolve semantics in Promise.try, following [tc39/ecma262/#3883](tc39/ecma262#3883)
  • Added detection of missed Webkit ~ Safari < 26.2 Iterator.prototype.flatMap bug case, #1538
  • Deno 2.9+ replaces Object.prototype.__proto__ instead of removing it, so the feature detection updated
  • Fixed JSON.stringify polyfill with an array replacer - keys order now follows the replacer, inherited and non-enumerable properties are no longer ignored, #1539
  • Make URL / URLSearchParams parsing a little more correct (char sets, percent coding, etc)
  • Ensure opaque paths always roundtrip in URL polyfill (still without adding to feature detection), whatwg/url#844
  • Fix URL#toJSON when URL#toString is reassigned after core-js is imported
  • Fixed possible crash on some keys in Symbol.for
  • Some get-iterator / get-iterator-method fixes
  • Fixed String.prototype.{ match, search } polyfills conversion order
  • Added missed MAX_SAFE_INTEGER excess check in Array.from and { Map, Object }.groupBy polyfills
  • Improved the way of inner iterators cleaning in iterator helpers
  • Improved accuracy of Math.{ asinh, cbrt, log1p } polyfills with big and small values
  • Improved performance of Uint8Array base64 methods
  • Improved performance of escape
  • Slight performance improvement for engines with native Array.prototype.fill on ArrayBuffer constructor and %TypedArray%.prototype.fill
  • Clarify supported Node versions in package.json of some missed packages (just to satisfy publint)
  • Compat data improvements:

... (truncated)

Commits
  • 486e8d6 v3.50.0
  • 6a78c6a Cache URL#toString (#1533)
  • 5d4f138 fix: correct Iterator zip entry exports
  • 7e8e1cc rename method for consistency
  • 2507115 fix JSON.stringify polyfill with an array replacerJSON.stringify polyfill...
  • 4036342 make URL / URLSearchParams parsing a little more correct (char sets, perc...
  • 37375ba fix order of detection
  • 276de47 add detection of missed Webkit ~ Safari < 26.2 Iterator.prototype.flatMap b...
  • 6e4a942 use PromiseResolve semantics in Promise.try
  • ff52b9f backport await dictionary stage 3 proposal
  • Additional commits viewable in compare view

Updates `puppeteer` from 25.6.0 to 25.8.0
Release notes

Sourced from puppeteer's releases.

puppeteer-core: v25.8.0

25.8.0 (2026-08-17)

🎉 Features

🛠️ Fixes

  • computeSystemExecutablePath support validatePath (#15340) (73da9a4)
  • launch browsers in detached mode on Windows to fix flakiness (#15339) (8e1022b)
  • remove redundant overwrite and file access from ScreenRecorder (#15352) (61a1675)

Dependencies

  • The following workspace dependencies were updated
    • dependencies
      • @​puppeteer/browsers bumped from 3.2.0 to 3.2.1

puppeteer: v25.8.0

25.8.0 (2026-08-17)

🎉 Features

🛠️ Fixes

  • tell the user how to recover a partial browser folder (#15319) (bbc51bd)

Dependencies

  • The following workspace dependencies were updated
    • dependencies
      • @​puppeteer/browsers bumped from 3.2.0 to 3.2.1
      • puppeteer-core bumped from 25.7.0 to 25.8.0

puppeteer-core: v25.7.0

25.7.0 (2026-08-13)

🎉 Features

... (truncated)

Changelog

Sourced from puppeteer's changelog.

25.8.0 (2026-08-17)

🎉 Features

🛠️ Fixes

  • computeSystemExecutablePath support validatePath (#15340) (73da9a4)
  • launch browsers in detached mode on Windows to fix flakiness (#15339) (8e1022b)
  • remove redundant overwrite and file access from ScreenRecorder (#15352) (61a1675)

Dependencies

  • The following workspace dependencies were updated
    • dependencies
      • @​puppeteer/browsers bumped from 3.2.0 to 3.2.1

25.7.0 (2026-08-13)

🎉 Features

Dependencies

  • The following workspace dependencies were updated
    • dependencies
      • puppeteer-core bumped from 25.6.0 to 25.7.0

🛠️ Fixes

🏗️ Refactor

Commits

--------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Nicholas Roscino --- package-lock.json | 53 +++++++++++----------- package.json | 4 +- tests/tools/console.test.js.snapshot | 2 +- tests/tools/pages.test.ts | 66 +++++++++++++++------------- 4 files changed, 66 insertions(+), 59 deletions(-) diff --git a/package-lock.json b/package-lock.json index ab75f15a..b2b12b99 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,14 +29,14 @@ "@types/yargs": "^17.0.33", "@typescript-eslint/eslint-plugin": "^8.43.0", "@typescript-eslint/parser": "^8.43.0", - "core-js": "3.49.0", + "core-js": "3.50.0", "eslint": "^10.7.0", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "^2.32.0", "globals": "^17.0.0", "lighthouse": "13.4.1", "prettier": "^3.6.2", - "puppeteer": "25.6.0", + "puppeteer": "25.8.0", "rollup": "4.62.4", "rollup-plugin-cleanup": "^3.2.1", "rollup-plugin-license": "^3.6.0", @@ -746,9 +746,9 @@ "license": "BSD-3-Clause" }, "node_modules/@puppeteer/browsers": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.2.0.tgz", - "integrity": "sha512-LlBrE8oqGfU7b1Nk2d5Q1SbuPhZxTj0cJEMDPEws28OjNMELlflekmPPuf4FnK03x0ZRjKaYwJElUcKK4kyqJA==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.2.1.tgz", + "integrity": "sha512-KDz+3qDRdBAlRlMjmKyj6dEs33YHTk/xRHEENSXq6TNnhgoU15ruSHtEBeVF6OZ9tBDY55Se4P0nFMNsipzU9A==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -2742,12 +2742,15 @@ } }, "node_modules/core-js": { - "version": "3.49.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", - "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "version": "3.50.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz", + "integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==", "dev": true, "hasInstallScript": true, "license": "MIT", + "engines": { + "node": "*" + }, "funding": { "type": "opencollective", "url": "https://opencollective.com/core-js" @@ -2942,9 +2945,9 @@ } }, "node_modules/devtools-protocol": { - "version": "0.0.1653615", - "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1653615.tgz", - "integrity": "sha512-pGVkY3T/qXxAp2nFPodwYqOevk6ncNMSmvL8QfRCx5ZWGd6Vor7AFNmyaA8Zs6uJyP1QAfjuLandCgvSix1BNA==", + "version": "0.0.1666840", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1666840.tgz", + "integrity": "sha512-gCcO42XCHKEs7Ag0S7aGYsnJ7hlgrO3qderYqeiY0Eqk+0GFfuvT13IA0hHreJTa2KCdDVyGMeOhdMNmrrTjVg==", "dev": true, "license": "BSD-3-Clause" }, @@ -5520,9 +5523,9 @@ "license": "MIT" }, "node_modules/modern-tar": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.8.1.tgz", - "integrity": "sha512-G/OFF6yTWgdGWS5IvhcrETxsgjsUdotEtHhW2nffB5vP5uCwkhnn38qRO7h6RF4uV0UlqyHx/bq2Vd5Oco4uoQ==", + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.8.4.tgz", + "integrity": "sha512-gN54ddmyzEg10orwZ2u4OOv+bjpMWdIl5jIkodK97bMq8QBSL5c0D7YX0lT1Ooz+99S7+PvFbnxzdjgHo1r41g==", "dev": true, "license": "MIT", "engines": { @@ -6045,18 +6048,18 @@ } }, "node_modules/puppeteer": { - "version": "25.6.0", - "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-25.6.0.tgz", - "integrity": "sha512-TXUolDddU4AwISjOOrGk2AhJDpbM/ZDt2KvGIqz74EOk+8bKwXFo+acUvP1sQx3hUda7owOeNuuT1UnJT1o0qA==", + "version": "25.8.0", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-25.8.0.tgz", + "integrity": "sha512-3gcUJ+Jfodb5zNa/lWLZukBUwYiRIwAc8WRICoqfi+ZYmNWqpsPFyanTU3Gw/lhgII9aotVbAmhT6/NKHWgyUA==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@puppeteer/browsers": "3.2.0", + "@puppeteer/browsers": "3.2.1", "chromium-bidi": "17.0.2", - "devtools-protocol": "0.0.1653615", + "devtools-protocol": "0.0.1666840", "lilconfig": "^3.1.3", - "puppeteer-core": "25.6.0", + "puppeteer-core": "25.8.0", "typed-query-selector": "^2.12.2" }, "bin": { @@ -6067,15 +6070,15 @@ } }, "node_modules/puppeteer-core": { - "version": "25.6.0", - "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.6.0.tgz", - "integrity": "sha512-GJ67rjZdVQzZmD2Ab0cgttfQN9j387QYMv3t6MN3/4nmjursNt6M5Utj4/T/4y0AwNrSwJzjw6Q/zuWFEIizOg==", + "version": "25.8.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.8.0.tgz", + "integrity": "sha512-LDOrawV8vfCVk+yLj2ozvajNP4Sv3OV9y3Tpiyy2g2Z+aQlbcozP6KJfI4iSBq7YQER+86ihEtPa5ioiZyWxMQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@puppeteer/browsers": "3.2.0", + "@puppeteer/browsers": "3.2.1", "chromium-bidi": "17.0.2", - "devtools-protocol": "0.0.1653615", + "devtools-protocol": "0.0.1666840", "typed-query-selector": "^2.12.2", "webdriver-bidi-protocol": "0.4.2", "ws": "^8.21.1" diff --git a/package.json b/package.json index 442adbf2..53b5bab6 100644 --- a/package.json +++ b/package.json @@ -69,14 +69,14 @@ "@types/yargs": "^17.0.33", "@typescript-eslint/eslint-plugin": "^8.43.0", "@typescript-eslint/parser": "^8.43.0", - "core-js": "3.49.0", + "core-js": "3.50.0", "eslint": "^10.7.0", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "^2.32.0", "globals": "^17.0.0", "lighthouse": "13.4.1", "prettier": "^3.6.2", - "puppeteer": "25.6.0", + "puppeteer": "25.8.0", "rollup": "4.62.4", "rollup-plugin-cleanup": "^3.2.1", "rollup-plugin-license": "^3.6.0", diff --git a/tests/tools/console.test.js.snapshot b/tests/tools/console.test.js.snapshot index 0677097b..8c2cb51c 100644 --- a/tests/tools/console.test.js.snapshot +++ b/tests/tools/console.test.js.snapshot @@ -141,7 +141,7 @@ exports[`console > get_console_message > when dialog is open 1`] = ` "args": [ "This is an error" ], - "stackTrace": "at (VM7:1:9)\\nat (pptr:;CdpFrame.%3Canonymous%3E%20()\\n--- PendingScript ----------------------\\nat (pptr:;CdpFrame.%3Canonymous%3E%20()\\nNote: line and column numbers use 1-based indexing" + "stackTrace": "at (VM5:1:9)\\nat (pptr:;CdpFrame.%3Canonymous%3E%20()\\n--- PendingScript ----------------------\\nat (pptr:;CdpFrame.%3Canonymous%3E%20()\\nNote: line and column numbers use 1-based indexing" }, "pagination": { "currentPage": 0, diff --git a/tests/tools/pages.test.ts b/tests/tools/pages.test.ts index a63702b4..51a7a96f 100644 --- a/tests/tools/pages.test.ts +++ b/tests/tools/pages.test.ts @@ -1077,39 +1077,43 @@ describe('pages', () => { }); }); - it('resize when window state is fullscreen', async () => { - await withMcpContext(async (response, context) => { - const page = context.getSelectedMcpPage().pptrPage; - const browser = page.browser(); - const windowId = await page.windowId(); - await browser.setWindowBounds(windowId, {windowState: 'fullscreen'}); - - const {windowState} = await browser.getWindowBounds(windowId); - assert.strictEqual(windowState, 'fullscreen'); - - const resizePromise = page.evaluate(() => { - return new Promise(resolve => { - window.addEventListener('resize', resolve, {once: true}); + it.only( + 'resize when window state is fullscreen', + {skip: process.platform === 'darwin'}, + async () => { + await withMcpContext(async (response, context) => { + const page = context.getSelectedMcpPage().pptrPage; + const browser = page.browser(); + const windowId = await page.windowId(); + await browser.setWindowBounds(windowId, {windowState: 'fullscreen'}); + + const {windowState} = await browser.getWindowBounds(windowId); + assert.strictEqual(windowState, 'fullscreen'); + + const resizePromise = page.evaluate(() => { + return new Promise(resolve => { + window.addEventListener('resize', resolve, {once: true}); + }); }); + await resizePage.handler( + { + params: {width: 850, height: 650}, + page: context.getSelectedMcpPage(), + }, + response, + context, + ); + await resizePromise; + await page.waitForFunction( + () => window.innerWidth === 850 && window.innerHeight === 650, + ); + const dimensions = await page.evaluate(() => { + return [window.innerWidth, window.innerHeight]; + }); + assert.deepStrictEqual(dimensions, [850, 650]); }); - await resizePage.handler( - { - params: {width: 850, height: 650}, - page: context.getSelectedMcpPage(), - }, - response, - context, - ); - await resizePromise; - await page.waitForFunction( - () => window.innerWidth === 850 && window.innerHeight === 650, - ); - const dimensions = await page.evaluate(() => { - return [window.innerWidth, window.innerHeight]; - }); - assert.deepStrictEqual(dimensions, [850, 650]); - }); - }); + }, + ); it('when dialog is open', async t => { await withMcpContext(async (response, context) => { From adacf237f45e1bbebd10d13215075cfb0d873f08 Mon Sep 17 00:00:00 2001 From: yulunz <11618243+yulunz@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:52:19 +0000 Subject: [PATCH 14/72] chore(telemetry): add more mcp client name matchers. (#2591) --- src/telemetry/ClearcutLogger.ts | 10 +++++++++- src/telemetry/types.ts | 4 ++++ tests/telemetry/ClearcutLogger.test.ts | 8 ++++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/telemetry/ClearcutLogger.ts b/src/telemetry/ClearcutLogger.ts index 35f711ef..cff84305 100644 --- a/src/telemetry/ClearcutLogger.ts +++ b/src/telemetry/ClearcutLogger.ts @@ -90,7 +90,9 @@ export class ClearcutLogger { setClientName(clientName: string): void { const lowerName = clientName.toLowerCase(); - if (lowerName.includes('claude')) { + if (lowerName.includes('claude-desktop')) { + this.#mcpClient = McpClient.MCP_CLIENT_CLAUDE_DESKTOP; + } else if (lowerName.includes('claude')) { this.#mcpClient = McpClient.MCP_CLIENT_CLAUDE_CODE; } else if (lowerName.includes('gemini')) { this.#mcpClient = McpClient.MCP_CLIENT_GEMINI_CLI; @@ -98,10 +100,16 @@ export class ClearcutLogger { this.#mcpClient = McpClient.MCP_CLIENT_DT_MCP_CLI; } else if (lowerName.includes('openclaw')) { this.#mcpClient = McpClient.MCP_CLIENT_OPENCLAW; + } else if (lowerName.includes('opencode')) { + this.#mcpClient = McpClient.MCP_CLIENT_OPENCODE; } else if (lowerName.includes('codex')) { this.#mcpClient = McpClient.MCP_CLIENT_CODEX; } else if (lowerName.includes('antigravity')) { this.#mcpClient = McpClient.MCP_CLIENT_ANTIGRAVITY; + } else if (lowerName.includes('grok') || lowerName.includes('xai')) { + this.#mcpClient = McpClient.MCP_CLIENT_GROK; + } else if (lowerName.includes('copilot')) { + this.#mcpClient = McpClient.MCP_CLIENT_GITHUB_COPILOT; } else { this.#mcpClient = McpClient.MCP_CLIENT_OTHER; } diff --git a/src/telemetry/types.ts b/src/telemetry/types.ts index cac3f242..b4b3a8b8 100644 --- a/src/telemetry/types.ts +++ b/src/telemetry/types.ts @@ -92,6 +92,10 @@ export enum McpClient { MCP_CLIENT_OPENCLAW = 5, MCP_CLIENT_CODEX = 6, MCP_CLIENT_ANTIGRAVITY = 7, + MCP_CLIENT_GROK = 8, + MCP_CLIENT_OPENCODE = 9, + MCP_CLIENT_CLAUDE_DESKTOP = 10, + MCP_CLIENT_GITHUB_COPILOT = 11, MCP_CLIENT_OTHER = 3, } diff --git a/tests/telemetry/ClearcutLogger.test.ts b/tests/telemetry/ClearcutLogger.test.ts index 90196e8e..43179719 100644 --- a/tests/telemetry/ClearcutLogger.test.ts +++ b/tests/telemetry/ClearcutLogger.test.ts @@ -125,12 +125,20 @@ describe('ClearcutLogger', () => { describe('setClientName', () => { const clients = [ + {name: 'claude-desktop', expected: 10}, // MCP_CLIENT_CLAUDE_DESKTOP {name: 'claude-code', expected: 1}, // MCP_CLIENT_CLAUDE_CODE + {name: 'claude', expected: 1}, // MCP_CLIENT_CLAUDE_CODE {name: 'gemini-cli', expected: 2}, // MCP_CLIENT_GEMINI_CLI {name: DAEMON_CLIENT_NAME, expected: 4}, // MCP_CLIENT_DT_MCP_CLI {name: 'openclaw-browser', expected: 5}, // MCP_CLIENT_OPENCLAW + {name: 'opencode', expected: 9}, // MCP_CLIENT_OPENCODE {name: 'codex-mcp-client', expected: 6}, // MCP_CLIENT_CODEX {name: 'antigravity-client', expected: 7}, // MCP_CLIENT_ANTIGRAVITY + {name: 'grok-build', expected: 8}, // MCP_CLIENT_GROK + {name: 'xai-sdk', expected: 8}, // MCP_CLIENT_GROK + {name: 'github-copilot-developer', expected: 11}, // MCP_CLIENT_GITHUB_COPILOT + {name: 'copilot-intellij', expected: 11}, // MCP_CLIENT_GITHUB_COPILOT + {name: 'unknown-client', expected: 3}, // MCP_CLIENT_OTHER ]; for (const {name, expected} of clients) { From 49c35cd89cbe8cec6231c1ae4553a558d1aa2601 Mon Sep 17 00:00:00 2001 From: Nicholas Roscino Date: Wed, 19 Aug 2026 15:33:32 +0000 Subject: [PATCH 15/72] test: update tests (#2593) Update tests --- tests/tools/pages.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/tools/pages.test.ts b/tests/tools/pages.test.ts index 51a7a96f..d9d4b999 100644 --- a/tests/tools/pages.test.ts +++ b/tests/tools/pages.test.ts @@ -1077,7 +1077,10 @@ describe('pages', () => { }); }); - it.only( + /* + * The following test fails after the release of chrome 152. + * */ + it( 'resize when window state is fullscreen', {skip: process.platform === 'darwin'}, async () => { From 1bac85c90bcb38d5a695674eb8730015e51beb04 Mon Sep 17 00:00:00 2001 From: Alex Rudenko Date: Wed, 19 Aug 2026 17:42:36 +0000 Subject: [PATCH 16/72] fix: puppeteer actions would not follow symlinks anymore (#2592) Closes https://github.com/ChromeDevTools/chrome-devtools-mcp/pull/2565 --- src/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 84e8e2c7..ecbd8965 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,11 +25,13 @@ import {ToolHandler} from './ToolHandler.js'; import type {DefinedPageTool, ToolDefinition} from './tools/ToolDefinition.js'; import {createTools} from './tools/tools.js'; import {logger} from './utils/logger.js'; -import {Mutex} from './third_party/index.js'; +import {Mutex, puppeteer} from './third_party/index.js'; import {VERSION} from './version.js'; export {buildFlag} from './ToolHandler.js'; +puppeteer.setFollowSymlinks(false); + /** * Timeout for a `roots/list` that a tool call is waiting on, matching the 5s * default used for page operations. `getContext()` awaits it while From c6cff23fbfb1d8c7ee7e79ee3b74f7eb52e725eb Mon Sep 17 00:00:00 2001 From: Alex Rudenko Date: Thu, 20 Aug 2026 08:49:39 +0000 Subject: [PATCH 17/72] fix(cli): adjust chrome-devtools start defaults (#2597) Refs https://github.com/ChromeDevTools/chrome-devtools-mcp/issues/2458#issuecomment-5344854403 --- src/bin/chrome-devtools.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/bin/chrome-devtools.ts b/src/bin/chrome-devtools.ts index ff2fd5c9..733b0419 100644 --- a/src/bin/chrome-devtools.ts +++ b/src/bin/chrome-devtools.ts @@ -141,10 +141,21 @@ y.command( await stopDaemon(argv.sessionId); } // Defaults but we do not want to affect the yargs conflict resolution. - if (argv.isolated === undefined && argv.userDataDir === undefined) { + if ( + argv.isolated === undefined && + argv.userDataDir === undefined && + !argv.autoConnect && + !argv.browserUrl && + !argv.wsEndpoint + ) { argv.isolated = true; } - if (argv.headless === undefined) { + if ( + argv.headless === undefined && + !argv.autoConnect && + !argv.browserUrl && + !argv.wsEndpoint + ) { argv.headless = true; } const args = serializeArgs(mcpOptions, argv); From 8f25f69e79a50ffae7406ba70d76ba1fc0da3339 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:42:22 +0000 Subject: [PATCH 18/72] chore(deps): bump third_party/devtools-frontend from `9e32095` to `23cccaa` (#2595) Bumps [third_party/devtools-frontend](https://github.com/ChromeDevTools/devtools-frontend) from `9e32095` to `23cccaa`. Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Nicholas Roscino --- third_party/devtools-frontend | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/third_party/devtools-frontend b/third_party/devtools-frontend index 9e320957..23cccaa7 160000 --- a/third_party/devtools-frontend +++ b/third_party/devtools-frontend @@ -1 +1 @@ -Subproject commit 9e3209578a3ba27cd3af620392bd10027b59c0fe +Subproject commit 23cccaa78f7458a5aad99c1af98dc1856d2494a3 From 2ce42f873877673deedacca4fe5bfda22cede6a6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 05:42:15 +0000 Subject: [PATCH 19/72] chore(deps): bump third_party/devtools-frontend from `23cccaa` to `6fdb53e` (#2602) Bumps [third_party/devtools-frontend](https://github.com/ChromeDevTools/devtools-frontend) from `23cccaa` to `6fdb53e`.
Commits
  • 6fdb53e Ensure consistent UI Strings in front_end/ui/legacy/components/color_picker
  • f6da8dd Update DevTools DEPS (trusted)
  • 22bb576 Migrate ai generation to foundation modules
  • fbcc42e [WebAudio] Add renderQuantumSize to WebAudio pane
  • 3360297 Update DevTools DEPS (trusted)
  • b1a5fba Ensure consistent UI strings in front_end/panels/elements
  • d65f8da Turn ai__code_completion model into a foundation module
  • 02687ca Roll browser-protocol and CfT
  • 73a8374 Update DevTools DEPS (trusted)
  • 2c52a74 Add visual logging contexts to Layers panel for comment anchoring
  • Additional commits viewable in compare view

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- third_party/devtools-frontend | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/third_party/devtools-frontend b/third_party/devtools-frontend index 23cccaa7..6fdb53e0 160000 --- a/third_party/devtools-frontend +++ b/third_party/devtools-frontend @@ -1 +1 @@ -Subproject commit 23cccaa78f7458a5aad99c1af98dc1856d2494a3 +Subproject commit 6fdb53e0f7a5cb1ebf16e40c45439a5c8007d9be From ebf58f2f4aa8f1dfbbae38e440fde4e5fef7deef Mon Sep 17 00:00:00 2001 From: Shixi Li <40780706+shixi-li@users.noreply.github.com> Date: Fri, 21 Aug 2026 19:12:22 +0000 Subject: [PATCH 20/72] fix: respect screenshot bounds on HiDPI displays (#2536) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #2531 ## What changed - factor the page's actual `window.devicePixelRatio` into screenshot downscaling - keep CDP clip dimensions in CSS pixels while bounding the resulting bitmap dimensions - add a regression test for the real `defaultViewport: null` path with a forced 2x device scale factor ## Why Screenshot source boxes and CDP clip dimensions are expressed in CSS pixels, but the returned bitmap is scaled by the page's device pixel ratio. The previous calculation compared the CSS width directly with `screenshotMaxWidth`, so a 2x page could return an image twice the configured bound. The new calculation derives the clip scale from `box × devicePixelRatio`, making the limit apply to the image pixels sent to the model. ## Validation - `npm run check-format` - `npm run build` - `npm run test -- tests/tools/screenshot.test.ts --test-name-pattern='honors screenshotMaxWidth at device scale factors above 1'` - `npm run test -- tests/tools/screenshot.test.ts --test-skip-pattern='with full page resulting in a large screenshot'` The new regression fails on `main` with a 200px-wide image and passes with an exact 100px result after this change. I also attempted the full suite with retries; this local environment still times out in unrelated daemon/extension E2E tests and hits the existing `Page.captureScreenshot: Page is too large` case, which reproduces on pristine `main`. --- src/tools/screenshot.ts | 57 ++++++++++++++++++++++++++++------ tests/tools/screenshot.test.ts | 39 +++++++++++++++++++++++ 2 files changed, 86 insertions(+), 10 deletions(-) diff --git a/src/tools/screenshot.ts b/src/tools/screenshot.ts index 33202658..6465cd2a 100644 --- a/src/tools/screenshot.ts +++ b/src/tools/screenshot.ts @@ -17,14 +17,24 @@ import {definePageTool} from './ToolDefinition.js'; type ScreenshotFormat = 'png' | 'jpeg' | 'webp'; +type SourceBox = BoundingBox & { + devicePixelRatio: number; +}; + async function getSourceBox( page: Page, element: ElementHandle | undefined, fullPage: boolean, -): Promise { +): Promise { if (element) { - const box = await element.boundingBox(); - return box ?? undefined; + const viewport = page.viewport(); + const [box, devicePixelRatio] = await Promise.all([ + element.boundingBox(), + viewport + ? (viewport.deviceScaleFactor ?? 1) + : page.evaluate(() => window.devicePixelRatio), + ]); + return box ? {...box, devicePixelRatio} : undefined; } if (fullPage) { const dims = await page.evaluate(() => ({ @@ -36,15 +46,28 @@ async function getSourceBox( document.documentElement.scrollHeight, document.body?.scrollHeight ?? 0, ), + devicePixelRatio: window.devicePixelRatio, })); if (dims.width <= 0 || dims.height <= 0) { return undefined; } - return {x: 0, y: 0, width: dims.width, height: dims.height}; + return { + x: 0, + y: 0, + width: dims.width, + height: dims.height, + devicePixelRatio: dims.devicePixelRatio, + }; } const viewport = page.viewport(); if (viewport) { - return {x: 0, y: 0, width: viewport.width, height: viewport.height}; + return { + x: 0, + y: 0, + width: viewport.width, + height: viewport.height, + devicePixelRatio: viewport.deviceScaleFactor ?? 1, + }; } // The browser is launched and connected with `defaultViewport: null`, so // `page.viewport()` stays null until something emulates one. Fall back to the @@ -52,28 +75,42 @@ async function getSourceBox( const dims = await page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight, + devicePixelRatio: window.devicePixelRatio, })); if (dims.width <= 0 || dims.height <= 0) { return undefined; } - return {x: 0, y: 0, width: dims.width, height: dims.height}; + return { + x: 0, + y: 0, + width: dims.width, + height: dims.height, + devicePixelRatio: dims.devicePixelRatio, + }; } function computeDownscaleClip( - box: BoundingBox, + box: SourceBox, maxWidth: number | undefined, maxHeight: number | undefined, ): ScreenshotClip | undefined { const widthScale = - maxWidth !== undefined ? Math.min(1, maxWidth / box.width) : 1; + maxWidth !== undefined + ? Math.min(1, maxWidth / (box.width * box.devicePixelRatio)) + : 1; const heightScale = - maxHeight !== undefined ? Math.min(1, maxHeight / box.height) : 1; + maxHeight !== undefined + ? Math.min(1, maxHeight / (box.height * box.devicePixelRatio)) + : 1; const scale = Math.min(widthScale, heightScale); if (scale >= 1) { return undefined; } // Skip degenerate sub-pixel results. - if (Math.round(box.width * scale) < 1 || Math.round(box.height * scale) < 1) { + if ( + Math.round(box.width * box.devicePixelRatio * scale) < 1 || + Math.round(box.height * box.devicePixelRatio * scale) < 1 + ) { return undefined; } return { diff --git a/tests/tools/screenshot.test.ts b/tests/tools/screenshot.test.ts index 2c6f52a7..587bec5e 100644 --- a/tests/tools/screenshot.test.ts +++ b/tests/tools/screenshot.test.ts @@ -403,6 +403,45 @@ describe('screenshot', () => { }); }); + it('honors screenshotMaxWidth at device scale factors above 1', async () => { + const tool = screenshot({ + screenshotMaxWidth: 100, + } as ParsedArguments); + await withMcpContext( + async (response, context) => { + const page = context.getSelectedMcpPage().pptrPage; + assert.equal(page.viewport(), null); + await page.setContent( + html`
`, + ); + const source = await page.evaluate(() => ({ + width: window.innerWidth, + height: window.innerHeight, + devicePixelRatio: window.devicePixelRatio, + })); + assert.equal(source.devicePixelRatio, 2); + + await tool.handler( + {params: {format: 'png'}, page: context.getSelectedMcpPage()}, + response, + context, + ); + + assert.equal(response.images.length, 1); + const buf = Buffer.from(response.images[0].data, 'base64'); + assert.equal(pngWidth(buf), 100); + const expectedHeight = Math.round( + source.height * (100 / source.width), + ); + assert.ok( + Math.abs(pngHeight(buf) - expectedHeight) <= 1, + `expected height ~${expectedHeight}, got ${pngHeight(buf)}`, + ); + }, + {args: ['--force-device-scale-factor=2']}, + ); + }); + it('downscales viewport screenshot when no viewport is emulated', async () => { const tool = screenshot({ screenshotMaxWidth: 100, From 50a16fae69c5c28990328160f79db50af260e256 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tolgahan=20Demirba=C5=9F?= <49946947+bcfmtolgahan@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:54:06 +0000 Subject: [PATCH 21/72] feat: make pageId required for page-scoped tools by default (#1777) Removes `--experimental-page-id-routing` making it the default behavior. To go back to the previous behavior, pass `--pageIdRouting=false` when starting the server. --------- Co-authored-by: Alex Rudenko Co-authored-by: Samiya Caur --- README.md | 29 +-- docs/cli.md | 38 ++- docs/tool-reference.md | 67 ++++-- scripts/eval_result.ts | 4 +- .../page_focus_keyboard_test.ts | 1 - .../page_id_routing_concurrent_form_test.ts | 70 ++++++ .../page_id_routing_cross_page_state_test.ts | 93 ++++++++ .../eval_scenarios/page_id_routing_test.ts | 1 - scripts/generate-docs.ts | 18 +- skills/a11y-debugging/SKILL.md | 2 + skills/chrome-devtools-cli/SKILL.md | 130 +++++------ skills/chrome-devtools/SKILL.md | 9 +- skills/debug-optimize-lcp/SKILL.md | 18 +- skills/memory-leak-debugging/SKILL.md | 4 +- skills/troubleshooting/SKILL.md | 1 + src/ToolHandler.ts | 4 +- src/bin/chrome-devtools.ts | 9 +- src/config/cli-options.ts | 216 ++++++++++++++++-- src/config/mcp-options.ts | 5 +- src/telemetry/flag_usage_metrics.json | 14 +- src/telemetry/tool_call_metrics.json | 7 +- src/tools/console.ts | 2 +- src/tools/emulation.ts | 2 +- src/tools/memory.ts | 2 +- src/tools/network.ts | 2 +- src/tools/pages.ts | 14 +- src/tools/performance.ts | 6 +- src/tools/screencast.ts | 4 +- src/tools/script.ts | 28 ++- src/tools/snapshot.ts | 2 +- tests/ToolHandler.test.ts | 50 +++- tests/cli.test.ts | 1 + tests/e2e/chrome-devtools-commands.test.ts | 10 +- tests/index.test.ts | 13 +- tests/tools/pages.test.ts | 2 +- tests/tools/script.test.ts | 39 +++- 36 files changed, 722 insertions(+), 195 deletions(-) create mode 100644 scripts/eval_scenarios/page_id_routing_concurrent_form_test.ts create mode 100644 scripts/eval_scenarios/page_id_routing_cross_page_state_test.ts diff --git a/README.md b/README.md index c0ae2f34..5068429a 100644 --- a/README.md +++ b/README.md @@ -663,10 +663,10 @@ The Chrome DevTools MCP server supports the following configuration option: - **Type:** boolean - **Default:** `false` -- **`--experimentalPageIdRouting`/ `--experimental-page-id-routing`** - Whether to expose pageId on page-scoped tools and route requests by page ID (useful for concurrent agent sessions). +- **`--pageIdRouting`/ `--page-id-routing`** + Require pageId on page-scoped tools and route requests by page ID (useful for concurrent agent sessions). Use --no-page-id-routing to disable. - **Type:** boolean - - **Default:** `false` + - **Default:** `true` - **`--experimentalDevtools`/ `--experimental-devtools`** Whether to enable automation over DevTools targets @@ -851,22 +851,25 @@ You can also run `npx chrome-devtools-mcp@latest --help` to see all available co ### Concurrent sessions -Most MCP clients start one Chrome DevTools MCP server per conversation. If your -client shares a single server instance across concurrent agents or subagents, -start the server with `--experimentalPageIdRouting`. This exposes `pageId` on -page-scoped tools so each agent can route tool calls to the tab it is working -with. +Most MCP clients start one Chrome DevTools MCP server per conversation. +By default, the server runs with `--pageIdRouting` enabled, making `pageId` a +required parameter on page-scoped tools (such as `click`, `fill`, `navigate_page`, +`take_snapshot`, etc.) so multiple agents or subagents sharing a server instance can +route tool calls directly to the specific tab they are working with. + +For `evaluate_script`, `pageId` is required by default for targeting pages, but +becomes optional when `--categoryExtensions` is enabled so that `serviceWorkerId` +can be specified instead to evaluate inside an extension background service worker. + +To disable this behavior and default to the currently selected page, pass +`--no-page-id-routing`. ```json { "mcpServers": { "chrome-devtools": { "command": "npx", - "args": [ - "-y", - "chrome-devtools-mcp@latest", - "--experimentalPageIdRouting" - ] + "args": ["-y", "chrome-devtools-mcp@latest"] } } } diff --git a/docs/cli.md b/docs/cli.md index a0d8d291..50c94e34 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -23,11 +23,11 @@ The CLI acts as a client to a background `chrome-devtools-mcp` daemon (uses Unix # Check if the daemon is running chrome-devtools status -# Navigate the current page to a URL -chrome-devtools navigate_page "https://google.com" +# Navigate page 1 to a URL +chrome-devtools navigate_page 1 --url "https://google.com" -# Take a screenshot and save it to a file -chrome-devtools take_screenshot --filePath screenshot.png +# Take a screenshot of page 1 and save it to a file +chrome-devtools take_screenshot 1 --filePath screenshot.png # Stop the background daemon when finished chrome-devtools stop @@ -42,7 +42,7 @@ Thus, `--categoryExtensions` tools are currently not available in the CLI. chrome-devtools [arguments] [flags] ``` -- **Required Arguments**: Passed as positional arguments. +- **Required Arguments**: Passed as positional arguments. Page-scoped tools require `` as their first positional argument. - **Optional Arguments**: Passed as flags (e.g., `--filePath`, `--fullPage`). ### Examples @@ -51,24 +51,38 @@ chrome-devtools [arguments] [flags] ```sh chrome-devtools new_page "https://example.com" -chrome-devtools navigate_page "https://web.dev" --type url +chrome-devtools navigate_page 1 --url "https://web.dev" ``` **Interaction:** ```sh -# Click an element by its UID from a snapshot -chrome-devtools click "element-uid-123" +# Click an element by its UID from a snapshot on page 1 +chrome-devtools click 1 "element-uid-123" -# Fill a form field -chrome-devtools fill "input-uid-456" "search query" +# Fill a form field on page 1 +chrome-devtools fill 1 "input-uid-456" "search query" +``` + +**Script Evaluation:** + +- When `--categoryExtensions` and `--pageIdRouting` are enabled: + - Target a page using `--pageId `: `chrome-devtools evaluate_script "() => document.title" --pageId 1` + - Target an extension service worker using `--serviceWorkerId `: `chrome-devtools evaluate_script "() => self.registration.scope" --serviceWorkerId sw-1` + +```sh +# Evaluate a JavaScript expression on page 1 +chrome-devtools evaluate_script "() => document.title" --pageId 1 + +# Evaluate inside an extension service worker +chrome-devtools evaluate_script "() => self.registration.scope" --serviceWorkerId sw-1 ``` **Analysis:** ```sh -# Run a Lighthouse audit (defaults to navigation mode) -chrome-devtools lighthouse_audit --mode snapshot +# Run a Lighthouse audit on page 1 (defaults to navigation mode) +chrome-devtools lighthouse_audit 1 --mode snapshot ``` ## Output format diff --git a/docs/tool-reference.md b/docs/tool-reference.md index d6aa0f2b..52b08a9f 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -79,6 +79,7 @@ **Parameters:** +- **pageId** (number) **(required)**: Targets a specific page by ID. - **uid** (string) **(required)**: The uid of an element on the page from the page content snapshot - **dblClick** (boolean) _(optional)_: Set to true for double clicks. Default is false. - **includeSnapshot** (boolean) _(optional)_: Whether to include a snapshot in the response. Default is false. @@ -92,6 +93,7 @@ **Parameters:** - **from_uid** (string) **(required)**: The uid of the element to [`drag`](#drag) +- **pageId** (number) **(required)**: Targets a specific page by ID. - **to_uid** (string) **(required)**: The uid of the element to drop into - **includeSnapshot** (boolean) _(optional)_: Whether to include a snapshot in the response. Default is false. @@ -103,6 +105,7 @@ **Parameters:** +- **pageId** (number) **(required)**: Targets a specific page by ID. - **uid** (string) **(required)**: The uid of an element on the page from the page content snapshot - **value** (string) **(required)**: The value to [`fill`](#fill) in. "true" or "false" for checkboxes and toggles, "true" for radio buttons. - **includeSnapshot** (boolean) _(optional)_: Whether to include a snapshot in the response. Default is false. @@ -116,6 +119,7 @@ **Parameters:** - **elements** (array) **(required)**: Elements from snapshot to [`fill`](#fill) out. +- **pageId** (number) **(required)**: Targets a specific page by ID. - **includeSnapshot** (boolean) _(optional)_: Whether to include a snapshot in the response. Default is false. --- @@ -127,6 +131,7 @@ **Parameters:** - **action** (enum: "accept", "dismiss") **(required)**: Whether to dismiss or accept the dialog +- **pageId** (number) **(required)**: Targets a specific page by ID. - **promptText** (string) _(optional)_: Optional prompt text to enter into the dialog. --- @@ -137,6 +142,7 @@ **Parameters:** +- **pageId** (number) **(required)**: Targets a specific page by ID. - **uid** (string) **(required)**: The uid of an element on the page from the page content snapshot - **includeSnapshot** (boolean) _(optional)_: Whether to include a snapshot in the response. Default is false. @@ -149,6 +155,7 @@ **Parameters:** - **key** (string) **(required)**: A key or a combination (e.g., "Enter", "Control+A", "Control++", "Control+Shift+R"). Modifiers: Control, Shift, Alt, Meta +- **pageId** (number) **(required)**: Targets a specific page by ID. - **includeSnapshot** (boolean) _(optional)_: Whether to include a snapshot in the response. Default is false. --- @@ -159,6 +166,7 @@ **Parameters:** +- **pageId** (number) **(required)**: Targets a specific page by ID. - **text** (string) **(required)**: The text to type - **submitKey** (string) _(optional)_: Optional key to press after typing. E.g., "Enter", "Tab", "Escape" @@ -171,6 +179,7 @@ **Parameters:** - **filePaths** (array) **(required)**: One or more files paths to upload. File paths have to be local to the browser instance (not the MCP). +- **pageId** (number) **(required)**: Targets a specific page by ID. - **uid** (string) **(required)**: The uid of the file input element or an element that will open file chooser on the page from the page content snapshot - **includeSnapshot** (boolean) _(optional)_: Whether to include a snapshot in the response. Default is false. @@ -182,6 +191,7 @@ **Parameters:** +- **pageId** (number) **(required)**: Targets a specific page by ID. - **x** (number) **(required)**: The x coordinate - **y** (number) **(required)**: The y coordinate - **dblClick** (boolean) _(optional)_: Set to true for double clicks. Default is false. @@ -215,6 +225,7 @@ **Parameters:** +- **pageId** (number) **(required)**: Targets a specific page by ID. - **handleBeforeUnload** (enum: "accept", "dismiss") _(optional)_: Whether to auto accept or beforeunload dialogs triggered by this navigation. Default is accept. - **ignoreCache** (boolean) _(optional)_: Whether to ignore cache on reload. - **initScript** (string) _(optional)_: A JavaScript script to be executed on each new document before any other scripts for the next navigation. @@ -254,6 +265,7 @@ **Parameters:** +- **pageId** (number) **(required)**: Targets a specific page by ID. - **text** (array) **(required)**: Non-empty list of texts. Resolves when any value appears on the page. - **timeout** (integer) _(optional)_: Maximum wait time in milliseconds. If set to 0, the default timeout will be used. @@ -263,10 +275,11 @@ ### `emulate` -**Description:** Emulates various features on the selected page. +**Description:** Emulates various features on the target page. **Parameters:** +- **pageId** (number) **(required)**: Targets a specific page by ID. - **colorScheme** (enum: "dark", "light", "auto") _(optional)_: [`Emulate`](#emulate) the dark or the light mode. Set to "auto" to reset to the default. - **cpuThrottlingRate** (number) _(optional)_: Represents the CPU slowdown factor. Omit or set the rate to 1 to disable throttling - **extraHttpHeaders** (string) _(optional)_: Extra HTTP headers as a JSON string object, e.g. {"X-Custom": "value", "Authorization": "Bearer token"}. Headers are included into every HTTP request originating from the page and persist across navigations until cleared. Pass an empty string to clear all extra headers. @@ -279,11 +292,12 @@ ### `resize_page` -**Description:** Resizes the selected page's window so that the page has specified dimension +**Description:** Resizes the page's window so that the page has specified dimension **Parameters:** - **height** (number) **(required)**: Page height +- **pageId** (number) **(required)**: Targets a specific page by ID. - **width** (number) **(required)**: Page width --- @@ -298,27 +312,30 @@ - **insightName** (string) **(required)**: The name of the Insight you want more information on. For example: "DocumentLatency" or "LCPBreakdown" - **insightSetId** (string) **(required)**: The id for the specific insight set. Only use the ids given in the "Available insight sets" list. +- **pageId** (number) **(required)**: Targets a specific page by ID. --- ### `performance_start_trace` -**Description:** Start a performance trace on the selected webpage. Use to find frontend performance issues, Core Web Vitals (LCP, INP, CLS), and improve page load speed. +**Description:** Start a performance trace on the target webpage. Use to find frontend performance issues, Core Web Vitals (LCP, INP, CLS), and improve page load speed. **Parameters:** +- **pageId** (number) **(required)**: Targets a specific page by ID. - **autoStop** (boolean) _(optional)_: Determines if the trace recording should be automatically stopped. - **filePath** (string) _(optional)_: The absolute file path, or a file path relative to the current working directory, to save the raw trace data. For example, trace.json.gz (compressed) or trace.json (uncompressed). -- **reload** (boolean) _(optional)_: Determines if, once tracing has started, the current selected page should be automatically reloaded. Navigate the page to the right URL using the [`navigate_page`](#navigate_page) tool BEFORE starting the trace if reload or autoStop is set to true. +- **reload** (boolean) _(optional)_: Determines if, once tracing has started, the target page should be automatically reloaded. Navigate the page to the right URL using the [`navigate_page`](#navigate_page) tool BEFORE starting the trace if reload or autoStop is set to true. --- ### `performance_stop_trace` -**Description:** Stop the active performance trace recording on the selected webpage. +**Description:** Stop the active performance trace recording on the target webpage. **Parameters:** +- **pageId** (number) **(required)**: Targets a specific page by ID. - **filePath** (string) _(optional)_: The absolute file path, or a file path relative to the current working directory, to save the raw trace data. For example, trace.json.gz (compressed) or trace.json (uncompressed). --- @@ -331,6 +348,7 @@ **Parameters:** +- **pageId** (number) **(required)**: Targets a specific page by ID. - **reqid** (number) _(optional)_: The reqid of the network request. If omitted returns the currently selected request in the DevTools Network panel. - **requestFilePath** (string) _(optional)_: The absolute or relative path to a .network-request file to save the request body to. If omitted, the body is returned inline. - **responseFilePath** (string) _(optional)_: The absolute or relative path to a .network-response file to save the response body to. If omitted, the body is returned inline. @@ -339,10 +357,11 @@ ### `list_network_requests` -**Description:** Lists the most recent requests for the currently selected page since the last navigation. +**Description:** Lists the most recent requests for the target page since the last navigation. **Parameters:** +- **pageId** (number) **(required)**: Targets a specific page by ID. - **includePreservedRequests** (boolean) _(optional)_: Set to true to return the preserved requests over the last 3 navigations. - **pageIdx** (integer) _(optional)_: Page number to return (0-based). When omitted, returns the first page. - **pageSize** (integer) _(optional)_: Maximum number of requests to return. When omitted, returns all requests. @@ -354,14 +373,15 @@ ### `evaluate_script` -**Description:** Evaluate a JavaScript function inside the currently selected page. Returns the response as JSON, so returned values have to be JSON-serializable. +**Description:** Evaluate a JavaScript function inside the target page. Returns the response as JSON, so returned values have to be JSON-serializable. **Parameters:** -- **function** (string) **(required)**: A JavaScript function declaration to be executed by the tool in the currently selected page. +- **function** (string) **(required)**: A JavaScript function declaration to be executed by the tool in the target page. Example without arguments: `() => document.title` or `async () => await fetch("example.com")`. Example with arguments: `(el) => el.innerText` +- **pageId** (number) **(required)**: Targets a specific page by ID. - **args** (array) _(optional)_: An optional list of arguments to pass to the function. - **dialogAction** (string) _(optional)_: Handle dialogs while execution. "accept", "dismiss", or string for response of window.prompt. Defaults to accept. - **filePath** (string) _(optional)_: The absolute or relative path to a file to save the script output to. If omitted, the output is returned inline. @@ -376,6 +396,7 @@ **Parameters:** - **msgid** (number) **(required)**: The msgid of a console message on the page from the listed console messages +- **pageId** (number) **(required)**: Targets a specific page by ID. --- @@ -385,6 +406,7 @@ **Parameters:** +- **pageId** (number) **(required)**: Targets a specific page by ID. - **device** (enum: "desktop", "mobile") _(optional)_: Device to [`emulate`](#emulate). - **mode** (enum: "navigation", "snapshot") _(optional)_: "navigation" reloads & audits. "snapshot" analyzes current state. - **outputDirPath** (string) _(optional)_: Directory for reports. If omitted, uses temporary files. @@ -393,10 +415,11 @@ ### `list_console_messages` -**Description:** List all console messages for the currently selected page since the last navigation. +**Description:** List all console messages for the target page since the last navigation. **Parameters:** +- **pageId** (number) **(required)**: Targets a specific page by ID. - **includePreservedMessages** (boolean) _(optional)_: Set to true to return the preserved messages over the last 3 navigations. - **includeStackTraces** (boolean) _(optional)_: Set to true to include the stack trace for each message when available. Increases the response size. - **pageIdx** (integer) _(optional)_: Page number to return (0-based). When omitted, returns the first page. @@ -412,6 +435,7 @@ **Parameters:** +- **pageId** (number) **(required)**: Targets a specific page by ID. - **filePath** (string) _(optional)_: The absolute path, or a path relative to the current working directory, to save the screenshot to instead of attaching it to the response. - **format** (enum: "png", "jpeg", "webp") _(optional)_: Type of format to save the screenshot as. Default is "png" - **fullPage** (boolean) _(optional)_: If set to true takes a screenshot of the full page instead of the currently visible viewport. Incompatible with uid. @@ -422,12 +446,13 @@ ### `take_snapshot` -**Description:** Take a text snapshot of the currently selected page based on the a11y tree. The snapshot lists page elements along with a unique +**Description:** Take a text snapshot of the target page based on the a11y tree. The snapshot lists page elements along with a unique identifier (uid). Always use the latest snapshot. Prefer taking a snapshot over taking a screenshot. The snapshot indicates the element selected in the DevTools Elements panel (if any). **Parameters:** +- **pageId** (number) **(required)**: Targets a specific page by ID. - **filePath** (string) _(optional)_: The absolute path, or a path relative to the current working directory, to save the snapshot to instead of attaching it to the response. - **verbose** (boolean) _(optional)_: Whether to include all possible information available in the full a11y tree. Default is false. @@ -435,19 +460,22 @@ in the DevTools Elements panel (if any). ### `screencast_start` -**Description:** Starts recording a screencast (video) of the selected page in specified format. (requires flag: --experimentalScreencast=true) +**Description:** Starts recording a screencast (video) of the target page in specified format. (requires flag: --experimentalScreencast=true) **Parameters:** +- **pageId** (number) **(required)**: Targets a specific page by ID. - **filePath** (string) _(optional)_: Output file path (.webm,.mp4 are supported). Uses mkdtemp to generate a unique path if not provided. --- ### `screencast_stop` -**Description:** Stops the active screencast recording on the selected page. (requires flag: --experimentalScreencast=true) +**Description:** Stops the active screencast recording on the target page. (requires flag: --experimentalScreencast=true) -**Parameters:** None +**Parameters:** + +- **pageId** (number) **(required)**: Targets a specific page by ID. --- @@ -455,11 +483,12 @@ in the DevTools Elements panel (if any). ### `take_heapsnapshot` -**Description:** Capture a heap snapshot of the currently selected page. Use to analyze the memory distribution of JavaScript objects and debug memory leaks. +**Description:** Capture a heap snapshot of the target page. Use to analyze the memory distribution of JavaScript objects and debug memory leaks. **Parameters:** - **filePath** (string) **(required)**: A path to a .heapsnapshot file to save the heapsnapshot to. +- **pageId** (number) **(required)**: Targets a specific page by ID. --- @@ -682,6 +711,7 @@ in the DevTools Elements panel (if any). **Parameters:** +- **pageId** (number) **(required)**: Targets a specific page by ID. - **toolName** (string) **(required)**: The name of the tool to execute - **params** (string) _(optional)_: The JSON-stringified parameters to pass to the tool @@ -697,7 +727,9 @@ following command to the script: This might be helpful when the third-party developer tools return non-serializable values or when composing third-party developer tools with additional functionality. (requires flag: --categoryExperimentalThirdParty=true) -**Parameters:** None +**Parameters:** + +- **pageId** (number) **(required)**: Targets a specific page by ID. --- @@ -711,6 +743,7 @@ third-party developer tools with additional functionality. (requires flag: --cat **Parameters:** +- **pageId** (number) **(required)**: Targets a specific page by ID. - **toolName** (string) **(required)**: The name of the WebMCP tool to execute - **input** (string) _(optional)_: The JSON-stringified parameters to pass to the WebMCP tool @@ -720,7 +753,9 @@ third-party developer tools with additional functionality. (requires flag: --cat **Description:** Lists all WebMCP tools the page exposes. (requires flag: --categoryExperimentalWebmcp=true) -**Parameters:** None +**Parameters:** + +- **pageId** (number) **(required)**: Targets a specific page by ID. --- diff --git a/scripts/eval_result.ts b/scripts/eval_result.ts index 9b734b29..14a46aee 100644 --- a/scripts/eval_result.ts +++ b/scripts/eval_result.ts @@ -22,7 +22,7 @@ export class Result { } get hasPageIdRouting(): boolean { - return this.serverArgs.includes('--experimental-page-id-routing'); + return !this.serverArgs.includes('--no-page-id-routing'); } get remainingCalls(): CapturedFunctionCall[] { @@ -102,6 +102,6 @@ export interface TestScenario { path: string; htmlContent: string; }; - /** Extra CLI flags passed to the MCP server (e.g. '--experimental-page-id-routing'). */ + /** Extra CLI flags passed to the MCP server (e.g. '--no-page-id-routing'). */ serverArgs?: string[]; } diff --git a/scripts/eval_scenarios/page_focus_keyboard_test.ts b/scripts/eval_scenarios/page_focus_keyboard_test.ts index f56c7c62..9812b868 100644 --- a/scripts/eval_scenarios/page_focus_keyboard_test.ts +++ b/scripts/eval_scenarios/page_focus_keyboard_test.ts @@ -9,7 +9,6 @@ import assert from 'node:assert'; import type {TestScenario} from '../eval_gemini.ts'; export const scenario: TestScenario = { - serverArgs: ['--experimental-page-id-routing'], prompt: `Open two pages in the same isolated context "session": - Page 1 at data:text/html, - Page 2 at data:text/html,

Other

diff --git a/scripts/eval_scenarios/page_id_routing_concurrent_form_test.ts b/scripts/eval_scenarios/page_id_routing_concurrent_form_test.ts new file mode 100644 index 00000000..2443f460 --- /dev/null +++ b/scripts/eval_scenarios/page_id_routing_concurrent_form_test.ts @@ -0,0 +1,70 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'node:assert'; + +import type {TestScenario} from '../eval_gemini.ts'; + +const PAGE_A_URL = + 'data:text/html,
'; +const PAGE_B_URL = + 'data:text/html,
'; + +export const scenario: TestScenario = { + prompt: `Open two new pages in isolated contexts: +- Page A (isolatedContext "login_ctx") at ${PAGE_A_URL} +- Page B (isolatedContext "feedback_ctx") at ${PAGE_B_URL} + +Take a snapshot of both pages. Then, perform the following actions individually in the exact order specified below: +1. Fill "admin" into the Username input on Page A. +2. Fill "user@example.com" into the Email input on Page B. +3. Fill "secret123" into the Password input on Page A. +4. Fill "Great tools!" into the Comments textarea on Page B. + +Finally, submit both forms by clicking the submit buttons on Page A and Page B.`, + maxTurns: 15, + expectations: result => { + const newPages = result.calls.filter(c => c.name === 'new_page'); + assert.strictEqual(newPages.length, 2, 'Should open 2 pages'); + const snapshots = result.calls.filter(c => c.name === 'take_snapshot'); + assert.ok(snapshots.length >= 2, 'Should snapshot both pages'); + + const fills = result.calls.filter(c => c.name === 'fill'); + assert.strictEqual( + fills.length, + 4, + 'Should fill 4 inputs across the forms', + ); + + // Verify that each fill targeted the correct pageId based on its value/element + for (const fill of fills) { + const value = String(fill.args['value'] || ''); + if (value === 'admin' || value === 'secret123') { + assert.strictEqual( + fill.args['pageId'], + 2, + `Filling '${value}' should target login page (pageId 2)`, + ); + } else if (value === 'user@example.com' || value === 'Great tools!') { + assert.strictEqual( + fill.args['pageId'], + 3, + `Filling '${value}' should target feedback page (pageId 3)`, + ); + } else { + assert.fail(`Unexpected fill value: ${value}`); + } + } + + // Verify no select_page calls were made between the interleaved actions + const selects = result.calls.filter(c => c.name === 'select_page'); + assert.strictEqual( + selects.length, + 0, + 'Should not use select_page when pageId routing is active', + ); + }, +}; diff --git a/scripts/eval_scenarios/page_id_routing_cross_page_state_test.ts b/scripts/eval_scenarios/page_id_routing_cross_page_state_test.ts new file mode 100644 index 00000000..9f0fda54 --- /dev/null +++ b/scripts/eval_scenarios/page_id_routing_cross_page_state_test.ts @@ -0,0 +1,93 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'node:assert'; + +import type {TestScenario} from '../eval_gemini.ts'; + +const PAGE_COUNTER = + 'data:text/html,

Counter Page

0
'; +const PAGE_INPUT = + 'data:text/html,

Input Page

'; + +export const scenario: TestScenario = { + prompt: `Open two new pages: +- Page A at ${PAGE_COUNTER} +- Page B at ${PAGE_INPUT} + +Take snapshot of both pages and then perform the following steps: +1. Click the "Increment" button on Page A twice. +2. Take a snapshot of Page A to read the updated counter value. +3. On Page B, fill that exact counter value into the input field, and click the "Submit" button.`, + maxTurns: 12, + expectations: result => { + const newPages = result.calls.filter(c => c.name === 'new_page'); + assert.strictEqual(newPages.length, 2, 'Should open 2 pages'); + + const clicks = result.calls.filter(c => c.name === 'click'); + assert.ok( + clicks.length >= 3, + 'Should click increment twice and then submit', + ); + + // First click and second click should target the increment button on Page A + const counterClicks = clicks.filter(c => c.args['pageId'] === 2); + assert.strictEqual( + counterClicks.length, + 2, + 'Should click increment button on Page A exactly twice', + ); + + // There should be a snapshot of Page A to read the value + const snapshots = result.calls.filter(c => c.name === 'take_snapshot'); + const counterSnapshot = snapshots.find(s => s.args['pageId'] === 2); + assert.ok( + counterSnapshot, + 'Should snapshot Page A to read the counter value', + ); + + // The fill and final click should target Page B + const fills = result.calls.filter( + c => c.name === 'fill' || c.name === 'fill_form', + ); + assert.strictEqual( + fills.length, + 1, + 'Should fill the input field on Page B', + ); + assert.strictEqual(fills[0].args['pageId'], 3, 'Fill should target Page B'); + + let filledValue = ''; + if (fills[0].name === 'fill_form') { + const elements = fills[0].args['elements']; + assert.ok(Array.isArray(elements), 'elements should be an array'); + filledValue = elements[0]['value']; + } else if (fills[0].name === 'fill') { + filledValue = String(fills[0].args['value']); + } + + assert.strictEqual( + filledValue, + '2', + 'Should fill the value "2" (since we incremented twice)', + ); + + const finalClick = clicks[clicks.length - 1]; + assert.strictEqual( + finalClick.args['pageId'], + 3, + 'Submit click should target Page B', + ); + + // Verify no select_page calls were made between the interleaved actions + const selects = result.calls.filter(c => c.name === 'select_page'); + assert.strictEqual( + selects.length, + 0, + 'Should not use select_page when pageId routing is active', + ); + }, +}; diff --git a/scripts/eval_scenarios/page_id_routing_test.ts b/scripts/eval_scenarios/page_id_routing_test.ts index a65c9e83..864c6bf6 100644 --- a/scripts/eval_scenarios/page_id_routing_test.ts +++ b/scripts/eval_scenarios/page_id_routing_test.ts @@ -9,7 +9,6 @@ import assert from 'node:assert'; import type {TestScenario} from '../eval_gemini.ts'; export const scenario: TestScenario = { - serverArgs: ['--experimental-page-id-routing'], prompt: `Open two new pages in isolated contexts: - Page A (isolatedContext "contextA") at data:text/html, - Page B (isolatedContext "contextB") at data:text/html, diff --git a/scripts/generate-docs.ts b/scripts/generate-docs.ts index 949be885..3c7d8a4c 100644 --- a/scripts/generate-docs.ts +++ b/scripts/generate-docs.ts @@ -18,6 +18,7 @@ import { OFF_BY_DEFAULT_CATEGORIES, labels, } from '../build/src/tools/categories.js'; +import {pageIdSchema} from '../build/src/tools/ToolDefinition.js'; import {createTools} from '../build/src/tools/tools.js'; const OUTPUT_PATH = './docs/tool-reference.md'; @@ -434,7 +435,7 @@ async function generateReference( } // eslint-disable-next-line @typescript-eslint/no-explicit-any -function getToolsAndCategories(tools: any) { +function getToolsAndCategories(tools: any, slim = false) { // Convert ToolDefinitions to ToolWithAnnotations const toolsWithAnnotations: ToolWithAnnotations[] = tools .filter(tool => { @@ -455,8 +456,12 @@ function getToolsAndCategories(tools: any) { const properties: Record = {}; const required: string[] = []; + const toolSchema = { + ...tool.schema, + ...(tool.pageScoped && !slim ? pageIdSchema : {}), + }; for (const [key, schema] of Object.entries( - tool.schema as unknown as Record, + toolSchema as unknown as Record, )) { const info = getZodTypeInfo(schema); properties[key] = info; @@ -520,7 +525,9 @@ async function generateToolDocumentation(): Promise { { const {toolsWithAnnotations, categories, sortedCategories} = - getToolsAndCategories(createTools({slim: false} as ParsedArguments)); + getToolsAndCategories( + createTools({slim: false, pageIdRouting: true} as ParsedArguments), + ); await generateReference( 'Chrome DevTools MCP Tool Reference', OUTPUT_PATH, @@ -536,7 +543,10 @@ async function generateToolDocumentation(): Promise { { const {toolsWithAnnotations, categories, sortedCategories} = - getToolsAndCategories(createTools({slim: true} as ParsedArguments)); + getToolsAndCategories( + createTools({slim: true} as ParsedArguments), + true, + ); await generateReference( 'Chrome DevTools MCP Slim Tool Reference', SLIM_OUTPUT_PATH, diff --git a/skills/a11y-debugging/SKILL.md b/skills/a11y-debugging/SKILL.md index 183a78f2..324c81c2 100644 --- a/skills/a11y-debugging/SKILL.md +++ b/skills/a11y-debugging/SKILL.md @@ -5,6 +5,8 @@ description: Uses Chrome DevTools MCP for accessibility (a11y) debugging and aud ## Core Concepts +**Page Targeting**: Page-scoped tools (`take_snapshot`, `list_console_messages`, `evaluate_script`, `press_key`, `take_screenshot`, `lighthouse_audit`, etc.) require a `pageId` parameter. Retrieve available page IDs using `list_pages` or from `new_page`. + **Accessibility Tree vs DOM**: Visually hiding an element (e.g., `CSS opacity: 0`) behaves differently for screen readers than `display: none` or `aria-hidden="true"`. The `take_snapshot` tool returns the accessibility tree of the page, which represents what assistive technologies "see", making it the most reliable source of truth for semantic structure. **Reading web.dev documentation**: If you need to research specific accessibility guidelines (like `https://web.dev/articles/accessible-tap-targets`), you can append `.md.txt` to the URL (e.g., `https://web.dev/articles/accessible-tap-targets.md.txt`) to fetch the clean, raw markdown version. This is much easier to read! diff --git a/skills/chrome-devtools-cli/SKILL.md b/skills/chrome-devtools-cli/SKILL.md index 6b7749a3..2f28ddf2 100644 --- a/skills/chrome-devtools-cli/SKILL.md +++ b/skills/chrome-devtools-cli/SKILL.md @@ -11,9 +11,9 @@ _Note: If this is your very first time using the CLI, see [references/installati ## AI Workflow -1. **Execute**: Run tools directly (e.g., `chrome-devtools list_pages`). The background server starts implicitly; **do not** run `start`/`status`/`stop` before each use. -2. **Inspect**: Use `take_snapshot` to get an element ``. -3. **Act**: Use `click`, `fill`, etc. State persists across commands. +1. **Execute**: Run tools directly. If you don't know the target page's ID, run `chrome-devtools list_pages` to find it. The background server starts implicitly; **do not** run `start`/`status`/`stop` before each use. +2. **Inspect**: Use `chrome-devtools take_snapshot ` to get an element ``. +3. **Act**: Use `chrome-devtools click `, `chrome-devtools fill `, etc. State persists across commands. Snapshot example: @@ -44,23 +44,23 @@ chrome-devtools [arguments] [flags] ## Input Automation ( from snapshot) ```bash -chrome-devtools take_snapshot # Take a text snapshot of the page to get UIDs for elements -chrome-devtools click "id" # Clicks on the provided element -chrome-devtools click "id" --dblClick true --includeSnapshot true # Double clicks and returns a snapshot -chrome-devtools drag "src" "dst" # Drag an element onto another element -chrome-devtools drag "src" "dst" --includeSnapshot true # Drag an element and return a snapshot -chrome-devtools fill "id" "text" # Type text into an input, textarea, or select an option -chrome-devtools fill "id" "text" --includeSnapshot true # Fill an element and return a snapshot -chrome-devtools handle_dialog accept # Handle a browser dialog (accept/dismiss) -chrome-devtools handle_dialog dismiss --promptText "hi" # Dismiss a dialog with prompt text -chrome-devtools hover "id" # Hover over the provided element -chrome-devtools hover "id" --includeSnapshot true # Hover over an element and return a snapshot -chrome-devtools press_key "Enter" # Press a key or key combination ("Control+A", "Escape") -chrome-devtools press_key "Control+A" --includeSnapshot true # Press a key and return a snapshot -chrome-devtools type_text "hello" # Type text using keyboard into a focused input -chrome-devtools type_text "hello" --submitKey "Enter" # Type text and press a submit key -chrome-devtools upload_file "id" "file.txt" # Upload a file through a provided element -chrome-devtools upload_file "id" "file.txt" --includeSnapshot true # Upload a file and return a snapshot +chrome-devtools take_snapshot 1 # Take a text snapshot of the page to get UIDs for elements +chrome-devtools click 1 "id" # Clicks on the provided element +chrome-devtools click 1 "id" --dblClick true --includeSnapshot true # Double clicks and returns a snapshot +chrome-devtools drag 1 "src" "dst" # Drag an element onto another element +chrome-devtools drag 1 "src" "dst" --includeSnapshot true # Drag an element and return a snapshot +chrome-devtools fill 1 "id" "text" # Type text into an input, textarea, or select an option +chrome-devtools fill 1 "id" "text" --includeSnapshot true # Fill an element and return a snapshot +chrome-devtools handle_dialog 1 accept # Handle a browser dialog (accept/dismiss) +chrome-devtools handle_dialog 1 dismiss --promptText "hi" # Dismiss a dialog with prompt text +chrome-devtools hover 1 "id" # Hover over the provided element +chrome-devtools hover 1 "id" --includeSnapshot true # Hover over an element and return a snapshot +chrome-devtools press_key 1 "Enter" # Press a key or key combination ("Control+A", "Escape") +chrome-devtools press_key 1 "Control+A" --includeSnapshot true # Press a key and return a snapshot +chrome-devtools type_text 1 "hello" # Type text using keyboard into a focused input +chrome-devtools type_text 1 "hello" --submitKey "Enter" # Type text and press a submit key +chrome-devtools upload_file 1 "id" "file.txt" # Upload a file through a provided element +chrome-devtools upload_file 1 "id" "file.txt" --includeSnapshot true # Upload a file and return a snapshot ``` ## Navigation @@ -68,11 +68,11 @@ chrome-devtools upload_file "id" "file.txt" --includeSnapshot true # Upload a fi ```bash chrome-devtools close_page 1 # Closes the page by its index chrome-devtools list_pages # Get a list of pages open in the browser -chrome-devtools navigate_page --url "https://example.com" # Navigates the currently selected page to a URL -chrome-devtools navigate_page --type "reload" --ignoreCache true # Reload page ignoring cache -chrome-devtools navigate_page --url "https://example.com" --timeout 5000 # Navigate with a timeout -chrome-devtools navigate_page --handleBeforeUnload "accept" # Handle before unload dialog -chrome-devtools navigate_page --type "back" --initScript "foo()" # Navigate back and run an init script +chrome-devtools navigate_page 1 --url "https://example.com" # Navigates the currently selected page to a URL +chrome-devtools navigate_page 1 --type "reload" --ignoreCache true # Reload page ignoring cache +chrome-devtools navigate_page 1 --url "https://example.com" --timeout 5000 # Navigate with a timeout +chrome-devtools navigate_page 1 --handleBeforeUnload "accept" # Handle before unload dialog +chrome-devtools navigate_page 1 --type "back" --initScript "foo()" # Navigate back and run an init script chrome-devtools new_page "https://example.com" # Creates a new page chrome-devtools new_page "https://example.com" --background true --timeout 5000 # Create new page in background chrome-devtools new_page "https://example.com" --isolatedContext "ctx" # Create new page with isolated context @@ -83,27 +83,27 @@ chrome-devtools select_page 1 --bringToFront true # Select a page and bring it t ## Emulation ```bash -chrome-devtools emulate --networkConditions "Offline" # Emulate network conditions -chrome-devtools emulate --cpuThrottlingRate 4 --geolocation "0x0" # Emulate CPU throttling and geolocation -chrome-devtools emulate --colorScheme "dark" --viewport "1920x1080" # Emulate color scheme and viewport -chrome-devtools emulate --userAgent "Mozilla/5.0..." # Emulate user agent -chrome-devtools resize_page 1920 1080 # Resizes the selected page's window +chrome-devtools emulate 1 --networkConditions "Offline" # Emulate network conditions +chrome-devtools emulate 1 --cpuThrottlingRate 4 --geolocation "0x0" # Emulate CPU throttling and geolocation +chrome-devtools emulate 1 --colorScheme "dark" --viewport "1920x1080" # Emulate color scheme and viewport +chrome-devtools emulate 1 --userAgent "Mozilla/5.0..." # Emulate user agent +chrome-devtools resize_page 1 1920 1080 # Resizes the selected page's window ``` ## Performance ```bash -chrome-devtools performance_analyze_insight "1" "LCPBreakdown" # Get more details on a specific Performance Insight -chrome-devtools performance_start_trace true false # Starts a performance trace recording (reload, autoStop) -chrome-devtools performance_start_trace true true --filePath "t.json.gz" # Start trace and save to a file -chrome-devtools performance_stop_trace # Stops the active performance trace -chrome-devtools performance_stop_trace --filePath "t.json.gz" # Stop trace and save to a file +chrome-devtools performance_analyze_insight 1 "1" "LCPBreakdown" # Get more details on a specific Performance Insight (pageId, insightSetId, insightName) +chrome-devtools performance_start_trace 1 --reload true --autoStop false # Starts a performance trace recording (reload, autoStop) +chrome-devtools performance_start_trace 1 --reload true --autoStop true --filePath "t.json.gz" # Start trace and save to a file +chrome-devtools performance_stop_trace 1 # Stops the active performance trace +chrome-devtools performance_stop_trace 1 --filePath "t.json.gz" # Stop trace and save to a file ``` ## Memory ```bash -chrome-devtools take_heapsnapshot "./snap.heapsnapshot" # Capture a memory heap snapshot +chrome-devtools take_heapsnapshot 1 "./snap.heapsnapshot" # Capture a memory heap snapshot ``` ### Memory Debugging (requires `--memoryDebugging=true`) @@ -125,33 +125,33 @@ chrome-devtools close_heapsnapshot "./snap.heapsnapshot" # Free memory from load ## Network ```bash -chrome-devtools get_network_request # Get the currently selected network request -chrome-devtools get_network_request --reqid 1 --requestFilePath "req.md" # Get request by id and save to file -chrome-devtools get_network_request --responseFilePath "res.md" # Save response body to file -chrome-devtools list_network_requests # List all network requests -chrome-devtools list_network_requests --pageSize 50 --pageIdx 0 # List network requests with pagination -chrome-devtools list_network_requests --resourceTypes Fetch # Filter requests by resource type -chrome-devtools list_network_requests --includePreservedRequests true # Include preserved requests +chrome-devtools get_network_request 1 # Get the currently selected network request for page 1 +chrome-devtools get_network_request 1 --reqid 1 --requestFilePath "req.md" # Get request by id and save to file +chrome-devtools get_network_request 1 --responseFilePath "res.md" # Save response body to file +chrome-devtools list_network_requests 1 # List all network requests for page 1 +chrome-devtools list_network_requests 1 --pageSize 50 --pageIdx 0 # List network requests with pagination +chrome-devtools list_network_requests 1 --resourceTypes Fetch # Filter requests by resource type +chrome-devtools list_network_requests 1 --includePreservedRequests true # Include preserved requests ``` ## Debugging & Inspection ```bash -chrome-devtools evaluate_script "() => document.title" # Evaluate a JavaScript function on the page -chrome-devtools evaluate_script "(a) => a.innerText" --args 1_4 # Evaluate JS with UID arguments -chrome-devtools get_console_message 1 # Gets a console message by its ID -chrome-devtools lighthouse_audit --mode "navigation" # Run Lighthouse audit for navigation -chrome-devtools lighthouse_audit --mode "snapshot" --device "mobile" # Run Lighthouse audit for a snapshot on mobile -chrome-devtools lighthouse_audit --outputDirPath ./out # Run Lighthouse audit and save reports -chrome-devtools list_console_messages # List all console messages -chrome-devtools list_console_messages --pageSize 20 --pageIdx 1 # List console messages with pagination -chrome-devtools list_console_messages --types error --types info # Filter console messages by type -chrome-devtools list_console_messages --includePreservedMessages true # Include preserved messages -chrome-devtools take_screenshot # Take a screenshot of the page viewport -chrome-devtools take_screenshot --fullPage true --format "jpeg" --quality 80 # Take a full page screenshot as JPEG with quality -chrome-devtools take_screenshot --uid "id" --filePath "s.png" # Take a screenshot of an element -chrome-devtools take_snapshot # Take a text snapshot of the page from the a11y tree -chrome-devtools take_snapshot --verbose true --filePath "s.txt" # Take a verbose snapshot and save to file +chrome-devtools evaluate_script "() => document.title" --pageId 1 # Evaluate a JavaScript function on page 1 +chrome-devtools evaluate_script "(a) => a.innerText" --pageId 1 --args 1_4 # Evaluate JS with UID arguments on page 1 +chrome-devtools get_console_message 1 1 # Gets a console message by its ID +chrome-devtools lighthouse_audit 1 --mode "navigation" # Run Lighthouse audit for navigation +chrome-devtools lighthouse_audit 1 --mode "snapshot" --device "mobile" # Run Lighthouse audit for a snapshot on mobile +chrome-devtools lighthouse_audit 1 --outputDirPath ./out # Run Lighthouse audit and save reports +chrome-devtools list_console_messages 1 # List all console messages +chrome-devtools list_console_messages 1 --pageSize 20 --pageIdx 1 # List console messages with pagination +chrome-devtools list_console_messages 1 --types error --types info # Filter console messages by type +chrome-devtools list_console_messages 1 --includePreservedMessages true # Include preserved messages +chrome-devtools take_screenshot 1 # Take a screenshot of the page viewport +chrome-devtools take_screenshot 1 --fullPage true --format "jpeg" --quality 80 # Take a full page screenshot as JPEG with quality +chrome-devtools take_screenshot 1 --uid "id" --filePath "s.png" # Take a screenshot of an element +chrome-devtools take_snapshot 1 # Take a text snapshot of the page from the a11y tree +chrome-devtools take_snapshot 1 --verbose true --filePath "s.txt" # Take a verbose snapshot and save to file ``` ## Extensions @@ -178,13 +178,13 @@ chrome-devtools uninstall_pwa "https://example.com/" # Uninstall PWA and close w Experimental tools are disabled by default. Enable them with the corresponding flag during `start`. ```bash -chrome-devtools click_at 100 200 # Clicks at the provided coordinates (requires --experimentalVision=true) -chrome-devtools screencast_start --filePath "screen.mp4" # Starts a screencast recording (requires --experimentalScreencast=true and ffmpeg) -chrome-devtools screencast_stop # Stops the active screencast -chrome-devtools list_webmcp_tools # List all WebMCP tools (requires --categoryExperimentalWebmcp=true) -chrome-devtools execute_webmcp_tool "tool_name" '{"arg":"val"}' # Execute a WebMCP tool (requires --categoryExperimentalWebmcp=true) -chrome-devtools list_3p_developer_tools # List third-party developer tools (requires --categoryExperimentalThirdParty=true) -chrome-devtools execute_3p_developer_tool "tool_name" '{"arg":"val"}' # Execute third-party developer tool (requires --categoryExperimentalThirdParty=true) +chrome-devtools click_at 1 100 200 # Clicks at the provided coordinates on page 1 (requires --experimentalVision=true) +chrome-devtools screencast_start 1 --filePath "screen.mp4" # Starts a screencast recording on page 1 (requires --experimentalScreencast=true and ffmpeg) +chrome-devtools screencast_stop 1 # Stops the active screencast on page 1 +chrome-devtools list_webmcp_tools 1 # List all WebMCP tools on page 1 (requires --categoryExperimentalWebmcp=true) +chrome-devtools execute_webmcp_tool 1 "tool_name" --input '{"arg":"val"}' # Execute a WebMCP tool on page 1 (requires --categoryExperimentalWebmcp=true) +chrome-devtools list_3p_developer_tools 1 # List third-party developer tools on page 1 (requires --categoryExperimentalThirdParty=true) +chrome-devtools execute_3p_developer_tool 1 "tool_name" --params '{"arg":"val"}' # Execute third-party developer tool on page 1 (requires --categoryExperimentalThirdParty=true) ``` ## Service Management diff --git a/skills/chrome-devtools/SKILL.md b/skills/chrome-devtools/SKILL.md index d62f66ec..d74727c3 100644 --- a/skills/chrome-devtools/SKILL.md +++ b/skills/chrome-devtools/SKILL.md @@ -11,7 +11,8 @@ Addional tooling can be enabled by providing the following flags: - For extension tooling, use the `--categoryExtensions` flag. - For memory tooling, use the `--memoryDebugging` flag. -**Page selection**: Tools operate on the currently selected page. Use `list_pages` to see available pages, then `select_page` to switch context. +**Page targeting**: Page-scoped tools require a `pageId` parameter to target a specific page. Use `list_pages` to see available pages and their IDs (e.g. `pageId: 1`), or use the ID returned when creating a page with `new_page`. +Note: For `evaluate_script`, `pageId` is required when targeting pages. However, when `--categoryExtensions` is enabled, `pageId` is optional so you can pass `serviceWorkerId` instead to evaluate inside an extension background service worker. **Element interaction**: Use `take_snapshot` to get page structure with element `uid`s. Each element has a unique `uid` for interaction. If an element isn't found, take a fresh snapshot - the element may have been removed or the page changed. ## Workflow Patterns @@ -20,8 +21,8 @@ Addional tooling can be enabled by providing the following flags: 1. Navigate: `navigate_page` or `new_page` 2. Wait: `wait_for` to ensure content is loaded if you know what you look for. -3. Snapshot: `take_snapshot` to understand page structure -4. Interact: Use element `uid`s from snapshot for `click`, `fill`, etc. +3. Snapshot: `take_snapshot` with `pageId` to understand page structure +4. Interact: Use element `uid`s from snapshot for `click`, `fill`, etc., passing the corresponding `pageId`. ### Efficient data retrieval @@ -59,7 +60,7 @@ You can send multiple tool calls in parallel, but maintain correct order: naviga 1. **Install**: Use `install_extension` with the path to the unpacked extension. 2. **Identify**: Get the extension ID from the response or by calling `list_extensions`. 3. **Trigger Action**: Use `trigger_extension_action` to open the popup or side panel if applicable. -4. **Verify Service Worker**: Use `evaluate_script` with `serviceWorkerId` to check extension state or trigger background actions. +4. **Verify Service Worker**: Use `evaluate_script` with `serviceWorkerId` (omitting `pageId` and `args`) to check extension state or trigger background actions. When evaluating in a page, pass `pageId` (omitting `serviceWorkerId`). 5. **Verify Page Behavior**: Navigate to a page where the extension operates and use `take_snapshot` to check if content scripts injected elements or modified the page correctly. ## Troubleshooting diff --git a/skills/debug-optimize-lcp/SKILL.md b/skills/debug-optimize-lcp/SKILL.md index ebd0aea3..4067d908 100644 --- a/skills/debug-optimize-lcp/SKILL.md +++ b/skills/debug-optimize-lcp/SKILL.md @@ -36,8 +36,8 @@ Follow these steps in order. Each step builds on the previous one. Navigate to the page, then record a trace with reload to capture the full page load including LCP: -1. `navigate_page` to the target URL. -2. `performance_start_trace` with `reload: true` and `autoStop: true`. +1. `navigate_page` with `pageId` to the target URL. +2. `performance_start_trace` with `pageId`, `reload: true` and `autoStop: true`. The trace results will include LCP timing and available insight sets. Note the insight set IDs from the output — you'll need them in the next step. @@ -50,11 +50,11 @@ Use `performance_analyze_insight` to drill into LCP-specific insights. Look for - **RenderBlocking** — Resources blocking the LCP element from rendering. - **LCPDiscovery** — Whether the LCP resource was discoverable early. -Call `performance_analyze_insight` with the insight set ID and the insight name from the trace results. +Call `performance_analyze_insight` with `pageId`, the insight set ID, and the insight name from the trace results. ### Step 3: Identify the LCP Element -Use `evaluate_script` with the **"Identify LCP Element" snippet** found in [references/lcp-snippets.md](references/lcp-snippets.md) to reveal the LCP element's tag, resource URL, and raw timing data. +Use `evaluate_script` (with `pageId`) and the **"Identify LCP Element" snippet** found in [references/lcp-snippets.md](references/lcp-snippets.md) to reveal the LCP element's tag, resource URL, and raw timing data. The `url` field tells you what resource to look for in the network waterfall. If `url` is empty, the LCP element is text-based (no resource to load). @@ -62,8 +62,8 @@ The `url` field tells you what resource to look for in the network waterfall. If Use `list_network_requests` to see when the LCP resource loaded relative to other resources: -- Call `list_network_requests` filtered by `resourceTypes: ["Image", "Font"]` (adjust based on Step 3). -- Then use `get_network_request` with the LCP resource's request ID for full details. +- Call `list_network_requests` with `pageId` filtered by `resourceTypes: ["Image", "Font"]` (adjust based on Step 3). +- Then use `get_network_request` with `pageId` and the LCP resource's request ID for full details. **Key Checks:** @@ -72,7 +72,7 @@ Use `list_network_requests` to see when the LCP resource loaded relative to othe ### Step 5: Inspect HTML for Common Issues -Use `evaluate_script` with the **"Audit Common Issues" snippet** found in [references/lcp-snippets.md](references/lcp-snippets.md) to check for lazy-loaded images in the viewport, missing fetchpriority, and render-blocking scripts. +Use `evaluate_script` (with `pageId`) and the **"Audit Common Issues" snippet** found in [references/lcp-snippets.md](references/lcp-snippets.md) to check for lazy-loaded images in the viewport, missing fetchpriority, and render-blocking scripts. ## Optimization Strategies @@ -115,7 +115,7 @@ The HTML document itself takes too long to arrive. ## Verifying Fixes & Emulation -- **Verification**: Re-run the trace (`performance_start_trace` with `reload: true`) and compare the new subpart breakdown. The bottleneck should shrink. +- **Verification**: Re-run the trace (`performance_start_trace` with `pageId` and `reload: true`) and compare the new subpart breakdown. The bottleneck should shrink. - **Emulation**: Lab measurements differ from real-world experience. Use `emulate` to test under constraints: - - `emulate` with `networkConditions: "Fast 3G"` and `cpuThrottlingRate: 4`. + - `emulate` with `pageId`, `networkConditions: "Fast 3G"` and `cpuThrottlingRate: 4`. - This surfaces issues visible only on slower connections/devices. diff --git a/skills/memory-leak-debugging/SKILL.md b/skills/memory-leak-debugging/SKILL.md index 8e77df8b..01a8a28b 100644 --- a/skills/memory-leak-debugging/SKILL.md +++ b/skills/memory-leak-debugging/SKILL.md @@ -20,10 +20,10 @@ This skill provides expert guidance and workflows for finding, diagnosing, and f When investigating a frontend web application memory leak, utilize the `chrome-devtools-mcp` tools to interact with the application and take snapshots. -- Use tools like `click`, `navigate_page`, `fill`, etc., to manipulate the page into the desired state. +- Use page-scoped tools like `click`, `navigate_page`, `fill`, etc. (specifying `pageId`) to manipulate the page into the desired state. - Revert the page back to the original state after interactions to see if memory is released. - Repeat the same user interactions 10 times to amplify the leak. -- Use `take_heapsnapshot` to save `.heapsnapshot` files to disk at baseline, target (after actions), and final (after reverting actions) states. +- Use `take_heapsnapshot` (with `pageId`) to save `.heapsnapshot` files to disk at baseline, target (after actions), and final (after reverting actions) states. ### 2. Comparing Snapshots diff --git a/skills/troubleshooting/SKILL.md b/skills/troubleshooting/SKILL.md index 6793f996..bf5b98c3 100644 --- a/skills/troubleshooting/SKILL.md +++ b/skills/troubleshooting/SKILL.md @@ -60,6 +60,7 @@ Identify other error messages from the failed tool call or the MCP initializatio - `Target closed` - "Tool not found" (check if they are using `--slim` which only enables navigation and screenshot tools). +- Missing `pageId`: Page-scoped tools require a `pageId` argument. Call `list_pages` to find active page IDs. - `ProtocolError: Network.enable timed out` or `The socket connection was closed unexpectedly` - `Error [ERR_MODULE_NOT_FOUND]: Cannot find module` - Any sandboxing or host validation errors. diff --git a/src/ToolHandler.ts b/src/ToolHandler.ts index d87e5e84..a5420997 100644 --- a/src/ToolHandler.ts +++ b/src/ToolHandler.ts @@ -242,7 +242,7 @@ export class ToolHandler { this.inputSchema = 'pageScoped' in tool && tool.pageScoped && - serverArgs.experimentalPageIdRouting && + serverArgs.pageIdRouting && !serverArgs.slim ? {...pageIdSchema, ...tool.schema} : tool.schema; @@ -311,7 +311,7 @@ export class ToolHandler { const pageId = typeof params.pageId === 'number' ? params.pageId : undefined; page = - this.serverArgs.experimentalPageIdRouting && + this.serverArgs.pageIdRouting && pageId !== undefined && !this.serverArgs.slim ? context.getPageById(pageId) diff --git a/src/bin/chrome-devtools.ts b/src/bin/chrome-devtools.ts index 733b0419..07191831 100644 --- a/src/bin/chrome-devtools.ts +++ b/src/bin/chrome-devtools.ts @@ -60,7 +60,6 @@ function getCliOptions() { // Change the defaults for the CLI. delete options.experimentalStructuredContent; delete options.experimentalInteropTools; - delete options.experimentalPageIdRouting; return options; } @@ -103,13 +102,11 @@ const y = yargs(hideBin(process.argv)) '1. Required parameters MUST be passed as positional arguments (without flags).', ); console.error( - ' - INCORRECT: chrome-devtools evaluate_script --expression "() => document.title"', + ' - INCORRECT: chrome-devtools click --pageId 1 --uid "1_2"', ); + console.error(' - CORRECT: chrome-devtools click 1 "1_2"'); console.error( - ' - CORRECT: chrome-devtools evaluate_script "() => document.title"', - ); - console.error( - '2. Optional parameters are passed as double-dash options/flags (e.g. --pageId 1).', + '2. Optional parameters are passed as double-dash options/flags (e.g. --dblClick true).', ); console.error( '3. Make sure to escape quotes properly for your shell environment.', diff --git a/src/config/cli-options.ts b/src/config/cli-options.ts index cf97b0aa..d2a659c1 100644 --- a/src/config/cli-options.ts +++ b/src/config/cli-options.ts @@ -31,6 +31,12 @@ export const commands: Commands = { description: 'Clicks on the provided element', category: 'Input automation', args: { + pageId: { + name: 'pageId', + type: 'number', + description: 'Targets a specific page by ID.', + required: true, + }, uid: { name: 'uid', type: 'string', @@ -58,6 +64,12 @@ export const commands: Commands = { 'Clicks at the provided coordinates (requires flag: --experimentalVision=true)', category: 'Input automation', args: { + pageId: { + name: 'pageId', + type: 'number', + description: 'Targets a specific page by ID.', + required: true, + }, x: { name: 'x', type: 'number', @@ -144,6 +156,12 @@ export const commands: Commands = { description: 'Drag an element onto another element', category: 'Input automation', args: { + pageId: { + name: 'pageId', + type: 'number', + description: 'Targets a specific page by ID.', + required: true, + }, from_uid: { name: 'from_uid', type: 'string', @@ -166,9 +184,15 @@ export const commands: Commands = { }, }, emulate: { - description: 'Emulates various features on the selected page.', + description: 'Emulates various features on the target page.', category: 'Emulation', args: { + pageId: { + name: 'pageId', + type: 'number', + description: 'Targets a specific page by ID.', + required: true, + }, networkConditions: { name: 'networkConditions', type: 'string', @@ -223,14 +247,21 @@ export const commands: Commands = { }, evaluate_script: { description: - 'Evaluate a JavaScript function inside the currently selected page or service worker. Returns the response as JSON, so returned values have to be JSON-serializable.', + 'Evaluate a JavaScript function inside the target page or service worker. Returns the response as JSON, so returned values have to be JSON-serializable.', category: 'Debugging', args: { + pageId: { + name: 'pageId', + type: 'number', + description: + 'Targets a specific page by ID. Required when not evaluating in a service worker.', + required: false, + }, function: { name: 'function', type: 'string', description: - 'A JavaScript function declaration to be executed by the tool in the currently selected page.\nExample without arguments: `() => document.title` or `async () => await fetch("example.com")`.\nExample with arguments: `(el) => el.innerText`\n', + 'A JavaScript function declaration to be executed by the tool in the target page.\nExample without arguments: `() => document.title` or `async () => await fetch("example.com")`.\nExample with arguments: `(el) => el.innerText`\n', required: true, }, args: { @@ -274,6 +305,12 @@ export const commands: Commands = { 'Executes a tool exposed by the page. (requires flag: --categoryExperimentalThirdParty=true)', category: 'Third-party', args: { + pageId: { + name: 'pageId', + type: 'number', + description: 'Targets a specific page by ID.', + required: true, + }, toolName: { name: 'toolName', type: 'string', @@ -293,6 +330,12 @@ export const commands: Commands = { 'Executes a WebMCP tool exposed by the page. (requires flag: --categoryExperimentalWebmcp=true)', category: 'WebMCP', args: { + pageId: { + name: 'pageId', + type: 'number', + description: 'Targets a specific page by ID.', + required: true, + }, toolName: { name: 'toolName', type: 'string', @@ -313,6 +356,12 @@ export const commands: Commands = { 'Type text into an input, text area or select an option from a