diff --git a/docs/tool-reference.md b/docs/tool-reference.md index 50b4c02c5..f56afdca4 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -1,6 +1,6 @@ -# Chrome DevTools MCP Tool Reference (~6940 cl100k_base tokens) +# Chrome DevTools MCP Tool Reference (~6975 cl100k_base tokens) - **[Input automation](#input-automation)** (9 tools) - [`click`](#click) @@ -393,6 +393,7 @@ in the DevTools Elements panel (if any). **Parameters:** +- **diff** (boolean) _(optional)_: Return only the changes since the last snapshot. The cache is reset on navigation. - **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. diff --git a/package.json b/package.json index 873d62498..294a0cf33 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 format", + "gen": "npm run bundle && npm run docs:generate && npm run cli:generate && npm run format", "docs:generate": "node --experimental-strip-types scripts/generate-docs.ts", "start": "npm run build && node build/src/index.js", "start-debug": "DEBUG=mcp:* DEBUG_COLORS=false npm run build && node build/src/index.js", diff --git a/scripts/post-build.ts b/scripts/post-build.ts index edf822599..886ab2940 100644 --- a/scripts/post-build.ts +++ b/scripts/post-build.ts @@ -26,6 +26,7 @@ function main(): void { // Create i18n mock const i18nDir = path.join(BUILD_DIR, devtoolsFrontEndCorePath, 'i18n'); + fs.mkdirSync(i18nDir, {recursive: true}); const localesFile = path.join(i18nDir, 'locales.js'); const localesContent = ` export const LOCALES = [ @@ -94,6 +95,45 @@ export const ExperimentName = { writeFile(runtimeFile, runtimeContent); copyDevToolsDescriptionFiles(); + copySnapshotFiles(); +} + +function copySnapshotFiles() { + const testsDir = path.join(process.cwd(), 'tests'); + const buildTestsDir = path.join(BUILD_DIR, 'tests'); + + if (!fs.existsSync(buildTestsDir)) { + fs.mkdirSync(buildTestsDir, {recursive: true}); + } + + const files = fs.readdirSync(testsDir); + for (const file of files) { + if (file.endsWith('.snapshot')) { + fs.copyFileSync( + path.join(testsDir, file), + path.join(buildTestsDir, file), + ); + } + } + + // Also handle subdirectories if needed, but for now we only have snapshots in the root of tests/ + // Wait, let's check tools/ + const toolsTestsDir = path.join(testsDir, 'tools'); + const buildToolsTestsDir = path.join(buildTestsDir, 'tools'); + if (fs.existsSync(toolsTestsDir)) { + if (!fs.existsSync(buildToolsTestsDir)) { + fs.mkdirSync(buildToolsTestsDir, {recursive: true}); + } + const toolsFiles = fs.readdirSync(toolsTestsDir); + for (const file of toolsFiles) { + if (file.endsWith('.snapshot')) { + fs.copyFileSync( + path.join(toolsTestsDir, file), + path.join(buildToolsTestsDir, file), + ); + } + } + } } function copyDevToolsDescriptionFiles() { diff --git a/skills/chrome-devtools-cli/SKILL.md b/skills/chrome-devtools-cli/SKILL.md index b6db1807a..733d2aa0d 100644 --- a/skills/chrome-devtools-cli/SKILL.md +++ b/skills/chrome-devtools-cli/SKILL.md @@ -120,6 +120,7 @@ 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 --diff true # Return only the changes since the last snapshot chrome-devtools take_snapshot --verbose true --filePath "s.txt" # Take a verbose snapshot and save to file ``` diff --git a/skills/chrome-devtools/SKILL.md b/skills/chrome-devtools/SKILL.md index 9b9fbce46..d0c0f7bf3 100644 --- a/skills/chrome-devtools/SKILL.md +++ b/skills/chrome-devtools/SKILL.md @@ -22,6 +22,7 @@ description: Uses Chrome DevTools via MCP for efficient debugging, troubleshooti ### Efficient data retrieval +- Use `diff: true` in `take_snapshot` to receive only the changes since the last snapshot (reset on navigation). - Use `filePath` parameter for large outputs (screenshots, snapshots, traces) - Use pagination (`pageIdx`, `pageSize`) and filtering (`types`) to minimize data - Set `includeSnapshot: false` on input actions unless you need updated page state diff --git a/src/McpContext.ts b/src/McpContext.ts index 4647c6118..fc91c2d65 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -728,6 +728,9 @@ export class McpContext implements Context { page: McpPage, verbose = false, devtoolsData: DevToolsData | undefined = undefined, + options: { + diff?: boolean; + } = {}, ): Promise { const rootNode = await page.pptrPage.accessibility.snapshot({ includeIframes: true, @@ -745,10 +748,19 @@ export class McpContext implements Context { let idCounter = 0; const idToNode = new Map(); const seenUniqueIds = new Set(); - const assignIds = (node: SerializedAXNode): TextSnapshotNode => { + const assignIds = ( + node: SerializedAXNode, + parentId = 'root', + index = 0, + ): TextSnapshotNode => { let id = ''; - // @ts-expect-error untyped loaderId & backendNodeId. - const uniqueBackendId = `${node.loaderId}_${node.backendNodeId}`; + const nodeAny = node as unknown as Record; + // StaticText nodes often have unstable backendNodeIds in some contexts, + // or we might want to group them by their parent. + const uniqueBackendId = nodeAny.backendNodeId + ? `${nodeAny.loaderId}_${nodeAny.backendNodeId}` + : `${nodeAny.loaderId}_${nodeAny.role}_${parentId}_${index}`; + if (uniqueBackendNodeIdToMcpId.has(uniqueBackendId)) { // Re-use MCP exposed ID if the uniqueId is the same. id = uniqueBackendNodeIdToMcpId.get(uniqueBackendId)!; @@ -763,7 +775,7 @@ export class McpContext implements Context { ...node, id, children: node.children - ? node.children.map(child => assignIds(child)) + ? node.children.map((child, i) => assignIds(child, id, i)) : [], }; @@ -787,8 +799,40 @@ export class McpContext implements Context { idToNode, hasSelectedElement: false, verbose, + diffReset: !!(options.diff && !page.lastSnapshot), }; + + if (options.diff && page.lastSnapshot) { + const lastIdToNode = page.lastSnapshot.idToNode; + const added: string[] = []; + const changed: string[] = []; + const removed: string[] = []; + + for (const [id, node] of idToNode) { + const lastNode = lastIdToNode.get(id); + if (!lastNode) { + added.push(id); + } else if ( + node.name !== lastNode.name || + node.value !== lastNode.value || + node.description !== lastNode.description || + node.role !== lastNode.role + ) { + changed.push(id); + } + } + + for (const id of lastIdToNode.keys()) { + if (!idToNode.has(id)) { + removed.push(id); + } + } + + snapshot.diff = {added, changed, removed}; + } + page.textSnapshot = snapshot; + page.lastSnapshot = snapshot; const data = devtoolsData ?? (await this.getDevToolsData(page)); if (data?.cdpBackendNodeId) { snapshot.hasSelectedElement = true; diff --git a/src/McpPage.ts b/src/McpPage.ts index 73717464b..c734f3189 100644 --- a/src/McpPage.ts +++ b/src/McpPage.ts @@ -33,6 +33,7 @@ export class McpPage implements ContextPage { // Snapshot textSnapshot: TextSnapshot | null = null; + lastSnapshot: TextSnapshot | null = null; uniqueBackendNodeIdToMcpId = new Map(); // Emulation @@ -45,6 +46,7 @@ export class McpPage implements ContextPage { // Dialog #dialog?: Dialog; #dialogHandler: (dialog: Dialog) => void; + #navigationHandler: () => void; constructor(page: Page, id: number) { this.pptrPage = page; @@ -52,7 +54,11 @@ export class McpPage implements ContextPage { this.#dialogHandler = (dialog: Dialog): void => { this.#dialog = dialog; }; + this.#navigationHandler = (): void => { + this.lastSnapshot = null; + }; page.on('dialog', this.#dialogHandler); + page.on('framenavigated', this.#navigationHandler); } get dialog(): Dialog | undefined { @@ -93,6 +99,7 @@ export class McpPage implements ContextPage { dispose(): void { this.pptrPage.off('dialog', this.#dialogHandler); + this.pptrPage.off('framenavigated', this.#navigationHandler); } async getElementByUid(uid: string): Promise> { diff --git a/src/McpResponse.ts b/src/McpResponse.ts index b6162c27c..511e3022b 100644 --- a/src/McpResponse.ts +++ b/src/McpResponse.ts @@ -276,6 +276,9 @@ export class McpResponse implements Response { this.#page, this.#snapshotParams.verbose, this.#devToolsData, + { + diff: this.#snapshotParams.diff, + }, ); const textSnapshot = this.#page.textSnapshot; if (textSnapshot) { diff --git a/src/bin/chrome-devtools-cli-options.ts b/src/bin/chrome-devtools-cli-options.ts deleted file mode 100644 index 6e317a333..000000000 --- a/src/bin/chrome-devtools-cli-options.ts +++ /dev/null @@ -1,745 +0,0 @@ -/** - * @license - * Copyright 2026 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -// NOTE: do not edit manually. Auto-generated by 'npm run cli:generate'. - -export interface ArgDef { - name: string; - type: string; - description: string; - required: boolean; - default?: string | number | boolean; - enum?: ReadonlyArray; -} -export type Commands = Record< - string, - { - description: string; - category: string; - args: Record; - } ->; -export const commands: Commands = { - click: { - description: 'Clicks on the provided element', - category: 'Input automation', - args: { - uid: { - name: 'uid', - type: 'string', - description: - 'The uid of an element on the page from the page content snapshot', - required: true, - }, - dblClick: { - name: 'dblClick', - type: 'boolean', - description: 'Set to true for double clicks. Default is false.', - required: false, - }, - includeSnapshot: { - name: 'includeSnapshot', - type: 'boolean', - description: - 'Whether to include a snapshot in the response. Default is false.', - required: false, - }, - }, - }, - close_page: { - description: - 'Closes the page by its index. The last open page cannot be closed.', - category: 'Navigation automation', - args: { - pageId: { - name: 'pageId', - type: 'number', - description: - 'The ID of the page to close. Call list_pages to list pages.', - required: true, - }, - }, - }, - drag: { - description: 'Drag an element onto another element', - category: 'Input automation', - args: { - from_uid: { - name: 'from_uid', - type: 'string', - description: 'The uid of the element to drag', - required: true, - }, - to_uid: { - name: 'to_uid', - type: 'string', - description: 'The uid of the element to drop into', - required: true, - }, - includeSnapshot: { - name: 'includeSnapshot', - type: 'boolean', - description: - 'Whether to include a snapshot in the response. Default is false.', - required: false, - }, - }, - }, - emulate: { - description: 'Emulates various features on the selected page.', - category: 'Emulation', - args: { - networkConditions: { - name: 'networkConditions', - type: 'string', - description: 'Throttle network. Omit to disable throttling.', - required: false, - enum: ['Offline', 'Slow 3G', 'Fast 3G', 'Slow 4G', 'Fast 4G'], - }, - cpuThrottlingRate: { - name: 'cpuThrottlingRate', - type: 'number', - description: - 'Represents the CPU slowdown factor. Omit or set the rate to 1 to disable throttling', - required: false, - }, - geolocation: { - name: 'geolocation', - type: 'string', - description: - 'Geolocation (`x`) to emulate. Latitude between -90 and 90. Longitude between -180 and 180. Omit clear the geolocation override.', - required: false, - }, - userAgent: { - name: 'userAgent', - type: 'string', - description: - 'User agent to emulate. Set to empty string to clear the user agent override.', - required: false, - }, - colorScheme: { - name: 'colorScheme', - type: 'string', - description: - 'Emulate the dark or the light mode. Set to "auto" to reset to the default.', - required: false, - enum: ['dark', 'light', 'auto'], - }, - viewport: { - name: 'viewport', - type: 'string', - description: - "Emulate device viewports 'xx[,mobile][,touch][,landscape]'. 'touch' and 'mobile' to emulate mobile devices. 'landscape' to emulate landscape mode.", - required: false, - }, - }, - }, - evaluate_script: { - description: - 'Evaluate a JavaScript function inside the currently selected page. Returns the response as JSON,\nso returned values have to be JSON-serializable.', - category: 'Debugging', - args: { - function: { - name: 'function', - type: 'string', - description: - 'A JavaScript function declaration to be executed by the tool in the currently selected page.\nExample without arguments: `() => {\n return document.title\n}` or `async () => {\n return await fetch("example.com")\n}`.\nExample with arguments: `(el) => {\n return el.innerText;\n}`\n', - required: true, - }, - args: { - name: 'args', - type: 'array', - description: 'An optional list of arguments to pass to the function.', - required: false, - }, - }, - }, - fill: { - description: - 'Type text into a input, text area or select an option from a