├── .nvmrc ├── .prettierignore ├── .release-please-manifest.json ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── 02-feature.yml │ └── 01-bug.yml ├── workflows │ ├── release-please.yml │ ├── convetional-commit.yml │ ├── pre-release.yml │ ├── presubmit.yml │ ├── run-tests.yml │ └── publish-to-npm-on-tag.yml └── dependabot.yml ├── tests ├── trace-processing │ ├── fixtures │ │ ├── basic-trace.json.gz │ │ ├── web-dev-with-commit.json.gz │ │ └── load.ts │ └── parse.test.ts ├── snapshot.ts ├── formatters │ ├── snapshotFormatter.test.js.snapshot │ ├── consoleFormatter.test.js.snapshot │ ├── consoleFormatter.test.ts │ └── snapshotFormatter.test.ts ├── setup.ts ├── tools │ ├── console.test.js.snapshot │ ├── network.test.js.snapshot │ ├── snapshot.test.ts │ ├── script.test.ts │ ├── network.test.ts │ └── emulation.test.ts ├── index.test.ts ├── browser.test.ts ├── server.ts ├── McpContext.test.ts ├── McpResponse.test.js.snapshot └── cli.test.ts ├── src ├── polyfill.ts ├── utils │ ├── types.ts │ ├── pagination.ts │ └── keyboard.ts ├── devtools.d.ts ├── tools │ ├── categories.ts │ ├── tools.ts │ ├── snapshot.ts │ ├── script.ts │ ├── console.ts │ ├── emulation.ts │ ├── screenshot.ts │ ├── network.ts │ ├── ToolDefinition.ts │ └── performance.ts ├── third_party │ ├── devtools.ts │ └── index.ts ├── index.ts ├── Mutex.ts ├── logger.ts ├── issue-descriptions.ts ├── formatters │ ├── snapshotFormatter.ts │ ├── networkFormatter.ts │ └── consoleFormatter.ts ├── DevToolsConnectionAdapter.ts ├── trace-processing │ └── parse.ts ├── WaitForHelper.ts ├── main.ts └── browser.ts ├── SECURITY.md ├── gemini-extension.json ├── scripts ├── eslint_rules │ ├── local-plugin.js │ └── check-license-rule.js ├── tsconfig.json ├── sed.ts ├── verify-server-json-version.ts ├── prepare.ts └── post-build.ts ├── .claude-plugin ├── plugin.json └── marketplace.json ├── puppeteer.config.cjs ├── .prettierrc.cjs ├── docs ├── design-principles.md ├── debugging-android.md └── troubleshooting.md ├── server.json ├── release-please-config.json ├── CONTRIBUTING.md ├── .gitignore ├── package.json ├── eslint.config.mjs ├── tsconfig.json └── rollup.config.mjs /.nvmrc: -------------------------------------------------------------------------------- 1 | v22 -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Prettier-only ignores. 2 | CHANGELOG.md 3 | -------------------------------------------------------------------------------- /.release-please-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | ".": "0.12.1" 3 | } 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /tests/trace-processing/fixtures/basic-trace.json.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChromeDevTools/chrome-devtools-mcp/HEAD/tests/trace-processing/fixtures/basic-trace.json.gz -------------------------------------------------------------------------------- /tests/trace-processing/fixtures/web-dev-with-commit.json.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChromeDevTools/chrome-devtools-mcp/HEAD/tests/trace-processing/fixtures/web-dev-with-commit.json.gz -------------------------------------------------------------------------------- /src/polyfill.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google Inc. 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | // polyfills are now bundled with all other dependencies 8 | import './third_party/index.js'; 9 | -------------------------------------------------------------------------------- /src/utils/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | export interface PaginationOptions { 8 | pageSize?: number; 9 | pageIdx?: number; 10 | } 11 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | ## Security policy 2 | 3 | The Chrome DevTools MCP project takes security very seriously. Please use [Chromium’s process to report security issues](https://www.chromium.org/Home/chromium-security/reporting-security-bugs/). 4 | -------------------------------------------------------------------------------- /gemini-extension.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chrome-devtools-mcp", 3 | "version": "latest", 4 | "mcpServers": { 5 | "chrome-devtools": { 6 | "command": "npx", 7 | "args": ["chrome-devtools-mcp@latest"] 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/devtools.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | type CSSInJS = string & {_tag: 'CSS-in-JS'}; 8 | declare module '*.css.js' { 9 | const styles: CSSInJS; 10 | export default styles; 11 | } 12 | -------------------------------------------------------------------------------- /scripts/eslint_rules/local-plugin.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import checkLicenseRule from './check-license-rule.js'; 8 | 9 | export default {rules: {'check-license': checkLicenseRule}}; 10 | -------------------------------------------------------------------------------- /.claude-plugin/plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chrome-devtools-mcp", 3 | "version": "latest", 4 | "description": "Reliable automation, in-depth debugging, and performance analysis in Chrome using Chrome DevTools and Puppeteer", 5 | "mcpServers": { 6 | "chrome-devtools": { 7 | "command": "npx", 8 | "args": ["chrome-devtools-mcp@latest"] 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /puppeteer.config.cjs: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google Inc. 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | /** 8 | * @type {import("puppeteer").Configuration} 9 | */ 10 | module.exports = { 11 | chrome: { 12 | skipDownload: false, 13 | }, 14 | ['chrome-headless-shell']: { 15 | skipDownload: true, 16 | }, 17 | firefox: { 18 | skipDownload: true, 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | /** 8 | * @type {import('prettier').Config} 9 | */ 10 | module.exports = { 11 | bracketSpacing: false, 12 | singleQuote: true, 13 | trailingComma: 'all', 14 | arrowParens: 'avoid', 15 | singleAttributePerLine: true, 16 | htmlWhitespaceSensitivity: 'strict', 17 | endOfLine: 'lf', 18 | }; 19 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | 6 | permissions: read-all 7 | name: release-please 8 | 9 | jobs: 10 | release-please: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: googleapis/release-please-action@v4 14 | with: 15 | token: ${{ secrets.BROWSER_AUTOMATION_BOT_TOKEN }} 16 | target-branch: main 17 | config-file: release-please-config.json 18 | manifest-file: .release-please-manifest.json 19 | -------------------------------------------------------------------------------- /tests/snapshot.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | interface ScreenshotData { 8 | html: string; 9 | } 10 | 11 | export const screenshots: Record = { 12 | basic: { 13 | html: '
Hello MCP
', 14 | }, 15 | viewportOverflow: { 16 | html: '
View Port overflow
', 17 | }, 18 | button: { 19 | html: '', 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /.claude-plugin/marketplace.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chrome-devtools-plugins", 3 | "version": "1.0.0", 4 | "description": "Bundled plugins for actuating and debugging the Chrome browser.", 5 | "owner": { 6 | "name": "Chrome DevTools Team", 7 | "email": "devtools-dev@chromium.org" 8 | }, 9 | "plugins": [ 10 | { 11 | "name": "chrome-devtools-mcp", 12 | "source": { 13 | "source": "github", 14 | "repo": "ChromeDevTools/chrome-devtools-mcp" 15 | }, 16 | "description": "Reliable automation, in-depth debugging, and performance analysis in Chrome using Chrome DevTools and Puppeteer" 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /src/tools/categories.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | export enum ToolCategory { 8 | INPUT = 'input', 9 | NAVIGATION = 'navigation', 10 | EMULATION = 'emulation', 11 | PERFORMANCE = 'performance', 12 | NETWORK = 'network', 13 | DEBUGGING = 'debugging', 14 | } 15 | 16 | export const labels = { 17 | [ToolCategory.INPUT]: 'Input automation', 18 | [ToolCategory.NAVIGATION]: 'Navigation automation', 19 | [ToolCategory.EMULATION]: 'Emulation', 20 | [ToolCategory.PERFORMANCE]: 'Performance', 21 | [ToolCategory.NETWORK]: 'Network', 22 | [ToolCategory.DEBUGGING]: 'Debugging', 23 | }; 24 | -------------------------------------------------------------------------------- /scripts/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "target": "esnext", 5 | "module": "nodenext", 6 | "moduleResolution": "nodenext", 7 | "outDir": "./ignored", 8 | "rootDir": ".", 9 | "strict": true, 10 | "esModuleInterop": true, 11 | "skipLibCheck": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "noImplicitReturns": true, 14 | "noImplicitOverride": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "incremental": true, 17 | "allowJs": true, 18 | "allowImportingTsExtensions": true, 19 | "noEmit": true, 20 | "useUnknownInCatchVariables": false 21 | }, 22 | "include": ["./**/*.ts", "./**/*.js"] 23 | } 24 | -------------------------------------------------------------------------------- /docs/design-principles.md: -------------------------------------------------------------------------------- 1 | # Design Principles 2 | 3 | These are rough guidelines to follow when shipping features for the MCP server. 4 | Apply them with nuance. 5 | 6 | - **Agent-Agnostic API**: Use standards like MCP. Don't lock in to one LLM. Interoperability is key. 7 | - **Token-Optimized**: Return semantic summaries. "LCP was 3.2s" is better than 50k lines of JSON. Files are the right location for large amounts of data. 8 | - **Small, Deterministic Blocks**: Give agents composable tools (Click, Screenshot), not magic buttons. 9 | - **Self-Healing Errors**: Return actionable errors that include context and potential fixes. 10 | - **Human-Agent Collaboration**: Output must be readable by machines (structured) AND humans (summaries). 11 | -------------------------------------------------------------------------------- /server.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json", 3 | "name": "io.github.ChromeDevTools/chrome-devtools-mcp", 4 | "title": "Chrome DevTools MCP", 5 | "description": "MCP server for Chrome DevTools", 6 | "repository": { 7 | "url": "https://github.com/ChromeDevTools/chrome-devtools-mcp", 8 | "source": "github" 9 | }, 10 | "version": "0.12.1", 11 | "packages": [ 12 | { 13 | "registryType": "npm", 14 | "registryBaseUrl": "https://registry.npmjs.org", 15 | "identifier": "chrome-devtools-mcp", 16 | "version": "0.12.1", 17 | "transport": { 18 | "type": "stdio" 19 | }, 20 | "environmentVariables": [] 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /scripts/sed.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import * as fs from 'node:fs'; 8 | 9 | /** 10 | * Replaces content in a file. 11 | * @param filePath The path to the file. 12 | * @param find The regex to find. 13 | * @param replace The string to replace with. 14 | */ 15 | export function sed( 16 | filePath: string, 17 | find: RegExp | string, 18 | replace: string, 19 | ): void { 20 | if (!fs.existsSync(filePath)) { 21 | console.warn(`File not found for sed operation: ${filePath}`); 22 | return; 23 | } 24 | const content = fs.readFileSync(filePath, 'utf-8'); 25 | const newContent = content.replace(find, replace); 26 | fs.writeFileSync(filePath, newContent, 'utf-8'); 27 | } 28 | -------------------------------------------------------------------------------- /src/third_party/devtools.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | export type { 8 | IssuesManagerEventTypes, 9 | CDPConnection, 10 | } from '../../node_modules/chrome-devtools-frontend/mcp/mcp.js'; 11 | export { 12 | AgentFocus, 13 | TraceEngine, 14 | PerformanceTraceFormatter, 15 | PerformanceInsightFormatter, 16 | AggregatedIssue, 17 | Issue, 18 | Target as SDKTarget, 19 | DebuggerModel, 20 | Foundation, 21 | TargetManager, 22 | MarkdownIssueDescription, 23 | Marked, 24 | ProtocolClient, 25 | Common, 26 | I18n, 27 | IssueAggregatorEvents, 28 | IssuesManagerEvents, 29 | createIssuesFromProtocolIssue, 30 | IssueAggregator, 31 | } from '../../node_modules/chrome-devtools-frontend/mcp/mcp.js'; 32 | -------------------------------------------------------------------------------- /tests/formatters/snapshotFormatter.test.js.snapshot: -------------------------------------------------------------------------------- 1 | exports[`snapshotFormatter > does not include a note if the snapshot is already verbose 1`] = ` 2 | Note: there is a selected element in the DevTools Elements panel but it is not included into the current a11y tree snapshot. 3 | Get a verbose snapshot to include all elements if you are interested in the selected element. 4 | 5 | uid=1_1 checkbox "checkbox" checked 6 | uid=1_2 statictext "text" 7 | 8 | `; 9 | 10 | exports[`snapshotFormatter > formats with DevTools data included into a snapshot 1`] = ` 11 | uid=1_1 checkbox "checkbox" checked [selected in the DevTools Elements panel] 12 | uid=1_2 statictext "text" 13 | 14 | `; 15 | 16 | exports[`snapshotFormatter > formats with DevTools data not included into a snapshot 1`] = ` 17 | uid=1_1 checkbox "checkbox" checked 18 | uid=1_2 statictext "text" 19 | 20 | `; 21 | -------------------------------------------------------------------------------- /.github/workflows/convetional-commit.yml: -------------------------------------------------------------------------------- 1 | name: 'Conventional Commit' 2 | 3 | on: 4 | pull_request_target: 5 | types: 6 | # Defaults 7 | # https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#pull_request_target 8 | - opened 9 | - reopened 10 | - synchronize 11 | # Tracks editing PR title or description, or base branch changes 12 | # https://docs.github.com/en/webhooks/webhook-events-and-payloads?actionType=edited#pull_request 13 | - edited 14 | 15 | jobs: 16 | main: 17 | name: '[Required] Validate PR title' 18 | runs-on: ubuntu-latest 19 | permissions: 20 | pull-requests: read 21 | steps: 22 | - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 23 | env: 24 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 25 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * @license 5 | * Copyright 2025 Google LLC 6 | * SPDX-License-Identifier: Apache-2.0 7 | */ 8 | 9 | import {version} from 'node:process'; 10 | 11 | const [major, minor] = version.substring(1).split('.').map(Number); 12 | 13 | if (major === 20 && minor < 19) { 14 | console.error( 15 | `ERROR: \`chrome-devtools-mcp\` does not support Node ${process.version}. Please upgrade to Node 20.19.0 LTS or a newer LTS.`, 16 | ); 17 | process.exit(1); 18 | } 19 | 20 | if (major === 22 && minor < 12) { 21 | console.error( 22 | `ERROR: \`chrome-devtools-mcp\` does not support Node ${process.version}. Please upgrade to Node 22.12.0 LTS or a newer LTS.`, 23 | ); 24 | process.exit(1); 25 | } 26 | 27 | if (major < 20) { 28 | console.error( 29 | `ERROR: \`chrome-devtools-mcp\` does not support Node ${process.version}. Please upgrade to Node 20.19.0 LTS or a newer LTS.`, 30 | ); 31 | process.exit(1); 32 | } 33 | 34 | await import('./main.js'); 35 | -------------------------------------------------------------------------------- /src/Mutex.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google Inc. 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | export class Mutex { 8 | static Guard = class Guard { 9 | #mutex: Mutex; 10 | constructor(mutex: Mutex) { 11 | this.#mutex = mutex; 12 | } 13 | dispose(): void { 14 | return this.#mutex.release(); 15 | } 16 | }; 17 | 18 | #locked = false; 19 | #acquirers: Array<() => void> = []; 20 | 21 | // This is FIFO. 22 | async acquire(): Promise> { 23 | if (!this.#locked) { 24 | this.#locked = true; 25 | return new Mutex.Guard(this); 26 | } 27 | const {resolve, promise} = Promise.withResolvers(); 28 | this.#acquirers.push(resolve); 29 | await promise; 30 | return new Mutex.Guard(this); 31 | } 32 | 33 | release(): void { 34 | const resolve = this.#acquirers.shift(); 35 | if (!resolve) { 36 | this.#locked = false; 37 | return; 38 | } 39 | resolve(); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import fs from 'node:fs'; 8 | 9 | import {debug} from './third_party/index.js'; 10 | 11 | const mcpDebugNamespace = 'mcp:log'; 12 | 13 | const namespacesToEnable = [ 14 | mcpDebugNamespace, 15 | ...(process.env['DEBUG'] ? [process.env['DEBUG']] : []), 16 | ]; 17 | 18 | export function saveLogsToFile(fileName: string): fs.WriteStream { 19 | // Enable overrides everything so we need to add them 20 | debug.enable(namespacesToEnable.join(',')); 21 | 22 | const logFile = fs.createWriteStream(fileName, {flags: 'a+'}); 23 | debug.log = function (...chunks: any[]) { 24 | logFile.write(`${chunks.join(' ')}\n`); 25 | }; 26 | logFile.on('error', function (error) { 27 | console.error(`Error when opening/writing to log file: ${error.message}`); 28 | logFile.end(); 29 | process.exit(1); 30 | }); 31 | return logFile; 32 | } 33 | 34 | export const logger = debug(mcpDebugNamespace); 35 | -------------------------------------------------------------------------------- /.github/workflows/pre-release.yml: -------------------------------------------------------------------------------- 1 | name: Pre-release 2 | 3 | permissions: read-all 4 | 5 | on: 6 | push: 7 | branches: 8 | - release-please-* 9 | 10 | jobs: 11 | pre-release: 12 | name: 'Verify MCP server schema unchanged' 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Check out repository 16 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 17 | with: 18 | fetch-depth: 2 19 | 20 | - name: Set up Node.js 21 | uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 22 | with: 23 | cache: npm 24 | node-version-file: '.nvmrc' 25 | 26 | - name: Install MCP Publisher 27 | run: | 28 | export OS=$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/') 29 | curl -L "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_${OS}.tar.gz" | tar xz mcp-publisher 30 | 31 | - name: Verify server.json 32 | run: npm run verify-server-json-version 33 | -------------------------------------------------------------------------------- /tests/setup.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import '../src/polyfill.js'; 8 | 9 | import path from 'node:path'; 10 | import {it} from 'node:test'; 11 | 12 | if (!it.snapshot) { 13 | it.snapshot = { 14 | setResolveSnapshotPath: () => { 15 | // Internally empty 16 | }, 17 | setDefaultSnapshotSerializers: () => { 18 | // Internally empty 19 | }, 20 | }; 21 | } 22 | 23 | // This is run by Node when we execute the tests via the --import flag. 24 | it.snapshot.setResolveSnapshotPath(testPath => { 25 | // By default the snapshots go into the build directory, but we want them 26 | // in the tests/ directory. 27 | const correctPath = testPath?.replace(path.join('build', 'tests'), 'tests'); 28 | return correctPath + '.snapshot'; 29 | }); 30 | 31 | // The default serializer is JSON.stringify which outputs a very hard to read 32 | // snapshot. So we override it to one that shows new lines literally rather 33 | // than via `\n`. 34 | it.snapshot.setDefaultSnapshotSerializers([String]); 35 | -------------------------------------------------------------------------------- /scripts/verify-server-json-version.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import {execSync} from 'node:child_process'; 8 | import fs from 'node:fs'; 9 | 10 | const serverJsonFilePath = './server.json'; 11 | const serverJson = JSON.parse(fs.readFileSync(serverJsonFilePath, 'utf-8')); 12 | fs.unlinkSync(serverJsonFilePath); 13 | 14 | // Create the new server.json 15 | execSync('./mcp-publisher init'); 16 | 17 | const newServerJson = JSON.parse(fs.readFileSync(serverJsonFilePath, 'utf-8')); 18 | 19 | const propertyToVerify = ['$schema']; 20 | const diffProps = []; 21 | 22 | for (const prop of propertyToVerify) { 23 | if (serverJson[prop] !== newServerJson[prop]) { 24 | diffProps.push(prop); 25 | } 26 | } 27 | 28 | fs.writeFileSync('./server.json', JSON.stringify(serverJson, null, 2)); 29 | 30 | if (diffProps.length) { 31 | throw new Error( 32 | `The following props did not match the latest init value:\n${diffProps.map( 33 | prop => `- "${prop}": "${newServerJson[prop]}"`, 34 | )}`, 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /scripts/prepare.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import {rm} from 'node:fs/promises'; 8 | import {resolve} from 'node:path'; 9 | 10 | const projectRoot = process.cwd(); 11 | 12 | const filesToRemove = [ 13 | 'node_modules/chrome-devtools-frontend/package.json', 14 | 'node_modules/chrome-devtools-frontend/front_end/models/trace/lantern/testing', 15 | 'node_modules/chrome-devtools-frontend/front_end/third_party/intl-messageformat/package/package.json', 16 | ]; 17 | 18 | async function main() { 19 | console.log('Running prepare script to clean up chrome-devtools-frontend...'); 20 | for (const file of filesToRemove) { 21 | const fullPath = resolve(projectRoot, file); 22 | console.log(`Removing: ${file}`); 23 | try { 24 | await rm(fullPath, {recursive: true, force: true}); 25 | } catch (error) { 26 | console.error(`Failed to remove ${file}:`, error); 27 | process.exit(1); 28 | } 29 | } 30 | console.log('Clean up of chrome-devtools-frontend complete.'); 31 | } 32 | 33 | void main(); 34 | -------------------------------------------------------------------------------- /src/tools/tools.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | import * as consoleTools from './console.js'; 7 | import * as emulationTools from './emulation.js'; 8 | import * as inputTools from './input.js'; 9 | import * as networkTools from './network.js'; 10 | import * as pagesTools from './pages.js'; 11 | import * as performanceTools from './performance.js'; 12 | import * as screenshotTools from './screenshot.js'; 13 | import * as scriptTools from './script.js'; 14 | import * as snapshotTools from './snapshot.js'; 15 | import type {ToolDefinition} from './ToolDefinition.js'; 16 | 17 | const tools = [ 18 | ...Object.values(consoleTools), 19 | ...Object.values(emulationTools), 20 | ...Object.values(inputTools), 21 | ...Object.values(networkTools), 22 | ...Object.values(pagesTools), 23 | ...Object.values(performanceTools), 24 | ...Object.values(screenshotTools), 25 | ...Object.values(scriptTools), 26 | ...Object.values(snapshotTools), 27 | ] as ToolDefinition[]; 28 | 29 | tools.sort((a, b) => { 30 | return a.name.localeCompare(b.name); 31 | }); 32 | 33 | export {tools}; 34 | -------------------------------------------------------------------------------- /release-please-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "changelog-sections": [ 3 | {"type": "feat", "section": "🎉 Features", "hidden": false}, 4 | {"type": "fix", "section": "🛠️ Fixes", "hidden": false}, 5 | {"type": "docs", "section": "📄 Documentation", "hidden": false}, 6 | 7 | {"type": "perf", "section": "♻️ Chores", "hidden": false}, 8 | {"type": "refactor", "section": "♻️ Chores", "hidden": false}, 9 | {"type": "chore", "section": "♻️ Chores", "hidden": true}, 10 | {"type": "test", "section": "♻️ Chores", "hidden": true}, 11 | 12 | {"type": "build", "section": "⚙️ Automation", "hidden": true}, 13 | {"type": "ci", "section": "⚙️ Automation", "hidden": true} 14 | ], 15 | 16 | "packages": { 17 | ".": { 18 | "extra-files": [ 19 | { 20 | "type": "generic", 21 | "path": "src/main.ts" 22 | }, 23 | { 24 | "type": "json", 25 | "path": "server.json", 26 | "jsonpath": "version" 27 | }, 28 | { 29 | "type": "json", 30 | "path": "server.json", 31 | "jsonpath": "packages[0].version" 32 | } 33 | ] 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: / 5 | schedule: 6 | interval: weekly 7 | day: 'sunday' 8 | time: '02:00' 9 | timezone: Europe/Berlin 10 | groups: 11 | dependencies: 12 | dependency-type: production 13 | patterns: 14 | - '*' 15 | dev-dependencies: 16 | dependency-type: development 17 | exclude-patterns: 18 | - 'puppeteer*' 19 | - 'chrome-devtools-frontend' 20 | - '@modelcontextprotocol/sdk' 21 | - 'yargs' 22 | - 'debug' 23 | - 'core-js' 24 | patterns: 25 | - '*' 26 | # breaks often so better to roll separetely. 27 | bundled-devtools: 28 | patterns: 29 | - 'chrome-devtools-frontend' 30 | bundled: 31 | patterns: 32 | - 'puppeteer*' 33 | - '@modelcontextprotocol/sdk' 34 | - 'yargs' 35 | - 'debug' 36 | - 'core-js' 37 | - package-ecosystem: github-actions 38 | directory: / 39 | schedule: 40 | interval: weekly 41 | day: 'sunday' 42 | time: '04:00' 43 | timezone: Europe/Berlin 44 | groups: 45 | all: 46 | patterns: 47 | - '*' 48 | -------------------------------------------------------------------------------- /src/third_party/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import 'core-js/modules/es.promise.with-resolvers.js'; 8 | import 'core-js/proposals/iterator-helpers.js'; 9 | 10 | export type {Options as YargsOptions} from 'yargs'; 11 | export {default as yargs} from 'yargs'; 12 | export {hideBin} from 'yargs/helpers'; 13 | export {default as debug} from 'debug'; 14 | export type {Debugger} from 'debug'; 15 | export {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js'; 16 | export {StdioServerTransport} from '@modelcontextprotocol/sdk/server/stdio.js'; 17 | export { 18 | type CallToolResult, 19 | SetLevelRequestSchema, 20 | type ImageContent, 21 | type TextContent, 22 | } from '@modelcontextprotocol/sdk/types.js'; 23 | export {z as zod} from 'zod'; 24 | export { 25 | Locator, 26 | PredefinedNetworkConditions, 27 | CDPSessionEvent, 28 | } from 'puppeteer-core'; 29 | export {default as puppeteer} from 'puppeteer-core'; 30 | export type * from 'puppeteer-core'; 31 | export type {CdpPage} from 'puppeteer-core/internal/cdp/Page.js'; 32 | export { 33 | resolveDefaultUserDataDir, 34 | detectBrowserPlatform, 35 | Browser as BrowserEnum, 36 | type ChromeReleaseChannel as BrowsersChromeReleaseChannel, 37 | } from '@puppeteer/browsers'; 38 | 39 | export * as DevTools from './devtools.js'; 40 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/02-feature.yml: -------------------------------------------------------------------------------- 1 | name: Feature 2 | description: Suggest an idea for for chrome-devtools-mcp 3 | title: '' 4 | type: 'Feature' 5 | labels: 6 | - enhancement 7 | body: 8 | - id: description 9 | type: textarea 10 | attributes: 11 | label: Is your feature request related to a problem? Please describe. 12 | description: > 13 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 14 | placeholder: 15 | validations: 16 | required: true 17 | 18 | - id: solution 19 | type: textarea 20 | attributes: 21 | label: Describe the solution you'd like 22 | description: > 23 | A clear and concise description of what you want to happen. 24 | placeholder: 25 | validations: 26 | required: true 27 | 28 | - id: alternatives 29 | type: textarea 30 | attributes: 31 | label: Describe alternatives you've considered 32 | description: > 33 | A clear and concise description of any alternative solutions or features you've considered. 34 | placeholder: 35 | validations: 36 | required: true 37 | 38 | - id: additional-context 39 | type: textarea 40 | attributes: 41 | label: Additional context 42 | description: > 43 | Add any other context or screenshots about the feature request here. 44 | placeholder: 45 | -------------------------------------------------------------------------------- /tests/trace-processing/fixtures/load.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import fs from 'node:fs'; 8 | import path from 'node:path'; 9 | import zlib from 'node:zlib'; 10 | 11 | /** 12 | * Reads a gzipped JSON file, decompresses it, parses the JSON, 13 | * and returns a Uint8Array buffer of the parsed data. 14 | * @param filePath The path to the .json.gz file. 15 | * @returns A Uint8Array containing the stringified JSON data. 16 | */ 17 | export function loadTraceAsBuffer(filePath: string): Uint8Array { 18 | try { 19 | const compressedData = fs.readFileSync( 20 | path.join( 21 | import.meta.dirname, 22 | // Get back up to the root directory as fixtures aren't moved ito the build/ dir. 23 | '..', 24 | '..', 25 | '..', 26 | '..', 27 | 'tests', 28 | 'trace-processing', 29 | 'fixtures', 30 | filePath, 31 | ), 32 | ); 33 | const decompressedData = zlib.gunzipSync(compressedData); 34 | const jsonString = decompressedData.toString('utf-8'); 35 | const jsonObject = JSON.parse(jsonString); 36 | const finalBuffer = Buffer.from(JSON.stringify(jsonObject)); 37 | const uint8Array = new Uint8Array(finalBuffer); 38 | return uint8Array; 39 | } catch (error) { 40 | console.error('Error parsing the file:', error); 41 | throw error; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /docs/debugging-android.md: -------------------------------------------------------------------------------- 1 | # Experimental: Debugging Chrome on Android 2 | 3 | This is an experimental feature as Puppeteer does not officially support Chrome on Android as a target. 4 | 5 | The workflow below works for most users. See [Troubleshooting: DevTools is not detecting the Android device for more help](https://developer.chrome.com/docs/devtools/remote-debugging#troubleshooting) for more help. 6 | 7 | 1. Open the Developer Options screen on your Android. See [Configure on-device developer Options](https://developer.android.com/studio/debug/dev-options.html). 8 | 2. Select Enable USB Debugging. 9 | 3. Connect your Android device directly to your development machine using a USB cable. 10 | 4. On your development machine setup port forwarding from your development machine to your android device: 11 | ```shell 12 | adb forward tcp:9222 localabstract:chrome_devtools_remote 13 | ``` 14 | 5. Configure your MCP server to connect to the Chrome 15 | ```json 16 | "chrome-devtools": { 17 | "command": "npx", 18 | "args": [ 19 | "chrome-devtools-mcp@latest", 20 | "--wsEndpoint=ws://127.0.0.1:9222/devtools/browser/" 21 | ], 22 | "trust": true 23 | } 24 | ``` 25 | 6. Test your setup by running the following prompt in your coding agent: 26 | ```none 27 | Check the performance of developers.chrome.com 28 | ``` 29 | 30 | The Chrome DevTools MCP server should now control Chrome on your Android device. 31 | -------------------------------------------------------------------------------- /src/issue-descriptions.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import * as fs from 'node:fs'; 8 | import * as path from 'node:path'; 9 | 10 | const DESCRIPTIONS_PATH = path.join( 11 | import.meta.dirname, 12 | 'third_party/issue-descriptions', 13 | ); 14 | 15 | let issueDescriptions: Record = {}; 16 | 17 | /** 18 | * Reads all issue descriptions from the filesystem into memory. 19 | */ 20 | export async function loadIssueDescriptions(): Promise { 21 | if (Object.keys(issueDescriptions).length > 0) { 22 | return; 23 | } 24 | 25 | const files = await fs.promises.readdir(DESCRIPTIONS_PATH); 26 | const descriptions: Record = {}; 27 | 28 | for (const file of files) { 29 | if (!file.endsWith('.md')) { 30 | continue; 31 | } 32 | const content = await fs.promises.readFile( 33 | path.join(DESCRIPTIONS_PATH, file), 34 | 'utf-8', 35 | ); 36 | descriptions[file] = content; 37 | } 38 | 39 | issueDescriptions = descriptions; 40 | } 41 | 42 | /** 43 | * Gets an issue description from the in-memory cache. 44 | * @param fileName The file name of the issue description. 45 | * @returns The description of the issue, or null if it doesn't exist. 46 | */ 47 | export function getIssueDescription(fileName: string): string | null { 48 | return issueDescriptions[fileName] ?? null; 49 | } 50 | 51 | export const ISSUE_UTILS = { 52 | loadIssueDescriptions, 53 | getIssueDescription, 54 | }; 55 | -------------------------------------------------------------------------------- /tests/formatters/consoleFormatter.test.js.snapshot: -------------------------------------------------------------------------------- 1 | exports[`consoleFormatter > formatConsoleEventShort > formats a console.log message 1`] = ` 2 | msgid=1 [log] Hello, world! (0 args) 3 | `; 4 | 5 | exports[`consoleFormatter > formatConsoleEventShort > formats a console.log message with multiple arguments 1`] = ` 6 | msgid=3 [log] Processing file: (2 args) 7 | `; 8 | 9 | exports[`consoleFormatter > formatConsoleEventShort > formats a console.log message with one argument 1`] = ` 10 | msgid=2 [log] Processing file: (1 args) 11 | `; 12 | 13 | exports[`consoleFormatter > formatConsoleEventVerbose > formats a console.error message 1`] = ` 14 | ID: 4 15 | Message: error> Something went wrong 16 | `; 17 | 18 | exports[`consoleFormatter > formatConsoleEventVerbose > formats a console.log message 1`] = ` 19 | ID: 1 20 | Message: log> Hello, world! 21 | `; 22 | 23 | exports[`consoleFormatter > formatConsoleEventVerbose > formats a console.log message with multiple arguments 1`] = ` 24 | ID: 3 25 | Message: log> Processing file: 26 | ### Arguments 27 | Arg #0: file.txt 28 | Arg #1: another file 29 | `; 30 | 31 | exports[`consoleFormatter > formatConsoleEventVerbose > formats a console.log message with one argument 1`] = ` 32 | ID: 2 33 | Message: log> Processing file: 34 | ### Arguments 35 | Arg #0: file.txt 36 | `; 37 | 38 | exports[`consoleFormatter > formats a console.log message with issue type 1`] = ` 39 | ID: 5 40 | Message: issue> Mock Issue Title 41 | 42 | This is a mock issue description 43 | Learn more: 44 | [Learn more](http://example.com/learnmore) 45 | [Learn more 2](http://example.com/another-learnmore) 46 | `; 47 | -------------------------------------------------------------------------------- /tests/trace-processing/parse.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import assert from 'node:assert'; 8 | import {describe, it} from 'node:test'; 9 | 10 | import { 11 | getTraceSummary, 12 | parseRawTraceBuffer, 13 | } from '../../src/trace-processing/parse.js'; 14 | 15 | import '../../src/DevtoolsUtils.js'; 16 | 17 | import {loadTraceAsBuffer} from './fixtures/load.js'; 18 | 19 | describe('Trace parsing', async () => { 20 | it('can parse a Uint8Array from Tracing.stop())', async () => { 21 | const rawData = loadTraceAsBuffer('basic-trace.json.gz'); 22 | const result = await parseRawTraceBuffer(rawData); 23 | if ('error' in result) { 24 | assert.fail(`Unexpected parse failure: ${result.error}`); 25 | } 26 | assert.ok(result?.parsedTrace); 27 | assert.ok(result?.insights); 28 | }); 29 | 30 | it('can format results of a trace', async t => { 31 | const rawData = loadTraceAsBuffer('web-dev-with-commit.json.gz'); 32 | const result = await parseRawTraceBuffer(rawData); 33 | if ('error' in result) { 34 | assert.fail(`Unexpected parse failure: ${result.error}`); 35 | } 36 | assert.ok(result?.parsedTrace); 37 | assert.ok(result?.insights); 38 | 39 | const output = getTraceSummary(result); 40 | t.assert.snapshot?.(output); 41 | }); 42 | 43 | it('will return a message if there is an error', async () => { 44 | const result = await parseRawTraceBuffer(undefined); 45 | assert.deepEqual(result, { 46 | error: 'No buffer was provided.', 47 | }); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /.github/workflows/presubmit.yml: -------------------------------------------------------------------------------- 1 | name: Check code before submitting 2 | 3 | permissions: read-all 4 | 5 | on: 6 | push: 7 | branches: 8 | - main 9 | pull_request: 10 | 11 | jobs: 12 | check-format: 13 | name: '[Required] Check correct format' 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: Check out repository 18 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 19 | with: 20 | fetch-depth: 2 21 | 22 | - name: Set up Node.js 23 | uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 24 | with: 25 | cache: npm 26 | node-version-file: '.nvmrc' 27 | 28 | - name: Install dependencies 29 | run: npm ci 30 | 31 | - name: Run format check 32 | run: npm run check-format 33 | 34 | check-docs: 35 | name: '[Required] Check docs updated' 36 | runs-on: ubuntu-latest 37 | 38 | steps: 39 | - name: Check out repository 40 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 41 | with: 42 | fetch-depth: 2 43 | 44 | - name: Set up Node.js 45 | uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 46 | with: 47 | cache: npm 48 | node-version-file: '.nvmrc' 49 | 50 | - name: Install dependencies 51 | run: npm ci 52 | 53 | - name: Generate documents 54 | run: npm run docs 55 | 56 | - name: Check if autogenerated docs differ 57 | run: | 58 | diff_file=$(mktemp doc_diff_XXXXXX) 59 | git diff --color > $diff_file 60 | if [[ -s $diff_file ]]; then 61 | echo "Please update the documentation by running 'npm run generate-docs'. The following was the diff" 62 | cat $diff_file 63 | rm $diff_file 64 | exit 1 65 | fi 66 | rm $diff_file 67 | -------------------------------------------------------------------------------- /tests/tools/console.test.js.snapshot: -------------------------------------------------------------------------------- 1 | exports[`console > get_console_message > issues type > gets issue details with node id parsing 1`] = ` 2 | # test response 3 | ID: 1 4 | Message: issue> An element doesn't have an autocomplete attribute 5 | 6 | A form field has an \`id\` or \`name\` attribute that the browser's autofill recognizes. However, it doesn't have an \`autocomplete\` attribute assigned. This might prevent the browser from correctly autofilling the form. 7 | 8 | To fix this issue, provide an \`autocomplete\` attribute. 9 | Learn more: 10 | [HTML attribute: autocomplete](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete#values) 11 | ### Affected resources 12 | uid=1_1 data={"violatingNodeAttribute":"name"} 13 | `; 14 | 15 | exports[`console > get_console_message > issues type > gets issue details with request id parsing 1`] = ` 16 | # test response 17 | ID: 18 | Message: issue> Ensure CORS response header values are valid 19 | 20 | A cross-origin resource sharing (CORS) request was blocked because of invalid or missing response headers of the request or the associated [preflight request](issueCorsPreflightRequest). 21 | 22 | To fix this issue, ensure the response to the CORS request and/or the associated [preflight request](issueCorsPreflightRequest) are not missing headers and use valid header values. 23 | 24 | Note that if an opaque response is sufficient, the request's mode can be set to \`no-cors\` to fetch the resource with CORS disabled; that way CORS headers are not required but the response content is inaccessible (opaque). 25 | Learn more: 26 | [Cross-Origin Resource Sharing (\`CORS\`)](https://web.dev/cross-origin-resource-sharing) 27 | ### Affected resources 28 | reqid= data={"corsErrorStatus":{"corsError":"PreflightMissingAllowOriginHeader","failedParameter":""},"isWarning":false,"request":{"url":"http://hostname:port/data.json"},"initiatorOrigin":"","clientSecurityState":{"initiatorIsSecureContext":false,"initiatorIPAddressSpace":"Loopback","privateNetworkRequestPolicy":"BlockFromInsecureToMorePrivate"}} 29 | `; 30 | -------------------------------------------------------------------------------- /tests/tools/network.test.js.snapshot: -------------------------------------------------------------------------------- 1 | exports[`network > network_get_request > should get request from previous navigations 1`] = ` 2 | # get_request response 3 | ## Request http://localhost:/one 4 | Status: [success - 200] 5 | ### Request Headers 6 | - accept-language:en-US,en;q=0.9 7 | - upgrade-insecure-requests:1 8 | - user-agent: 9 | - sec-ch-ua:"Chromium";v="", "Not A(Brand";v="24" 10 | - sec-ch-ua-mobile:?0 11 | - sec-ch-ua-platform:"" 12 | - accept:text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7 13 | - accept-encoding:gzip, deflate, br, zstd 14 | - connection:keep-alive 15 | - host:localhost: 16 | - sec-fetch-dest:document 17 | - sec-fetch-mode:navigate 18 | - sec-fetch-site:none 19 | - sec-fetch-user:?1 20 | ### Response Headers 21 | - connection:keep-alive 22 | - content-length:239 23 | - content-type:text/html; charset=utf-8 24 | - date: 25 | - keep-alive:timeout=5 26 | ### Response Body 27 | 28 | `; 29 | 30 | exports[`network > network_list_requests > list requests form current navigations only 1`] = ` 31 | # list_request response 32 | ## Network requests 33 | Showing 1-1 of 1 (Page 1 of 1). 34 | reqid=3 GET http://localhost:/three [success - 200] 35 | `; 36 | 37 | exports[`network > network_list_requests > list requests from previous navigations 1`] = ` 38 | # list_request response 39 | ## Network requests 40 | Showing 1-3 of 3 (Page 1 of 1). 41 | reqid=1 GET http://localhost:/one [success - 200] 42 | reqid=2 GET http://localhost:/two [success - 200] 43 | reqid=3 GET http://localhost:/three [success - 200] 44 | `; 45 | 46 | exports[`network > network_list_requests > list requests from previous navigations from redirects 1`] = ` 47 | # list_request response 48 | ## Network requests 49 | Showing 1-3 of 3 (Page 1 of 1). 50 | reqid=1 GET http://localhost:/redirect [failed - 302] 51 | reqid=2 GET http://localhost:/redirected [success - 200] 52 | reqid=3 GET http://localhost:/redirected-page [success - 200] 53 | `; 54 | -------------------------------------------------------------------------------- /.github/workflows/run-tests.yml: -------------------------------------------------------------------------------- 1 | name: Compile and run tests 2 | 3 | permissions: read-all 4 | 5 | on: 6 | push: 7 | branches: 8 | - main 9 | pull_request: 10 | 11 | jobs: 12 | run-tests: 13 | name: Tests on ${{ matrix.os }} with node ${{ matrix.node }} 14 | runs-on: ${{ matrix.os }} 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | os: 19 | - ubuntu-latest 20 | - windows-latest 21 | - macos-latest 22 | node: 23 | - 20 24 | - 22 25 | - 23 26 | - 24 27 | steps: 28 | - name: Check out repository 29 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 30 | with: 31 | fetch-depth: 2 32 | 33 | - name: Set up Node.js 34 | uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 35 | with: 36 | cache: npm 37 | node-version: 22 # build works only with 22+. 38 | 39 | - name: Install dependencies 40 | shell: bash 41 | run: npm ci 42 | 43 | - name: Build 44 | run: npm run bundle 45 | env: 46 | NODE_OPTIONS: '--max_old_space_size=4096' 47 | 48 | - name: Set up Node.js 49 | uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 50 | with: 51 | cache: npm 52 | node-version: ${{ matrix.node }} 53 | 54 | - name: Disable AppArmor 55 | if: ${{ matrix.os == 'ubuntu-latest' }} 56 | shell: bash 57 | run: echo 0 | sudo tee /proc/sys/kernel/apparmor_restrict_unprivileged_userns 58 | 59 | - name: Run tests (node20) 60 | if: ${{ matrix.node == '20' }} 61 | shell: bash 62 | run: npm run test:node20 63 | 64 | - name: Run tests 65 | shell: bash 66 | if: ${{ matrix.node != '20' }} 67 | run: npm run test:no-build 68 | 69 | # Gating job for branch protection. 70 | test-success: 71 | name: '[Required] Tests passed' 72 | runs-on: ubuntu-latest 73 | needs: run-tests 74 | if: ${{ !cancelled() }} 75 | steps: 76 | - if: ${{ needs.run-tests.result != 'success' }} 77 | run: 'exit 1' 78 | - run: 'exit 0' 79 | -------------------------------------------------------------------------------- /scripts/eslint_rules/check-license-rule.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | const currentYear = new Date().getFullYear(); 8 | const licenseHeader = ` 9 | /** 10 | * @license 11 | * Copyright ${currentYear} Google LLC 12 | * SPDX-License-Identifier: Apache-2.0 13 | */ 14 | `; 15 | 16 | export default { 17 | name: 'check-license', 18 | meta: { 19 | type: 'layout', 20 | docs: { 21 | description: 'Validate existence of license header', 22 | }, 23 | fixable: 'code', 24 | schema: [], 25 | messages: { 26 | licenseRule: 'Add license header.', 27 | }, 28 | }, 29 | defaultOptions: [], 30 | create(context) { 31 | const sourceCode = context.getSourceCode(); 32 | const comments = sourceCode.getAllComments(); 33 | let insertAfter = [0, 0]; 34 | let header = null; 35 | // Check only the first 2 comments 36 | for (let index = 0; index < 2; index++) { 37 | const comment = comments[index]; 38 | if (!comment) { 39 | break; 40 | } 41 | // Shebang comments should be at the top 42 | if ( 43 | comment.type === 'Shebang' || 44 | (comment.type === 'Line' && comment.value.startsWith('#!')) 45 | ) { 46 | insertAfter = comment.range; 47 | continue; 48 | } 49 | if (comment.type === 'Block') { 50 | header = comment; 51 | break; 52 | } 53 | } 54 | 55 | return { 56 | Program(node) { 57 | if (context.getFilename().endsWith('.json')) { 58 | return; 59 | } 60 | 61 | if ( 62 | header && 63 | (header.value.includes('@license') || 64 | header.value.includes('License') || 65 | header.value.includes('Copyright')) 66 | ) { 67 | return; 68 | } 69 | 70 | // Add header license 71 | if (!header || !header.value.includes('@license')) { 72 | context.report({ 73 | node: node, 74 | messageId: 'licenseRule', 75 | fix(fixer) { 76 | return fixer.insertTextAfterRange(insertAfter, licenseHeader); 77 | }, 78 | }); 79 | } 80 | }, 81 | }; 82 | }, 83 | }; 84 | -------------------------------------------------------------------------------- /src/tools/snapshot.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import {zod} from '../third_party/index.js'; 8 | 9 | import {ToolCategory} from './categories.js'; 10 | import {defineTool, timeoutSchema} from './ToolDefinition.js'; 11 | 12 | export const takeSnapshot = defineTool({ 13 | name: 'take_snapshot', 14 | description: `Take a text snapshot of the currently selected page based on the a11y tree. The snapshot lists page elements along with a unique 15 | identifier (uid). Always use the latest snapshot. Prefer taking a snapshot over taking a screenshot. The snapshot indicates the element selected 16 | in the DevTools Elements panel (if any).`, 17 | annotations: { 18 | category: ToolCategory.DEBUGGING, 19 | // Not read-only due to filePath param. 20 | readOnlyHint: false, 21 | }, 22 | schema: { 23 | verbose: zod 24 | .boolean() 25 | .optional() 26 | .describe( 27 | 'Whether to include all possible information available in the full a11y tree. Default is false.', 28 | ), 29 | filePath: zod 30 | .string() 31 | .optional() 32 | .describe( 33 | 'The absolute path, or a path relative to the current working directory, to save the snapshot to instead of attaching it to the response.', 34 | ), 35 | }, 36 | handler: async (request, response) => { 37 | response.includeSnapshot({ 38 | verbose: request.params.verbose ?? false, 39 | filePath: request.params.filePath, 40 | }); 41 | }, 42 | }); 43 | 44 | export const waitFor = defineTool({ 45 | name: 'wait_for', 46 | description: `Wait for the specified text to appear on the selected page.`, 47 | annotations: { 48 | category: ToolCategory.NAVIGATION, 49 | readOnlyHint: true, 50 | }, 51 | schema: { 52 | text: zod.string().describe('Text to appear on the page'), 53 | ...timeoutSchema, 54 | }, 55 | handler: async (request, response, context) => { 56 | await context.waitForTextOnPage( 57 | request.params.text, 58 | request.params.timeout, 59 | ); 60 | 61 | response.appendResponseLine( 62 | `Element with text "${request.params.text}" found.`, 63 | ); 64 | 65 | response.includeSnapshot(); 66 | }, 67 | }); 68 | -------------------------------------------------------------------------------- /src/utils/pagination.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import type {PaginationOptions} from './types.js'; 8 | 9 | export interface PaginationResult { 10 | items: readonly Item[]; 11 | currentPage: number; 12 | totalPages: number; 13 | hasNextPage: boolean; 14 | hasPreviousPage: boolean; 15 | startIndex: number; 16 | endIndex: number; 17 | invalidPage: boolean; 18 | } 19 | 20 | const DEFAULT_PAGE_SIZE = 20; 21 | 22 | export function paginate( 23 | items: readonly Item[], 24 | options?: PaginationOptions, 25 | ): PaginationResult { 26 | const total = items.length; 27 | 28 | if (!options || noPaginationOptions(options)) { 29 | return { 30 | items, 31 | currentPage: 0, 32 | totalPages: 1, 33 | hasNextPage: false, 34 | hasPreviousPage: false, 35 | startIndex: 0, 36 | endIndex: total, 37 | invalidPage: false, 38 | }; 39 | } 40 | 41 | const pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE; 42 | const totalPages = Math.max(1, Math.ceil(total / pageSize)); 43 | const {currentPage, invalidPage} = resolvePageIndex( 44 | options.pageIdx, 45 | totalPages, 46 | ); 47 | 48 | const startIndex = currentPage * pageSize; 49 | const pageItems = items.slice(startIndex, startIndex + pageSize); 50 | const endIndex = startIndex + pageItems.length; 51 | 52 | return { 53 | items: pageItems, 54 | currentPage, 55 | totalPages, 56 | hasNextPage: currentPage < totalPages - 1, 57 | hasPreviousPage: currentPage > 0, 58 | startIndex, 59 | endIndex, 60 | invalidPage, 61 | }; 62 | } 63 | 64 | function noPaginationOptions(options: PaginationOptions): boolean { 65 | return options.pageSize === undefined && options.pageIdx === undefined; 66 | } 67 | 68 | function resolvePageIndex( 69 | pageIdx: number | undefined, 70 | totalPages: number, 71 | ): { 72 | currentPage: number; 73 | invalidPage: boolean; 74 | } { 75 | if (pageIdx === undefined) { 76 | return {currentPage: 0, invalidPage: false}; 77 | } 78 | 79 | if (pageIdx < 0 || pageIdx >= totalPages) { 80 | return {currentPage: 0, invalidPage: true}; 81 | } 82 | 83 | return {currentPage: pageIdx, invalidPage: false}; 84 | } 85 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/01-bug.yml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: File a bug report for chrome-devtools-mcp 3 | title: '' 4 | type: 'Bug' 5 | body: 6 | - id: description 7 | type: textarea 8 | attributes: 9 | label: Description of the bug 10 | description: > 11 | A clear and concise description of what the bug is. 12 | placeholder: 13 | validations: 14 | required: true 15 | 16 | - id: reproduce 17 | type: textarea 18 | attributes: 19 | label: Reproduction 20 | description: > 21 | Steps to reproduce the behavior: 22 | placeholder: | 23 | 1. Use tool '...' 24 | 2. Then use tool '...' 25 | 26 | - id: expectation 27 | type: textarea 28 | attributes: 29 | label: Expectation 30 | description: A clear and concise description of what you expected to happen. 31 | 32 | - id: mcp-configuration 33 | type: textarea 34 | attributes: 35 | label: MCP configuration 36 | 37 | - id: chrome-devtools-mcp-version 38 | type: input 39 | attributes: 40 | label: Chrome DevTools MCP version 41 | validations: 42 | required: true 43 | 44 | - id: chrome-version 45 | type: input 46 | attributes: 47 | label: Chrome version 48 | 49 | - id: coding-agent-version 50 | type: input 51 | attributes: 52 | label: Coding agent version 53 | 54 | - id: model-version 55 | type: input 56 | attributes: 57 | label: Model version 58 | 59 | - id: chat-log 60 | type: input 61 | attributes: 62 | label: Chat log 63 | 64 | - id: node-version 65 | type: input 66 | attributes: 67 | label: Node version 68 | description: > 69 | Please verify you have the minimal supported version listed in the README.md 70 | 71 | - id: operating-system 72 | type: dropdown 73 | attributes: 74 | label: Operating system 75 | description: What supported operating system are you running? 76 | options: 77 | - Windows 78 | - Windows Subsystem for Linux (WSL) 79 | - macOS 80 | - Linux 81 | 82 | - type: checkboxes 83 | id: provide-pr 84 | attributes: 85 | label: Extra checklist 86 | options: 87 | - label: I want to provide a PR to fix this bug 88 | -------------------------------------------------------------------------------- /docs/troubleshooting.md: -------------------------------------------------------------------------------- 1 | # Troubleshooting 2 | 3 | ## General tips 4 | 5 | - Run `npx chrome-devtools-mcp@latest --help` to test if the MCP server runs on your machine. 6 | - Make sure that your MCP client uses the same npm and node version as your terminal. 7 | - When configuring your MCP client, try using the `--yes` argument to `npx` to 8 | auto-accept installation prompt. 9 | - Find a specific error in the output of the `chrome-devtools-mcp` server. 10 | Usually, if your client is an IDE, logs would be in the Output pane. 11 | 12 | ## Debugging 13 | 14 | Start the MCP server with debugging enabled and a log file: 15 | 16 | - `DEBUG=* npx chrome-devtools-mcp@latest --log-file=/path/to/chrome-devtools-mcp.log` 17 | 18 | Using `.mcp.json` to debug while using a client: 19 | 20 | ```json 21 | { 22 | "mcpServers": { 23 | "chrome-devtools": { 24 | "type": "stdio", 25 | "command": "npx", 26 | "args": [ 27 | "chrome-devtools-mcp@latest", 28 | "--log-file", 29 | "/path/to/chrome-devtools-mcp.log" 30 | ], 31 | "env": { 32 | "DEBUG": "*" 33 | } 34 | } 35 | } 36 | } 37 | ``` 38 | 39 | ## Specific problems 40 | 41 | ### `Error [ERR_MODULE_NOT_FOUND]: Cannot find module ...` 42 | 43 | This usually indicates either a non-supported Node version is in use or that the 44 | `npm`/`npx` cache is corrupted. Try clearing the cache, uninstalling 45 | `chrome-devtools-mcp` and installing it again. Clear the cache by running: 46 | 47 | ```sh 48 | rm -rf ~/.npm/_npx # NOTE: this might remove other installed npx executables. 49 | npm cache clean --force 50 | ``` 51 | 52 | ### `Target closed` error 53 | 54 | This indicates that the browser could not be started. Make sure that no Chrome 55 | instances are running or close them. Make sure you have the latest stable Chrome 56 | installed and that [your system is able to run Chrome](https://support.google.com/chrome/a/answer/7100626?hl=en). 57 | 58 | ### Remote debugging between virtual machine (VM) and host fails 59 | 60 | When connecting DevTools inside a VM to Chrome running on the host, any domain is rejected by Chrome because of host header validation. Tunneling the port over SSH bypasses this restriction. In the VM, run: 61 | 62 | ```sh 63 | ssh -N -L 127.0.0.1:9222:127.0.0.1:9222 @ 64 | ``` 65 | 66 | Point the MCP connection inside the VM to `http://127.0.0.1:9222` and DevTools 67 | will reach the host browser without triggering the Host validation. 68 | -------------------------------------------------------------------------------- /src/formatters/snapshotFormatter.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import type {TextSnapshot, TextSnapshotNode} from '../McpContext.js'; 8 | 9 | export function formatSnapshotNode( 10 | root: TextSnapshotNode, 11 | snapshot?: TextSnapshot, 12 | depth = 0, 13 | ): string { 14 | const chunks: string[] = []; 15 | 16 | if (depth === 0) { 17 | // Top-level content of the snapshot. 18 | if ( 19 | snapshot?.verbose && 20 | snapshot?.hasSelectedElement && 21 | !snapshot.selectedElementUid 22 | ) { 23 | chunks.push(`Note: there is a selected element in the DevTools Elements panel but it is not included into the current a11y tree snapshot. 24 | Get a verbose snapshot to include all elements if you are interested in the selected element.\n\n`); 25 | } 26 | } 27 | 28 | const attributes = getAttributes(root); 29 | const line = 30 | ' '.repeat(depth * 2) + 31 | attributes.join(' ') + 32 | (root.id === snapshot?.selectedElementUid 33 | ? ' [selected in the DevTools Elements panel]' 34 | : '') + 35 | '\n'; 36 | chunks.push(line); 37 | 38 | for (const child of root.children) { 39 | chunks.push(formatSnapshotNode(child, snapshot, depth + 1)); 40 | } 41 | 42 | return chunks.join(''); 43 | } 44 | 45 | function getAttributes(serializedAXNodeRoot: TextSnapshotNode): string[] { 46 | const attributes = [`uid=${serializedAXNodeRoot.id}`]; 47 | if (serializedAXNodeRoot.role) { 48 | // To match representation in DevTools. 49 | attributes.push( 50 | serializedAXNodeRoot.role === 'none' 51 | ? 'ignored' 52 | : serializedAXNodeRoot.role, 53 | ); 54 | } 55 | if (serializedAXNodeRoot.name) { 56 | attributes.push(`"${serializedAXNodeRoot.name}"`); 57 | } 58 | 59 | const excluded = new Set([ 60 | 'id', 61 | 'role', 62 | 'name', 63 | 'elementHandle', 64 | 'children', 65 | 'backendNodeId', 66 | ]); 67 | 68 | const booleanPropertyMap: Record = { 69 | disabled: 'disableable', 70 | expanded: 'expandable', 71 | focused: 'focusable', 72 | selected: 'selectable', 73 | }; 74 | 75 | for (const attr of Object.keys(serializedAXNodeRoot).sort()) { 76 | if (excluded.has(attr)) { 77 | continue; 78 | } 79 | const value = (serializedAXNodeRoot as unknown as Record)[ 80 | attr 81 | ]; 82 | if (typeof value === 'boolean') { 83 | if (booleanPropertyMap[attr]) { 84 | attributes.push(booleanPropertyMap[attr]); 85 | } 86 | if (value) { 87 | attributes.push(attr); 88 | } 89 | } else if (typeof value === 'string' || typeof value === 'number') { 90 | attributes.push(`${attr}="${value}"`); 91 | } 92 | } 93 | return attributes; 94 | } 95 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute 2 | 3 | We'd love to accept your patches and contributions to this project. 4 | 5 | ## Before you begin 6 | 7 | ### Sign our Contributor License Agreement 8 | 9 | Contributions to this project must be accompanied by a 10 | [Contributor License Agreement](https://cla.developers.google.com/about) (CLA). 11 | You (or your employer) retain the copyright to your contribution; this simply 12 | gives us permission to use and redistribute your contributions as part of the 13 | project. 14 | 15 | If you or your current employer have already signed the Google CLA (even if it 16 | was for a different project), you probably don't need to do it again. 17 | 18 | Visit to see your current agreements or to 19 | sign a new one. 20 | 21 | ### Review our community guidelines 22 | 23 | This project follows 24 | [Google's Open Source Community Guidelines](https://opensource.google/conduct/). 25 | 26 | ## Contribution process 27 | 28 | ### Code reviews 29 | 30 | All submissions, including submissions by project members, require review. We 31 | use GitHub pull requests for this purpose. Consult 32 | [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more 33 | information on using pull requests. 34 | 35 | ### Conventional commits 36 | 37 | Please follow [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/) 38 | for PR and commit titles. 39 | 40 | ## Installation 41 | 42 | Check that you are using node version specified in .nvmrc, then run following commands: 43 | 44 | ```sh 45 | git clone https://github.com/ChromeDevTools/chrome-devtools-mcp.git 46 | cd chrome-devtools-mcp 47 | npm ci 48 | npm run build 49 | ``` 50 | 51 | ### Testing with @modelcontextprotocol/inspector 52 | 53 | ```sh 54 | npx @modelcontextprotocol/inspector node build/src/index.js 55 | ``` 56 | 57 | ### Testing with an MCP client 58 | 59 | Add the MCP server to your client's config. 60 | 61 | ```json 62 | { 63 | "mcpServers": { 64 | "chrome-devtools": { 65 | "command": "node", 66 | "args": ["/path-to/build/src/index.js"] 67 | } 68 | } 69 | } 70 | ``` 71 | 72 | #### Using with VS Code SSH 73 | 74 | When running the `@modelcontextprotocol/inspector` it spawns 2 services - one on port `6274` and one on `6277`. 75 | Usually VS Code automatically detects and forwards `6274` but fails to detect `6277` so you need to manually forward it. 76 | 77 | ### Debugging 78 | 79 | To write debug logs to `log.txt` in the working directory, run with the following commands: 80 | 81 | ```sh 82 | npx @modelcontextprotocol/inspector node build/src/index.js --log-file=/your/desired/path/log.txt 83 | ``` 84 | 85 | You can use the `DEBUG` environment variable as usual to control categories that are logged. 86 | 87 | ### Updating documentation 88 | 89 | When adding a new tool or updating a tool name or description, make sure to run `npm run docs` to generate the tool reference documentation. 90 | -------------------------------------------------------------------------------- /src/tools/script.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import {zod} from '../third_party/index.js'; 8 | import type {Frame, JSHandle, Page} from '../third_party/index.js'; 9 | 10 | import {ToolCategory} from './categories.js'; 11 | import {defineTool} from './ToolDefinition.js'; 12 | 13 | export const evaluateScript = defineTool({ 14 | name: 'evaluate_script', 15 | description: `Evaluate a JavaScript function inside the currently selected page. Returns the response as JSON 16 | so returned values have to JSON-serializable.`, 17 | annotations: { 18 | category: ToolCategory.DEBUGGING, 19 | readOnlyHint: false, 20 | }, 21 | schema: { 22 | function: zod.string().describe( 23 | `A JavaScript function declaration to be executed by the tool in the currently selected page. 24 | Example without arguments: \`() => { 25 | return document.title 26 | }\` or \`async () => { 27 | return await fetch("example.com") 28 | }\`. 29 | Example with arguments: \`(el) => { 30 | return el.innerText; 31 | }\` 32 | `, 33 | ), 34 | args: zod 35 | .array( 36 | zod.object({ 37 | uid: zod 38 | .string() 39 | .describe( 40 | 'The uid of an element on the page from the page content snapshot', 41 | ), 42 | }), 43 | ) 44 | .optional() 45 | .describe(`An optional list of arguments to pass to the function.`), 46 | }, 47 | handler: async (request, response, context) => { 48 | const args: Array> = []; 49 | try { 50 | const frames = new Set(); 51 | for (const el of request.params.args ?? []) { 52 | const handle = await context.getElementByUid(el.uid); 53 | frames.add(handle.frame); 54 | args.push(handle); 55 | } 56 | let pageOrFrame: Page | Frame; 57 | // We can't evaluate the element handle across frames 58 | if (frames.size > 1) { 59 | throw new Error( 60 | "Elements from different frames can't be evaluated together.", 61 | ); 62 | } else { 63 | pageOrFrame = [...frames.values()][0] ?? context.getSelectedPage(); 64 | } 65 | const fn = await pageOrFrame.evaluateHandle( 66 | `(${request.params.function})`, 67 | ); 68 | args.unshift(fn); 69 | await context.waitForEventsAfterAction(async () => { 70 | const result = await pageOrFrame.evaluate( 71 | async (fn, ...args) => { 72 | // @ts-expect-error no types. 73 | return JSON.stringify(await fn(...args)); 74 | }, 75 | ...args, 76 | ); 77 | response.appendResponseLine('Script ran on page and returned:'); 78 | response.appendResponseLine('```json'); 79 | response.appendResponseLine(`${result}`); 80 | response.appendResponseLine('```'); 81 | }); 82 | } finally { 83 | void Promise.allSettled(args.map(arg => arg.dispose())); 84 | } 85 | }, 86 | }); 87 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # vitepress build output 108 | **/.vitepress/dist 109 | 110 | # vitepress cache directory 111 | **/.vitepress/cache 112 | 113 | # Docusaurus cache and generated files 114 | .docusaurus 115 | 116 | # Serverless directories 117 | .serverless/ 118 | 119 | # FuseBox cache 120 | .fusebox/ 121 | 122 | # DynamoDB Local files 123 | .dynamodb/ 124 | 125 | # TernJS port file 126 | .tern-port 127 | 128 | # Stores VSCode versions used for testing VSCode extensions 129 | .vscode-test 130 | 131 | # Stores VSCode specific settings 132 | .vscode 133 | !.vscode/*.template.json 134 | !.vscode/extensions.json 135 | 136 | # yarn v2 137 | .yarn/cache 138 | .yarn/unplugged 139 | .yarn/build-state.yml 140 | .yarn/install-state.gz 141 | .pnp.* 142 | 143 | # Build output directory 144 | build/ 145 | 146 | log.txt 147 | 148 | .DS_Store -------------------------------------------------------------------------------- /.github/workflows/publish-to-npm-on-tag.yml: -------------------------------------------------------------------------------- 1 | name: publish-on-tag 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'chrome-devtools-mcp-v*' 7 | workflow_dispatch: 8 | inputs: 9 | npm-publish: 10 | description: 'Try to publish to NPM' 11 | default: false 12 | type: boolean 13 | mcp-publish: 14 | description: 'Try to publish to MCP registry' 15 | default: true 16 | type: boolean 17 | 18 | permissions: 19 | id-token: write # Required for OIDC 20 | contents: read 21 | 22 | jobs: 23 | publish-to-npm: 24 | runs-on: ubuntu-latest 25 | if: ${{ (github.event_name != 'workflow_dispatch') || (inputs.npm-publish && always()) }} 26 | steps: 27 | - name: Check out repository 28 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 29 | with: 30 | fetch-depth: 2 31 | 32 | - name: Set up Node.js 33 | uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 34 | with: 35 | cache: npm 36 | node-version-file: '.nvmrc' 37 | registry-url: 'https://registry.npmjs.org' 38 | 39 | # Ensure npm 11.5.1 or later is installed 40 | - name: Update npm 41 | run: npm install -g npm@latest 42 | 43 | - name: Install dependencies 44 | run: npm ci 45 | 46 | - name: Build and bundle 47 | run: npm run bundle 48 | env: 49 | NODE_ENV: 'production' 50 | 51 | - name: Publish 52 | run: | 53 | npm publish --provenance --access public 54 | 55 | publish-to-mcp-registry: 56 | runs-on: ubuntu-latest 57 | needs: publish-to-npm 58 | if: ${{ (github.event_name != 'workflow_dispatch' && needs.publish-to-npm.result == 'success') || (inputs.mcp-publish && always()) }} 59 | steps: 60 | - name: Check out repository 61 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 62 | with: 63 | fetch-depth: 2 64 | 65 | - name: Set up Node.js 66 | uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 67 | with: 68 | cache: npm 69 | node-version-file: '.nvmrc' 70 | registry-url: 'https://registry.npmjs.org' 71 | 72 | # Ensure npm 11.5.1 or later is installed 73 | - name: Update npm 74 | run: npm install -g npm@latest 75 | 76 | - name: Install dependencies 77 | run: npm ci 78 | 79 | - name: Build and bundle 80 | run: npm run bundle 81 | env: 82 | NODE_ENV: 'production' 83 | 84 | - name: Install MCP Publisher 85 | run: | 86 | export OS=$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/') 87 | curl -L "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_${OS}.tar.gz" | tar xz mcp-publisher 88 | 89 | - name: Login to MCP Registry 90 | run: ./mcp-publisher login github-oidc 91 | 92 | - name: Publish to MCP Registry 93 | run: ./mcp-publisher publish 94 | -------------------------------------------------------------------------------- /tests/index.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import assert from 'node:assert'; 8 | import fs from 'node:fs'; 9 | import {describe, it} from 'node:test'; 10 | 11 | import {Client} from '@modelcontextprotocol/sdk/client/index.js'; 12 | import {StdioClientTransport} from '@modelcontextprotocol/sdk/client/stdio.js'; 13 | import {executablePath} from 'puppeteer'; 14 | 15 | describe('e2e', () => { 16 | async function withClient(cb: (client: Client) => Promise) { 17 | const transport = new StdioClientTransport({ 18 | command: 'node', 19 | args: [ 20 | 'build/src/index.js', 21 | '--headless', 22 | '--isolated', 23 | '--executable-path', 24 | executablePath(), 25 | ], 26 | }); 27 | const client = new Client( 28 | { 29 | name: 'e2e-test', 30 | version: '1.0.0', 31 | }, 32 | { 33 | capabilities: {}, 34 | }, 35 | ); 36 | 37 | try { 38 | await client.connect(transport); 39 | await cb(client); 40 | } finally { 41 | await client.close(); 42 | } 43 | } 44 | it('calls a tool', async () => { 45 | await withClient(async client => { 46 | const result = await client.callTool({ 47 | name: 'list_pages', 48 | arguments: {}, 49 | }); 50 | assert.deepStrictEqual(result, { 51 | content: [ 52 | { 53 | type: 'text', 54 | text: '# list_pages response\n## Pages\n0: about:blank [selected]', 55 | }, 56 | ], 57 | }); 58 | }); 59 | }); 60 | 61 | it('calls a tool multiple times', async () => { 62 | await withClient(async client => { 63 | let result = await client.callTool({ 64 | name: 'list_pages', 65 | arguments: {}, 66 | }); 67 | result = await client.callTool({ 68 | name: 'list_pages', 69 | arguments: {}, 70 | }); 71 | assert.deepStrictEqual(result, { 72 | content: [ 73 | { 74 | type: 'text', 75 | text: '# list_pages response\n## Pages\n0: about:blank [selected]', 76 | }, 77 | ], 78 | }); 79 | }); 80 | }); 81 | 82 | it('has all tools', async () => { 83 | await withClient(async client => { 84 | const {tools} = await client.listTools(); 85 | const exposedNames = tools.map(t => t.name).sort(); 86 | const files = fs.readdirSync('build/src/tools'); 87 | const definedNames = []; 88 | for (const file of files) { 89 | if (file === 'ToolDefinition.js') { 90 | continue; 91 | } 92 | const fileTools = await import(`../src/tools/${file}`); 93 | for (const maybeTool of Object.values(fileTools)) { 94 | if ('name' in maybeTool) { 95 | definedNames.push(maybeTool.name); 96 | } 97 | } 98 | } 99 | definedNames.sort(); 100 | assert.deepStrictEqual(exposedNames, definedNames); 101 | }); 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /src/tools/console.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | import {zod} from '../third_party/index.js'; 7 | import type {ConsoleMessageType} from '../third_party/index.js'; 8 | 9 | import {ToolCategory} from './categories.js'; 10 | import {defineTool} from './ToolDefinition.js'; 11 | type ConsoleResponseType = ConsoleMessageType | 'issue'; 12 | 13 | const FILTERABLE_MESSAGE_TYPES: [ 14 | ConsoleResponseType, 15 | ...ConsoleResponseType[], 16 | ] = [ 17 | 'log', 18 | 'debug', 19 | 'info', 20 | 'error', 21 | 'warn', 22 | 'dir', 23 | 'dirxml', 24 | 'table', 25 | 'trace', 26 | 'clear', 27 | 'startGroup', 28 | 'startGroupCollapsed', 29 | 'endGroup', 30 | 'assert', 31 | 'profile', 32 | 'profileEnd', 33 | 'count', 34 | 'timeEnd', 35 | 'verbose', 36 | 'issue', 37 | ]; 38 | 39 | export const listConsoleMessages = defineTool({ 40 | name: 'list_console_messages', 41 | description: 42 | 'List all console messages for the currently selected page since the last navigation.', 43 | annotations: { 44 | category: ToolCategory.DEBUGGING, 45 | readOnlyHint: true, 46 | }, 47 | schema: { 48 | pageSize: zod 49 | .number() 50 | .int() 51 | .positive() 52 | .optional() 53 | .describe( 54 | 'Maximum number of messages to return. When omitted, returns all requests.', 55 | ), 56 | pageIdx: zod 57 | .number() 58 | .int() 59 | .min(0) 60 | .optional() 61 | .describe( 62 | 'Page number to return (0-based). When omitted, returns the first page.', 63 | ), 64 | types: zod 65 | .array(zod.enum(FILTERABLE_MESSAGE_TYPES)) 66 | .optional() 67 | .describe( 68 | 'Filter messages to only return messages of the specified resource types. When omitted or empty, returns all messages.', 69 | ), 70 | includePreservedMessages: zod 71 | .boolean() 72 | .default(false) 73 | .optional() 74 | .describe( 75 | 'Set to true to return the preserved messages over the last 3 navigations.', 76 | ), 77 | }, 78 | handler: async (request, response) => { 79 | response.setIncludeConsoleData(true, { 80 | pageSize: request.params.pageSize, 81 | pageIdx: request.params.pageIdx, 82 | types: request.params.types, 83 | includePreservedMessages: request.params.includePreservedMessages, 84 | }); 85 | }, 86 | }); 87 | 88 | export const getConsoleMessage = defineTool({ 89 | name: 'get_console_message', 90 | description: `Gets a console message by its ID. You can get all messages by calling ${listConsoleMessages.name}.`, 91 | annotations: { 92 | category: ToolCategory.DEBUGGING, 93 | readOnlyHint: true, 94 | }, 95 | schema: { 96 | msgid: zod 97 | .number() 98 | .describe( 99 | 'The msgid of a console message on the page from the listed console messages', 100 | ), 101 | }, 102 | handler: async (request, response) => { 103 | response.attachConsoleMessage(request.params.msgid); 104 | }, 105 | }); 106 | -------------------------------------------------------------------------------- /src/formatters/networkFormatter.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import {isUtf8} from 'node:buffer'; 8 | 9 | import type {HTTPRequest, HTTPResponse} from '../third_party/index.js'; 10 | 11 | const BODY_CONTEXT_SIZE_LIMIT = 10000; 12 | 13 | export function getShortDescriptionForRequest( 14 | request: HTTPRequest, 15 | id: number, 16 | selectedInDevToolsUI = false, 17 | ): string { 18 | // TODO truncate the URL 19 | return `reqid=${id} ${request.method()} ${request.url()} ${getStatusFromRequest(request)}${selectedInDevToolsUI ? ` [selected in the DevTools Network panel]` : ''}`; 20 | } 21 | 22 | export function getStatusFromRequest(request: HTTPRequest): string { 23 | const httpResponse = request.response(); 24 | const failure = request.failure(); 25 | let status: string; 26 | if (httpResponse) { 27 | const responseStatus = httpResponse.status(); 28 | status = 29 | responseStatus >= 200 && responseStatus <= 299 30 | ? `[success - ${responseStatus}]` 31 | : `[failed - ${responseStatus}]`; 32 | } else if (failure) { 33 | status = `[failed - ${failure.errorText}]`; 34 | } else { 35 | status = '[pending]'; 36 | } 37 | return status; 38 | } 39 | 40 | export function getFormattedHeaderValue( 41 | headers: Record, 42 | ): string[] { 43 | const response: string[] = []; 44 | for (const [name, value] of Object.entries(headers)) { 45 | response.push(`- ${name}:${value}`); 46 | } 47 | return response; 48 | } 49 | 50 | export async function getFormattedResponseBody( 51 | httpResponse: HTTPResponse, 52 | sizeLimit = BODY_CONTEXT_SIZE_LIMIT, 53 | ): Promise { 54 | try { 55 | const responseBuffer = await httpResponse.buffer(); 56 | 57 | if (isUtf8(responseBuffer)) { 58 | const responseAsTest = responseBuffer.toString('utf-8'); 59 | 60 | if (responseAsTest.length === 0) { 61 | return ``; 62 | } 63 | 64 | return `${getSizeLimitedString(responseAsTest, sizeLimit)}`; 65 | } 66 | 67 | return ``; 68 | } catch { 69 | return ``; 70 | } 71 | } 72 | 73 | export async function getFormattedRequestBody( 74 | httpRequest: HTTPRequest, 75 | sizeLimit: number = BODY_CONTEXT_SIZE_LIMIT, 76 | ): Promise { 77 | if (httpRequest.hasPostData()) { 78 | const data = httpRequest.postData(); 79 | 80 | if (data) { 81 | return `${getSizeLimitedString(data, sizeLimit)}`; 82 | } 83 | 84 | try { 85 | const fetchData = await httpRequest.fetchPostData(); 86 | 87 | if (fetchData) { 88 | return `${getSizeLimitedString(fetchData, sizeLimit)}`; 89 | } 90 | } catch { 91 | return ``; 92 | } 93 | } 94 | 95 | return; 96 | } 97 | 98 | function getSizeLimitedString(text: string, sizeLimit: number) { 99 | if (text.length > sizeLimit) { 100 | return `${text.substring(0, sizeLimit) + '... '}`; 101 | } 102 | 103 | return `${text}`; 104 | } 105 | -------------------------------------------------------------------------------- /tests/browser.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import assert from 'node:assert'; 8 | import os from 'node:os'; 9 | import path from 'node:path'; 10 | import {describe, it} from 'node:test'; 11 | 12 | import {executablePath} from 'puppeteer'; 13 | 14 | import {ensureBrowserConnected, launch} from '../src/browser.js'; 15 | 16 | describe('browser', () => { 17 | it('cannot launch multiple times with the same profile', async () => { 18 | const tmpDir = os.tmpdir(); 19 | const folderPath = path.join(tmpDir, `temp-folder-${crypto.randomUUID()}`); 20 | const browser1 = await launch({ 21 | headless: true, 22 | isolated: false, 23 | userDataDir: folderPath, 24 | executablePath: executablePath(), 25 | devtools: false, 26 | }); 27 | try { 28 | try { 29 | const browser2 = await launch({ 30 | headless: true, 31 | isolated: false, 32 | userDataDir: folderPath, 33 | executablePath: executablePath(), 34 | devtools: false, 35 | }); 36 | await browser2.close(); 37 | assert.fail('not reached'); 38 | } catch (err) { 39 | assert.strictEqual( 40 | err.message, 41 | `The browser is already running for ${folderPath}. Use --isolated to run multiple browser instances.`, 42 | ); 43 | } 44 | } finally { 45 | await browser1.close(); 46 | } 47 | }); 48 | 49 | it('launches with the initial viewport', async () => { 50 | const tmpDir = os.tmpdir(); 51 | const folderPath = path.join(tmpDir, `temp-folder-${crypto.randomUUID()}`); 52 | const browser = await launch({ 53 | headless: true, 54 | isolated: false, 55 | userDataDir: folderPath, 56 | executablePath: executablePath(), 57 | viewport: { 58 | width: 1501, 59 | height: 801, 60 | }, 61 | devtools: false, 62 | }); 63 | try { 64 | const [page] = await browser.pages(); 65 | const result = await page.evaluate(() => { 66 | return {width: window.innerWidth, height: window.innerHeight}; 67 | }); 68 | assert.deepStrictEqual(result, { 69 | width: 1501, 70 | height: 801, 71 | }); 72 | } finally { 73 | await browser.close(); 74 | } 75 | }); 76 | it('connects to an existing browser with userDataDir', async () => { 77 | const tmpDir = os.tmpdir(); 78 | const folderPath = path.join(tmpDir, `temp-folder-${crypto.randomUUID()}`); 79 | const browser = await launch({ 80 | headless: true, 81 | isolated: false, 82 | userDataDir: folderPath, 83 | executablePath: executablePath(), 84 | devtools: false, 85 | args: ['--remote-debugging-port=0'], 86 | }); 87 | try { 88 | const connectedBrowser = await ensureBrowserConnected({ 89 | userDataDir: folderPath, 90 | devtools: false, 91 | }); 92 | assert.ok(connectedBrowser); 93 | assert.ok(connectedBrowser.connected); 94 | connectedBrowser.disconnect(); 95 | } finally { 96 | await browser.close(); 97 | } 98 | }); 99 | }); 100 | -------------------------------------------------------------------------------- /tests/server.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import http, { 8 | type IncomingMessage, 9 | type Server, 10 | type ServerResponse, 11 | } from 'node:http'; 12 | import {before, after, afterEach} from 'node:test'; 13 | 14 | import {html} from './utils.js'; 15 | 16 | class TestServer { 17 | #port: number; 18 | #server: Server; 19 | 20 | static randomPort() { 21 | /** 22 | * Some ports are restricted by Chromium and will fail to connect 23 | * to prevent we start after the 24 | * 25 | * https://source.chromium.org/chromium/chromium/src/+/main:net/base/port_util.cc;l=107?q=kRestrictedPorts&ss=chromium 26 | */ 27 | const min = 10101; 28 | const max = 20202; 29 | return Math.floor(Math.random() * (max - min + 1) + min); 30 | } 31 | 32 | #routes: Record void> = 33 | {}; 34 | 35 | constructor(port: number) { 36 | this.#port = port; 37 | this.#server = http.createServer((req, res) => this.#handle(req, res)); 38 | } 39 | 40 | get baseUrl(): string { 41 | return `http://localhost:${this.#port}`; 42 | } 43 | 44 | getRoute(path: string) { 45 | if (!this.#routes[path]) { 46 | throw new Error(`Route ${path} was not setup.`); 47 | } 48 | return `${this.baseUrl}${path}`; 49 | } 50 | 51 | addHtmlRoute(path: string, htmlContent: string) { 52 | if (this.#routes[path]) { 53 | throw new Error(`Route ${path} was already setup.`); 54 | } 55 | this.#routes[path] = (_req: IncomingMessage, res: ServerResponse) => { 56 | res.setHeader('Content-Type', 'text/html; charset=utf-8'); 57 | res.statusCode = 200; 58 | res.end(htmlContent); 59 | }; 60 | } 61 | 62 | addRoute( 63 | path: string, 64 | handler: (req: IncomingMessage, res: ServerResponse) => void, 65 | ) { 66 | if (this.#routes[path]) { 67 | throw new Error(`Route ${path} was already setup.`); 68 | } 69 | this.#routes[path] = handler; 70 | } 71 | 72 | #handle(req: IncomingMessage, res: ServerResponse) { 73 | const url = req.url ?? ''; 74 | const routeHandler = this.#routes[url]; 75 | 76 | if (routeHandler) { 77 | routeHandler(req, res); 78 | } else { 79 | res.writeHead(404, {'Content-Type': 'text/html'}); 80 | res.end( 81 | html`

404 - Not Found

The requested page does not exist.

`, 82 | ); 83 | } 84 | } 85 | 86 | restore() { 87 | this.#routes = {}; 88 | } 89 | 90 | start(): Promise { 91 | return new Promise(res => { 92 | this.#server.listen(this.#port, res); 93 | }); 94 | } 95 | 96 | stop(): Promise { 97 | return new Promise((res, rej) => { 98 | this.#server.close(err => { 99 | if (err) { 100 | rej(err); 101 | } else { 102 | res(); 103 | } 104 | }); 105 | }); 106 | } 107 | } 108 | 109 | export function serverHooks() { 110 | const server = new TestServer(TestServer.randomPort()); 111 | before(async () => { 112 | await server.start(); 113 | }); 114 | after(async () => { 115 | await server.stop(); 116 | }); 117 | afterEach(() => { 118 | server.restore(); 119 | }); 120 | 121 | return server; 122 | } 123 | -------------------------------------------------------------------------------- /src/tools/emulation.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import {zod, PredefinedNetworkConditions} from '../third_party/index.js'; 8 | 9 | import {ToolCategory} from './categories.js'; 10 | import {defineTool} from './ToolDefinition.js'; 11 | 12 | const throttlingOptions: [string, ...string[]] = [ 13 | 'No emulation', 14 | 'Offline', 15 | ...Object.keys(PredefinedNetworkConditions), 16 | ]; 17 | 18 | export const emulate = defineTool({ 19 | name: 'emulate', 20 | description: `Emulates various features on the selected page.`, 21 | annotations: { 22 | category: ToolCategory.EMULATION, 23 | readOnlyHint: false, 24 | }, 25 | schema: { 26 | networkConditions: zod 27 | .enum(throttlingOptions) 28 | .optional() 29 | .describe( 30 | `Throttle network. Set to "No emulation" to disable. If omitted, conditions remain unchanged.`, 31 | ), 32 | cpuThrottlingRate: zod 33 | .number() 34 | .min(1) 35 | .max(20) 36 | .optional() 37 | .describe( 38 | 'Represents the CPU slowdown factor. Set the rate to 1 to disable throttling. If omitted, throttling remains unchanged.', 39 | ), 40 | geolocation: zod 41 | .object({ 42 | latitude: zod 43 | .number() 44 | .min(-90) 45 | .max(90) 46 | .describe('Latitude between -90 and 90.'), 47 | longitude: zod 48 | .number() 49 | .min(-180) 50 | .max(180) 51 | .describe('Longitude between -180 and 180.'), 52 | }) 53 | .nullable() 54 | .optional() 55 | .describe( 56 | 'Geolocation to emulate. Set to null to clear the geolocation override.', 57 | ), 58 | }, 59 | handler: async (request, _response, context) => { 60 | const page = context.getSelectedPage(); 61 | const {networkConditions, cpuThrottlingRate, geolocation} = request.params; 62 | 63 | if (networkConditions) { 64 | if (networkConditions === 'No emulation') { 65 | await page.emulateNetworkConditions(null); 66 | context.setNetworkConditions(null); 67 | } else if (networkConditions === 'Offline') { 68 | await page.emulateNetworkConditions({ 69 | offline: true, 70 | download: 0, 71 | upload: 0, 72 | latency: 0, 73 | }); 74 | context.setNetworkConditions('Offline'); 75 | } else if (networkConditions in PredefinedNetworkConditions) { 76 | const networkCondition = 77 | PredefinedNetworkConditions[ 78 | networkConditions as keyof typeof PredefinedNetworkConditions 79 | ]; 80 | await page.emulateNetworkConditions(networkCondition); 81 | context.setNetworkConditions(networkConditions); 82 | } 83 | } 84 | 85 | if (cpuThrottlingRate) { 86 | await page.emulateCPUThrottling(cpuThrottlingRate); 87 | context.setCpuThrottlingRate(cpuThrottlingRate); 88 | } 89 | 90 | if (geolocation !== undefined) { 91 | if (geolocation === null) { 92 | await page.setGeolocation({latitude: 0, longitude: 0}); 93 | context.setGeolocation(null); 94 | } else { 95 | await page.setGeolocation(geolocation); 96 | context.setGeolocation(geolocation); 97 | } 98 | } 99 | }, 100 | }); 101 | -------------------------------------------------------------------------------- /scripts/post-build.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import * as fs from 'node:fs'; 8 | import * as path from 'node:path'; 9 | 10 | import {sed} from './sed.ts'; 11 | 12 | const BUILD_DIR = path.join(process.cwd(), 'build'); 13 | 14 | /** 15 | * Writes content to a file. 16 | * @param filePath The path to the file. 17 | * @param content The content to write. 18 | */ 19 | function writeFile(filePath: string, content: string): void { 20 | fs.writeFileSync(filePath, content, 'utf-8'); 21 | } 22 | 23 | function main(): void { 24 | const devtoolsThirdPartyPath = 25 | 'node_modules/chrome-devtools-frontend/front_end/third_party'; 26 | const devtoolsFrontEndCorePath = 27 | 'node_modules/chrome-devtools-frontend/front_end/core'; 28 | 29 | // Create i18n mock 30 | const i18nDir = path.join(BUILD_DIR, devtoolsFrontEndCorePath, 'i18n'); 31 | const localesFile = path.join(i18nDir, 'locales.js'); 32 | const localesContent = ` 33 | export const LOCALES = [ 34 | 'en-US', 35 | ]; 36 | 37 | export const BUNDLED_LOCALES = [ 38 | 'en-US', 39 | ]; 40 | 41 | export const DEFAULT_LOCALE = 'en-US'; 42 | 43 | export const REMOTE_FETCH_PATTERN = '@HOST@/remote/serve_file/@VERSION@/core/i18n/locales/@LOCALE@.json'; 44 | 45 | export const LOCAL_FETCH_PATTERN = './locales/@LOCALE@.json';`; 46 | writeFile(localesFile, localesContent); 47 | 48 | // Create codemirror.next mock. 49 | const codeMirrorDir = path.join( 50 | BUILD_DIR, 51 | devtoolsThirdPartyPath, 52 | 'codemirror.next', 53 | ); 54 | fs.mkdirSync(codeMirrorDir, {recursive: true}); 55 | const codeMirrorFile = path.join(codeMirrorDir, 'codemirror.next.js'); 56 | const codeMirrorContent = `export default {}`; 57 | writeFile(codeMirrorFile, codeMirrorContent); 58 | 59 | // Create root mock 60 | const rootDir = path.join(BUILD_DIR, devtoolsFrontEndCorePath, 'root'); 61 | fs.mkdirSync(rootDir, {recursive: true}); 62 | const runtimeFile = path.join(rootDir, 'Runtime.js'); 63 | const runtimeContent = ` 64 | export function getChromeVersion() { return ''; }; 65 | export const hostConfig = {}; 66 | export const Runtime = { 67 | isDescriptorEnabled: () => true, 68 | queryParam: () => null, 69 | } 70 | export const experiments = { 71 | isEnabled: () => false, 72 | } 73 | `; 74 | writeFile(runtimeFile, runtimeContent); 75 | 76 | // Update protocol_client to remove: 77 | // 1. self.Protocol assignment 78 | // 2. Call to register backend commands. 79 | const protocolClientDir = path.join( 80 | BUILD_DIR, 81 | devtoolsFrontEndCorePath, 82 | 'protocol_client', 83 | ); 84 | const clientFile = path.join(protocolClientDir, 'protocol_client.js'); 85 | const globalAssignment = /self\.Protocol = self\.Protocol \|\| \{\};/; 86 | const registerCommands = 87 | /InspectorBackendCommands\.registerCommands\(InspectorBackend\.inspectorBackend\);/; 88 | sed(clientFile, globalAssignment, ''); 89 | sed(clientFile, registerCommands, ''); 90 | 91 | copyDevToolsDescriptionFiles(); 92 | } 93 | 94 | function copyDevToolsDescriptionFiles() { 95 | const devtoolsIssuesDescriptionPath = 96 | 'node_modules/chrome-devtools-frontend/front_end/models/issues_manager/descriptions'; 97 | const sourceDir = path.join(process.cwd(), devtoolsIssuesDescriptionPath); 98 | const destDir = path.join( 99 | BUILD_DIR, 100 | 'src', 101 | 'third_party', 102 | 'issue-descriptions', 103 | ); 104 | fs.cpSync(sourceDir, destDir, {recursive: true}); 105 | } 106 | 107 | main(); 108 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chrome-devtools-mcp", 3 | "version": "0.12.1", 4 | "description": "MCP server for Chrome DevTools", 5 | "type": "module", 6 | "bin": "./build/src/index.js", 7 | "main": "index.js", 8 | "scripts": { 9 | "clean": "node -e \"require('fs').rmSync('build', {recursive: true, force: true})\"", 10 | "bundle": "npm run clean && npm run build && rollup -c rollup.config.mjs && node -e \"require('fs').rmSync('build/node_modules', {recursive: true, force: true})\"", 11 | "build": "tsc && node --experimental-strip-types --no-warnings=ExperimentalWarning scripts/post-build.ts", 12 | "typecheck": "tsc --noEmit", 13 | "format": "eslint --cache --fix . && prettier --write --cache .", 14 | "check-format": "eslint --cache . && prettier --check --cache .;", 15 | "docs": "npm run build && npm run docs:generate && npm run format", 16 | "docs:generate": "node --experimental-strip-types scripts/generate-docs.ts", 17 | "start": "npm run build && node build/src/index.js", 18 | "start-debug": "DEBUG=mcp:* DEBUG_COLORS=false npm run build && node build/src/index.js", 19 | "test:node20": "node --import ./build/tests/setup.js --test-reporter spec --test-force-exit --test build/tests", 20 | "test:no-build": "node --import ./build/tests/setup.js --no-warnings=ExperimentalWarning --experimental-print-required-tla --test-reporter spec --test-force-exit --test \"build/tests/**/*.test.js\"", 21 | "test": "npm run build && npm run test:no-build", 22 | "test:only": "npm run build && npm run test:only:no-build", 23 | "test:only:no-build": "node --import ./build/tests/setup.js --no-warnings=ExperimentalWarning --test-reporter spec --test-force-exit --test --test-only \"build/tests/**/*.test.js\"", 24 | "test:update-snapshots": "npm run build && node --import ./build/tests/setup.js --no-warnings=ExperimentalWarning --test-force-exit --test --test-update-snapshots \"build/tests/**/*.test.js\"", 25 | "prepare": "node --experimental-strip-types scripts/prepare.ts", 26 | "verify-server-json-version": "node --experimental-strip-types scripts/verify-server-json-version.ts" 27 | }, 28 | "files": [ 29 | "build/src", 30 | "build/node_modules", 31 | "LICENSE", 32 | "!*.tsbuildinfo" 33 | ], 34 | "repository": "ChromeDevTools/chrome-devtools-mcp", 35 | "author": "Google LLC", 36 | "license": "Apache-2.0", 37 | "bugs": { 38 | "url": "https://github.com/ChromeDevTools/chrome-devtools-mcp/issues" 39 | }, 40 | "homepage": "https://github.com/ChromeDevTools/chrome-devtools-mcp#readme", 41 | "mcpName": "io.github.ChromeDevTools/chrome-devtools-mcp", 42 | "devDependencies": { 43 | "@eslint/js": "^9.35.0", 44 | "@modelcontextprotocol/sdk": "1.24.3", 45 | "@rollup/plugin-commonjs": "^29.0.0", 46 | "@rollup/plugin-json": "^6.1.0", 47 | "@rollup/plugin-node-resolve": "^16.0.3", 48 | "@stylistic/eslint-plugin": "^5.4.0", 49 | "@types/debug": "^4.1.12", 50 | "@types/filesystem": "^0.0.36", 51 | "@types/node": "^25.0.0", 52 | "@types/sinon": "^21.0.0", 53 | "@types/yargs": "^17.0.33", 54 | "@typescript-eslint/eslint-plugin": "^8.43.0", 55 | "@typescript-eslint/parser": "^8.43.0", 56 | "chrome-devtools-frontend": "1.0.1555430", 57 | "core-js": "3.47.0", 58 | "debug": "4.4.3", 59 | "eslint": "^9.35.0", 60 | "eslint-import-resolver-typescript": "^4.4.4", 61 | "eslint-plugin-import": "^2.32.0", 62 | "globals": "^16.4.0", 63 | "prettier": "^3.6.2", 64 | "puppeteer": "24.33.0", 65 | "rollup": "4.53.3", 66 | "rollup-plugin-cleanup": "^3.2.1", 67 | "rollup-plugin-license": "^3.6.0", 68 | "sinon": "^21.0.0", 69 | "typescript": "^5.9.2", 70 | "typescript-eslint": "^8.43.0", 71 | "yargs": "18.0.0" 72 | }, 73 | "engines": { 74 | "node": "^20.19.0 || ^22.12.0 || >=23" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/tools/screenshot.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import {zod} from '../third_party/index.js'; 8 | import type {ElementHandle, Page} from '../third_party/index.js'; 9 | 10 | import {ToolCategory} from './categories.js'; 11 | import {defineTool} from './ToolDefinition.js'; 12 | 13 | export const screenshot = defineTool({ 14 | name: 'take_screenshot', 15 | description: `Take a screenshot of the page or element.`, 16 | annotations: { 17 | category: ToolCategory.DEBUGGING, 18 | // Not read-only due to filePath param. 19 | readOnlyHint: false, 20 | }, 21 | schema: { 22 | format: zod 23 | .enum(['png', 'jpeg', 'webp']) 24 | .default('png') 25 | .describe('Type of format to save the screenshot as. Default is "png"'), 26 | quality: zod 27 | .number() 28 | .min(0) 29 | .max(100) 30 | .optional() 31 | .describe( 32 | 'Compression quality for JPEG and WebP formats (0-100). Higher values mean better quality but larger file sizes. Ignored for PNG format.', 33 | ), 34 | uid: zod 35 | .string() 36 | .optional() 37 | .describe( 38 | 'The uid of an element on the page from the page content snapshot. If omitted takes a pages screenshot.', 39 | ), 40 | fullPage: zod 41 | .boolean() 42 | .optional() 43 | .describe( 44 | 'If set to true takes a screenshot of the full page instead of the currently visible viewport. Incompatible with uid.', 45 | ), 46 | filePath: zod 47 | .string() 48 | .optional() 49 | .describe( 50 | 'The absolute path, or a path relative to the current working directory, to save the screenshot to instead of attaching it to the response.', 51 | ), 52 | }, 53 | handler: async (request, response, context) => { 54 | if (request.params.uid && request.params.fullPage) { 55 | throw new Error('Providing both "uid" and "fullPage" is not allowed.'); 56 | } 57 | 58 | let pageOrHandle: Page | ElementHandle; 59 | if (request.params.uid) { 60 | pageOrHandle = await context.getElementByUid(request.params.uid); 61 | } else { 62 | pageOrHandle = context.getSelectedPage(); 63 | } 64 | 65 | const format = request.params.format; 66 | const quality = format === 'png' ? undefined : request.params.quality; 67 | 68 | const screenshot = await pageOrHandle.screenshot({ 69 | type: format, 70 | fullPage: request.params.fullPage, 71 | quality, 72 | optimizeForSpeed: true, // Bonus: optimize encoding for speed 73 | }); 74 | 75 | if (request.params.uid) { 76 | response.appendResponseLine( 77 | `Took a screenshot of node with uid "${request.params.uid}".`, 78 | ); 79 | } else if (request.params.fullPage) { 80 | response.appendResponseLine( 81 | 'Took a screenshot of the full current page.', 82 | ); 83 | } else { 84 | response.appendResponseLine( 85 | "Took a screenshot of the current page's viewport.", 86 | ); 87 | } 88 | 89 | if (request.params.filePath) { 90 | const file = await context.saveFile(screenshot, request.params.filePath); 91 | response.appendResponseLine(`Saved screenshot to ${file.filename}.`); 92 | } else if (screenshot.length >= 2_000_000) { 93 | const {filename} = await context.saveTemporaryFile( 94 | screenshot, 95 | `image/${request.params.format}`, 96 | ); 97 | response.appendResponseLine(`Saved screenshot to ${filename}.`); 98 | } else { 99 | response.attachImage({ 100 | mimeType: `image/${request.params.format}`, 101 | data: Buffer.from(screenshot).toString('base64'), 102 | }); 103 | } 104 | }, 105 | }); 106 | -------------------------------------------------------------------------------- /tests/tools/snapshot.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import assert from 'node:assert'; 8 | import {describe, it} from 'node:test'; 9 | 10 | import {takeSnapshot, waitFor} from '../../src/tools/snapshot.js'; 11 | import {html, withMcpContext} from '../utils.js'; 12 | 13 | describe('snapshot', () => { 14 | describe('browser_snapshot', () => { 15 | it('includes a snapshot', async () => { 16 | await withMcpContext(async (response, context) => { 17 | await takeSnapshot.handler({params: {}}, response, context); 18 | assert.ok(response.includeSnapshot); 19 | }); 20 | }); 21 | }); 22 | describe('browser_wait_for', () => { 23 | it('should work', async () => { 24 | await withMcpContext(async (response, context) => { 25 | const page = context.getSelectedPage(); 26 | 27 | await page.setContent( 28 | html`
Hello
World
`, 29 | ); 30 | await waitFor.handler( 31 | { 32 | params: { 33 | text: 'Hello', 34 | }, 35 | }, 36 | response, 37 | context, 38 | ); 39 | 40 | assert.equal( 41 | response.responseLines[0], 42 | 'Element with text "Hello" found.', 43 | ); 44 | assert.ok(response.includeSnapshot); 45 | }); 46 | }); 47 | it('should work with element that show up later', async () => { 48 | await withMcpContext(async (response, context) => { 49 | const page = context.getSelectedPage(); 50 | 51 | const handlePromise = waitFor.handler( 52 | { 53 | params: { 54 | text: 'Hello World', 55 | }, 56 | }, 57 | response, 58 | context, 59 | ); 60 | 61 | await page.setContent( 62 | html`
Hello
World
`, 63 | ); 64 | 65 | await handlePromise; 66 | 67 | assert.equal( 68 | response.responseLines[0], 69 | 'Element with text "Hello World" found.', 70 | ); 71 | assert.ok(response.includeSnapshot); 72 | }); 73 | }); 74 | it('should work with aria elements', async () => { 75 | await withMcpContext(async (response, context) => { 76 | const page = context.getSelectedPage(); 77 | 78 | await page.setContent( 79 | html`

Header

Text
`, 80 | ); 81 | 82 | await waitFor.handler( 83 | { 84 | params: { 85 | text: 'Header', 86 | }, 87 | }, 88 | response, 89 | context, 90 | ); 91 | 92 | assert.equal( 93 | response.responseLines[0], 94 | 'Element with text "Header" found.', 95 | ); 96 | assert.ok(response.includeSnapshot); 97 | }); 98 | }); 99 | 100 | it('should work with iframe content', async () => { 101 | await withMcpContext(async (response, context) => { 102 | const page = context.getSelectedPage(); 103 | 104 | await page.setContent( 105 | html`

Top level

106 | `, 107 | ); 108 | 109 | await waitFor.handler( 110 | { 111 | params: { 112 | text: 'Hello iframe', 113 | }, 114 | }, 115 | response, 116 | context, 117 | ); 118 | 119 | assert.equal( 120 | response.responseLines[0], 121 | 'Element with text "Hello iframe" found.', 122 | ); 123 | assert.ok(response.includeSnapshot); 124 | }); 125 | }); 126 | }); 127 | }); 128 | -------------------------------------------------------------------------------- /tests/McpContext.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import assert from 'node:assert'; 8 | import {describe, it} from 'node:test'; 9 | 10 | import sinon from 'sinon'; 11 | 12 | import type {TraceResult} from '../src/trace-processing/parse.js'; 13 | 14 | import {html, withMcpContext} from './utils.js'; 15 | 16 | describe('McpContext', () => { 17 | it('list pages', async () => { 18 | await withMcpContext(async (_response, context) => { 19 | const page = context.getSelectedPage(); 20 | await page.setContent( 21 | html``, 26 | ); 27 | await context.createTextSnapshot(); 28 | assert.ok(await context.getElementByUid('1_1')); 29 | await context.createTextSnapshot(); 30 | try { 31 | await context.getElementByUid('1_1'); 32 | assert.fail('not reached'); 33 | } catch (err) { 34 | assert.strict( 35 | err.message, 36 | 'This uid is coming from a stale snapshot. Call take_snapshot to get a fresh snapshot', 37 | ); 38 | } 39 | }); 40 | }); 41 | 42 | it('can store and retrieve performance traces', async () => { 43 | await withMcpContext(async (_response, context) => { 44 | const fakeTrace1 = {} as unknown as TraceResult; 45 | const fakeTrace2 = {} as unknown as TraceResult; 46 | context.storeTraceRecording(fakeTrace1); 47 | context.storeTraceRecording(fakeTrace2); 48 | assert.deepEqual(context.recordedTraces(), [fakeTrace1, fakeTrace2]); 49 | }); 50 | }); 51 | 52 | it('should update default timeout when cpu throttling changes', async () => { 53 | await withMcpContext(async (_response, context) => { 54 | const page = await context.newPage(); 55 | const timeoutBefore = page.getDefaultTimeout(); 56 | context.setCpuThrottlingRate(2); 57 | const timeoutAfter = page.getDefaultTimeout(); 58 | assert(timeoutBefore < timeoutAfter, 'Timeout was less then expected'); 59 | }); 60 | }); 61 | 62 | it('should update default timeout when network conditions changes', async () => { 63 | await withMcpContext(async (_response, context) => { 64 | const page = await context.newPage(); 65 | const timeoutBefore = page.getDefaultNavigationTimeout(); 66 | context.setNetworkConditions('Slow 3G'); 67 | const timeoutAfter = page.getDefaultNavigationTimeout(); 68 | assert(timeoutBefore < timeoutAfter, 'Timeout was less then expected'); 69 | }); 70 | }); 71 | 72 | it('should call waitForEventsAfterAction with correct multipliers', async () => { 73 | await withMcpContext(async (_response, context) => { 74 | const page = await context.newPage(); 75 | 76 | context.setCpuThrottlingRate(2); 77 | context.setNetworkConditions('Slow 3G'); 78 | const stub = sinon.spy(context, 'getWaitForHelper'); 79 | 80 | await context.waitForEventsAfterAction(async () => { 81 | // trigger the waiting only 82 | }); 83 | 84 | sinon.assert.calledWithExactly(stub, page, 2, 10); 85 | }); 86 | }); 87 | 88 | it('should should detect open DevTools pages', async () => { 89 | await withMcpContext( 90 | async (_response, context) => { 91 | const page = await context.newPage(); 92 | // TODO: we do not know when the CLI flag to auto open DevTools will run 93 | // so we need this until 94 | // https://github.com/puppeteer/puppeteer/issues/14368 is there. 95 | await new Promise(resolve => setTimeout(resolve, 5000)); 96 | await context.createPagesSnapshot(); 97 | assert.ok(context.getDevToolsPage(page)); 98 | }, 99 | { 100 | autoOpenDevTools: true, 101 | }, 102 | ); 103 | }); 104 | }); 105 | -------------------------------------------------------------------------------- /src/DevToolsConnectionAdapter.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import type * as puppeteer from './third_party/index.js'; 8 | import type {DevTools} from './third_party/index.js'; 9 | import {CDPSessionEvent} from './third_party/index.js'; 10 | 11 | /** 12 | * This class makes a puppeteer connection look like DevTools CDPConnection. 13 | * 14 | * Since we connect "root" DevTools targets to specific pages, we scope everything to a puppeteer CDP session. 15 | * 16 | * We don't have to recursively listen for 'sessionattached' as the "root" CDP session sees all child session attached 17 | * events, regardless how deeply nested they are. 18 | */ 19 | export class PuppeteerDevToolsConnection 20 | implements DevTools.CDPConnection.CDPConnection 21 | { 22 | readonly #connection: puppeteer.Connection; 23 | readonly #observers = new Set(); 24 | readonly #sessionEventHandlers = new Map< 25 | string, 26 | puppeteer.Handler 27 | >(); 28 | 29 | constructor(session: puppeteer.CDPSession) { 30 | this.#connection = session.connection()!; 31 | 32 | session.on( 33 | CDPSessionEvent.SessionAttached, 34 | this.#startForwardingCdpEvents.bind(this), 35 | ); 36 | session.on( 37 | CDPSessionEvent.SessionDetached, 38 | this.#stopForwardingCdpEvents.bind(this), 39 | ); 40 | 41 | this.#startForwardingCdpEvents(session); 42 | } 43 | 44 | send( 45 | method: T, 46 | params: DevTools.CDPConnection.CommandParams, 47 | sessionId: string | undefined, 48 | ): Promise< 49 | | {result: DevTools.CDPConnection.CommandResult} 50 | | {error: DevTools.CDPConnection.CDPError} 51 | > { 52 | if (sessionId === undefined) { 53 | throw new Error( 54 | 'Attempting to send on the root session. This must not happen', 55 | ); 56 | } 57 | const session = this.#connection.session(sessionId); 58 | if (!session) { 59 | throw new Error('Unknown session ' + sessionId); 60 | } 61 | // Rolled protocol version between puppeteer and DevTools doesn't necessarily match 62 | /* eslint-disable @typescript-eslint/no-explicit-any */ 63 | return session 64 | .send(method as any, params) 65 | .then(result => ({result})) 66 | .catch(error => ({error})) as any; 67 | /* eslint-enable @typescript-eslint/no-explicit-any */ 68 | } 69 | 70 | observe(observer: DevTools.CDPConnection.CDPConnectionObserver): void { 71 | this.#observers.add(observer); 72 | } 73 | 74 | unobserve(observer: DevTools.CDPConnection.CDPConnectionObserver): void { 75 | this.#observers.delete(observer); 76 | } 77 | 78 | #startForwardingCdpEvents(session: puppeteer.CDPSession): void { 79 | const handler = this.#handleEvent.bind( 80 | this, 81 | session.id(), 82 | ) as puppeteer.Handler; 83 | this.#sessionEventHandlers.set(session.id(), handler); 84 | session.on('*', handler); 85 | } 86 | 87 | #stopForwardingCdpEvents(session: puppeteer.CDPSession): void { 88 | const handler = this.#sessionEventHandlers.get(session.id()); 89 | if (handler) { 90 | session.off('*', handler); 91 | } 92 | } 93 | 94 | #handleEvent( 95 | sessionId: string, 96 | type: string | symbol | number, 97 | event: any, // eslint-disable-line @typescript-eslint/no-explicit-any 98 | ): void { 99 | if ( 100 | typeof type === 'string' && 101 | type !== CDPSessionEvent.SessionAttached && 102 | type !== CDPSessionEvent.SessionDetached 103 | ) { 104 | this.#observers.forEach(observer => 105 | observer.onEvent({ 106 | method: type as DevTools.CDPConnection.Event, 107 | sessionId, 108 | params: event, 109 | }), 110 | ); 111 | } 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/tools/network.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import {zod} from '../third_party/index.js'; 8 | import type {ResourceType} from '../third_party/index.js'; 9 | 10 | import {ToolCategory} from './categories.js'; 11 | import {defineTool} from './ToolDefinition.js'; 12 | 13 | const FILTERABLE_RESOURCE_TYPES: readonly [ResourceType, ...ResourceType[]] = [ 14 | 'document', 15 | 'stylesheet', 16 | 'image', 17 | 'media', 18 | 'font', 19 | 'script', 20 | 'texttrack', 21 | 'xhr', 22 | 'fetch', 23 | 'prefetch', 24 | 'eventsource', 25 | 'websocket', 26 | 'manifest', 27 | 'signedexchange', 28 | 'ping', 29 | 'cspviolationreport', 30 | 'preflight', 31 | 'fedcm', 32 | 'other', 33 | ]; 34 | 35 | export const listNetworkRequests = defineTool({ 36 | name: 'list_network_requests', 37 | description: `List all requests for the currently selected page since the last navigation.`, 38 | annotations: { 39 | category: ToolCategory.NETWORK, 40 | readOnlyHint: true, 41 | }, 42 | schema: { 43 | pageSize: zod 44 | .number() 45 | .int() 46 | .positive() 47 | .optional() 48 | .describe( 49 | 'Maximum number of requests to return. When omitted, returns all requests.', 50 | ), 51 | pageIdx: zod 52 | .number() 53 | .int() 54 | .min(0) 55 | .optional() 56 | .describe( 57 | 'Page number to return (0-based). When omitted, returns the first page.', 58 | ), 59 | resourceTypes: zod 60 | .array(zod.enum(FILTERABLE_RESOURCE_TYPES)) 61 | .optional() 62 | .describe( 63 | 'Filter requests to only return requests of the specified resource types. When omitted or empty, returns all requests.', 64 | ), 65 | includePreservedRequests: zod 66 | .boolean() 67 | .default(false) 68 | .optional() 69 | .describe( 70 | 'Set to true to return the preserved requests over the last 3 navigations.', 71 | ), 72 | }, 73 | handler: async (request, response, context) => { 74 | const data = await context.getDevToolsData(); 75 | response.attachDevToolsData(data); 76 | const reqid = data?.cdpRequestId 77 | ? context.resolveCdpRequestId(data.cdpRequestId) 78 | : undefined; 79 | response.setIncludeNetworkRequests(true, { 80 | pageSize: request.params.pageSize, 81 | pageIdx: request.params.pageIdx, 82 | resourceTypes: request.params.resourceTypes, 83 | includePreservedRequests: request.params.includePreservedRequests, 84 | networkRequestIdInDevToolsUI: reqid, 85 | }); 86 | }, 87 | }); 88 | 89 | export const getNetworkRequest = defineTool({ 90 | name: 'get_network_request', 91 | description: `Gets a network request by an optional reqid, if omitted returns the currently selected request in the DevTools Network panel.`, 92 | annotations: { 93 | category: ToolCategory.NETWORK, 94 | readOnlyHint: true, 95 | }, 96 | schema: { 97 | reqid: zod 98 | .number() 99 | .optional() 100 | .describe( 101 | 'The reqid of the network request. If omitted returns the currently selected request in the DevTools Network panel.', 102 | ), 103 | }, 104 | handler: async (request, response, context) => { 105 | if (request.params.reqid) { 106 | response.attachNetworkRequest(request.params.reqid); 107 | } else { 108 | const data = await context.getDevToolsData(); 109 | response.attachDevToolsData(data); 110 | const reqid = data?.cdpRequestId 111 | ? context.resolveCdpRequestId(data.cdpRequestId) 112 | : undefined; 113 | if (reqid) { 114 | response.attachNetworkRequest(reqid); 115 | } else { 116 | response.appendResponseLine( 117 | `Nothing is currently selected in the DevTools Network panel.`, 118 | ); 119 | } 120 | } 121 | }, 122 | }); 123 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import js from '@eslint/js'; 8 | import stylisticPlugin from '@stylistic/eslint-plugin'; 9 | import {defineConfig, globalIgnores} from 'eslint/config'; 10 | import importPlugin from 'eslint-plugin-import'; 11 | import globals from 'globals'; 12 | import tseslint from 'typescript-eslint'; 13 | 14 | import localPlugin from './scripts/eslint_rules/local-plugin.js'; 15 | 16 | export default defineConfig([ 17 | globalIgnores(['**/node_modules', '**/build/']), 18 | importPlugin.flatConfigs.typescript, 19 | { 20 | languageOptions: { 21 | ecmaVersion: 'latest', 22 | sourceType: 'module', 23 | 24 | globals: { 25 | ...globals.node, 26 | }, 27 | 28 | parserOptions: { 29 | projectService: { 30 | allowDefaultProject: [ 31 | '.prettierrc.cjs', 32 | 'puppeteer.config.cjs', 33 | 'eslint.config.mjs', 34 | 'rollup.config.mjs', 35 | ], 36 | }, 37 | }, 38 | 39 | parser: tseslint.parser, 40 | }, 41 | 42 | plugins: { 43 | js, 44 | '@local': localPlugin, 45 | '@typescript-eslint': tseslint.plugin, 46 | '@stylistic': stylisticPlugin, 47 | }, 48 | 49 | settings: { 50 | 'import/resolver': { 51 | typescript: true, 52 | }, 53 | }, 54 | 55 | extends: ['js/recommended'], 56 | }, 57 | tseslint.configs.recommended, 58 | tseslint.configs.stylistic, 59 | { 60 | name: 'TypeScript rules', 61 | rules: { 62 | '@local/check-license': 'error', 63 | 64 | 'no-undef': 'off', 65 | 'no-unused-vars': 'off', 66 | '@typescript-eslint/no-unused-vars': [ 67 | 'error', 68 | { 69 | argsIgnorePattern: '^_', 70 | varsIgnorePattern: '^_', 71 | }, 72 | ], 73 | '@typescript-eslint/no-explicit-any': [ 74 | 'error', 75 | { 76 | ignoreRestArgs: true, 77 | }, 78 | ], 79 | // This optimizes the dependency tracking for type-only files. 80 | '@typescript-eslint/consistent-type-imports': 'error', 81 | // So type-only exports get elided. 82 | '@typescript-eslint/consistent-type-exports': 'error', 83 | // Prefer interfaces over types for shape like. 84 | '@typescript-eslint/consistent-type-definitions': ['error', 'interface'], 85 | '@typescript-eslint/array-type': [ 86 | 'error', 87 | { 88 | default: 'array-simple', 89 | }, 90 | ], 91 | '@typescript-eslint/no-floating-promises': 'error', 92 | 93 | 'import/order': [ 94 | 'error', 95 | { 96 | 'newlines-between': 'always', 97 | 98 | alphabetize: { 99 | order: 'asc', 100 | caseInsensitive: true, 101 | }, 102 | }, 103 | ], 104 | 105 | 'import/no-cycle': [ 106 | 'error', 107 | { 108 | maxDepth: Infinity, 109 | }, 110 | ], 111 | 112 | 'import/enforce-node-protocol-usage': ['error', 'always'], 113 | 114 | '@stylistic/function-call-spacing': 'error', 115 | '@stylistic/semi': 'error', 116 | 117 | 'no-restricted-imports': [ 118 | 'error', 119 | { 120 | patterns: [ 121 | { 122 | regex: '.*chrome-devtools-frontend/(?!mcp/mcp.js$).*', 123 | message: 124 | 'Import only the devtools-frontend code exported via node_modules/chrome-devtools-frontend/mcp/mcp.js', 125 | }, 126 | ], 127 | }, 128 | ], 129 | }, 130 | }, 131 | { 132 | name: 'Tests', 133 | files: ['**/*.test.ts'], 134 | rules: { 135 | // With the Node.js test runner, `describe` and `it` are technically 136 | // promises, but we don't need to await them. 137 | '@typescript-eslint/no-floating-promises': 'off', 138 | }, 139 | }, 140 | ]); 141 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2023", 4 | "lib": [ 5 | "ES2023", 6 | "DOM", 7 | "ES2024.Promise", 8 | "ESNext.Iterator", 9 | "ESNext.Collection" 10 | ], 11 | "module": "esnext", 12 | "moduleResolution": "bundler", 13 | "outDir": "./build", 14 | "rootDir": ".", 15 | "strict": true, 16 | "esModuleInterop": true, 17 | "skipLibCheck": true, 18 | "forceConsistentCasingInFileNames": true, 19 | "noImplicitReturns": true, 20 | "noImplicitOverride": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "incremental": true, 23 | "allowJs": true, 24 | "useUnknownInCatchVariables": false 25 | }, 26 | "include": [ 27 | "src/**/*.ts", 28 | "tests/**/*.ts", 29 | "node_modules/chrome-devtools-frontend/front_end/core/common", 30 | "node_modules/chrome-devtools-frontend/front_end/core/host", 31 | "node_modules/chrome-devtools-frontend/front_end/core/i18n", 32 | "node_modules/chrome-devtools-frontend/front_end/core/platform", 33 | "node_modules/chrome-devtools-frontend/front_end/core/protocol_client", 34 | "node_modules/chrome-devtools-frontend/front_end/core/root", 35 | "node_modules/chrome-devtools-frontend/front_end/core/sdk", 36 | "node_modules/chrome-devtools-frontend/front_end/foundation/foundation.ts", 37 | "node_modules/chrome-devtools-frontend/front_end/foundation/Universe.ts", 38 | "node_modules/chrome-devtools-frontend/front_end/generated", 39 | "node_modules/chrome-devtools-frontend/front_end/legacy/legacy-defs.d.ts", 40 | "node_modules/chrome-devtools-frontend/front_end/models/annotations", 41 | "node_modules/chrome-devtools-frontend/front_end/models/ai_assistance/data_formatters/NetworkRequestFormatter.ts", 42 | "node_modules/chrome-devtools-frontend/front_end/models/ai_assistance/data_formatters/PerformanceInsightFormatter.ts", 43 | "node_modules/chrome-devtools-frontend/front_end/models/ai_assistance/data_formatters/PerformanceTraceFormatter.ts", 44 | "node_modules/chrome-devtools-frontend/front_end/models/ai_assistance/data_formatters/UnitFormatters.ts", 45 | "node_modules/chrome-devtools-frontend/front_end/models/ai_assistance/performance", 46 | "node_modules/chrome-devtools-frontend/front_end/models/bindings", 47 | "node_modules/chrome-devtools-frontend/front_end/models/cpu_profile", 48 | "node_modules/chrome-devtools-frontend/front_end/models/crux-manager", 49 | "node_modules/chrome-devtools-frontend/front_end/models/emulation", 50 | "node_modules/chrome-devtools-frontend/front_end/models/formatter", 51 | "node_modules/chrome-devtools-frontend/front_end/models/geometry", 52 | "node_modules/chrome-devtools-frontend/front_end/models/issues_manager", 53 | "node_modules/chrome-devtools-frontend/front_end/models/logs", 54 | "node_modules/chrome-devtools-frontend/front_end/models/network_time_calculator", 55 | "node_modules/chrome-devtools-frontend/front_end/models/source_map_scopes", 56 | "node_modules/chrome-devtools-frontend/front_end/models/stack_trace", 57 | "node_modules/chrome-devtools-frontend/front_end/models/text_utils", 58 | "node_modules/chrome-devtools-frontend/front_end/models/trace_source_maps_resolver", 59 | "node_modules/chrome-devtools-frontend/front_end/models/trace", 60 | "node_modules/chrome-devtools-frontend/front_end/models/workspace", 61 | "node_modules/chrome-devtools-frontend/front_end/panels/issues/IssueAggregator.ts", 62 | "node_modules/chrome-devtools-frontend/front_end/third_party/i18n", 63 | "node_modules/chrome-devtools-frontend/front_end/third_party/intl-messageformat", 64 | "node_modules/chrome-devtools-frontend/front_end/third_party/legacy-javascript", 65 | "node_modules/chrome-devtools-frontend/front_end/third_party/marked", 66 | "node_modules/chrome-devtools-frontend/front_end/third_party/source-map-scopes-codec", 67 | "node_modules/chrome-devtools-frontend/front_end/third_party/third-party-web", 68 | "node_modules/chrome-devtools-frontend/mcp" 69 | ], 70 | "exclude": ["node_modules/chrome-devtools-frontend/**/*.test.ts"] 71 | } 72 | -------------------------------------------------------------------------------- /src/trace-processing/parse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import {logger} from '../logger.js'; 8 | import {DevTools} from '../third_party/index.js'; 9 | 10 | const engine = DevTools.TraceEngine.TraceModel.Model.createWithAllHandlers(); 11 | 12 | export interface TraceResult { 13 | parsedTrace: DevTools.TraceEngine.TraceModel.ParsedTrace; 14 | insights: DevTools.TraceEngine.Insights.Types.TraceInsightSets | null; 15 | } 16 | 17 | export function traceResultIsSuccess( 18 | x: TraceResult | TraceParseError, 19 | ): x is TraceResult { 20 | return 'parsedTrace' in x; 21 | } 22 | 23 | export interface TraceParseError { 24 | error: string; 25 | } 26 | 27 | export async function parseRawTraceBuffer( 28 | buffer: Uint8Array | undefined, 29 | ): Promise { 30 | engine.resetProcessor(); 31 | if (!buffer) { 32 | return { 33 | error: 'No buffer was provided.', 34 | }; 35 | } 36 | const asString = new TextDecoder().decode(buffer); 37 | if (!asString) { 38 | return { 39 | error: 'Decoding the trace buffer returned an empty string.', 40 | }; 41 | } 42 | try { 43 | const data = JSON.parse(asString) as 44 | | { 45 | traceEvents: DevTools.TraceEngine.Types.Events.Event[]; 46 | } 47 | | DevTools.TraceEngine.Types.Events.Event[]; 48 | 49 | const events = Array.isArray(data) ? data : data.traceEvents; 50 | await engine.parse(events); 51 | const parsedTrace = engine.parsedTrace(); 52 | if (!parsedTrace) { 53 | return { 54 | error: 'No parsed trace was returned from the trace engine.', 55 | }; 56 | } 57 | 58 | const insights = parsedTrace?.insights ?? null; 59 | 60 | return { 61 | parsedTrace, 62 | insights, 63 | }; 64 | } catch (e) { 65 | const errorText = e instanceof Error ? e.message : JSON.stringify(e); 66 | logger(`Unexpected error parsing trace: ${errorText}`); 67 | return { 68 | error: errorText, 69 | }; 70 | } 71 | } 72 | 73 | const extraFormatDescriptions = `Information on performance traces may contain main thread activity represented as call frames and network requests. 74 | 75 | ${DevTools.PerformanceTraceFormatter.callFrameDataFormatDescription} 76 | 77 | ${DevTools.PerformanceTraceFormatter.networkDataFormatDescription}`; 78 | 79 | export function getTraceSummary(result: TraceResult): string { 80 | const focus = DevTools.AgentFocus.fromParsedTrace(result.parsedTrace); 81 | const formatter = new DevTools.PerformanceTraceFormatter(focus); 82 | const summaryText = formatter.formatTraceSummary(); 83 | return `## Summary of Performance trace findings: 84 | ${summaryText} 85 | 86 | ## Details on call tree & network request formats: 87 | ${extraFormatDescriptions}`; 88 | } 89 | 90 | export type InsightName = 91 | keyof DevTools.TraceEngine.Insights.Types.InsightModels; 92 | export type InsightOutput = {output: string} | {error: string}; 93 | 94 | export function getInsightOutput( 95 | result: TraceResult, 96 | insightSetId: string, 97 | insightName: InsightName, 98 | ): InsightOutput { 99 | if (!result.insights) { 100 | return { 101 | error: 'No Performance insights are available for this trace.', 102 | }; 103 | } 104 | 105 | const insightSet = result.insights.get(insightSetId); 106 | if (!insightSet) { 107 | return { 108 | error: 109 | 'No Performance Insights for the given insight set id. Only use ids given in the "Available insight sets" list.', 110 | }; 111 | } 112 | 113 | const matchingInsight = 114 | insightName in insightSet.model ? insightSet.model[insightName] : null; 115 | if (!matchingInsight) { 116 | return { 117 | error: `No Insight with the name ${insightName} found. Double check the name you provided is accurate and try again.`, 118 | }; 119 | } 120 | 121 | const formatter = new DevTools.PerformanceInsightFormatter( 122 | DevTools.AgentFocus.fromParsedTrace(result.parsedTrace), 123 | matchingInsight, 124 | ); 125 | return {output: formatter.formatInsight()}; 126 | } 127 | -------------------------------------------------------------------------------- /tests/formatters/consoleFormatter.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import {describe, it} from 'node:test'; 8 | 9 | import type {ConsoleMessageData} from '../../src/formatters/consoleFormatter.js'; 10 | import { 11 | formatConsoleEventShort, 12 | formatConsoleEventVerbose, 13 | } from '../../src/formatters/consoleFormatter.js'; 14 | import {getMockAggregatedIssue} from '../utils.js'; 15 | 16 | describe('consoleFormatter', () => { 17 | describe('formatConsoleEventShort', () => { 18 | it('formats a console.log message', t => { 19 | const message: ConsoleMessageData = { 20 | consoleMessageStableId: 1, 21 | type: 'log', 22 | message: 'Hello, world!', 23 | args: [], 24 | }; 25 | const result = formatConsoleEventShort(message); 26 | t.assert.snapshot?.(result); 27 | }); 28 | 29 | it('formats a console.log message with one argument', t => { 30 | const message: ConsoleMessageData = { 31 | consoleMessageStableId: 2, 32 | type: 'log', 33 | message: 'Processing file:', 34 | args: ['file.txt'], 35 | }; 36 | const result = formatConsoleEventShort(message); 37 | t.assert.snapshot?.(result); 38 | }); 39 | 40 | it('formats a console.log message with multiple arguments', t => { 41 | const message: ConsoleMessageData = { 42 | consoleMessageStableId: 3, 43 | type: 'log', 44 | message: 'Processing file:', 45 | args: ['file.txt', 'another file'], 46 | }; 47 | const result = formatConsoleEventShort(message); 48 | t.assert.snapshot?.(result); 49 | }); 50 | }); 51 | 52 | describe('formatConsoleEventVerbose', () => { 53 | it('formats a console.log message', t => { 54 | const message: ConsoleMessageData = { 55 | consoleMessageStableId: 1, 56 | type: 'log', 57 | message: 'Hello, world!', 58 | args: [], 59 | }; 60 | const result = formatConsoleEventVerbose(message); 61 | t.assert.snapshot?.(result); 62 | }); 63 | 64 | it('formats a console.log message with one argument', t => { 65 | const message: ConsoleMessageData = { 66 | consoleMessageStableId: 2, 67 | type: 'log', 68 | message: 'Processing file:', 69 | args: ['file.txt'], 70 | }; 71 | const result = formatConsoleEventVerbose(message); 72 | t.assert.snapshot?.(result); 73 | }); 74 | 75 | it('formats a console.log message with multiple arguments', t => { 76 | const message: ConsoleMessageData = { 77 | consoleMessageStableId: 3, 78 | type: 'log', 79 | message: 'Processing file:', 80 | args: ['file.txt', 'another file'], 81 | }; 82 | const result = formatConsoleEventVerbose(message); 83 | t.assert.snapshot?.(result); 84 | }); 85 | 86 | it('formats a console.error message', t => { 87 | const message: ConsoleMessageData = { 88 | consoleMessageStableId: 4, 89 | type: 'error', 90 | message: 'Something went wrong', 91 | }; 92 | const result = formatConsoleEventVerbose(message); 93 | t.assert.snapshot?.(result); 94 | }); 95 | }); 96 | 97 | it('formats a console.log message with issue type', t => { 98 | const testGenericIssue = { 99 | details: () => { 100 | return { 101 | violatingNodeId: 2, 102 | violatingNodeAttribute: 'test', 103 | }; 104 | }, 105 | }; 106 | const mockAggregatedIssue = getMockAggregatedIssue(); 107 | const mockDescription = { 108 | file: 'mock.md', 109 | links: [ 110 | {link: 'http://example.com/learnmore', linkTitle: 'Learn more'}, 111 | { 112 | link: 'http://example.com/another-learnmore', 113 | linkTitle: 'Learn more 2', 114 | }, 115 | ], 116 | }; 117 | mockAggregatedIssue.getDescription.returns(mockDescription); 118 | // @ts-expect-error generic issue stub bypass 119 | mockAggregatedIssue.getGenericIssues.returns(new Set([testGenericIssue])); 120 | const mockDescriptionFileContent = 121 | '# Mock Issue Title\n\nThis is a mock issue description'; 122 | 123 | const message: ConsoleMessageData = { 124 | consoleMessageStableId: 5, 125 | type: 'issue', 126 | description: mockDescriptionFileContent, 127 | item: mockAggregatedIssue, 128 | }; 129 | 130 | const result = formatConsoleEventVerbose(message); 131 | t.assert.snapshot?.(result); 132 | }); 133 | }); 134 | -------------------------------------------------------------------------------- /src/tools/ToolDefinition.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import type {TextSnapshotNode, GeolocationOptions} from '../McpContext.js'; 8 | import {zod} from '../third_party/index.js'; 9 | import type {Dialog, ElementHandle, Page} from '../third_party/index.js'; 10 | import type {TraceResult} from '../trace-processing/parse.js'; 11 | import type {PaginationOptions} from '../utils/types.js'; 12 | 13 | import type {ToolCategory} from './categories.js'; 14 | 15 | export interface ToolDefinition< 16 | Schema extends zod.ZodRawShape = zod.ZodRawShape, 17 | > { 18 | name: string; 19 | description: string; 20 | annotations: { 21 | title?: string; 22 | category: ToolCategory; 23 | /** 24 | * If true, the tool does not modify its environment. 25 | */ 26 | readOnlyHint: boolean; 27 | }; 28 | schema: Schema; 29 | handler: ( 30 | request: Request, 31 | response: Response, 32 | context: Context, 33 | ) => Promise; 34 | } 35 | 36 | export interface Request { 37 | params: zod.objectOutputType; 38 | } 39 | 40 | export interface ImageContentData { 41 | data: string; 42 | mimeType: string; 43 | } 44 | 45 | export interface SnapshotParams { 46 | verbose?: boolean; 47 | filePath?: string; 48 | } 49 | 50 | export interface DevToolsData { 51 | cdpRequestId?: string; 52 | cdpBackendNodeId?: number; 53 | } 54 | 55 | export interface Response { 56 | appendResponseLine(value: string): void; 57 | setIncludePages(value: boolean): void; 58 | setIncludeNetworkRequests( 59 | value: boolean, 60 | options?: PaginationOptions & { 61 | resourceTypes?: string[]; 62 | includePreservedRequests?: boolean; 63 | networkRequestIdInDevToolsUI?: number; 64 | }, 65 | ): void; 66 | setIncludeConsoleData( 67 | value: boolean, 68 | options?: PaginationOptions & { 69 | types?: string[]; 70 | includePreservedMessages?: boolean; 71 | }, 72 | ): void; 73 | includeSnapshot(params?: SnapshotParams): void; 74 | attachImage(value: ImageContentData): void; 75 | attachNetworkRequest(reqid: number): void; 76 | attachConsoleMessage(msgid: number): void; 77 | // Allows re-using DevTools data queried by some tools. 78 | attachDevToolsData(data: DevToolsData): void; 79 | } 80 | 81 | /** 82 | * Only add methods required by tools/*. 83 | */ 84 | export type Context = Readonly<{ 85 | isRunningPerformanceTrace(): boolean; 86 | setIsRunningPerformanceTrace(x: boolean): void; 87 | recordedTraces(): TraceResult[]; 88 | storeTraceRecording(result: TraceResult): void; 89 | getSelectedPage(): Page; 90 | getDialog(): Dialog | undefined; 91 | clearDialog(): void; 92 | getPageByIdx(idx: number): Page; 93 | isPageSelected(page: Page): boolean; 94 | newPage(): Promise; 95 | closePage(pageIdx: number): Promise; 96 | selectPage(page: Page): void; 97 | getElementByUid(uid: string): Promise>; 98 | getAXNodeByUid(uid: string): TextSnapshotNode | undefined; 99 | setNetworkConditions(conditions: string | null): void; 100 | setCpuThrottlingRate(rate: number): void; 101 | setGeolocation(geolocation: GeolocationOptions | null): void; 102 | saveTemporaryFile( 103 | data: Uint8Array, 104 | mimeType: 'image/png' | 'image/jpeg' | 'image/webp', 105 | ): Promise<{filename: string}>; 106 | saveFile( 107 | data: Uint8Array, 108 | filename: string, 109 | ): Promise<{filename: string}>; 110 | waitForEventsAfterAction(action: () => Promise): Promise; 111 | waitForTextOnPage(text: string, timeout?: number): Promise; 112 | getDevToolsData(): Promise; 113 | /** 114 | * Returns a reqid for a cdpRequestId. 115 | */ 116 | resolveCdpRequestId(cdpRequestId: string): number | undefined; 117 | /** 118 | * Returns a reqid for a cdpRequestId. 119 | */ 120 | resolveCdpElementId(cdpBackendNodeId: number): string | undefined; 121 | }>; 122 | 123 | export function defineTool( 124 | definition: ToolDefinition, 125 | ) { 126 | return definition; 127 | } 128 | 129 | export const CLOSE_PAGE_ERROR = 130 | 'The last open page cannot be closed. It is fine to keep it open.'; 131 | 132 | export const timeoutSchema = { 133 | timeout: zod 134 | .number() 135 | .int() 136 | .optional() 137 | .describe( 138 | `Maximum wait time in milliseconds. If set to 0, the default timeout will be used.`, 139 | ) 140 | .transform(value => { 141 | return value && value <= 0 ? undefined : value; 142 | }), 143 | }; 144 | -------------------------------------------------------------------------------- /src/WaitForHelper.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import {logger} from './logger.js'; 8 | import type {Page, Protocol, CdpPage} from './third_party/index.js'; 9 | 10 | export class WaitForHelper { 11 | #abortController = new AbortController(); 12 | #page: CdpPage; 13 | #stableDomTimeout: number; 14 | #stableDomFor: number; 15 | #expectNavigationIn: number; 16 | #navigationTimeout: number; 17 | 18 | constructor( 19 | page: Page, 20 | cpuTimeoutMultiplier: number, 21 | networkTimeoutMultiplier: number, 22 | ) { 23 | this.#stableDomTimeout = 3000 * cpuTimeoutMultiplier; 24 | this.#stableDomFor = 100 * cpuTimeoutMultiplier; 25 | this.#expectNavigationIn = 100 * cpuTimeoutMultiplier; 26 | this.#navigationTimeout = 3000 * networkTimeoutMultiplier; 27 | this.#page = page as unknown as CdpPage; 28 | } 29 | 30 | /** 31 | * A wrapper that executes a action and waits for 32 | * a potential navigation, after which it waits 33 | * for the DOM to be stable before returning. 34 | */ 35 | async waitForStableDom(): Promise { 36 | const stableDomObserver = await this.#page.evaluateHandle(timeout => { 37 | let timeoutId: ReturnType; 38 | function callback() { 39 | clearTimeout(timeoutId); 40 | timeoutId = setTimeout(() => { 41 | domObserver.resolver.resolve(); 42 | domObserver.observer.disconnect(); 43 | }, timeout); 44 | } 45 | const domObserver = { 46 | resolver: Promise.withResolvers(), 47 | observer: new MutationObserver(callback), 48 | }; 49 | // It's possible that the DOM is not gonna change so we 50 | // need to start the timeout initially. 51 | callback(); 52 | 53 | domObserver.observer.observe(document.body, { 54 | childList: true, 55 | subtree: true, 56 | attributes: true, 57 | }); 58 | 59 | return domObserver; 60 | }, this.#stableDomFor); 61 | 62 | this.#abortController.signal.addEventListener('abort', async () => { 63 | try { 64 | await stableDomObserver.evaluate(observer => { 65 | observer.observer.disconnect(); 66 | observer.resolver.resolve(); 67 | }); 68 | await stableDomObserver.dispose(); 69 | } catch { 70 | // Ignored cleanup errors 71 | } 72 | }); 73 | 74 | return Promise.race([ 75 | stableDomObserver.evaluate(async observer => { 76 | return await observer.resolver.promise; 77 | }), 78 | this.timeout(this.#stableDomTimeout).then(() => { 79 | throw new Error('Timeout'); 80 | }), 81 | ]); 82 | } 83 | 84 | async waitForNavigationStarted() { 85 | // Currently Puppeteer does not have API 86 | // For when a navigation is about to start 87 | const navigationStartedPromise = new Promise(resolve => { 88 | const listener = (event: Protocol.Page.FrameStartedNavigatingEvent) => { 89 | if ( 90 | [ 91 | 'historySameDocument', 92 | 'historyDifferentDocument', 93 | 'sameDocument', 94 | ].includes(event.navigationType) 95 | ) { 96 | resolve(false); 97 | return; 98 | } 99 | 100 | resolve(true); 101 | }; 102 | 103 | this.#page._client().on('Page.frameStartedNavigating', listener); 104 | this.#abortController.signal.addEventListener('abort', () => { 105 | resolve(false); 106 | this.#page._client().off('Page.frameStartedNavigating', listener); 107 | }); 108 | }); 109 | 110 | return await Promise.race([ 111 | navigationStartedPromise, 112 | this.timeout(this.#expectNavigationIn).then(() => false), 113 | ]); 114 | } 115 | 116 | timeout(time: number): Promise { 117 | return new Promise(res => { 118 | const id = setTimeout(res, time); 119 | this.#abortController.signal.addEventListener('abort', () => { 120 | res(); 121 | clearTimeout(id); 122 | }); 123 | }); 124 | } 125 | 126 | async waitForEventsAfterAction( 127 | action: () => Promise, 128 | ): Promise { 129 | const navigationFinished = this.waitForNavigationStarted() 130 | .then(navigationStated => { 131 | if (navigationStated) { 132 | return this.#page.waitForNavigation({ 133 | timeout: this.#navigationTimeout, 134 | signal: this.#abortController.signal, 135 | }); 136 | } 137 | return; 138 | }) 139 | .catch(error => logger(error)); 140 | 141 | try { 142 | await action(); 143 | } catch (error) { 144 | // Clear up pending promises 145 | this.#abortController.abort(); 146 | throw error; 147 | } 148 | 149 | try { 150 | await navigationFinished; 151 | 152 | // Wait for stable dom after navigation so we execute in 153 | // the correct context 154 | await this.waitForStableDom(); 155 | } catch (error) { 156 | logger(error); 157 | } finally { 158 | this.#abortController.abort(); 159 | } 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/utils/keyboard.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import type {KeyInput} from '../third_party/index.js'; 8 | 9 | // See the KeyInput type for the list of supported keys. 10 | const validKeys = new Set([ 11 | '0', 12 | '1', 13 | '2', 14 | '3', 15 | '4', 16 | '5', 17 | '6', 18 | '7', 19 | '8', 20 | '9', 21 | 'Power', 22 | 'Eject', 23 | 'Abort', 24 | 'Help', 25 | 'Backspace', 26 | 'Tab', 27 | 'Numpad5', 28 | 'NumpadEnter', 29 | 'Enter', 30 | '\r', 31 | '\n', 32 | 'ShiftLeft', 33 | 'ShiftRight', 34 | 'ControlLeft', 35 | 'ControlRight', 36 | 'AltLeft', 37 | 'AltRight', 38 | 'Pause', 39 | 'CapsLock', 40 | 'Escape', 41 | 'Convert', 42 | 'NonConvert', 43 | 'Space', 44 | 'Numpad9', 45 | 'PageUp', 46 | 'Numpad3', 47 | 'PageDown', 48 | 'End', 49 | 'Numpad1', 50 | 'Home', 51 | 'Numpad7', 52 | 'ArrowLeft', 53 | 'Numpad4', 54 | 'Numpad8', 55 | 'ArrowUp', 56 | 'ArrowRight', 57 | 'Numpad6', 58 | 'Numpad2', 59 | 'ArrowDown', 60 | 'Select', 61 | 'Open', 62 | 'PrintScreen', 63 | 'Insert', 64 | 'Numpad0', 65 | 'Delete', 66 | 'NumpadDecimal', 67 | 'Digit0', 68 | 'Digit1', 69 | 'Digit2', 70 | 'Digit3', 71 | 'Digit4', 72 | 'Digit5', 73 | 'Digit6', 74 | 'Digit7', 75 | 'Digit8', 76 | 'Digit9', 77 | 'KeyA', 78 | 'KeyB', 79 | 'KeyC', 80 | 'KeyD', 81 | 'KeyE', 82 | 'KeyF', 83 | 'KeyG', 84 | 'KeyH', 85 | 'KeyI', 86 | 'KeyJ', 87 | 'KeyK', 88 | 'KeyL', 89 | 'KeyM', 90 | 'KeyN', 91 | 'KeyO', 92 | 'KeyP', 93 | 'KeyQ', 94 | 'KeyR', 95 | 'KeyS', 96 | 'KeyT', 97 | 'KeyU', 98 | 'KeyV', 99 | 'KeyW', 100 | 'KeyX', 101 | 'KeyY', 102 | 'KeyZ', 103 | 'MetaLeft', 104 | 'MetaRight', 105 | 'ContextMenu', 106 | 'NumpadMultiply', 107 | 'NumpadAdd', 108 | 'NumpadSubtract', 109 | 'NumpadDivide', 110 | 'F1', 111 | 'F2', 112 | 'F3', 113 | 'F4', 114 | 'F5', 115 | 'F6', 116 | 'F7', 117 | 'F8', 118 | 'F9', 119 | 'F10', 120 | 'F11', 121 | 'F12', 122 | 'F13', 123 | 'F14', 124 | 'F15', 125 | 'F16', 126 | 'F17', 127 | 'F18', 128 | 'F19', 129 | 'F20', 130 | 'F21', 131 | 'F22', 132 | 'F23', 133 | 'F24', 134 | 'NumLock', 135 | 'ScrollLock', 136 | 'AudioVolumeMute', 137 | 'AudioVolumeDown', 138 | 'AudioVolumeUp', 139 | 'MediaTrackNext', 140 | 'MediaTrackPrevious', 141 | 'MediaStop', 142 | 'MediaPlayPause', 143 | 'Semicolon', 144 | 'Equal', 145 | 'NumpadEqual', 146 | 'Comma', 147 | 'Minus', 148 | 'Period', 149 | 'Slash', 150 | 'Backquote', 151 | 'BracketLeft', 152 | 'Backslash', 153 | 'BracketRight', 154 | 'Quote', 155 | 'AltGraph', 156 | 'Props', 157 | 'Cancel', 158 | 'Clear', 159 | 'Shift', 160 | 'Control', 161 | 'Alt', 162 | 'Accept', 163 | 'ModeChange', 164 | ' ', 165 | 'Print', 166 | 'Execute', 167 | '\u0000', 168 | 'a', 169 | 'b', 170 | 'c', 171 | 'd', 172 | 'e', 173 | 'f', 174 | 'g', 175 | 'h', 176 | 'i', 177 | 'j', 178 | 'k', 179 | 'l', 180 | 'm', 181 | 'n', 182 | 'o', 183 | 'p', 184 | 'q', 185 | 'r', 186 | 's', 187 | 't', 188 | 'u', 189 | 'v', 190 | 'w', 191 | 'x', 192 | 'y', 193 | 'z', 194 | 'Meta', 195 | '*', 196 | '+', 197 | '-', 198 | '/', 199 | ';', 200 | '=', 201 | ',', 202 | '.', 203 | '`', 204 | '[', 205 | '\\', 206 | ']', 207 | "'", 208 | 'Attn', 209 | 'CrSel', 210 | 'ExSel', 211 | 'EraseEof', 212 | 'Play', 213 | 'ZoomOut', 214 | ')', 215 | '!', 216 | '@', 217 | '#', 218 | '$', 219 | '%', 220 | '^', 221 | '&', 222 | '(', 223 | 'A', 224 | 'B', 225 | 'C', 226 | 'D', 227 | 'E', 228 | 'F', 229 | 'G', 230 | 'H', 231 | 'I', 232 | 'J', 233 | 'K', 234 | 'L', 235 | 'M', 236 | 'N', 237 | 'O', 238 | 'P', 239 | 'Q', 240 | 'R', 241 | 'S', 242 | 'T', 243 | 'U', 244 | 'V', 245 | 'W', 246 | 'X', 247 | 'Y', 248 | 'Z', 249 | ':', 250 | '<', 251 | '_', 252 | '>', 253 | '?', 254 | '~', 255 | '{', 256 | '|', 257 | '}', 258 | '"', 259 | 'SoftLeft', 260 | 'SoftRight', 261 | 'Camera', 262 | 'Call', 263 | 'EndCall', 264 | 'VolumeDown', 265 | 'VolumeUp', 266 | ]); 267 | 268 | function throwIfInvalidKey(key: string): KeyInput { 269 | if (validKeys.has(key)) { 270 | return key as KeyInput; 271 | } 272 | throw new Error( 273 | `${key} is invalid. Valid keys are: ${Array.from(validKeys.values()).join(',')}.`, 274 | ); 275 | } 276 | 277 | /** 278 | * Returns the primary key, followed by modifiers in original order. 279 | */ 280 | export function parseKey(keyInput: string): [KeyInput, ...KeyInput[]] { 281 | let key = ''; 282 | const result: KeyInput[] = []; 283 | for (const ch of keyInput) { 284 | // Handle cases like Shift++. 285 | if (ch === '+' && key) { 286 | result.push(throwIfInvalidKey(key)); 287 | key = ''; 288 | } else { 289 | key += ch; 290 | } 291 | } 292 | if (key) { 293 | result.push(throwIfInvalidKey(key)); 294 | } 295 | 296 | if (result.length === 0) { 297 | throw new Error(`Key ${keyInput} could not be parsed.`); 298 | } 299 | 300 | if (new Set(result).size !== result.length) { 301 | throw new Error(`Key ${keyInput} contains duplicate keys.`); 302 | } 303 | 304 | return [result.at(-1), ...result.slice(0, -1)] as [KeyInput, ...KeyInput[]]; 305 | } 306 | -------------------------------------------------------------------------------- /tests/McpResponse.test.js.snapshot: -------------------------------------------------------------------------------- 1 | exports[`McpResponse > add network request when attached 1`] = ` 2 | # test response 3 | ## Request http://example.com 4 | Status: [pending] 5 | ### Request Headers 6 | - content-size:10 7 | ## Network requests 8 | Showing 1-1 of 1 (Page 1 of 1). 9 | reqid=1 GET http://example.com [pending] 10 | `; 11 | 12 | exports[`McpResponse > add network request when attached with POST data 1`] = ` 13 | # test response 14 | ## Request http://example.com 15 | Status: [success - 200] 16 | ### Request Headers 17 | - content-size:10 18 | ### Request Body 19 | {"request":"body"} 20 | ### Response Headers 21 | - Content-Type:application/json 22 | ### Response Body 23 | {"response":"body"} 24 | ## Network requests 25 | Showing 1-1 of 1 (Page 1 of 1). 26 | reqid=1 POST http://example.com [success - 200] 27 | `; 28 | 29 | exports[`McpResponse > add network requests when setting is true 1`] = ` 30 | # test response 31 | ## Network requests 32 | Showing 1-2 of 2 (Page 1 of 1). 33 | reqid=1 GET http://example.com [pending] 34 | reqid=2 GET http://example.com [pending] 35 | `; 36 | 37 | exports[`McpResponse > adds a message when no console messages exist 1`] = ` 38 | # test response 39 | ## Console messages 40 | 41 | `; 42 | 43 | exports[`McpResponse > adds a prompt dialog 1`] = ` 44 | # test response 45 | # Open dialog 46 | prompt: message (default value: "default"). 47 | Call handle_dialog to handle it before continuing. 48 | `; 49 | 50 | exports[`McpResponse > adds an alert dialog 1`] = ` 51 | # test response 52 | # Open dialog 53 | alert: message. 54 | Call handle_dialog to handle it before continuing. 55 | `; 56 | 57 | exports[`McpResponse > adds console messages when the setting is true 1`] = ` 58 | # test response 59 | ## Console messages 60 | Showing 1-1 of 1 (Page 1 of 1). 61 | msgid=1 [log] Hello from the test (1 args) 62 | `; 63 | 64 | exports[`McpResponse > adds cpu throttling setting when it is over 1 1`] = ` 65 | # test response 66 | ## CPU emulation 67 | Emulating: 4x slowdown 68 | `; 69 | 70 | exports[`McpResponse > adds throttling setting when it is not null 1`] = ` 71 | # test response 72 | ## Network emulation 73 | Emulating: Slow 3G 74 | Default navigation timeout set to 100000 ms 75 | `; 76 | 77 | exports[`McpResponse > allows response text lines to be added 1`] = ` 78 | # test response 79 | Testing 1 80 | Testing 2 81 | `; 82 | 83 | exports[`McpResponse > list pages 1`] = ` 84 | # test response 85 | ## Pages 86 | 0: about:blank [selected] 87 | `; 88 | 89 | exports[`McpResponse > returns correctly formatted snapshot for a simple tree 1`] = ` 90 | # test response 91 | ## Latest page snapshot 92 | uid=1_0 RootWebArea "My test page" url="about:blank" 93 | uid=1_1 button "Click me" focusable focused 94 | uid=1_2 textbox value="Input" 95 | 96 | `; 97 | 98 | exports[`McpResponse > returns values for textboxes 1`] = ` 99 | # test response 100 | ## Latest page snapshot 101 | uid=1_0 RootWebArea "My test page" url="about:blank" 102 | uid=1_1 StaticText "username" 103 | uid=1_2 textbox "username" focusable focused value="mcp" 104 | 105 | `; 106 | 107 | exports[`McpResponse > returns verbose snapshot 1`] = ` 108 | # test response 109 | ## Latest page snapshot 110 | uid=1_0 RootWebArea "My test page" url="about:blank" 111 | uid=1_1 ignored 112 | uid=1_2 ignored 113 | uid=1_3 complementary 114 | uid=1_4 StaticText "test" 115 | uid=1_5 InlineTextBox "test" 116 | 117 | `; 118 | 119 | exports[`McpResponse > saves snapshot to file 1`] = ` 120 | # test response 121 | ## Latest page snapshot 122 | Saved snapshot to 123 | `; 124 | 125 | exports[`McpResponse > saves snapshot to file 2`] = ` 126 | uid=1_0 RootWebArea "My test page" url="about:blank" 127 | uid=1_1 ignored 128 | uid=1_2 ignored 129 | uid=1_3 complementary 130 | uid=1_4 StaticText "test" 131 | uid=1_5 InlineTextBox "test" 132 | 133 | `; 134 | 135 | exports[`McpResponse network request filtering > filters network requests by resource type 1`] = ` 136 | # test response 137 | ## Network requests 138 | Showing 1-2 of 2 (Page 1 of 1). 139 | reqid=1 GET http://example.com [pending] 140 | reqid=1 GET http://example.com [pending] 141 | `; 142 | 143 | exports[`McpResponse network request filtering > filters network requests by single resource type 1`] = ` 144 | # test response 145 | ## Network requests 146 | Showing 1-1 of 1 (Page 1 of 1). 147 | reqid=1 GET http://example.com [pending] 148 | `; 149 | 150 | exports[`McpResponse network request filtering > shows all requests when empty resourceTypes array is provided 1`] = ` 151 | # test response 152 | ## Network requests 153 | Showing 1-5 of 5 (Page 1 of 1). 154 | reqid=1 GET http://example.com [pending] 155 | reqid=1 GET http://example.com [pending] 156 | reqid=1 GET http://example.com [pending] 157 | reqid=1 GET http://example.com [pending] 158 | reqid=1 GET http://example.com [pending] 159 | `; 160 | 161 | exports[`McpResponse network request filtering > shows all requests when no filters are provided 1`] = ` 162 | # test response 163 | ## Network requests 164 | Showing 1-5 of 5 (Page 1 of 1). 165 | reqid=1 GET http://example.com [pending] 166 | reqid=1 GET http://example.com [pending] 167 | reqid=1 GET http://example.com [pending] 168 | reqid=1 GET http://example.com [pending] 169 | reqid=1 GET http://example.com [pending] 170 | `; 171 | 172 | exports[`McpResponse network request filtering > shows no requests when filter matches nothing 1`] = ` 173 | # test response 174 | ## Network requests 175 | No requests found. 176 | `; 177 | -------------------------------------------------------------------------------- /src/formatters/consoleFormatter.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import type {McpContext} from '../McpContext.js'; 8 | import type {DevTools} from '../third_party/index.js'; 9 | 10 | export interface ConsoleMessageData { 11 | consoleMessageStableId: number; 12 | type?: string; 13 | item?: DevTools.AggregatedIssue; 14 | message?: string; 15 | count?: number; 16 | description?: string; 17 | args?: string[]; 18 | } 19 | 20 | // The short format for a console message, based on a previous format. 21 | export function formatConsoleEventShort(msg: ConsoleMessageData): string { 22 | if (msg.type === 'issue') { 23 | return `msgid=${msg.consoleMessageStableId} [${msg.type}] ${msg.message} (count: ${msg.count})`; 24 | } 25 | return `msgid=${msg.consoleMessageStableId} [${msg.type}] ${msg.message} (${msg.args?.length ?? 0} args)`; 26 | } 27 | 28 | function getArgs(msg: ConsoleMessageData) { 29 | const args = [...(msg.args ?? [])]; 30 | 31 | // If there is no text, the first argument serves as text (see formatMessage). 32 | if (!msg.message) { 33 | args.shift(); 34 | } 35 | 36 | return args; 37 | } 38 | 39 | // The verbose format for a console message, including all details. 40 | export function formatConsoleEventVerbose( 41 | msg: ConsoleMessageData, 42 | context?: McpContext, 43 | ): string { 44 | const aggregatedIssue = msg.item; 45 | const result = [ 46 | `ID: ${msg.consoleMessageStableId}`, 47 | `Message: ${msg.type}> ${aggregatedIssue ? formatIssue(aggregatedIssue, msg.description, context) : msg.message}`, 48 | aggregatedIssue ? undefined : formatArgs(msg), 49 | ].filter(line => !!line); 50 | return result.join('\n'); 51 | } 52 | 53 | function formatArg(arg: unknown) { 54 | return typeof arg === 'object' ? JSON.stringify(arg) : String(arg); 55 | } 56 | 57 | function formatArgs(consoleData: ConsoleMessageData): string { 58 | const args = getArgs(consoleData); 59 | 60 | if (!args.length) { 61 | return ''; 62 | } 63 | 64 | const result = ['### Arguments']; 65 | 66 | for (const [key, arg] of args.entries()) { 67 | result.push(`Arg #${key}: ${formatArg(arg)}`); 68 | } 69 | 70 | return result.join('\n'); 71 | } 72 | 73 | export function formatIssue( 74 | issue: DevTools.AggregatedIssue, 75 | description?: string, 76 | context?: McpContext, 77 | ): string { 78 | const result: string[] = []; 79 | 80 | let processedMarkdown = description?.trim(); 81 | // Remove heading in order not to conflict with the whole console message response markdown 82 | if (processedMarkdown?.startsWith('# ')) { 83 | processedMarkdown = processedMarkdown.substring(2).trimStart(); 84 | } 85 | if (processedMarkdown) result.push(processedMarkdown); 86 | 87 | const links = issue.getDescription()?.links; 88 | if (links && links.length > 0) { 89 | result.push('Learn more:'); 90 | for (const link of links) { 91 | result.push(`[${link.linkTitle}](${link.link})`); 92 | } 93 | } 94 | 95 | const issues = issue.getAllIssues(); 96 | const affectedResources: Array<{ 97 | uid?: string; 98 | data?: object; 99 | request?: string | number; 100 | }> = []; 101 | for (const singleIssue of issues) { 102 | const details = singleIssue.details(); 103 | if (!details) continue; 104 | 105 | // We send the remaining details as untyped JSON because the DevTools 106 | // frontend code is currently not re-usable. 107 | // eslint-disable-next-line 108 | const data = structuredClone(details) as any; 109 | 110 | let uid; 111 | let request: number | string | undefined; 112 | if ('violatingNodeId' in details && details.violatingNodeId && context) { 113 | uid = context.resolveCdpElementId(details.violatingNodeId); 114 | delete data.violatingNodeId; 115 | } 116 | if ('nodeId' in details && details.nodeId && context) { 117 | uid = context.resolveCdpElementId(details.nodeId); 118 | delete data.nodeId; 119 | } 120 | if ('documentNodeId' in details && details.documentNodeId && context) { 121 | uid = context.resolveCdpElementId(details.documentNodeId); 122 | delete data.documentNodeId; 123 | } 124 | 125 | if ('request' in details && details.request) { 126 | request = details.request.url; 127 | if (details.request.requestId && context) { 128 | const resolvedId = context.resolveCdpRequestId( 129 | details.request.requestId, 130 | ); 131 | if (resolvedId) { 132 | request = resolvedId; 133 | delete data.request.requestId; 134 | } 135 | } 136 | } 137 | 138 | // These fields has no use for the MCP client (redundant or irrelevant). 139 | delete data.errorType; 140 | delete data.frameId; 141 | affectedResources.push({ 142 | uid, 143 | data: data, 144 | request, 145 | }); 146 | } 147 | if (affectedResources.length) { 148 | result.push('### Affected resources'); 149 | } 150 | result.push( 151 | ...affectedResources.map(item => { 152 | const details = []; 153 | if (item.uid) details.push(`uid=${item.uid}`); 154 | if (item.request) { 155 | details.push( 156 | (typeof item.request === 'number' ? `reqid=` : 'url=') + item.request, 157 | ); 158 | } 159 | if (item.data) details.push(`data=${JSON.stringify(item.data)}`); 160 | return details.join(' '); 161 | }), 162 | ); 163 | if (result.length === 0) return 'No affected resources found'; 164 | return result.join('\n'); 165 | } 166 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import './polyfill.js'; 8 | 9 | import process from 'node:process'; 10 | 11 | import type {Channel} from './browser.js'; 12 | import {ensureBrowserConnected, ensureBrowserLaunched} from './browser.js'; 13 | import {parseArguments} from './cli.js'; 14 | import {loadIssueDescriptions} from './issue-descriptions.js'; 15 | import {logger, saveLogsToFile} from './logger.js'; 16 | import {McpContext} from './McpContext.js'; 17 | import {McpResponse} from './McpResponse.js'; 18 | import {Mutex} from './Mutex.js'; 19 | import { 20 | McpServer, 21 | StdioServerTransport, 22 | type CallToolResult, 23 | SetLevelRequestSchema, 24 | } from './third_party/index.js'; 25 | import {ToolCategory} from './tools/categories.js'; 26 | import type {ToolDefinition} from './tools/ToolDefinition.js'; 27 | import {tools} from './tools/tools.js'; 28 | 29 | // If moved update release-please config 30 | // x-release-please-start-version 31 | const VERSION = '0.12.1'; 32 | // x-release-please-end 33 | 34 | export const args = parseArguments(VERSION); 35 | 36 | const logFile = args.logFile ? saveLogsToFile(args.logFile) : undefined; 37 | 38 | process.on('unhandledRejection', (reason, promise) => { 39 | logger('Unhandled promise rejection', promise, reason); 40 | }); 41 | 42 | logger(`Starting Chrome DevTools MCP Server v${VERSION}`); 43 | const server = new McpServer( 44 | { 45 | name: 'chrome_devtools', 46 | title: 'Chrome DevTools MCP server', 47 | version: VERSION, 48 | }, 49 | {capabilities: {logging: {}}}, 50 | ); 51 | server.server.setRequestHandler(SetLevelRequestSchema, () => { 52 | return {}; 53 | }); 54 | 55 | let context: McpContext; 56 | async function getContext(): Promise { 57 | const extraArgs: string[] = (args.chromeArg ?? []).map(String); 58 | if (args.proxyServer) { 59 | extraArgs.push(`--proxy-server=${args.proxyServer}`); 60 | } 61 | const devtools = args.experimentalDevtools ?? false; 62 | const browser = 63 | args.browserUrl || args.wsEndpoint || args.autoConnect 64 | ? await ensureBrowserConnected({ 65 | browserURL: args.browserUrl, 66 | wsEndpoint: args.wsEndpoint, 67 | wsHeaders: args.wsHeaders, 68 | // Important: only pass channel, if autoConnect is true. 69 | channel: args.autoConnect ? (args.channel as Channel) : undefined, 70 | userDataDir: args.userDataDir, 71 | devtools, 72 | }) 73 | : await ensureBrowserLaunched({ 74 | headless: args.headless, 75 | executablePath: args.executablePath, 76 | channel: args.channel as Channel, 77 | isolated: args.isolated ?? false, 78 | userDataDir: args.userDataDir, 79 | logFile, 80 | viewport: args.viewport, 81 | args: extraArgs, 82 | acceptInsecureCerts: args.acceptInsecureCerts, 83 | devtools, 84 | }); 85 | 86 | if (context?.browser !== browser) { 87 | context = await McpContext.from(browser, logger, { 88 | experimentalDevToolsDebugging: devtools, 89 | experimentalIncludeAllPages: args.experimentalIncludeAllPages, 90 | }); 91 | } 92 | return context; 93 | } 94 | 95 | const logDisclaimers = () => { 96 | console.error( 97 | `chrome-devtools-mcp exposes content of the browser instance to the MCP clients allowing them to inspect, 98 | debug, and modify any data in the browser or DevTools. 99 | Avoid sharing sensitive or personal information that you do not want to share with MCP clients.`, 100 | ); 101 | }; 102 | 103 | const toolMutex = new Mutex(); 104 | 105 | function registerTool(tool: ToolDefinition): void { 106 | if ( 107 | tool.annotations.category === ToolCategory.EMULATION && 108 | args.categoryEmulation === false 109 | ) { 110 | return; 111 | } 112 | if ( 113 | tool.annotations.category === ToolCategory.PERFORMANCE && 114 | args.categoryPerformance === false 115 | ) { 116 | return; 117 | } 118 | if ( 119 | tool.annotations.category === ToolCategory.NETWORK && 120 | args.categoryNetwork === false 121 | ) { 122 | return; 123 | } 124 | server.registerTool( 125 | tool.name, 126 | { 127 | description: tool.description, 128 | inputSchema: tool.schema, 129 | annotations: tool.annotations, 130 | }, 131 | async (params): Promise => { 132 | const guard = await toolMutex.acquire(); 133 | try { 134 | logger(`${tool.name} request: ${JSON.stringify(params, null, ' ')}`); 135 | const context = await getContext(); 136 | logger(`${tool.name} context: resolved`); 137 | await context.detectOpenDevToolsWindows(); 138 | const response = new McpResponse(); 139 | await tool.handler( 140 | { 141 | params, 142 | }, 143 | response, 144 | context, 145 | ); 146 | const content = await response.handle(tool.name, context); 147 | return { 148 | content, 149 | }; 150 | } catch (err) { 151 | logger(`${tool.name} error:`, err, err?.stack); 152 | let errorText = err && 'message' in err ? err.message : String(err); 153 | if ('cause' in err && err.cause) { 154 | errorText += `\nCause: ${err.cause.message}`; 155 | } 156 | return { 157 | content: [ 158 | { 159 | type: 'text', 160 | text: errorText, 161 | }, 162 | ], 163 | isError: true, 164 | }; 165 | } finally { 166 | guard.dispose(); 167 | } 168 | }, 169 | ); 170 | } 171 | 172 | for (const tool of tools) { 173 | registerTool(tool); 174 | } 175 | 176 | await loadIssueDescriptions(); 177 | const transport = new StdioServerTransport(); 178 | await server.connect(transport); 179 | logger('Chrome DevTools MCP Server connected'); 180 | logDisclaimers(); 181 | -------------------------------------------------------------------------------- /tests/tools/script.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import assert from 'node:assert'; 8 | import {describe, it} from 'node:test'; 9 | 10 | import {evaluateScript} from '../../src/tools/script.js'; 11 | import {serverHooks} from '../server.js'; 12 | import {html, withMcpContext} from '../utils.js'; 13 | 14 | describe('script', () => { 15 | const server = serverHooks(); 16 | 17 | describe('browser_evaluate_script', () => { 18 | it('evaluates', async () => { 19 | await withMcpContext(async (response, context) => { 20 | await evaluateScript.handler( 21 | {params: {function: String(() => 2 * 5)}}, 22 | response, 23 | context, 24 | ); 25 | const lineEvaluation = response.responseLines.at(2)!; 26 | assert.strictEqual(JSON.parse(lineEvaluation), 10); 27 | }); 28 | }); 29 | it('runs in selected page', async () => { 30 | await withMcpContext(async (response, context) => { 31 | await evaluateScript.handler( 32 | {params: {function: String(() => document.title)}}, 33 | response, 34 | context, 35 | ); 36 | 37 | let lineEvaluation = response.responseLines.at(2)!; 38 | assert.strictEqual(JSON.parse(lineEvaluation), ''); 39 | 40 | const page = await context.newPage(); 41 | await page.setContent(` 42 | 43 | New Page 44 | 45 | `); 46 | 47 | response.resetResponseLineForTesting(); 48 | await evaluateScript.handler( 49 | {params: {function: String(() => document.title)}}, 50 | response, 51 | context, 52 | ); 53 | 54 | lineEvaluation = response.responseLines.at(2)!; 55 | assert.strictEqual(JSON.parse(lineEvaluation), 'New Page'); 56 | }); 57 | }); 58 | 59 | it('work for complex objects', async () => { 60 | await withMcpContext(async (response, context) => { 61 | const page = context.getSelectedPage(); 62 | 63 | await page.setContent(html` `); 64 | 65 | await evaluateScript.handler( 66 | { 67 | params: { 68 | function: String(() => { 69 | const scripts = Array.from( 70 | document.head.querySelectorAll('script'), 71 | ).map(s => ({src: s.src, async: s.async, defer: s.defer})); 72 | 73 | return {scripts}; 74 | }), 75 | }, 76 | }, 77 | response, 78 | context, 79 | ); 80 | const lineEvaluation = response.responseLines.at(2)!; 81 | assert.deepEqual(JSON.parse(lineEvaluation), { 82 | scripts: [], 83 | }); 84 | }); 85 | }); 86 | 87 | it('work for async functions', async () => { 88 | await withMcpContext(async (response, context) => { 89 | const page = context.getSelectedPage(); 90 | 91 | await page.setContent(html` `); 92 | 93 | await evaluateScript.handler( 94 | { 95 | params: { 96 | function: String(async () => { 97 | await new Promise(res => setTimeout(res, 0)); 98 | return 'Works'; 99 | }), 100 | }, 101 | }, 102 | response, 103 | context, 104 | ); 105 | const lineEvaluation = response.responseLines.at(2)!; 106 | assert.strictEqual(JSON.parse(lineEvaluation), 'Works'); 107 | }); 108 | }); 109 | 110 | it('work with one argument', async () => { 111 | await withMcpContext(async (response, context) => { 112 | const page = context.getSelectedPage(); 113 | 114 | await page.setContent(html``); 115 | 116 | await context.createTextSnapshot(); 117 | 118 | await evaluateScript.handler( 119 | { 120 | params: { 121 | function: String(async (el: Element) => { 122 | return el.id; 123 | }), 124 | args: [{uid: '1_1'}], 125 | }, 126 | }, 127 | response, 128 | context, 129 | ); 130 | const lineEvaluation = response.responseLines.at(2)!; 131 | assert.strictEqual(JSON.parse(lineEvaluation), 'test'); 132 | }); 133 | }); 134 | 135 | it('work with multiple args', async () => { 136 | await withMcpContext(async (response, context) => { 137 | const page = context.getSelectedPage(); 138 | 139 | await page.setContent(html``); 140 | 141 | await context.createTextSnapshot(); 142 | 143 | await evaluateScript.handler( 144 | { 145 | params: { 146 | function: String((container: Element, child: Element) => { 147 | return container.contains(child); 148 | }), 149 | args: [{uid: '1_0'}, {uid: '1_1'}], 150 | }, 151 | }, 152 | response, 153 | context, 154 | ); 155 | const lineEvaluation = response.responseLines.at(2)!; 156 | assert.strictEqual(JSON.parse(lineEvaluation), true); 157 | }); 158 | }); 159 | 160 | it('work for elements inside iframes', async () => { 161 | server.addHtmlRoute( 162 | '/iframe', 163 | html`
`, 164 | ); 165 | server.addHtmlRoute('/main', html``); 166 | 167 | await withMcpContext(async (response, context) => { 168 | const page = context.getSelectedPage(); 169 | await page.goto(server.getRoute('/main')); 170 | await context.createTextSnapshot(); 171 | await evaluateScript.handler( 172 | { 173 | params: { 174 | function: String((element: Element) => { 175 | return element.textContent; 176 | }), 177 | args: [{uid: '1_3'}], 178 | }, 179 | }, 180 | response, 181 | context, 182 | ); 183 | const lineEvaluation = response.responseLines.at(2)!; 184 | assert.strictEqual(JSON.parse(lineEvaluation), 'I am iframe button'); 185 | }); 186 | }); 187 | }); 188 | }); 189 | -------------------------------------------------------------------------------- /tests/tools/network.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import assert from 'node:assert'; 8 | import {describe, it} from 'node:test'; 9 | 10 | import { 11 | getNetworkRequest, 12 | listNetworkRequests, 13 | } from '../../src/tools/network.js'; 14 | import {serverHooks} from '../server.js'; 15 | import { 16 | getTextContent, 17 | html, 18 | stabilizeResponseOutput, 19 | withMcpContext, 20 | } from '../utils.js'; 21 | 22 | describe('network', () => { 23 | const server = serverHooks(); 24 | describe('network_list_requests', () => { 25 | it('list requests', async () => { 26 | await withMcpContext(async (response, context) => { 27 | await listNetworkRequests.handler({params: {}}, response, context); 28 | assert.ok(response.includeNetworkRequests); 29 | assert.strictEqual(response.networkRequestsPageIdx, undefined); 30 | }); 31 | }); 32 | 33 | it('list requests form current navigations only', async t => { 34 | server.addHtmlRoute('/one', html`
First
`); 35 | server.addHtmlRoute('/two', html`
Second
`); 36 | server.addHtmlRoute('/three', html`
Third
`); 37 | 38 | await withMcpContext(async (response, context) => { 39 | await context.setUpNetworkCollectorForTesting(); 40 | const page = context.getSelectedPage(); 41 | await page.goto(server.getRoute('/one')); 42 | await page.goto(server.getRoute('/two')); 43 | await page.goto(server.getRoute('/three')); 44 | await listNetworkRequests.handler( 45 | { 46 | params: {}, 47 | }, 48 | response, 49 | context, 50 | ); 51 | const responseData = await response.handle('list_request', context); 52 | t.assert.snapshot?.( 53 | stabilizeResponseOutput(getTextContent(responseData[0])), 54 | ); 55 | }); 56 | }); 57 | 58 | it('list requests from previous navigations', async t => { 59 | server.addHtmlRoute('/one', html`
First
`); 60 | server.addHtmlRoute('/two', html`
Second
`); 61 | server.addHtmlRoute('/three', html`
Third
`); 62 | 63 | await withMcpContext(async (response, context) => { 64 | await context.setUpNetworkCollectorForTesting(); 65 | const page = context.getSelectedPage(); 66 | await page.goto(server.getRoute('/one')); 67 | await page.goto(server.getRoute('/two')); 68 | await page.goto(server.getRoute('/three')); 69 | await listNetworkRequests.handler( 70 | { 71 | params: { 72 | includePreservedRequests: true, 73 | }, 74 | }, 75 | response, 76 | context, 77 | ); 78 | const responseData = await response.handle('list_request', context); 79 | t.assert.snapshot?.( 80 | stabilizeResponseOutput(getTextContent(responseData[0])), 81 | ); 82 | }); 83 | }); 84 | 85 | it('list requests from previous navigations from redirects', async t => { 86 | server.addRoute('/redirect', async (_req, res) => { 87 | res.writeHead(302, { 88 | Location: server.getRoute('/redirected'), 89 | }); 90 | res.end(); 91 | }); 92 | 93 | server.addHtmlRoute( 94 | '/redirected', 95 | html``, 98 | ); 99 | 100 | server.addHtmlRoute( 101 | '/redirected-page', 102 | html`
I was redirected 2 times
`, 103 | ); 104 | 105 | await withMcpContext(async (response, context) => { 106 | await context.setUpNetworkCollectorForTesting(); 107 | const page = context.getSelectedPage(); 108 | await page.goto(server.getRoute('/redirect')); 109 | await listNetworkRequests.handler( 110 | { 111 | params: { 112 | includePreservedRequests: true, 113 | }, 114 | }, 115 | response, 116 | context, 117 | ); 118 | const responseData = await response.handle('list_request', context); 119 | t.assert.snapshot?.( 120 | stabilizeResponseOutput(getTextContent(responseData[0])), 121 | ); 122 | }); 123 | }); 124 | }); 125 | describe('network_get_request', () => { 126 | it('attaches request', async () => { 127 | await withMcpContext(async (response, context) => { 128 | const page = context.getSelectedPage(); 129 | await page.goto('data:text/html,
Hello MCP
'); 130 | await getNetworkRequest.handler( 131 | {params: {reqid: 1}}, 132 | response, 133 | context, 134 | ); 135 | 136 | assert.equal(response.attachedNetworkRequestId, 1); 137 | }); 138 | }); 139 | it('should not add the request list', async () => { 140 | await withMcpContext(async (response, context) => { 141 | const page = context.getSelectedPage(); 142 | await page.goto('data:text/html,
Hello MCP
'); 143 | await getNetworkRequest.handler( 144 | {params: {reqid: 1}}, 145 | response, 146 | context, 147 | ); 148 | assert(!response.includeNetworkRequests); 149 | }); 150 | }); 151 | it('should get request from previous navigations', async t => { 152 | server.addHtmlRoute('/one', html`
First
`); 153 | server.addHtmlRoute('/two', html`
Second
`); 154 | server.addHtmlRoute('/three', html`
Third
`); 155 | 156 | await withMcpContext(async (response, context) => { 157 | await context.setUpNetworkCollectorForTesting(); 158 | const page = context.getSelectedPage(); 159 | await page.goto(server.getRoute('/one')); 160 | await page.goto(server.getRoute('/two')); 161 | await page.goto(server.getRoute('/three')); 162 | await getNetworkRequest.handler( 163 | { 164 | params: { 165 | reqid: 1, 166 | }, 167 | }, 168 | response, 169 | context, 170 | ); 171 | const responseData = await response.handle('get_request', context); 172 | 173 | t.assert.snapshot?.( 174 | stabilizeResponseOutput(getTextContent(responseData[0])), 175 | ); 176 | }); 177 | }); 178 | }); 179 | }); 180 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2021 Google LLC. 3 | * Copyright (c) Microsoft Corporation. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | /** 18 | * @fileoverview take from {@link https://github.com/GoogleChromeLabs/chromium-bidi/blob/main/rollup.config.mjs | chromium-bidi} 19 | * and modified to specific requirement. 20 | */ 21 | 22 | import fs from 'node:fs'; 23 | import path from 'node:path'; 24 | 25 | import commonjs from '@rollup/plugin-commonjs'; 26 | import json from '@rollup/plugin-json'; 27 | import {nodeResolve} from '@rollup/plugin-node-resolve'; 28 | import cleanup from 'rollup-plugin-cleanup'; 29 | import license from 'rollup-plugin-license'; 30 | 31 | const isProduction = process.env.NODE_ENV === 'production'; 32 | 33 | const allowedLicenses = [ 34 | 'MIT', 35 | 'Apache 2.0', 36 | 'Apache-2.0', 37 | 'BSD-3-Clause', 38 | 'BSD-2-Clause', 39 | 'ISC', 40 | '0BSD', 41 | ]; 42 | 43 | /** 44 | * @param {string} wrapperIndexPath 45 | * @param {import('rollup').OutputOptions} [extraOutputOptions={}] 46 | * @param {import('rollup').ExternalOption} [external=[]] 47 | * @returns {import('rollup').RollupOptions} 48 | */ 49 | const bundleDependency = ( 50 | wrapperIndexPath, 51 | extraOutputOptions = {}, 52 | external = [], 53 | ) => ({ 54 | input: wrapperIndexPath, 55 | output: { 56 | ...extraOutputOptions, 57 | file: wrapperIndexPath, 58 | sourcemap: !isProduction, 59 | format: 'esm', 60 | }, 61 | plugins: [ 62 | cleanup({ 63 | // Keep license comments. Other comments are removed due to 64 | // http://b/390559299 and 65 | // https://github.com/microsoft/TypeScript/issues/60811. 66 | comments: [/Copyright/i], 67 | }), 68 | license({ 69 | thirdParty: { 70 | allow: { 71 | test: dependency => { 72 | return allowedLicenses.includes(dependency.license); 73 | }, 74 | failOnUnlicensed: true, 75 | failOnViolation: true, 76 | }, 77 | output: { 78 | file: path.join( 79 | path.dirname(wrapperIndexPath), 80 | 'THIRD_PARTY_NOTICES', 81 | ), 82 | template(dependencies) { 83 | const stringifiedDependencies = dependencies.map(dependency => { 84 | let arr = []; 85 | arr.push(`Name: ${dependency.name ?? 'N/A'}`); 86 | let url = dependency.homepage ?? dependency.repository; 87 | if (url !== null && typeof url !== 'string') { 88 | url = url.url; 89 | } 90 | arr.push(`URL: ${url ?? 'N/A'}`); 91 | arr.push(`Version: ${dependency.version ?? 'N/A'}`); 92 | arr.push(`License: ${dependency.license ?? 'N/A'}`); 93 | if (dependency.licenseText !== null) { 94 | arr.push(''); 95 | arr.push(dependency.licenseText.replaceAll('\r', '')); 96 | } 97 | return arr.join('\n'); 98 | }); 99 | 100 | // Manual license handling for chrome-devtools-frontend third_party 101 | const tsConfig = JSON.parse( 102 | fs.readFileSync( 103 | path.join(process.cwd(), 'tsconfig.json'), 104 | 'utf-8', 105 | ), 106 | ); 107 | const thirdPartyDirectories = tsConfig.include.filter(location => 108 | location.includes( 109 | 'node_modules/chrome-devtools-frontend/front_end/third_party', 110 | ), 111 | ); 112 | 113 | const manualLicenses = []; 114 | // Add chrome-devtools-frontend main license 115 | const cdtfLicensePath = path.join( 116 | process.cwd(), 117 | 'node_modules/chrome-devtools-frontend/LICENSE', 118 | ); 119 | if (fs.existsSync(cdtfLicensePath)) { 120 | manualLicenses.push( 121 | [ 122 | 'Name: chrome-devtools-frontend', 123 | 'License: Apache-2.0', 124 | '', 125 | fs.readFileSync(cdtfLicensePath, 'utf-8'), 126 | ].join('\n'), 127 | ); 128 | } 129 | 130 | for (const thirdPartyDir of thirdPartyDirectories) { 131 | const fullPath = path.join(process.cwd(), thirdPartyDir); 132 | const licenseFile = path.join(fullPath, 'LICENSE'); 133 | if (fs.existsSync(licenseFile)) { 134 | const name = path.basename(thirdPartyDir); 135 | manualLicenses.push( 136 | [ 137 | `Name: ${name}`, 138 | `License:`, 139 | '', 140 | fs.readFileSync(licenseFile, 'utf-8').replaceAll('\r', ''), 141 | ].join('\n'), 142 | ); 143 | } 144 | } 145 | 146 | if (manualLicenses.length > 0) { 147 | stringifiedDependencies.push(...manualLicenses); 148 | } 149 | 150 | const divider = 151 | '\n\n-------------------- DEPENDENCY DIVIDER --------------------\n\n'; 152 | return stringifiedDependencies.join(divider); 153 | }, 154 | }, 155 | }, 156 | }), 157 | commonjs(), 158 | json(), 159 | nodeResolve(), 160 | ], 161 | external, 162 | }); 163 | 164 | export default [ 165 | bundleDependency( 166 | './build/src/third_party/index.js', 167 | { 168 | inlineDynamicImports: true, 169 | }, 170 | (source, importer, _isResolved) => { 171 | if ( 172 | source === 'yargs' && 173 | importer && 174 | importer.includes('puppeteer-core') 175 | ) { 176 | return true; 177 | } 178 | 179 | const existingExternals = ['./bidi.js', '../bidi/bidi.js']; 180 | if (existingExternals.includes(source)) { 181 | return true; 182 | } 183 | 184 | return false; 185 | }, 186 | ), 187 | ]; 188 | -------------------------------------------------------------------------------- /tests/cli.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import assert from 'node:assert'; 8 | import {describe, it} from 'node:test'; 9 | 10 | import {parseArguments} from '../src/cli.js'; 11 | 12 | describe('cli args parsing', () => { 13 | const defaultArgs = { 14 | 'category-emulation': true, 15 | categoryEmulation: true, 16 | 'category-performance': true, 17 | categoryPerformance: true, 18 | 'category-network': true, 19 | categoryNetwork: true, 20 | 'auto-connect': undefined, 21 | autoConnect: undefined, 22 | }; 23 | 24 | it('parses with default args', async () => { 25 | const args = parseArguments('1.0.0', ['node', 'main.js']); 26 | assert.deepStrictEqual(args, { 27 | ...defaultArgs, 28 | _: [], 29 | headless: false, 30 | $0: 'npx chrome-devtools-mcp@latest', 31 | channel: 'stable', 32 | }); 33 | }); 34 | 35 | it('parses with browser url', async () => { 36 | const args = parseArguments('1.0.0', [ 37 | 'node', 38 | 'main.js', 39 | '--browserUrl', 40 | 'http://localhost:3000', 41 | ]); 42 | assert.deepStrictEqual(args, { 43 | ...defaultArgs, 44 | _: [], 45 | headless: false, 46 | $0: 'npx chrome-devtools-mcp@latest', 47 | 'browser-url': 'http://localhost:3000', 48 | browserUrl: 'http://localhost:3000', 49 | u: 'http://localhost:3000', 50 | }); 51 | }); 52 | 53 | it('parses with user data dir', async () => { 54 | const args = parseArguments('1.0.0', [ 55 | 'node', 56 | 'main.js', 57 | '--user-data-dir', 58 | '/tmp/chrome-profile', 59 | ]); 60 | assert.deepStrictEqual(args, { 61 | ...defaultArgs, 62 | _: [], 63 | headless: false, 64 | $0: 'npx chrome-devtools-mcp@latest', 65 | channel: 'stable', 66 | 'user-data-dir': '/tmp/chrome-profile', 67 | userDataDir: '/tmp/chrome-profile', 68 | }); 69 | }); 70 | 71 | it('parses an empty browser url', async () => { 72 | const args = parseArguments('1.0.0', [ 73 | 'node', 74 | 'main.js', 75 | '--browserUrl', 76 | '', 77 | ]); 78 | assert.deepStrictEqual(args, { 79 | ...defaultArgs, 80 | _: [], 81 | headless: false, 82 | $0: 'npx chrome-devtools-mcp@latest', 83 | 'browser-url': undefined, 84 | browserUrl: undefined, 85 | u: undefined, 86 | channel: 'stable', 87 | }); 88 | }); 89 | 90 | it('parses with executable path', async () => { 91 | const args = parseArguments('1.0.0', [ 92 | 'node', 93 | 'main.js', 94 | '--executablePath', 95 | '/tmp/test 123/chrome', 96 | ]); 97 | assert.deepStrictEqual(args, { 98 | ...defaultArgs, 99 | _: [], 100 | headless: false, 101 | $0: 'npx chrome-devtools-mcp@latest', 102 | 'executable-path': '/tmp/test 123/chrome', 103 | e: '/tmp/test 123/chrome', 104 | executablePath: '/tmp/test 123/chrome', 105 | }); 106 | }); 107 | 108 | it('parses viewport', async () => { 109 | const args = parseArguments('1.0.0', [ 110 | 'node', 111 | 'main.js', 112 | '--viewport', 113 | '888x777', 114 | ]); 115 | assert.deepStrictEqual(args, { 116 | ...defaultArgs, 117 | _: [], 118 | headless: false, 119 | $0: 'npx chrome-devtools-mcp@latest', 120 | channel: 'stable', 121 | viewport: { 122 | width: 888, 123 | height: 777, 124 | }, 125 | }); 126 | }); 127 | 128 | it('parses viewport', async () => { 129 | const args = parseArguments('1.0.0', [ 130 | 'node', 131 | 'main.js', 132 | `--chrome-arg='--no-sandbox'`, 133 | `--chrome-arg='--disable-setuid-sandbox'`, 134 | ]); 135 | assert.deepStrictEqual(args, { 136 | ...defaultArgs, 137 | _: [], 138 | headless: false, 139 | $0: 'npx chrome-devtools-mcp@latest', 140 | channel: 'stable', 141 | 'chrome-arg': ['--no-sandbox', '--disable-setuid-sandbox'], 142 | chromeArg: ['--no-sandbox', '--disable-setuid-sandbox'], 143 | }); 144 | }); 145 | 146 | it('parses wsEndpoint with ws:// protocol', async () => { 147 | const args = parseArguments('1.0.0', [ 148 | 'node', 149 | 'main.js', 150 | '--wsEndpoint', 151 | 'ws://127.0.0.1:9222/devtools/browser/abc123', 152 | ]); 153 | assert.deepStrictEqual(args, { 154 | ...defaultArgs, 155 | _: [], 156 | headless: false, 157 | $0: 'npx chrome-devtools-mcp@latest', 158 | 'ws-endpoint': 'ws://127.0.0.1:9222/devtools/browser/abc123', 159 | wsEndpoint: 'ws://127.0.0.1:9222/devtools/browser/abc123', 160 | w: 'ws://127.0.0.1:9222/devtools/browser/abc123', 161 | }); 162 | }); 163 | 164 | it('parses wsEndpoint with wss:// protocol', async () => { 165 | const args = parseArguments('1.0.0', [ 166 | 'node', 167 | 'main.js', 168 | '--wsEndpoint', 169 | 'wss://example.com:9222/devtools/browser/abc123', 170 | ]); 171 | assert.deepStrictEqual(args, { 172 | ...defaultArgs, 173 | _: [], 174 | headless: false, 175 | $0: 'npx chrome-devtools-mcp@latest', 176 | 'ws-endpoint': 'wss://example.com:9222/devtools/browser/abc123', 177 | wsEndpoint: 'wss://example.com:9222/devtools/browser/abc123', 178 | w: 'wss://example.com:9222/devtools/browser/abc123', 179 | }); 180 | }); 181 | 182 | it('parses wsHeaders with valid JSON', async () => { 183 | const args = parseArguments('1.0.0', [ 184 | 'node', 185 | 'main.js', 186 | '--wsEndpoint', 187 | 'ws://127.0.0.1:9222/devtools/browser/abc123', 188 | '--wsHeaders', 189 | '{"Authorization":"Bearer token","X-Custom":"value"}', 190 | ]); 191 | assert.deepStrictEqual(args.wsHeaders, { 192 | Authorization: 'Bearer token', 193 | 'X-Custom': 'value', 194 | }); 195 | }); 196 | 197 | it('parses disabled category', async () => { 198 | const args = parseArguments('1.0.0', [ 199 | 'node', 200 | 'main.js', 201 | '--no-category-emulation', 202 | ]); 203 | assert.deepStrictEqual(args, { 204 | ...defaultArgs, 205 | _: [], 206 | headless: false, 207 | $0: 'npx chrome-devtools-mcp@latest', 208 | channel: 'stable', 209 | 'category-emulation': false, 210 | categoryEmulation: false, 211 | }); 212 | }); 213 | it('parses auto-connect', async () => { 214 | const args = parseArguments('1.0.0', ['node', 'main.js', '--auto-connect']); 215 | assert.deepStrictEqual(args, { 216 | ...defaultArgs, 217 | _: [], 218 | headless: false, 219 | $0: 'npx chrome-devtools-mcp@latest', 220 | channel: 'stable', 221 | 'auto-connect': true, 222 | autoConnect: true, 223 | }); 224 | }); 225 | }); 226 | -------------------------------------------------------------------------------- /src/tools/performance.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import {logger} from '../logger.js'; 8 | import {zod} from '../third_party/index.js'; 9 | import type {Page} from '../third_party/index.js'; 10 | import type {InsightName} from '../trace-processing/parse.js'; 11 | import { 12 | getInsightOutput, 13 | getTraceSummary, 14 | parseRawTraceBuffer, 15 | traceResultIsSuccess, 16 | } from '../trace-processing/parse.js'; 17 | 18 | import {ToolCategory} from './categories.js'; 19 | import type {Context, Response} from './ToolDefinition.js'; 20 | import {defineTool} from './ToolDefinition.js'; 21 | 22 | export const startTrace = defineTool({ 23 | name: 'performance_start_trace', 24 | description: 25 | 'Starts a performance trace recording on the selected page. This can be used to look for performance problems and insights to improve the performance of the page. It will also report Core Web Vital (CWV) scores for the page.', 26 | annotations: { 27 | category: ToolCategory.PERFORMANCE, 28 | readOnlyHint: true, 29 | }, 30 | schema: { 31 | reload: zod 32 | .boolean() 33 | .describe( 34 | 'Determines if, once tracing has started, the page should be automatically reloaded.', 35 | ), 36 | autoStop: zod 37 | .boolean() 38 | .describe( 39 | 'Determines if the trace recording should be automatically stopped.', 40 | ), 41 | }, 42 | handler: async (request, response, context) => { 43 | if (context.isRunningPerformanceTrace()) { 44 | response.appendResponseLine( 45 | 'Error: a performance trace is already running. Use performance_stop_trace to stop it. Only one trace can be running at any given time.', 46 | ); 47 | return; 48 | } 49 | context.setIsRunningPerformanceTrace(true); 50 | 51 | const page = context.getSelectedPage(); 52 | const pageUrlForTracing = page.url(); 53 | 54 | if (request.params.reload) { 55 | // Before starting the recording, navigate to about:blank to clear out any state. 56 | await page.goto('about:blank', { 57 | waitUntil: ['networkidle0'], 58 | }); 59 | } 60 | 61 | // Keep in sync with the categories arrays in: 62 | // https://source.chromium.org/chromium/chromium/src/+/main:third_party/devtools-frontend/src/front_end/panels/timeline/TimelineController.ts 63 | // https://github.com/GoogleChrome/lighthouse/blob/master/lighthouse-core/gather/gatherers/trace.js 64 | const categories = [ 65 | '-*', 66 | 'blink.console', 67 | 'blink.user_timing', 68 | 'devtools.timeline', 69 | 'disabled-by-default-devtools.screenshot', 70 | 'disabled-by-default-devtools.timeline', 71 | 'disabled-by-default-devtools.timeline.invalidationTracking', 72 | 'disabled-by-default-devtools.timeline.frame', 73 | 'disabled-by-default-devtools.timeline.stack', 74 | 'disabled-by-default-v8.cpu_profiler', 75 | 'disabled-by-default-v8.cpu_profiler.hires', 76 | 'latencyInfo', 77 | 'loading', 78 | 'disabled-by-default-lighthouse', 79 | 'v8.execute', 80 | 'v8', 81 | ]; 82 | await page.tracing.start({ 83 | categories, 84 | }); 85 | 86 | if (request.params.reload) { 87 | await page.goto(pageUrlForTracing, { 88 | waitUntil: ['load'], 89 | }); 90 | } 91 | 92 | if (request.params.autoStop) { 93 | await new Promise(resolve => setTimeout(resolve, 5_000)); 94 | await stopTracingAndAppendOutput(page, response, context); 95 | } else { 96 | response.appendResponseLine( 97 | `The performance trace is being recorded. Use performance_stop_trace to stop it.`, 98 | ); 99 | } 100 | }, 101 | }); 102 | 103 | export const stopTrace = defineTool({ 104 | name: 'performance_stop_trace', 105 | description: 106 | 'Stops the active performance trace recording on the selected page.', 107 | annotations: { 108 | category: ToolCategory.PERFORMANCE, 109 | readOnlyHint: true, 110 | }, 111 | schema: {}, 112 | handler: async (_request, response, context) => { 113 | if (!context.isRunningPerformanceTrace()) { 114 | return; 115 | } 116 | const page = context.getSelectedPage(); 117 | await stopTracingAndAppendOutput(page, response, context); 118 | }, 119 | }); 120 | 121 | export const analyzeInsight = defineTool({ 122 | name: 'performance_analyze_insight', 123 | description: 124 | 'Provides more detailed information on a specific Performance Insight of an insight set that was highlighted in the results of a trace recording.', 125 | annotations: { 126 | category: ToolCategory.PERFORMANCE, 127 | readOnlyHint: true, 128 | }, 129 | schema: { 130 | insightSetId: zod 131 | .string() 132 | .describe( 133 | 'The id for the specific insight set. Only use the ids given in the "Available insight sets" list.', 134 | ), 135 | insightName: zod 136 | .string() 137 | .describe( 138 | 'The name of the Insight you want more information on. For example: "DocumentLatency" or "LCPBreakdown"', 139 | ), 140 | }, 141 | handler: async (request, response, context) => { 142 | const lastRecording = context.recordedTraces().at(-1); 143 | if (!lastRecording) { 144 | response.appendResponseLine( 145 | 'No recorded traces found. Record a performance trace so you have Insights to analyze.', 146 | ); 147 | return; 148 | } 149 | 150 | const insightOutput = getInsightOutput( 151 | lastRecording, 152 | request.params.insightSetId, 153 | request.params.insightName as InsightName, 154 | ); 155 | if ('error' in insightOutput) { 156 | response.appendResponseLine(insightOutput.error); 157 | return; 158 | } 159 | 160 | response.appendResponseLine(insightOutput.output); 161 | }, 162 | }); 163 | 164 | async function stopTracingAndAppendOutput( 165 | page: Page, 166 | response: Response, 167 | context: Context, 168 | ): Promise { 169 | try { 170 | const traceEventsBuffer = await page.tracing.stop(); 171 | const result = await parseRawTraceBuffer(traceEventsBuffer); 172 | response.appendResponseLine('The performance trace has been stopped.'); 173 | if (traceResultIsSuccess(result)) { 174 | context.storeTraceRecording(result); 175 | const traceSummaryText = getTraceSummary(result); 176 | response.appendResponseLine(traceSummaryText); 177 | } else { 178 | response.appendResponseLine( 179 | 'There was an unexpected error parsing the trace:', 180 | ); 181 | response.appendResponseLine(result.error); 182 | } 183 | } catch (e) { 184 | const errorText = e instanceof Error ? e.message : JSON.stringify(e); 185 | logger(`Error stopping performance trace: ${errorText}`); 186 | response.appendResponseLine( 187 | 'An error occurred generating the response for this trace:', 188 | ); 189 | response.appendResponseLine(errorText); 190 | } finally { 191 | context.setIsRunningPerformanceTrace(false); 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /tests/tools/emulation.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import assert from 'node:assert'; 8 | import {describe, it} from 'node:test'; 9 | 10 | import {emulate} from '../../src/tools/emulation.js'; 11 | import {withMcpContext} from '../utils.js'; 12 | 13 | describe('emulation', () => { 14 | describe('network', () => { 15 | it('emulates offline network conditions', async () => { 16 | await withMcpContext(async (response, context) => { 17 | await emulate.handler( 18 | { 19 | params: { 20 | networkConditions: 'Offline', 21 | }, 22 | }, 23 | response, 24 | context, 25 | ); 26 | 27 | assert.strictEqual(context.getNetworkConditions(), 'Offline'); 28 | }); 29 | }); 30 | it('emulates network throttling when the throttling option is valid', async () => { 31 | await withMcpContext(async (response, context) => { 32 | await emulate.handler( 33 | { 34 | params: { 35 | networkConditions: 'Slow 3G', 36 | }, 37 | }, 38 | response, 39 | context, 40 | ); 41 | 42 | assert.strictEqual(context.getNetworkConditions(), 'Slow 3G'); 43 | }); 44 | }); 45 | 46 | it('disables network emulation', async () => { 47 | await withMcpContext(async (response, context) => { 48 | await emulate.handler( 49 | { 50 | params: { 51 | networkConditions: 'No emulation', 52 | }, 53 | }, 54 | response, 55 | context, 56 | ); 57 | 58 | assert.strictEqual(context.getNetworkConditions(), null); 59 | }); 60 | }); 61 | 62 | it('does not set throttling when the network throttling is not one of the predefined options', async () => { 63 | await withMcpContext(async (response, context) => { 64 | await emulate.handler( 65 | { 66 | params: { 67 | networkConditions: 'Slow 11G', 68 | }, 69 | }, 70 | response, 71 | context, 72 | ); 73 | 74 | assert.strictEqual(context.getNetworkConditions(), null); 75 | }); 76 | }); 77 | 78 | it('report correctly for the currently selected page', async () => { 79 | await withMcpContext(async (response, context) => { 80 | await emulate.handler( 81 | { 82 | params: { 83 | networkConditions: 'Slow 3G', 84 | }, 85 | }, 86 | response, 87 | context, 88 | ); 89 | 90 | assert.strictEqual(context.getNetworkConditions(), 'Slow 3G'); 91 | 92 | const page = await context.newPage(); 93 | context.selectPage(page); 94 | 95 | assert.strictEqual(context.getNetworkConditions(), null); 96 | }); 97 | }); 98 | }); 99 | 100 | describe('cpu', () => { 101 | it('emulates cpu throttling when the rate is valid (1-20x)', async () => { 102 | await withMcpContext(async (response, context) => { 103 | await emulate.handler( 104 | { 105 | params: { 106 | cpuThrottlingRate: 4, 107 | }, 108 | }, 109 | response, 110 | context, 111 | ); 112 | 113 | assert.strictEqual(context.getCpuThrottlingRate(), 4); 114 | }); 115 | }); 116 | 117 | it('disables cpu throttling', async () => { 118 | await withMcpContext(async (response, context) => { 119 | context.setCpuThrottlingRate(4); // Set it to something first. 120 | await emulate.handler( 121 | { 122 | params: { 123 | cpuThrottlingRate: 1, 124 | }, 125 | }, 126 | response, 127 | context, 128 | ); 129 | 130 | assert.strictEqual(context.getCpuThrottlingRate(), 1); 131 | }); 132 | }); 133 | 134 | it('report correctly for the currently selected page', async () => { 135 | await withMcpContext(async (response, context) => { 136 | await emulate.handler( 137 | { 138 | params: { 139 | cpuThrottlingRate: 4, 140 | }, 141 | }, 142 | response, 143 | context, 144 | ); 145 | 146 | assert.strictEqual(context.getCpuThrottlingRate(), 4); 147 | 148 | const page = await context.newPage(); 149 | context.selectPage(page); 150 | 151 | assert.strictEqual(context.getCpuThrottlingRate(), 1); 152 | }); 153 | }); 154 | }); 155 | 156 | describe('geolocation', () => { 157 | it('emulates geolocation with latitude and longitude', async () => { 158 | await withMcpContext(async (response, context) => { 159 | await emulate.handler( 160 | { 161 | params: { 162 | geolocation: { 163 | latitude: 48.137154, 164 | longitude: 11.576124, 165 | }, 166 | }, 167 | }, 168 | response, 169 | context, 170 | ); 171 | 172 | const geolocation = context.getGeolocation(); 173 | assert.strictEqual(geolocation?.latitude, 48.137154); 174 | assert.strictEqual(geolocation?.longitude, 11.576124); 175 | }); 176 | }); 177 | 178 | it('clears geolocation override when geolocation is set to null', async () => { 179 | await withMcpContext(async (response, context) => { 180 | // First set a geolocation 181 | await emulate.handler( 182 | { 183 | params: { 184 | geolocation: { 185 | latitude: 48.137154, 186 | longitude: 11.576124, 187 | }, 188 | }, 189 | }, 190 | response, 191 | context, 192 | ); 193 | 194 | assert.notStrictEqual(context.getGeolocation(), null); 195 | 196 | // Then clear it by setting geolocation to null 197 | await emulate.handler( 198 | { 199 | params: { 200 | geolocation: null, 201 | }, 202 | }, 203 | response, 204 | context, 205 | ); 206 | 207 | assert.strictEqual(context.getGeolocation(), null); 208 | }); 209 | }); 210 | 211 | it('reports correctly for the currently selected page', async () => { 212 | await withMcpContext(async (response, context) => { 213 | await emulate.handler( 214 | { 215 | params: { 216 | geolocation: { 217 | latitude: 48.137154, 218 | longitude: 11.576124, 219 | }, 220 | }, 221 | }, 222 | response, 223 | context, 224 | ); 225 | 226 | const geolocation = context.getGeolocation(); 227 | assert.strictEqual(geolocation?.latitude, 48.137154); 228 | assert.strictEqual(geolocation?.longitude, 11.576124); 229 | 230 | const page = await context.newPage(); 231 | context.selectPage(page); 232 | 233 | assert.strictEqual(context.getGeolocation(), null); 234 | }); 235 | }); 236 | }); 237 | }); 238 | -------------------------------------------------------------------------------- /tests/formatters/snapshotFormatter.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import assert from 'node:assert'; 8 | import {describe, it} from 'node:test'; 9 | 10 | import type {ElementHandle} from 'puppeteer-core'; 11 | 12 | import {formatSnapshotNode} from '../../src/formatters/snapshotFormatter.js'; 13 | import type {TextSnapshotNode} from '../../src/McpContext.js'; 14 | 15 | describe('snapshotFormatter', () => { 16 | it('formats a snapshot with value properties', () => { 17 | const node: TextSnapshotNode = { 18 | id: '1_1', 19 | role: 'textbox', 20 | name: 'textbox', 21 | value: 'value', 22 | children: [ 23 | { 24 | id: '1_2', 25 | role: 'statictext', 26 | name: 'text', 27 | children: [], 28 | elementHandle: async (): Promise | null> => { 29 | return null; 30 | }, 31 | }, 32 | ], 33 | elementHandle: async (): Promise | null> => { 34 | return null; 35 | }, 36 | }; 37 | 38 | const formatted = formatSnapshotNode(node); 39 | assert.strictEqual( 40 | formatted, 41 | `uid=1_1 textbox "textbox" value="value" 42 | uid=1_2 statictext "text" 43 | `, 44 | ); 45 | }); 46 | 47 | it('formats a snapshot with boolean properties', () => { 48 | const node: TextSnapshotNode = { 49 | id: '1_1', 50 | role: 'button', 51 | name: 'button', 52 | disabled: true, 53 | children: [ 54 | { 55 | id: '1_2', 56 | role: 'statictext', 57 | name: 'text', 58 | children: [], 59 | elementHandle: async (): Promise | null> => { 60 | return null; 61 | }, 62 | }, 63 | ], 64 | elementHandle: async (): Promise | null> => { 65 | return null; 66 | }, 67 | }; 68 | 69 | const formatted = formatSnapshotNode(node); 70 | assert.strictEqual( 71 | formatted, 72 | `uid=1_1 button "button" disableable disabled 73 | uid=1_2 statictext "text" 74 | `, 75 | ); 76 | }); 77 | 78 | it('formats a snapshot with checked properties', () => { 79 | const node: TextSnapshotNode = { 80 | id: '1_1', 81 | role: 'checkbox', 82 | name: 'checkbox', 83 | checked: true, 84 | children: [ 85 | { 86 | id: '1_2', 87 | role: 'statictext', 88 | name: 'text', 89 | children: [], 90 | elementHandle: async (): Promise | null> => { 91 | return null; 92 | }, 93 | }, 94 | ], 95 | elementHandle: async (): Promise | null> => { 96 | return null; 97 | }, 98 | }; 99 | 100 | const formatted = formatSnapshotNode(node); 101 | assert.strictEqual( 102 | formatted, 103 | `uid=1_1 checkbox "checkbox" checked 104 | uid=1_2 statictext "text" 105 | `, 106 | ); 107 | }); 108 | 109 | it('formats a snapshot with multiple different type attributes', () => { 110 | const node: TextSnapshotNode = { 111 | id: '1_1', 112 | role: 'root', 113 | name: 'root', 114 | children: [ 115 | { 116 | id: '1_2', 117 | role: 'button', 118 | name: 'button', 119 | focused: true, 120 | disabled: true, 121 | children: [], 122 | elementHandle: async (): Promise | null> => { 123 | return null; 124 | }, 125 | }, 126 | { 127 | id: '1_3', 128 | role: 'textbox', 129 | name: 'textbox', 130 | value: 'value', 131 | children: [], 132 | elementHandle: async (): Promise | null> => { 133 | return null; 134 | }, 135 | }, 136 | ], 137 | elementHandle: async (): Promise | null> => { 138 | return null; 139 | }, 140 | }; 141 | 142 | const formatted = formatSnapshotNode(node); 143 | assert.strictEqual( 144 | formatted, 145 | `uid=1_1 root "root" 146 | uid=1_2 button "button" disableable disabled focusable focused 147 | uid=1_3 textbox "textbox" value="value" 148 | `, 149 | ); 150 | }); 151 | 152 | it('formats with DevTools data not included into a snapshot', t => { 153 | const node: TextSnapshotNode = { 154 | id: '1_1', 155 | role: 'checkbox', 156 | name: 'checkbox', 157 | checked: true, 158 | children: [ 159 | { 160 | id: '1_2', 161 | role: 'statictext', 162 | name: 'text', 163 | children: [], 164 | elementHandle: async (): Promise | null> => { 165 | return null; 166 | }, 167 | }, 168 | ], 169 | elementHandle: async (): Promise | null> => { 170 | return null; 171 | }, 172 | }; 173 | 174 | const formatted = formatSnapshotNode(node, { 175 | snapshotId: '1', 176 | root: node, 177 | idToNode: new Map(), 178 | hasSelectedElement: true, 179 | verbose: false, 180 | }); 181 | 182 | t.assert.snapshot?.(formatted); 183 | }); 184 | 185 | it('does not include a note if the snapshot is already verbose', t => { 186 | const node: TextSnapshotNode = { 187 | id: '1_1', 188 | role: 'checkbox', 189 | name: 'checkbox', 190 | checked: true, 191 | children: [ 192 | { 193 | id: '1_2', 194 | role: 'statictext', 195 | name: 'text', 196 | children: [], 197 | elementHandle: async (): Promise | null> => { 198 | return null; 199 | }, 200 | }, 201 | ], 202 | elementHandle: async (): Promise | null> => { 203 | return null; 204 | }, 205 | }; 206 | 207 | const formatted = formatSnapshotNode(node, { 208 | snapshotId: '1', 209 | root: node, 210 | idToNode: new Map(), 211 | hasSelectedElement: true, 212 | verbose: true, 213 | }); 214 | 215 | t.assert.snapshot?.(formatted); 216 | }); 217 | 218 | it('formats with DevTools data included into a snapshot', t => { 219 | const node: TextSnapshotNode = { 220 | id: '1_1', 221 | role: 'checkbox', 222 | name: 'checkbox', 223 | checked: true, 224 | children: [ 225 | { 226 | id: '1_2', 227 | role: 'statictext', 228 | name: 'text', 229 | children: [], 230 | elementHandle: async (): Promise | null> => { 231 | return null; 232 | }, 233 | }, 234 | ], 235 | elementHandle: async (): Promise | null> => { 236 | return null; 237 | }, 238 | }; 239 | 240 | const formatted = formatSnapshotNode(node, { 241 | snapshotId: '1', 242 | root: node, 243 | idToNode: new Map(), 244 | hasSelectedElement: true, 245 | selectedElementUid: '1_1', 246 | verbose: false, 247 | }); 248 | 249 | t.assert.snapshot?.(formatted); 250 | }); 251 | }); 252 | -------------------------------------------------------------------------------- /src/browser.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import fs from 'node:fs'; 8 | import os from 'node:os'; 9 | import path from 'node:path'; 10 | 11 | import {logger} from './logger.js'; 12 | import type { 13 | Browser, 14 | ChromeReleaseChannel, 15 | LaunchOptions, 16 | Target, 17 | } from './third_party/index.js'; 18 | import {puppeteer} from './third_party/index.js'; 19 | 20 | let browser: Browser | undefined; 21 | 22 | function makeTargetFilter() { 23 | const ignoredPrefixes = new Set([ 24 | 'chrome://', 25 | 'chrome-extension://', 26 | 'chrome-untrusted://', 27 | ]); 28 | 29 | return function targetFilter(target: Target): boolean { 30 | if (target.url() === 'chrome://newtab/') { 31 | return true; 32 | } 33 | // Could be the only page opened in the browser. 34 | if (target.url().startsWith('chrome://inspect')) { 35 | return true; 36 | } 37 | for (const prefix of ignoredPrefixes) { 38 | if (target.url().startsWith(prefix)) { 39 | return false; 40 | } 41 | } 42 | return true; 43 | }; 44 | } 45 | 46 | export async function ensureBrowserConnected(options: { 47 | browserURL?: string; 48 | wsEndpoint?: string; 49 | wsHeaders?: Record; 50 | devtools: boolean; 51 | channel?: Channel; 52 | userDataDir?: string; 53 | }) { 54 | const {channel} = options; 55 | if (browser?.connected) { 56 | return browser; 57 | } 58 | 59 | const connectOptions: Parameters[0] = { 60 | targetFilter: makeTargetFilter(), 61 | defaultViewport: null, 62 | handleDevToolsAsPage: true, 63 | }; 64 | 65 | if (options.wsEndpoint) { 66 | connectOptions.browserWSEndpoint = options.wsEndpoint; 67 | if (options.wsHeaders) { 68 | connectOptions.headers = options.wsHeaders; 69 | } 70 | } else if (options.browserURL) { 71 | connectOptions.browserURL = options.browserURL; 72 | } else if (channel || options.userDataDir) { 73 | const userDataDir = options.userDataDir; 74 | if (userDataDir) { 75 | // TODO: re-expose this logic via Puppeteer. 76 | const portPath = path.join(userDataDir, 'DevToolsActivePort'); 77 | try { 78 | const fileContent = await fs.promises.readFile(portPath, 'utf8'); 79 | const [rawPort, rawPath] = fileContent 80 | .split('\n') 81 | .map(line => { 82 | return line.trim(); 83 | }) 84 | .filter(line => { 85 | return !!line; 86 | }); 87 | if (!rawPort || !rawPath) { 88 | throw new Error(`Invalid DevToolsActivePort '${fileContent}' found`); 89 | } 90 | const port = parseInt(rawPort, 10); 91 | if (isNaN(port) || port <= 0 || port > 65535) { 92 | throw new Error(`Invalid port '${rawPort}' found`); 93 | } 94 | const browserWSEndpoint = `ws://127.0.0.1:${port}${rawPath}`; 95 | connectOptions.browserWSEndpoint = browserWSEndpoint; 96 | } catch (error) { 97 | throw new Error( 98 | `Could not connect to Chrome in ${userDataDir}. Check if Chrome is running and remote debugging is enabled.`, 99 | { 100 | cause: error, 101 | }, 102 | ); 103 | } 104 | } else { 105 | if (!channel) { 106 | throw new Error('Channel must be provided if userDataDir is missing'); 107 | } 108 | connectOptions.channel = ( 109 | channel === 'stable' ? 'chrome' : `chrome-${channel}` 110 | ) as ChromeReleaseChannel; 111 | } 112 | } else { 113 | throw new Error( 114 | 'Either browserURL, wsEndpoint, channel or userDataDir must be provided', 115 | ); 116 | } 117 | 118 | logger('Connecting Puppeteer to ', JSON.stringify(connectOptions)); 119 | try { 120 | browser = await puppeteer.connect(connectOptions); 121 | } catch (err) { 122 | throw new Error( 123 | 'Could not connect to Chrome. Check if Chrome is running and remote debugging is enabled by going to chrome://inspect/#remote-debugging.', 124 | { 125 | cause: err, 126 | }, 127 | ); 128 | } 129 | logger('Connected Puppeteer'); 130 | return browser; 131 | } 132 | 133 | interface McpLaunchOptions { 134 | acceptInsecureCerts?: boolean; 135 | executablePath?: string; 136 | channel?: Channel; 137 | userDataDir?: string; 138 | headless: boolean; 139 | isolated: boolean; 140 | logFile?: fs.WriteStream; 141 | viewport?: { 142 | width: number; 143 | height: number; 144 | }; 145 | args?: string[]; 146 | devtools: boolean; 147 | } 148 | 149 | export async function launch(options: McpLaunchOptions): Promise { 150 | const {channel, executablePath, headless, isolated} = options; 151 | const profileDirName = 152 | channel && channel !== 'stable' 153 | ? `chrome-profile-${channel}` 154 | : 'chrome-profile'; 155 | 156 | let userDataDir = options.userDataDir; 157 | if (!isolated && !userDataDir) { 158 | userDataDir = path.join( 159 | os.homedir(), 160 | '.cache', 161 | 'chrome-devtools-mcp', 162 | profileDirName, 163 | ); 164 | await fs.promises.mkdir(userDataDir, { 165 | recursive: true, 166 | }); 167 | } 168 | 169 | const args: LaunchOptions['args'] = [ 170 | ...(options.args ?? []), 171 | '--hide-crash-restore-bubble', 172 | ]; 173 | if (headless) { 174 | args.push('--screen-info={3840x2160}'); 175 | } 176 | let puppeteerChannel: ChromeReleaseChannel | undefined; 177 | if (options.devtools) { 178 | args.push('--auto-open-devtools-for-tabs'); 179 | } 180 | if (!executablePath) { 181 | puppeteerChannel = 182 | channel && channel !== 'stable' 183 | ? (`chrome-${channel}` as ChromeReleaseChannel) 184 | : 'chrome'; 185 | } 186 | 187 | try { 188 | const browser = await puppeteer.launch({ 189 | channel: puppeteerChannel, 190 | targetFilter: makeTargetFilter(), 191 | executablePath, 192 | defaultViewport: null, 193 | userDataDir, 194 | pipe: true, 195 | headless, 196 | args, 197 | acceptInsecureCerts: options.acceptInsecureCerts, 198 | handleDevToolsAsPage: true, 199 | }); 200 | if (options.logFile) { 201 | // FIXME: we are probably subscribing too late to catch startup logs. We 202 | // should expose the process earlier or expose the getRecentLogs() getter. 203 | browser.process()?.stderr?.pipe(options.logFile); 204 | browser.process()?.stdout?.pipe(options.logFile); 205 | } 206 | if (options.viewport) { 207 | const [page] = await browser.pages(); 208 | // @ts-expect-error internal API for now. 209 | await page?.resize({ 210 | contentWidth: options.viewport.width, 211 | contentHeight: options.viewport.height, 212 | }); 213 | } 214 | return browser; 215 | } catch (error) { 216 | if ( 217 | userDataDir && 218 | (error as Error).message.includes('The browser is already running') 219 | ) { 220 | throw new Error( 221 | `The browser is already running for ${userDataDir}. Use --isolated to run multiple browser instances.`, 222 | { 223 | cause: error, 224 | }, 225 | ); 226 | } 227 | throw error; 228 | } 229 | } 230 | 231 | export async function ensureBrowserLaunched( 232 | options: McpLaunchOptions, 233 | ): Promise { 234 | if (browser?.connected) { 235 | return browser; 236 | } 237 | browser = await launch(options); 238 | return browser; 239 | } 240 | 241 | export type Channel = 'stable' | 'canary' | 'beta' | 'dev'; 242 | --------------------------------------------------------------------------------