├── .nvmrc ├── .prettierignore ├── .release-please-manifest.json ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.md │ └── bug_report.md ├── workflows │ ├── release-please.yml │ ├── run-tests.yml │ ├── presubmit.yml │ └── publish-to-npm-on-tag.yml └── dependabot.yml ├── release-please-config.json ├── tests ├── trace-processing │ ├── fixtures │ │ ├── basic-trace.json.gz │ │ ├── web-dev-with-commit.json.gz │ │ └── load.ts │ ├── parse.test.ts │ └── parse.test.js.snapshot ├── snapshot.ts ├── tools │ ├── console.test.ts │ ├── network.test.ts │ ├── snapshot.test.ts │ ├── screenshot.test.ts │ ├── emulation.test.ts │ ├── script.test.ts │ ├── pages.test.ts │ └── performance.test.js.snapshot ├── setup.ts ├── browser.test.ts ├── cli.test.ts ├── utils.ts ├── McpContext.test.ts ├── index.test.ts ├── server.ts ├── formatters │ ├── networkFormatter.test.ts │ ├── snapshotFormatter.test.ts │ └── consoleFormatter.test.ts └── PageCollector.test.ts ├── SECURITY.md ├── gemini-extension.json ├── src ├── devtools.d.ts ├── tools │ ├── categories.ts │ ├── console.ts │ ├── snapshot.ts │ ├── script.ts │ ├── emulation.ts │ ├── network.ts │ ├── screenshot.ts │ ├── ToolDefinition.ts │ ├── pages.ts │ ├── performance.ts │ └── input.ts ├── index.ts ├── logger.ts ├── Mutex.ts ├── formatters │ ├── networkFormatter.ts │ ├── consoleFormatter.ts │ └── snapshotFormatter.ts ├── utils │ └── pagination.ts ├── PageCollector.ts ├── cli.ts ├── browser.ts ├── trace-processing │ └── parse.ts ├── WaitForHelper.ts ├── main.ts └── McpResponse.ts ├── scripts ├── eslint_rules │ ├── local-plugin.js │ └── check-license-rule.js ├── sync-server-json-version.ts ├── tsconfig.json ├── prepare.ts └── post-build.ts ├── .prettierrc.cjs ├── server.json ├── CONTRIBUTING.md ├── .gitignore ├── package.json ├── tsconfig.json ├── eslint.config.mjs └── docs └── tool-reference.md /.nvmrc: -------------------------------------------------------------------------------- 1 | v22 -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Prettier-only ignores. 2 | CHANGELOG.md 3 | -------------------------------------------------------------------------------- /.release-please-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | ".": "0.4.0" 3 | } 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /release-please-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": { 3 | ".": {} 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /tests/trace-processing/fixtures/basic-trace.json.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madskristensen/chrome-devtools-mcp/main/tests/trace-processing/fixtures/basic-trace.json.gz -------------------------------------------------------------------------------- /tests/trace-processing/fixtures/web-dev-with-commit.json.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madskristensen/chrome-devtools-mcp/main/tests/trace-processing/fixtures/web-dev-with-commit.json.gz -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/tools/categories.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | export enum ToolCategories { 8 | INPUT_AUTOMATION = 'Input automation', 9 | NAVIGATION_AUTOMATION = 'Navigation automation', 10 | EMULATION = 'Emulation', 11 | PERFORMANCE = 'Performance', 12 | NETWORK = 'Network', 13 | DEBUGGING = 'Debugging', 14 | } 15 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 < 22 || (major === 22 && minor < 12)) { 14 | console.error( 15 | `ERROR: \`chrome-devtools-mcp\` does not support Node ${process.version}. Please upgrade to Node 22.12.0 or newer.`, 16 | ); 17 | process.exit(1); 18 | } 19 | 20 | await import('./main.js'); 21 | -------------------------------------------------------------------------------- /scripts/sync-server-json-version.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | import fs from 'node:fs'; 7 | 8 | const packageJson = JSON.parse(fs.readFileSync('./package.json', 'utf-8')); 9 | const serverJson = JSON.parse(fs.readFileSync('./server.json', 'utf-8')); 10 | 11 | serverJson.version = packageJson.version; 12 | for (const pkg of serverJson.packages) { 13 | pkg.version = packageJson.version; 14 | } 15 | 16 | fs.writeFileSync('./server.json', JSON.stringify(serverJson, null, 2)); 17 | -------------------------------------------------------------------------------- /src/tools/console.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import {ToolCategories} from './categories.js'; 8 | import {defineTool} from './ToolDefinition.js'; 9 | 10 | export const consoleTool = defineTool({ 11 | name: 'list_console_messages', 12 | description: 'List all console messages for the currently selected page', 13 | annotations: { 14 | category: ToolCategories.DEBUGGING, 15 | readOnlyHint: true, 16 | }, 17 | schema: {}, 18 | handler: async (_request, response) => { 19 | response.setIncludeConsoleData(true); 20 | }, 21 | }); 22 | -------------------------------------------------------------------------------- /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 | "useUnknownInCatchVariables": false 19 | }, 20 | "include": ["./**/*.ts", "./**/*.js"] 21 | } 22 | -------------------------------------------------------------------------------- /tests/tools/console.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | import assert from 'node:assert'; 7 | import {describe, it} from 'node:test'; 8 | 9 | import {consoleTool} from '../../src/tools/console.js'; 10 | import {withBrowser} from '../utils.js'; 11 | 12 | describe('console', () => { 13 | describe('list_console_messages', () => { 14 | it('list messages', async () => { 15 | await withBrowser(async (response, context) => { 16 | await consoleTool.handler({params: {}}, response, context); 17 | assert.ok(response.includeConsoleData); 18 | }); 19 | }); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /.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 | patterns: 18 | - '*' 19 | - package-ecosystem: github-actions 20 | directory: / 21 | schedule: 22 | interval: weekly 23 | day: 'sunday' 24 | time: '04:00' 25 | timezone: Europe/Berlin 26 | groups: 27 | all: 28 | patterns: 29 | - '*' 30 | -------------------------------------------------------------------------------- /server.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-16/server.schema.json", 3 | "name": "io.github.ChromeDevTools/chrome-devtools-mcp", 4 | "description": "MCP server for Chrome DevTools", 5 | "status": "active", 6 | "repository": { 7 | "url": "https://github.com/ChromeDevTools/chrome-devtools-mcp", 8 | "source": "github" 9 | }, 10 | "version": "0.2.5", 11 | "packages": [ 12 | { 13 | "registryType": "npm", 14 | "registryBaseUrl": "https://registry.npmjs.org", 15 | "identifier": "chrome-devtools-mcp", 16 | "version": "0.2.5", 17 | "transport": { 18 | "type": "stdio" 19 | }, 20 | "environmentVariables": [] 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Chrome version:** 24 | **Coding agent version:** 25 | **Model version:** 26 | 27 | **Screenshots** 28 | If applicable, add screenshots to help explain your problem. 29 | 30 | **Chat log** 31 | The full log of the chat with your coding agent. 32 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | import fs from 'node:fs'; 7 | 8 | import debug from 'debug'; 9 | 10 | const mcpDebugNamespace = 'mcp:log'; 11 | 12 | const namespacesToEnable = [ 13 | mcpDebugNamespace, 14 | ...(process.env['DEBUG'] ? [process.env['DEBUG']] : []), 15 | ]; 16 | 17 | export function saveLogsToFile(fileName: string): fs.WriteStream { 18 | // Enable overrides everything so we need to add them 19 | debug.enable(namespacesToEnable.join(',')); 20 | 21 | const logFile = fs.createWriteStream(fileName, {flags: 'a'}); 22 | debug.log = function (...chunks: any[]) { 23 | logFile.write(`${chunks.join(' ')}\n`); 24 | }; 25 | logFile.on('error', function (error) { 26 | console.log(`Error when opening/writing to log file: ${error.message}`); 27 | logFile.end(); 28 | process.exit(1); 29 | }); 30 | return logFile; 31 | } 32 | 33 | export const logger = debug(mcpDebugNamespace); 34 | -------------------------------------------------------------------------------- /tests/setup.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | import path from 'node:path'; 7 | import {it} from 'node:test'; 8 | 9 | if (!it.snapshot) { 10 | it.snapshot = { 11 | setResolveSnapshotPath: () => { 12 | // Internally empty 13 | }, 14 | setDefaultSnapshotSerializers: () => { 15 | // Internally empty 16 | }, 17 | }; 18 | } 19 | 20 | // This is run by Node when we execute the tests via the --require flag. 21 | it.snapshot.setResolveSnapshotPath(testPath => { 22 | // By default the snapshots go into the build directory, but we want them 23 | // in the tests/ directory. 24 | const correctPath = testPath?.replace(path.join('build', 'tests'), 'tests'); 25 | return correctPath + '.snapshot'; 26 | }); 27 | 28 | // The default serializer is JSON.stringify which outputs a very hard to read 29 | // snapshot. So we override it to one that shows new lines literally rather 30 | // than via `\n`. 31 | it.snapshot.setDefaultSnapshotSerializers([String]); 32 | -------------------------------------------------------------------------------- /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 | #onRelease?: () => void; 11 | constructor(mutex: Mutex, onRelease?: () => void) { 12 | this.#mutex = mutex; 13 | this.#onRelease = onRelease; 14 | } 15 | dispose(): void { 16 | this.#onRelease?.(); 17 | return this.#mutex.release(); 18 | } 19 | }; 20 | 21 | #locked = false; 22 | #acquirers: Array<() => void> = []; 23 | 24 | // This is FIFO. 25 | async acquire( 26 | onRelease?: () => void, 27 | ): Promise> { 28 | if (!this.#locked) { 29 | this.#locked = true; 30 | return new Mutex.Guard(this); 31 | } 32 | const {resolve, promise} = Promise.withResolvers(); 33 | this.#acquirers.push(resolve); 34 | await promise; 35 | return new Mutex.Guard(this, onRelease); 36 | } 37 | 38 | release(): void { 39 | const resolve = this.#acquirers.shift(); 40 | if (!resolve) { 41 | this.#locked = false; 42 | return; 43 | } 44 | resolve(); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /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 | 'node_modules/chrome-devtools-frontend/front_end/third_party/codemirror.next/codemirror.next.js', 17 | ]; 18 | 19 | async function main() { 20 | console.log('Running prepare script to clean up chrome-devtools-frontend...'); 21 | for (const file of filesToRemove) { 22 | const fullPath = resolve(projectRoot, file); 23 | console.log(`Removing: ${file}`); 24 | try { 25 | await rm(fullPath, {recursive: true, force: true}); 26 | } catch (error) { 27 | console.error(`Failed to remove ${file}:`, error); 28 | process.exit(1); 29 | } 30 | } 31 | console.log('Clean up of chrome-devtools-frontend complete.'); 32 | } 33 | 34 | void main(); 35 | -------------------------------------------------------------------------------- /src/formatters/networkFormatter.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import type {HTTPRequest} from 'puppeteer-core'; 8 | 9 | export function getShortDescriptionForRequest(request: HTTPRequest): string { 10 | return `${request.url()} ${request.method()} ${getStatusFromRequest(request)}`; 11 | } 12 | 13 | export function getStatusFromRequest(request: HTTPRequest): string { 14 | const httpResponse = request.response(); 15 | const failure = request.failure(); 16 | let status: string; 17 | if (httpResponse) { 18 | const responseStatus = httpResponse.status(); 19 | status = 20 | responseStatus >= 200 && responseStatus <= 299 21 | ? `[success - ${responseStatus}]` 22 | : `[failed - ${responseStatus}]`; 23 | } else if (failure) { 24 | status = `[failed - ${failure.errorText}]`; 25 | } else { 26 | status = '[pending]'; 27 | } 28 | return status; 29 | } 30 | 31 | export function getFormattedHeaderValue( 32 | headers: Record, 33 | ): string[] { 34 | const response: string[] = []; 35 | for (const [name, value] of Object.entries(headers)) { 36 | response.push(`- ${name}:${value}`); 37 | } 38 | return response; 39 | } 40 | -------------------------------------------------------------------------------- /tests/browser.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | import assert from 'node:assert'; 7 | import os from 'node:os'; 8 | import path from 'node:path'; 9 | import {describe, it} from 'node:test'; 10 | 11 | import {executablePath} from 'puppeteer'; 12 | 13 | import {launch} from '../src/browser.js'; 14 | 15 | describe('browser', () => { 16 | it('cannot launch multiple times with the same profile', async () => { 17 | const tmpDir = os.tmpdir(); 18 | const folderPath = path.join(tmpDir, `temp-folder-${crypto.randomUUID()}`); 19 | const browser1 = await launch({ 20 | headless: true, 21 | isolated: false, 22 | userDataDir: folderPath, 23 | executablePath: executablePath(), 24 | }); 25 | try { 26 | try { 27 | const browser2 = await launch({ 28 | headless: true, 29 | isolated: false, 30 | userDataDir: folderPath, 31 | executablePath: executablePath(), 32 | }); 33 | await browser2.close(); 34 | assert.fail('not reached'); 35 | } catch (err) { 36 | assert.strictEqual( 37 | err.message, 38 | `The browser is already running for ${folderPath}. Use --isolated to run multiple browser instances.`, 39 | ); 40 | } 41 | } finally { 42 | await browser1.close(); 43 | } 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tests/tools/network.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | import assert from 'node:assert'; 7 | import {describe, it} from 'node:test'; 8 | 9 | import { 10 | getNetworkRequest, 11 | listNetworkRequests, 12 | } from '../../src/tools/network.js'; 13 | import {withBrowser} from '../utils.js'; 14 | 15 | describe('network', () => { 16 | describe('network_list_requests', () => { 17 | it('list requests', async () => { 18 | await withBrowser(async (response, context) => { 19 | await listNetworkRequests.handler({params: {}}, response, context); 20 | assert.ok(response.includeNetworkRequests); 21 | assert.strictEqual(response.networkRequestsPageIdx, undefined); 22 | }); 23 | }); 24 | }); 25 | describe('network_get_request', () => { 26 | it('attaches request', async () => { 27 | await withBrowser(async (response, context) => { 28 | const page = await context.getSelectedPage(); 29 | await page.goto('data:text/html,
Hello MCP
'); 30 | await getNetworkRequest.handler( 31 | {params: {url: 'data:text/html,
Hello MCP
'}}, 32 | response, 33 | context, 34 | ); 35 | assert.equal( 36 | response.attachedNetworkRequestUrl, 37 | 'data:text/html,
Hello MCP
', 38 | ); 39 | }); 40 | }); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /tests/trace-processing/parse.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | import assert from 'node:assert'; 7 | import {describe, it} from 'node:test'; 8 | 9 | import { 10 | getTraceSummary, 11 | parseRawTraceBuffer, 12 | } from '../../src/trace-processing/parse.js'; 13 | 14 | import {loadTraceAsBuffer} from './fixtures/load.js'; 15 | 16 | describe('Trace parsing', async () => { 17 | it('can parse a Uint8Array from Tracing.stop())', async () => { 18 | const rawData = loadTraceAsBuffer('basic-trace.json.gz'); 19 | const result = await parseRawTraceBuffer(rawData); 20 | if ('error' in result) { 21 | assert.fail(`Unexpected parse failure: ${result.error}`); 22 | } 23 | assert.ok(result?.parsedTrace); 24 | assert.ok(result?.insights); 25 | }); 26 | 27 | it('can format results of a trace', async t => { 28 | const rawData = loadTraceAsBuffer('web-dev-with-commit.json.gz'); 29 | const result = await parseRawTraceBuffer(rawData); 30 | if ('error' in result) { 31 | assert.fail(`Unexpected parse failure: ${result.error}`); 32 | } 33 | assert.ok(result?.parsedTrace); 34 | assert.ok(result?.insights); 35 | 36 | const output = getTraceSummary(result); 37 | t.assert.snapshot?.(output); 38 | }); 39 | 40 | it('will return a message if there is an error', async () => { 41 | const result = await parseRawTraceBuffer(undefined); 42 | assert.deepEqual(result, { 43 | error: 'No buffer was provided.', 44 | }); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /src/tools/snapshot.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import {Locator} from 'puppeteer-core'; 8 | import z from 'zod'; 9 | 10 | import {ToolCategories} from './categories.js'; 11 | import {defineTool} from './ToolDefinition.js'; 12 | 13 | export const takeSnapshot = defineTool({ 14 | name: 'take_snapshot', 15 | description: `Take a text snapshot of the currently selected page. The snapshot lists page elements along with a unique 16 | identifier (uid). Always use the latest snapshot. Prefer taking a snapshot over taking a screenshot.`, 17 | annotations: { 18 | category: ToolCategories.DEBUGGING, 19 | readOnlyHint: true, 20 | }, 21 | schema: {}, 22 | handler: async (_request, response) => { 23 | response.setIncludeSnapshot(true); 24 | }, 25 | }); 26 | 27 | export const waitFor = defineTool({ 28 | name: 'wait_for', 29 | description: `Wait for the specified text to appear on the selected page.`, 30 | annotations: { 31 | category: ToolCategories.NAVIGATION_AUTOMATION, 32 | readOnlyHint: true, 33 | }, 34 | schema: { 35 | text: z.string().describe('Text to appear on the page'), 36 | }, 37 | handler: async (request, response, context) => { 38 | const page = context.getSelectedPage(); 39 | 40 | await Locator.race([ 41 | page.locator(`aria/${request.params.text}`), 42 | page.locator(`text/${request.params.text}`), 43 | ]).wait(); 44 | 45 | response.appendResponseLine( 46 | `Element with text "${request.params.text}" found.`, 47 | ); 48 | 49 | response.setIncludeSnapshot(true); 50 | }, 51 | }); 52 | -------------------------------------------------------------------------------- /.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 | - 22.12.0 24 | - 22 25 | - 23 26 | - 24 27 | steps: 28 | - name: Check out repository 29 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 30 | with: 31 | fetch-depth: 2 32 | 33 | - name: Set up Node.js 34 | uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 35 | with: 36 | cache: npm 37 | node-version: ${{ matrix.node }} 38 | 39 | - name: Install dependencies 40 | shell: bash 41 | run: npm ci 42 | 43 | - name: Disable AppArmor 44 | if: ${{ matrix.os == 'ubuntu-latest' }} 45 | shell: bash 46 | run: echo 0 | sudo tee /proc/sys/kernel/apparmor_restrict_unprivileged_userns 47 | 48 | - name: Run tests 49 | shell: bash 50 | run: npm run test 51 | 52 | # Gating job for branch protection. 53 | test-success: 54 | name: '[Required] Tests passed' 55 | runs-on: ubuntu-latest 56 | needs: run-tests 57 | if: ${{ !cancelled() }} 58 | steps: 59 | - if: ${{ needs.run-tests.result != 'success' }} 60 | run: 'exit 1' 61 | - run: 'exit 0' 62 | -------------------------------------------------------------------------------- /tests/cli.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | import assert from 'node:assert'; 7 | import {describe, it} from 'node:test'; 8 | 9 | import {parseArguments} from '../src/cli.js'; 10 | 11 | describe('cli args parsing', () => { 12 | it('parses with default args', async () => { 13 | const args = parseArguments('1.0.0', ['node', 'main.js']); 14 | assert.deepStrictEqual(args, { 15 | _: [], 16 | headless: false, 17 | isolated: false, 18 | $0: 'npx chrome-devtools-mcp@latest', 19 | channel: 'stable', 20 | }); 21 | }); 22 | 23 | it('parses with browser url', async () => { 24 | const args = parseArguments('1.0.0', [ 25 | 'node', 26 | 'main.js', 27 | '--browserUrl', 28 | 'http://localhost:3000', 29 | ]); 30 | assert.deepStrictEqual(args, { 31 | _: [], 32 | headless: false, 33 | isolated: false, 34 | $0: 'npx chrome-devtools-mcp@latest', 35 | 'browser-url': 'http://localhost:3000', 36 | browserUrl: 'http://localhost:3000', 37 | u: 'http://localhost:3000', 38 | }); 39 | }); 40 | 41 | it('parses with executable path', async () => { 42 | const args = parseArguments('1.0.0', [ 43 | 'node', 44 | 'main.js', 45 | '--executablePath', 46 | '/tmp/test 123/chrome', 47 | ]); 48 | assert.deepStrictEqual(args, { 49 | _: [], 50 | headless: false, 51 | isolated: false, 52 | $0: 'npx chrome-devtools-mcp@latest', 53 | 'executable-path': '/tmp/test 123/chrome', 54 | e: '/tmp/test 123/chrome', 55 | executablePath: '/tmp/test 123/chrome', 56 | }); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /.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 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Check out repository 17 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 18 | with: 19 | fetch-depth: 2 20 | 21 | - name: Set up Node.js 22 | uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 23 | with: 24 | cache: npm 25 | node-version-file: '.nvmrc' 26 | 27 | - name: Install dependencies 28 | run: npm ci 29 | 30 | - name: Run format check 31 | run: npm run check-format 32 | 33 | check-docs: 34 | runs-on: ubuntu-latest 35 | 36 | steps: 37 | - name: Check out repository 38 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 39 | with: 40 | fetch-depth: 2 41 | 42 | - name: Set up Node.js 43 | uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 44 | with: 45 | cache: npm 46 | node-version-file: '.nvmrc' 47 | 48 | - name: Install dependencies 49 | run: npm ci 50 | 51 | - name: Generate documents 52 | run: npm run docs 53 | 54 | - name: Check if autogenerated docs differ 55 | run: | 56 | diff_file=$(mktemp doc_diff_XXXXXX) 57 | git diff --color > $diff_file 58 | if [[ -s $diff_file ]]; then 59 | echo "Please update the documentation by running 'npm run generate-docs'. The following was the diff" 60 | cat $diff_file 61 | rm $diff_file 62 | exit 1 63 | fi 64 | rm $diff_file 65 | -------------------------------------------------------------------------------- /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/utils/pagination.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 | 12 | export interface PaginationResult { 13 | items: readonly Item[]; 14 | currentPage: number; 15 | totalPages: number; 16 | hasNextPage: boolean; 17 | hasPreviousPage: boolean; 18 | startIndex: number; 19 | endIndex: number; 20 | invalidPage: boolean; 21 | } 22 | 23 | const DEFAULT_PAGE_SIZE = 20; 24 | 25 | export function paginate( 26 | items: readonly Item[], 27 | options?: PaginationOptions, 28 | ): PaginationResult { 29 | const total = items.length; 30 | 31 | if (!options || noPaginationOptions(options)) { 32 | return { 33 | items, 34 | currentPage: 0, 35 | totalPages: 1, 36 | hasNextPage: false, 37 | hasPreviousPage: false, 38 | startIndex: 0, 39 | endIndex: total, 40 | invalidPage: false, 41 | }; 42 | } 43 | 44 | const pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE; 45 | const totalPages = Math.max(1, Math.ceil(total / pageSize)); 46 | const {currentPage, invalidPage} = resolvePageIndex( 47 | options.pageIdx, 48 | totalPages, 49 | ); 50 | 51 | const startIndex = currentPage * pageSize; 52 | const pageItems = items.slice(startIndex, startIndex + pageSize); 53 | const endIndex = startIndex + pageItems.length; 54 | 55 | return { 56 | items: pageItems, 57 | currentPage, 58 | totalPages, 59 | hasNextPage: currentPage < totalPages - 1, 60 | hasPreviousPage: currentPage > 0, 61 | startIndex, 62 | endIndex, 63 | invalidPage, 64 | }; 65 | } 66 | 67 | function noPaginationOptions(options: PaginationOptions): boolean { 68 | return options.pageSize === undefined && options.pageIdx === undefined; 69 | } 70 | 71 | function resolvePageIndex( 72 | pageIdx: number | undefined, 73 | totalPages: number, 74 | ): { 75 | currentPage: number; 76 | invalidPage: boolean; 77 | } { 78 | if (pageIdx === undefined) { 79 | return {currentPage: 0, invalidPage: false}; 80 | } 81 | 82 | if (pageIdx < 0 || pageIdx >= totalPages) { 83 | return {currentPage: 0, invalidPage: true}; 84 | } 85 | 86 | return {currentPage: pageIdx, invalidPage: false}; 87 | } 88 | -------------------------------------------------------------------------------- /src/PageCollector.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import type {Browser, HTTPRequest, Page} from 'puppeteer-core'; 8 | 9 | export class PageCollector { 10 | #browser: Browser; 11 | #initializer: (page: Page, collector: (item: T) => void) => void; 12 | protected storage = new WeakMap(); 13 | 14 | constructor( 15 | browser: Browser, 16 | initializer: (page: Page, collector: (item: T) => void) => void, 17 | ) { 18 | this.#browser = browser; 19 | this.#initializer = initializer; 20 | } 21 | 22 | async init() { 23 | const pages = await this.#browser.pages(); 24 | for (const page of pages) { 25 | this.#initializePage(page); 26 | } 27 | 28 | this.#browser.on('targetcreated', async target => { 29 | const page = await target.page(); 30 | if (!page) { 31 | return; 32 | } 33 | 34 | this.#initializePage(page); 35 | }); 36 | } 37 | 38 | public addPage(page: Page) { 39 | this.#initializePage(page); 40 | } 41 | 42 | #initializePage(page: Page) { 43 | if (this.storage.has(page)) { 44 | return; 45 | } 46 | 47 | page.on('framenavigated', frame => { 48 | // Only reset the storage on main frame navigation 49 | if (frame !== page.mainFrame()) { 50 | return; 51 | } 52 | this.cleanup(page); 53 | }); 54 | this.#initializer(page, value => { 55 | const stored = this.storage.get(page) ?? []; 56 | stored.push(value); 57 | this.storage.set(page, stored); 58 | }); 59 | } 60 | 61 | protected cleanup(page: Page) { 62 | const collection = this.storage.get(page) ?? []; 63 | // Keep the reference alive 64 | collection.length = 0; 65 | } 66 | 67 | getData(page: Page): T[] { 68 | return this.storage.get(page) ?? []; 69 | } 70 | } 71 | 72 | export class NetworkCollector extends PageCollector { 73 | override cleanup(page: Page) { 74 | const requests = this.storage.get(page) ?? []; 75 | const lastRequestIdx = requests.findLastIndex(request => { 76 | return request.frame() === page.mainFrame() 77 | ? request.isNavigationRequest() 78 | : false; 79 | }); 80 | // Keep all requests since the last navigation request including that 81 | // navigation request itself. 82 | this.storage.set(page, requests.slice(Math.max(lastRequestIdx, 0))); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /.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 | 8 | permissions: 9 | id-token: write # Required for OIDC 10 | contents: read 11 | 12 | jobs: 13 | publish-to-npm: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Check out repository 17 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 18 | with: 19 | fetch-depth: 2 20 | 21 | - name: Set up Node.js 22 | uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 23 | with: 24 | cache: npm 25 | node-version-file: '.nvmrc' 26 | registry-url: 'https://registry.npmjs.org' 27 | 28 | # Ensure npm 11.5.1 or later is installed 29 | - name: Update npm 30 | run: npm install -g npm@latest 31 | 32 | - name: Install dependencies 33 | run: npm ci 34 | 35 | - name: Build 36 | run: npm run build 37 | 38 | - name: Publish 39 | run: | 40 | npm publish --provenance --access public 41 | 42 | publish-to-mcp-registry: 43 | runs-on: ubuntu-latest 44 | needs: publish-to-npm 45 | steps: 46 | - name: Check out repository 47 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 48 | with: 49 | fetch-depth: 2 50 | 51 | - name: Set up Node.js 52 | uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 53 | with: 54 | cache: npm 55 | node-version-file: '.nvmrc' 56 | registry-url: 'https://registry.npmjs.org' 57 | 58 | # Ensure npm 11.5.1 or later is installed 59 | - name: Update npm 60 | run: npm install -g npm@latest 61 | 62 | - name: Install dependencies 63 | run: npm ci 64 | 65 | - name: Build 66 | run: npm run build 67 | 68 | - name: Bump 69 | run: npm run sync-server-json-version 70 | 71 | - name: Install MCP Publisher 72 | run: | 73 | curl -L "https://github.com/modelcontextprotocol/registry/releases/download/v1.1.0/mcp-publisher_1.1.0_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz" | tar xz mcp-publisher 74 | 75 | - name: Login to MCP Registry 76 | run: ./mcp-publisher login github-oidc 77 | 78 | - name: Publish to MCP Registry 79 | run: ./mcp-publisher publish 80 | -------------------------------------------------------------------------------- /src/tools/script.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | import type {JSHandle} from 'puppeteer-core'; 7 | import z from 'zod'; 8 | 9 | import {ToolCategories} from './categories.js'; 10 | import {defineTool} from './ToolDefinition.js'; 11 | 12 | export const evaluateScript = defineTool({ 13 | name: 'evaluate_script', 14 | description: `Evaluate a JavaScript function inside the currently selected page. Returns the response as JSON 15 | so returned values have to JSON-serializable.`, 16 | annotations: { 17 | category: ToolCategories.DEBUGGING, 18 | readOnlyHint: false, 19 | }, 20 | schema: { 21 | function: z.string().describe( 22 | `A JavaScript function to run in the currently selected page. 23 | Example without arguments: \`() => { 24 | return document.title 25 | }\` or \`async () => { 26 | return await fetch("example.com") 27 | }\`. 28 | Example with arguments: \`(el) => { 29 | return el.innerText; 30 | }\` 31 | `, 32 | ), 33 | args: z 34 | .array( 35 | z.object({ 36 | uid: z 37 | .string() 38 | .describe( 39 | 'The uid of an element on the page from the page content snapshot', 40 | ), 41 | }), 42 | ) 43 | .optional() 44 | .describe(`An optional list of arguments to pass to the function.`), 45 | }, 46 | handler: async (request, response, context) => { 47 | const page = context.getSelectedPage(); 48 | const fn = await page.evaluateHandle(`(${request.params.function})`); 49 | const args: Array> = [fn]; 50 | try { 51 | for (const el of request.params.args ?? []) { 52 | args.push(await context.getElementByUid(el.uid)); 53 | } 54 | await context.waitForEventsAfterAction(async () => { 55 | const result = await page.evaluate( 56 | async (fn, ...args) => { 57 | // @ts-expect-error no types. 58 | return JSON.stringify(await fn(...args)); 59 | }, 60 | ...args, 61 | ); 62 | response.appendResponseLine('Script ran on page and returned:'); 63 | response.appendResponseLine('```json'); 64 | response.appendResponseLine(`${result}`); 65 | response.appendResponseLine('```'); 66 | }); 67 | } finally { 68 | Promise.allSettled(args.map(arg => arg.dispose())).catch(() => { 69 | // Ignore errors 70 | }); 71 | } 72 | }, 73 | }); 74 | -------------------------------------------------------------------------------- /src/tools/emulation.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import {PredefinedNetworkConditions} from 'puppeteer-core'; 8 | import z from 'zod'; 9 | 10 | import {ToolCategories} from './categories.js'; 11 | import {defineTool} from './ToolDefinition.js'; 12 | 13 | const throttlingOptions: [string, ...string[]] = [ 14 | 'No emulation', 15 | ...Object.keys(PredefinedNetworkConditions), 16 | ]; 17 | 18 | export const emulateNetwork = defineTool({ 19 | name: 'emulate_network', 20 | description: `Emulates network conditions such as throttling on the selected page.`, 21 | annotations: { 22 | category: ToolCategories.EMULATION, 23 | readOnlyHint: false, 24 | }, 25 | schema: { 26 | throttlingOption: z 27 | .enum(throttlingOptions) 28 | .describe( 29 | `The network throttling option to emulate. Available throttling options are: ${throttlingOptions.join(', ')}. Set to "No emulation" to disable.`, 30 | ), 31 | }, 32 | handler: async (request, _response, context) => { 33 | const page = context.getSelectedPage(); 34 | const conditions = request.params.throttlingOption; 35 | 36 | if (conditions === 'No emulation') { 37 | await page.emulateNetworkConditions(null); 38 | context.setNetworkConditions(null); 39 | return; 40 | } 41 | 42 | if (conditions in PredefinedNetworkConditions) { 43 | const networkCondition = 44 | PredefinedNetworkConditions[ 45 | conditions as keyof typeof PredefinedNetworkConditions 46 | ]; 47 | await page.emulateNetworkConditions(networkCondition); 48 | context.setNetworkConditions(conditions); 49 | } 50 | }, 51 | }); 52 | 53 | export const emulateCpu = defineTool({ 54 | name: 'emulate_cpu', 55 | description: `Emulates CPU throttling by slowing down the selected page's execution.`, 56 | annotations: { 57 | category: ToolCategories.EMULATION, 58 | readOnlyHint: false, 59 | }, 60 | schema: { 61 | throttlingRate: z 62 | .number() 63 | .min(1) 64 | .max(20) 65 | .describe( 66 | 'The CPU throttling rate representing the slowdown factor 1-20x. Set the rate to 1 to disable throttling', 67 | ), 68 | }, 69 | handler: async (request, _response, context) => { 70 | const page = context.getSelectedPage(); 71 | const {throttlingRate} = request.params; 72 | 73 | await page.emulateCPUThrottling(throttlingRate); 74 | context.setCpuThrottlingRate(throttlingRate); 75 | }, 76 | }); 77 | -------------------------------------------------------------------------------- /src/tools/network.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import type {ResourceType} from 'puppeteer-core'; 8 | import z from 'zod'; 9 | 10 | import {ToolCategories} 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`, 38 | annotations: { 39 | category: ToolCategories.NETWORK, 40 | readOnlyHint: true, 41 | }, 42 | schema: { 43 | pageSize: z 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: z 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: z 60 | .array(z.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 | }, 66 | handler: async (request, response) => { 67 | response.setIncludeNetworkRequests(true, { 68 | pageSize: request.params.pageSize, 69 | pageIdx: request.params.pageIdx, 70 | resourceTypes: request.params.resourceTypes, 71 | }); 72 | }, 73 | }); 74 | 75 | export const getNetworkRequest = defineTool({ 76 | name: 'get_network_request', 77 | description: `Gets a network request by URL. You can get all requests by calling ${listNetworkRequests.name}.`, 78 | annotations: { 79 | category: ToolCategories.NETWORK, 80 | readOnlyHint: true, 81 | }, 82 | schema: { 83 | url: z.string().describe('The URL of the request.'), 84 | }, 85 | handler: async (request, response, _context) => { 86 | response.attachNetworkRequest(request.params.url); 87 | response.setIncludeNetworkRequests(true); 88 | }, 89 | }); 90 | -------------------------------------------------------------------------------- /src/tools/screenshot.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import type {ElementHandle, Page} from 'puppeteer-core'; 8 | import z from 'zod'; 9 | 10 | import {ToolCategories} 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: ToolCategories.DEBUGGING, 18 | readOnlyHint: true, 19 | }, 20 | schema: { 21 | format: z 22 | .enum(['png', 'jpeg']) 23 | .default('png') 24 | .describe('Type of format to save the screenshot as. Default is "png"'), 25 | uid: z 26 | .string() 27 | .optional() 28 | .describe( 29 | 'The uid of an element on the page from the page content snapshot. If omitted takes a pages screenshot.', 30 | ), 31 | fullPage: z 32 | .boolean() 33 | .optional() 34 | .describe( 35 | 'If set to true takes a screenshot of the full page instead of the currently visible viewport. Incompatible with uid.', 36 | ), 37 | }, 38 | handler: async (request, response, context) => { 39 | if (request.params.uid && request.params.fullPage) { 40 | throw new Error('Providing both "uid" and "fullPage" is not allowed.'); 41 | } 42 | 43 | let pageOrHandle: Page | ElementHandle; 44 | if (request.params.uid) { 45 | pageOrHandle = await context.getElementByUid(request.params.uid); 46 | } else { 47 | pageOrHandle = context.getSelectedPage(); 48 | } 49 | 50 | const screenshot = await pageOrHandle.screenshot({ 51 | type: request.params.format, 52 | fullPage: request.params.fullPage, 53 | }); 54 | 55 | if (request.params.uid) { 56 | response.appendResponseLine( 57 | `Took a screenshot of node with uid "${request.params.uid}".`, 58 | ); 59 | } else if (request.params.fullPage) { 60 | response.appendResponseLine( 61 | 'Took a screenshot of the full current page.', 62 | ); 63 | } else { 64 | response.appendResponseLine( 65 | "Took a screenshot of the current page's viewport.", 66 | ); 67 | } 68 | 69 | if (screenshot.length >= 2_000_000) { 70 | const {filename} = await context.saveTemporaryFile( 71 | screenshot, 72 | `image/${request.params.format}`, 73 | ); 74 | response.appendResponseLine(`Saved screenshot to ${filename}.`); 75 | } else { 76 | response.attachImage({ 77 | mimeType: `image/${request.params.format}`, 78 | data: Buffer.from(screenshot).toString('base64'), 79 | }); 80 | } 81 | }, 82 | }); 83 | -------------------------------------------------------------------------------- /src/tools/ToolDefinition.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import type {Dialog, ElementHandle, Page} from 'puppeteer-core'; 8 | import type z from 'zod'; 9 | 10 | import type {TraceResult} from '../trace-processing/parse.js'; 11 | 12 | import type {ToolCategories} from './categories.js'; 13 | 14 | export interface ToolDefinition { 15 | name: string; 16 | description: string; 17 | annotations: { 18 | title?: string; 19 | category: ToolCategories; 20 | /** 21 | * If true, the tool does not modify its environment. 22 | */ 23 | readOnlyHint: boolean; 24 | }; 25 | schema: Schema; 26 | handler: ( 27 | request: Request, 28 | response: Response, 29 | context: Context, 30 | ) => Promise; 31 | } 32 | 33 | export interface Request { 34 | params: z.objectOutputType; 35 | } 36 | 37 | export interface ImageContentData { 38 | data: string; 39 | mimeType: string; 40 | } 41 | 42 | export interface Response { 43 | appendResponseLine(value: string): void; 44 | setIncludePages(value: boolean): void; 45 | setIncludeNetworkRequests( 46 | value: boolean, 47 | options?: {pageSize?: number; pageIdx?: number; resourceTypes?: string[]}, 48 | ): void; 49 | setIncludeConsoleData(value: boolean): void; 50 | setIncludeSnapshot(value: boolean): void; 51 | attachImage(value: ImageContentData): void; 52 | attachNetworkRequest(url: string): void; 53 | } 54 | 55 | /** 56 | * Only add methods required by tools/*. 57 | */ 58 | export type Context = Readonly<{ 59 | isRunningPerformanceTrace(): boolean; 60 | setIsRunningPerformanceTrace(x: boolean): void; 61 | recordedTraces(): TraceResult[]; 62 | storeTraceRecording(result: TraceResult): void; 63 | getSelectedPage(): Page; 64 | getDialog(): Dialog | undefined; 65 | clearDialog(): void; 66 | getPageByIdx(idx: number): Page; 67 | newPage(): Promise; 68 | closePage(pageIdx: number): Promise; 69 | setSelectedPageIdx(idx: number): void; 70 | getElementByUid(uid: string): Promise>; 71 | setNetworkConditions(conditions: string | null): void; 72 | setCpuThrottlingRate(rate: number): void; 73 | saveTemporaryFile( 74 | data: Uint8Array, 75 | mimeType: 'image/png' | 'image/jpeg', 76 | ): Promise<{filename: string}>; 77 | waitForEventsAfterAction(action: () => Promise): Promise; 78 | }>; 79 | 80 | export function defineTool( 81 | definition: ToolDefinition, 82 | ) { 83 | return definition; 84 | } 85 | 86 | export const CLOSE_PAGE_ERROR = 87 | 'The last open page cannot be closed. It is fine to keep it open.'; 88 | -------------------------------------------------------------------------------- /src/formatters/consoleFormatter.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import type { 8 | ConsoleMessage, 9 | JSHandle, 10 | ConsoleMessageLocation, 11 | } from 'puppeteer-core'; 12 | 13 | const logLevels: Record = { 14 | log: 'Log', 15 | info: 'Info', 16 | warning: 'Warning', 17 | error: 'Error', 18 | exception: 'Exception', 19 | assert: 'Assert', 20 | }; 21 | 22 | export async function formatConsoleEvent( 23 | event: ConsoleMessage | Error, 24 | ): Promise { 25 | // Check if the event object has the .type() method, which is unique to ConsoleMessage 26 | if ('type' in event) { 27 | return await formatConsoleMessage(event); 28 | } 29 | return `Error: ${event.message}`; 30 | } 31 | 32 | async function formatConsoleMessage(msg: ConsoleMessage): Promise { 33 | const logLevel = logLevels[msg.type()]; 34 | const args = msg.args(); 35 | 36 | if (logLevel === 'Error') { 37 | let message = `${logLevel}> `; 38 | if (msg.text() === 'JSHandle@error') { 39 | const errorHandle = args[0] as JSHandle; 40 | message += await errorHandle 41 | .evaluate(error => { 42 | return error.toString(); 43 | }) 44 | .catch(() => { 45 | return 'Error occured'; 46 | }); 47 | void errorHandle.dispose().catch(); 48 | 49 | const formattedArgs = await formatArgs(args.slice(1)); 50 | if (formattedArgs) { 51 | message += ` ${formattedArgs}`; 52 | } 53 | } else { 54 | message += msg.text(); 55 | const formattedArgs = await formatArgs(args); 56 | if (formattedArgs) { 57 | message += ` ${formattedArgs}`; 58 | } 59 | for (const frame of msg.stackTrace()) { 60 | message += '\n' + formatStackFrame(frame); 61 | } 62 | } 63 | return message; 64 | } 65 | 66 | const formattedArgs = await formatArgs(args); 67 | const text = msg.text(); 68 | 69 | return `${logLevel}> ${formatStackFrame( 70 | msg.location(), 71 | )}: ${text} ${formattedArgs}`.trim(); 72 | } 73 | 74 | async function formatArgs(args: readonly JSHandle[]): Promise { 75 | const argValues = await Promise.all( 76 | args.map(arg => 77 | arg.jsonValue().catch(() => { 78 | // Ignore errors 79 | }), 80 | ), 81 | ); 82 | 83 | return argValues 84 | .map(value => { 85 | return typeof value === 'object' ? JSON.stringify(value) : String(value); 86 | }) 87 | .join(' '); 88 | } 89 | 90 | function formatStackFrame(stackFrame: ConsoleMessageLocation): string { 91 | if (!stackFrame?.url) { 92 | return ''; 93 | } 94 | const filename = stackFrame.url.replace(/^.*\//, ''); 95 | return `${filename}:${stackFrame.lineNumber}:${stackFrame.columnNumber}`; 96 | } 97 | -------------------------------------------------------------------------------- /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 | ```sh 43 | git clone https://github.com/ChromeDevTools/chrome-devtools-mcp.git 44 | cd chrome-devtools-mcp 45 | npm ci 46 | npm run build 47 | ``` 48 | 49 | ### Testing with @modelcontextprotocol/inspector 50 | 51 | ```sh 52 | npx @modelcontextprotocol/inspector node build/src/index.js 53 | ``` 54 | 55 | ### Testing with an MCP client 56 | 57 | Add the MCP server to your client's config. 58 | 59 | ```json 60 | { 61 | "mcpServers": { 62 | "chrome-devtools": { 63 | "command": "node", 64 | "args": ["/path-to/build/src/index.js"] 65 | } 66 | } 67 | } 68 | ``` 69 | 70 | #### Using with VS Code SSH 71 | 72 | When running the `@modelcontextprotocol/inspector` it spawns 2 services - one on port `6274` and one on `6277`. 73 | Usually VS Code automatically detects and forwards `6274` but fails to detect `6277` so you need to manually forward it. 74 | 75 | ### Debugging 76 | 77 | To write debug logs to `log.txt` in the working directory, run with the following commands: 78 | 79 | ```sh 80 | npx @modelcontextprotocol/inspector node build/src/index.js --log-file=/your/desired/path/log.txt 81 | ``` 82 | 83 | You can use the `DEBUG` environment variable as usual to control categories that are logged. 84 | 85 | ### Updating documentation 86 | 87 | 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. 88 | -------------------------------------------------------------------------------- /src/formatters/snapshotFormatter.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | import type {TextSnapshotNode} from '../McpContext.js'; 7 | 8 | export function formatA11ySnapshot( 9 | serializedAXNodeRoot: TextSnapshotNode, 10 | depth = 0, 11 | ): string { 12 | let result = ''; 13 | const attributes = getAttributes(serializedAXNodeRoot); 14 | const line = ' '.repeat(depth * 2) + attributes.join(' ') + '\n'; 15 | result += line; 16 | 17 | for (const child of serializedAXNodeRoot.children) { 18 | result += formatA11ySnapshot(child, depth + 1); 19 | } 20 | 21 | return result; 22 | } 23 | 24 | function getAttributes(serializedAXNodeRoot: TextSnapshotNode): string[] { 25 | const attributes = [ 26 | `uid=${serializedAXNodeRoot.id}`, 27 | serializedAXNodeRoot.role, 28 | `"${serializedAXNodeRoot.name || ''}"`, // Corrected: Added quotes around name 29 | ]; 30 | 31 | // Value properties 32 | const valueProperties = [ 33 | 'value', 34 | 'valuetext', 35 | 'valuemin', 36 | 'valuemax', 37 | 'level', 38 | 'autocomplete', 39 | 'haspopup', 40 | 'invalid', 41 | 'orientation', 42 | 'description', 43 | 'keyshortcuts', 44 | 'roledescription', 45 | ] as const; 46 | for (const property of valueProperties) { 47 | if ( 48 | property in serializedAXNodeRoot && 49 | serializedAXNodeRoot[property] !== undefined 50 | ) { 51 | attributes.push(`${property}="${serializedAXNodeRoot[property]}"`); 52 | } 53 | } 54 | 55 | // Boolean properties that also have an 'able' attribute 56 | const booleanPropertyMap = { 57 | disabled: 'disableable', 58 | expanded: 'expandable', 59 | focused: 'focusable', 60 | selected: 'selectable', 61 | }; 62 | for (const [property, ableAttribute] of Object.entries(booleanPropertyMap)) { 63 | if (property in serializedAXNodeRoot) { 64 | attributes.push(ableAttribute); 65 | if (serializedAXNodeRoot[property as keyof typeof booleanPropertyMap]) { 66 | attributes.push(property); 67 | } 68 | } 69 | } 70 | 71 | const booleanProperties = [ 72 | 'modal', 73 | 'multiline', 74 | 'readonly', 75 | 'required', 76 | 'multiselectable', 77 | ] as const; 78 | 79 | for (const property of booleanProperties) { 80 | if (property in serializedAXNodeRoot && serializedAXNodeRoot[property]) { 81 | attributes.push(property); 82 | } 83 | } 84 | 85 | // Mixed boolean/string attributes 86 | for (const property of ['pressed', 'checked'] as const) { 87 | if (property in serializedAXNodeRoot) { 88 | attributes.push(property); 89 | if (serializedAXNodeRoot[property]) { 90 | attributes.push(`${property}="${serializedAXNodeRoot[property]}"`); 91 | } 92 | } 93 | } 94 | 95 | return attributes; 96 | } 97 | -------------------------------------------------------------------------------- /tests/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import logger from 'debug'; 8 | import type {Browser} from 'puppeteer'; 9 | import puppeteer from 'puppeteer'; 10 | import type {HTTPRequest, HTTPResponse} from 'puppeteer-core'; 11 | 12 | import {McpContext} from '../src/McpContext.js'; 13 | import {McpResponse} from '../src/McpResponse.js'; 14 | 15 | let browser: Browser | undefined; 16 | 17 | export async function withBrowser( 18 | cb: (response: McpResponse, context: McpContext) => Promise, 19 | options: {debug?: boolean} = {}, 20 | ) { 21 | const {debug = false} = options; 22 | if (!browser) { 23 | browser = await puppeteer.launch({ 24 | executablePath: process.env.PUPPETEER_EXECUTABLE_PATH, 25 | headless: !debug, 26 | defaultViewport: null, 27 | }); 28 | } 29 | const newPage = await browser.newPage(); 30 | // Close other pages. 31 | await Promise.all( 32 | (await browser.pages()).map(async page => { 33 | if (page !== newPage) { 34 | await page.close(); 35 | } 36 | }), 37 | ); 38 | const response = new McpResponse(); 39 | const context = await McpContext.from(browser, logger('test')); 40 | 41 | await cb(response, context); 42 | } 43 | 44 | export function getMockRequest( 45 | options: { 46 | method?: string; 47 | response?: HTTPResponse; 48 | failure?: HTTPRequest['failure']; 49 | resourceType?: string; 50 | } = {}, 51 | ): HTTPRequest { 52 | return { 53 | url() { 54 | return 'http://example.com'; 55 | }, 56 | method() { 57 | return options.method ?? 'GET'; 58 | }, 59 | response() { 60 | return options.response ?? null; 61 | }, 62 | failure() { 63 | return options.failure?.() ?? null; 64 | }, 65 | resourceType() { 66 | return options.resourceType ?? 'document'; 67 | }, 68 | headers(): Record { 69 | return { 70 | 'content-size': '10', 71 | }; 72 | }, 73 | redirectChain(): HTTPRequest[] { 74 | return []; 75 | }, 76 | } as HTTPRequest; 77 | } 78 | 79 | export function getMockResponse( 80 | options: { 81 | status?: number; 82 | } = {}, 83 | ): HTTPResponse { 84 | return { 85 | status() { 86 | return options.status ?? 200; 87 | }, 88 | } as HTTPResponse; 89 | } 90 | 91 | export function html( 92 | strings: TemplateStringsArray, 93 | ...values: unknown[] 94 | ): string { 95 | const bodyContent = strings.reduce((acc, str, i) => { 96 | return acc + str + (values[i] || ''); 97 | }, ''); 98 | 99 | return ` 100 | 101 | 102 | 103 | 104 | My test page 105 | 106 | 107 | ${bodyContent} 108 | 109 | `; 110 | } 111 | -------------------------------------------------------------------------------- /.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 -------------------------------------------------------------------------------- /tests/tools/snapshot.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | import assert from 'node:assert'; 7 | import {describe, it} from 'node:test'; 8 | 9 | import {takeSnapshot, waitFor} from '../../src/tools/snapshot.js'; 10 | import {html, withBrowser} from '../utils.js'; 11 | 12 | describe('snapshot', () => { 13 | describe('browser_snapshot', () => { 14 | it('includes a snapshot', async () => { 15 | await withBrowser(async (response, context) => { 16 | await takeSnapshot.handler({params: {}}, response, context); 17 | assert.ok(response.includeSnapshot); 18 | }); 19 | }); 20 | }); 21 | describe('browser_wait_for', () => { 22 | it('should work', async () => { 23 | await withBrowser(async (response, context) => { 24 | const page = await context.getSelectedPage(); 25 | 26 | await page.setContent( 27 | html`
Hello
World
`, 28 | ); 29 | await waitFor.handler( 30 | { 31 | params: { 32 | text: 'Hello', 33 | }, 34 | }, 35 | response, 36 | context, 37 | ); 38 | 39 | assert.equal( 40 | response.responseLines[0], 41 | 'Element with text "Hello" found.', 42 | ); 43 | assert.ok(response.includeSnapshot); 44 | }); 45 | }); 46 | it('should work with element that show up later', async () => { 47 | await withBrowser(async (response, context) => { 48 | const page = context.getSelectedPage(); 49 | 50 | const handlePromise = waitFor.handler( 51 | { 52 | params: { 53 | text: 'Hello World', 54 | }, 55 | }, 56 | response, 57 | context, 58 | ); 59 | 60 | await page.setContent( 61 | html`
Hello
World
`, 62 | ); 63 | 64 | await handlePromise; 65 | 66 | assert.equal( 67 | response.responseLines[0], 68 | 'Element with text "Hello World" found.', 69 | ); 70 | assert.ok(response.includeSnapshot); 71 | }); 72 | }); 73 | it('should work with aria elements', async () => { 74 | await withBrowser(async (response, context) => { 75 | const page = context.getSelectedPage(); 76 | 77 | await page.setContent( 78 | html`

Header

Text
`, 79 | ); 80 | 81 | await waitFor.handler( 82 | { 83 | params: { 84 | text: 'Header', 85 | }, 86 | }, 87 | response, 88 | context, 89 | ); 90 | 91 | assert.equal( 92 | response.responseLines[0], 93 | 'Element with text "Header" found.', 94 | ); 95 | assert.ok(response.includeSnapshot); 96 | }); 97 | }); 98 | }); 99 | }); 100 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import yargs from 'yargs'; 8 | import {hideBin} from 'yargs/helpers'; 9 | 10 | export const cliOptions = { 11 | browserUrl: { 12 | type: 'string' as const, 13 | description: 14 | 'Connect to a running Chrome instance using port forwarding. For more details see: https://developer.chrome.com/docs/devtools/remote-debugging/local-server.', 15 | alias: 'u', 16 | coerce: (url: string) => { 17 | new URL(url); 18 | return url; 19 | }, 20 | }, 21 | headless: { 22 | type: 'boolean' as const, 23 | description: 'Whether to run in headless (no UI) mode.', 24 | default: false, 25 | }, 26 | executablePath: { 27 | type: 'string' as const, 28 | description: 'Path to custom Chrome executable.', 29 | conflicts: 'browserUrl', 30 | alias: 'e', 31 | }, 32 | isolated: { 33 | type: 'boolean' as const, 34 | description: 35 | 'If specified, creates a temporary user-data-dir that is automatically cleaned up after the browser is closed.', 36 | default: false, 37 | }, 38 | customDevtools: { 39 | type: 'string' as const, 40 | description: 'Path to custom DevTools.', 41 | hidden: true, 42 | conflicts: 'browserUrl', 43 | alias: 'd', 44 | }, 45 | channel: { 46 | type: 'string' as const, 47 | description: 48 | 'Specify a different Chrome channel that should be used. The default is the stable channel version.', 49 | choices: ['stable', 'canary', 'beta', 'dev'] as const, 50 | conflicts: ['browserUrl', 'executablePath'], 51 | }, 52 | logFile: { 53 | type: 'string' as const, 54 | describe: 55 | 'Path to a file to write debug logs to. Set the env variable `DEBUG` to `*` to enable verbose logs. Useful for submitting bug reports.', 56 | }, 57 | }; 58 | 59 | export function parseArguments(version: string, argv = process.argv) { 60 | const yargsInstance = yargs(hideBin(argv)) 61 | .scriptName('npx chrome-devtools-mcp@latest') 62 | .options(cliOptions) 63 | .check(args => { 64 | // We can't set default in the options else 65 | // Yargs will complain 66 | if (!args.channel && !args.browserUrl && !args.executablePath) { 67 | args.channel = 'stable'; 68 | } 69 | return true; 70 | }) 71 | .example([ 72 | [ 73 | '$0 --browserUrl http://127.0.0.1:9222', 74 | 'Connect to an existing browser instance', 75 | ], 76 | ['$0 --channel beta', 'Use Chrome Beta installed on this system'], 77 | ['$0 --channel canary', 'Use Chrome Canary installed on this system'], 78 | ['$0 --channel dev', 'Use Chrome Dev installed on this system'], 79 | ['$0 --channel stable', 'Use stable Chrome installed on this system'], 80 | ['$0 --logFile /tmp/log.txt', 'Save logs to a file'], 81 | ['$0 --help', 'Print CLI options'], 82 | ]); 83 | 84 | return yargsInstance 85 | .wrap(Math.min(120, yargsInstance.terminalWidth())) 86 | .help() 87 | .version(version) 88 | .parseSync(); 89 | } 90 | -------------------------------------------------------------------------------- /tests/McpContext.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | import assert from 'node:assert'; 7 | import {describe, it} from 'node:test'; 8 | 9 | import sinon from 'sinon'; 10 | 11 | import type {TraceResult} from '../src/trace-processing/parse.js'; 12 | 13 | import {withBrowser} from './utils.js'; 14 | 15 | describe('McpContext', () => { 16 | it('list pages', async () => { 17 | await withBrowser(async (_response, context) => { 18 | const page = context.getSelectedPage(); 19 | await page.setContent(` 20 | `); 21 | await context.createTextSnapshot(); 22 | assert.ok(await context.getElementByUid('1_1')); 23 | await context.createTextSnapshot(); 24 | try { 25 | await context.getElementByUid('1_1'); 26 | assert.fail('not reached'); 27 | } catch (err) { 28 | assert.strict( 29 | err.message, 30 | 'This uid is coming from a stale snapshot. Call take_snapshot to get a fresh snapshot', 31 | ); 32 | } 33 | }); 34 | }); 35 | 36 | it('can store and retrieve performance traces', async () => { 37 | await withBrowser(async (_response, context) => { 38 | const fakeTrace1 = {} as unknown as TraceResult; 39 | const fakeTrace2 = {} as unknown as TraceResult; 40 | context.storeTraceRecording(fakeTrace1); 41 | context.storeTraceRecording(fakeTrace2); 42 | assert.deepEqual(context.recordedTraces(), [fakeTrace1, fakeTrace2]); 43 | }); 44 | }); 45 | 46 | it('should update default timeout when cpu throttling changes', async () => { 47 | await withBrowser(async (_response, context) => { 48 | const page = await context.newPage(); 49 | const timeoutBefore = page.getDefaultTimeout(); 50 | context.setCpuThrottlingRate(2); 51 | const timeoutAfter = page.getDefaultTimeout(); 52 | assert(timeoutBefore < timeoutAfter, 'Timeout was less then expected'); 53 | }); 54 | }); 55 | 56 | it('should update default timeout when network conditions changes', async () => { 57 | await withBrowser(async (_response, context) => { 58 | const page = await context.newPage(); 59 | const timeoutBefore = page.getDefaultNavigationTimeout(); 60 | context.setNetworkConditions('Slow 3G'); 61 | const timeoutAfter = page.getDefaultNavigationTimeout(); 62 | assert(timeoutBefore < timeoutAfter, 'Timeout was less then expected'); 63 | }); 64 | }); 65 | 66 | it('should call waitForEventsAfterAction with correct multipliers', async () => { 67 | await withBrowser(async (_response, context) => { 68 | const page = await context.newPage(); 69 | 70 | context.setCpuThrottlingRate(2); 71 | context.setNetworkConditions('Slow 3G'); 72 | const stub = sinon.spy(context, 'getWaitForHelper'); 73 | 74 | await context.waitForEventsAfterAction(async () => { 75 | // trigger the waiting only 76 | }); 77 | 78 | sinon.assert.calledWithExactly(stub, page, 2, 10); 79 | }); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /tests/index.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | import assert from 'node:assert'; 7 | import fs from 'node:fs'; 8 | import {describe, it} from 'node:test'; 9 | 10 | import {Client} from '@modelcontextprotocol/sdk/client/index.js'; 11 | import {StdioClientTransport} from '@modelcontextprotocol/sdk/client/stdio.js'; 12 | import {executablePath} from 'puppeteer'; 13 | 14 | describe('e2e', () => { 15 | async function withClient(cb: (client: Client) => Promise) { 16 | const transport = new StdioClientTransport({ 17 | command: 'node', 18 | args: [ 19 | 'build/src/index.js', 20 | '--headless', 21 | '--isolated', 22 | '--executable-path', 23 | executablePath(), 24 | ], 25 | }); 26 | const client = new Client( 27 | { 28 | name: 'e2e-test', 29 | version: '1.0.0', 30 | }, 31 | { 32 | capabilities: {}, 33 | }, 34 | ); 35 | 36 | try { 37 | await client.connect(transport); 38 | await cb(client); 39 | } finally { 40 | await client.close(); 41 | } 42 | } 43 | it('calls a tool', async () => { 44 | await withClient(async client => { 45 | const result = await client.callTool({ 46 | name: 'list_pages', 47 | arguments: {}, 48 | }); 49 | assert.deepStrictEqual(result, { 50 | content: [ 51 | { 52 | type: 'text', 53 | text: '# list_pages response\n## Pages\n0: about:blank [selected]', 54 | }, 55 | ], 56 | }); 57 | }); 58 | }); 59 | 60 | it('calls a tool multiple times', async () => { 61 | await withClient(async client => { 62 | let result = await client.callTool({ 63 | name: 'list_pages', 64 | arguments: {}, 65 | }); 66 | result = await client.callTool({ 67 | name: 'list_pages', 68 | arguments: {}, 69 | }); 70 | assert.deepStrictEqual(result, { 71 | content: [ 72 | { 73 | type: 'text', 74 | text: '# list_pages response\n## Pages\n0: about:blank [selected]', 75 | }, 76 | ], 77 | }); 78 | }); 79 | }); 80 | 81 | it('has all tools', async () => { 82 | await withClient(async client => { 83 | const {tools} = await client.listTools(); 84 | const exposedNames = tools.map(t => t.name).sort(); 85 | const files = fs.readdirSync('build/src/tools'); 86 | const definedNames = []; 87 | for (const file of files) { 88 | if (file === 'ToolDefinition.js') { 89 | continue; 90 | } 91 | const fileTools = await import(`../src/tools/${file}`); 92 | for (const maybeTool of Object.values(fileTools)) { 93 | if ('name' in maybeTool) { 94 | definedNames.push(maybeTool.name); 95 | } 96 | } 97 | } 98 | definedNames.sort(); 99 | assert.deepStrictEqual(exposedNames, definedNames); 100 | }); 101 | }); 102 | }); 103 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chrome-devtools-mcp", 3 | "version": "0.4.0", 4 | "description": "MCP server for Chrome DevTools", 5 | "type": "module", 6 | "bin": "./build/src/index.js", 7 | "main": "index.js", 8 | "scripts": { 9 | "build": "tsc && node --experimental-strip-types --no-warnings=ExperimentalWarning scripts/post-build.ts", 10 | "typecheck": "tsc --noEmit", 11 | "format": "eslint --cache --fix . && prettier --write --cache .", 12 | "check-format": "eslint --cache . && prettier --check --cache .;", 13 | "docs": "npm run build && npm run docs:generate && npm run format", 14 | "docs:generate": "node --experimental-strip-types scripts/generate-docs.ts", 15 | "start": "npm run build && node build/src/index.js", 16 | "start-debug": "DEBUG=mcp:* DEBUG_COLORS=false npm run build && node build/src/index.js", 17 | "test": "npm run build && node --require ./build/tests/setup.js --no-warnings=ExperimentalWarning --test-reporter spec --test-force-exit --test \"build/tests/**/*.test.js\"", 18 | "test:only": "npm run build && node --require ./build/tests/setup.js --no-warnings=ExperimentalWarning --test-reporter spec --test-force-exit --test --test-only \"build/tests/**/*.test.js\"", 19 | "test:only:no-build": "node --require ./build/tests/setup.js --no-warnings=ExperimentalWarning --test-reporter spec --test-force-exit --test --test-only \"build/tests/**/*.test.js\"", 20 | "test:update-snapshots": "npm run build && node --require ./build/tests/setup.js --no-warnings=ExperimentalWarning --test-force-exit --test --test-update-snapshots \"build/tests/**/*.test.js\"", 21 | "prepare": "node --experimental-strip-types scripts/prepare.ts", 22 | "sync-server-json-version": "node --experimental-strip-types scripts/sync-server-json-version.ts && npm run format" 23 | }, 24 | "files": [ 25 | "build/src", 26 | "build/node_modules", 27 | "LICENSE", 28 | "!*.tsbuildinfo" 29 | ], 30 | "repository": "ChromeDevTools/chrome-devtools-mcp", 31 | "author": "Google LLC", 32 | "license": "Apache-2.0", 33 | "bugs": { 34 | "url": "https://github.com/ChromeDevTools/chrome-devtools-mcp/issues" 35 | }, 36 | "homepage": "https://github.com/ChromeDevTools/chrome-devtools-mcp#readme", 37 | "mcpName": "io.github.ChromeDevTools/chrome-devtools-mcp", 38 | "dependencies": { 39 | "@modelcontextprotocol/sdk": "1.18.1", 40 | "debug": "4.4.3", 41 | "puppeteer-core": "24.22.3", 42 | "yargs": "18.0.0" 43 | }, 44 | "devDependencies": { 45 | "@eslint/js": "^9.35.0", 46 | "@stylistic/eslint-plugin": "^5.4.0", 47 | "@types/debug": "^4.1.12", 48 | "@types/filesystem": "^0.0.36", 49 | "@types/node": "^24.3.3", 50 | "@types/sinon": "^17.0.4", 51 | "@types/yargs": "^17.0.33", 52 | "@typescript-eslint/eslint-plugin": "^8.43.0", 53 | "@typescript-eslint/parser": "^8.43.0", 54 | "chrome-devtools-frontend": "1.0.1520535", 55 | "eslint": "^9.35.0", 56 | "eslint-plugin-import": "^2.32.0", 57 | "eslint-import-resolver-typescript": "^4.4.4", 58 | "globals": "^16.4.0", 59 | "prettier": "^3.6.2", 60 | "puppeteer": "24.22.3", 61 | "sinon": "^21.0.0", 62 | "typescript-eslint": "^8.43.0", 63 | "typescript": "^5.9.2" 64 | }, 65 | "engines": { 66 | "node": ">=22.12.0" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /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/legacy/legacy-defs.d.ts", 30 | "node_modules/chrome-devtools-frontend/front_end/models/trace", 31 | "node_modules/chrome-devtools-frontend/front_end/models/logs", 32 | "node_modules/chrome-devtools-frontend/front_end/models/text_utils", 33 | "node_modules/chrome-devtools-frontend/front_end/models/network_time_calculator", 34 | "node_modules/chrome-devtools-frontend/front_end/models/crux-manager", 35 | "node_modules/chrome-devtools-frontend/front_end/models/ai_assistance/data_formatters/PerformanceInsightFormatter.ts", 36 | "node_modules/chrome-devtools-frontend/front_end/models/ai_assistance/data_formatters/PerformanceTraceFormatter.ts", 37 | "node_modules/chrome-devtools-frontend/front_end/models/ai_assistance/data_formatters/NetworkRequestFormatter.ts", 38 | "node_modules/chrome-devtools-frontend/front_end/models/ai_assistance/data_formatters/UnitFormatters.ts", 39 | "node_modules/chrome-devtools-frontend/front_end/models/ai_assistance/performance", 40 | "node_modules/chrome-devtools-frontend/front_end/models/trace_source_maps_resolver", 41 | "node_modules/chrome-devtools-frontend/front_end/models/emulation", 42 | "node_modules/chrome-devtools-frontend/front_end/models/stack_trace", 43 | "node_modules/chrome-devtools-frontend/front_end/models/bindings", 44 | "node_modules/chrome-devtools-frontend/front_end/models/formatter", 45 | "node_modules/chrome-devtools-frontend/front_end/models/geometry", 46 | "node_modules/chrome-devtools-frontend/front_end/models/source_map_scopes", 47 | "node_modules/chrome-devtools-frontend/front_end/models/workspace", 48 | "node_modules/chrome-devtools-frontend/front_end/core/common", 49 | "node_modules/chrome-devtools-frontend/front_end/core/sdk", 50 | "node_modules/chrome-devtools-frontend/front_end/core/protocol_client", 51 | "node_modules/chrome-devtools-frontend/front_end/core/host", 52 | "node_modules/chrome-devtools-frontend/front_end/core/platform", 53 | "node_modules/chrome-devtools-frontend/front_end/models/cpu_profile", 54 | "node_modules/chrome-devtools-frontend/front_end/generated", 55 | "node_modules/chrome-devtools-frontend/front_end/third_party/legacy-javascript", 56 | "node_modules/chrome-devtools-frontend/front_end/third_party/source-map-scopes-codec", 57 | "node_modules/chrome-devtools-frontend/front_end/core/root", 58 | "node_modules/chrome-devtools-frontend/front_end/third_party/third-party-web" 59 | ], 60 | "exclude": ["node_modules/chrome-devtools-frontend/**/*.test.ts"] 61 | } 62 | -------------------------------------------------------------------------------- /tests/server.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | import http, { 7 | type IncomingMessage, 8 | type Server, 9 | type ServerResponse, 10 | } from 'node:http'; 11 | import {before, after, afterEach} from 'node:test'; 12 | 13 | import {html} from './utils.js'; 14 | 15 | class TestServer { 16 | #port: number; 17 | #server: Server; 18 | 19 | static randomPort() { 20 | /** 21 | * Some ports are restricted by Chromium and will fail to connect 22 | * to prevent we start after the 23 | * 24 | * https://source.chromium.org/chromium/chromium/src/+/main:net/base/port_util.cc;l=107?q=kRestrictedPorts&ss=chromium 25 | */ 26 | const min = 10101; 27 | const max = 20202; 28 | return Math.floor(Math.random() * (max - min + 1) + min); 29 | } 30 | 31 | #routes: Record void> = 32 | {}; 33 | 34 | constructor(port: number) { 35 | this.#port = port; 36 | this.#server = http.createServer((req, res) => this.#handle(req, res)); 37 | } 38 | 39 | get baseUrl(): string { 40 | return `http://localhost:${this.#port}`; 41 | } 42 | 43 | getRoute(path: string) { 44 | if (!this.#routes[path]) { 45 | throw new Error(`Route ${path} was not setup.`); 46 | } 47 | return `${this.baseUrl}${path}`; 48 | } 49 | 50 | addHtmlRoute(path: string, htmlContent: string) { 51 | if (this.#routes[path]) { 52 | throw new Error(`Route ${path} was already setup.`); 53 | } 54 | this.#routes[path] = (_req: IncomingMessage, res: ServerResponse) => { 55 | res.setHeader('Content-Type', 'text/html; charset=utf-8'); 56 | res.statusCode = 200; 57 | res.end(htmlContent); 58 | }; 59 | } 60 | 61 | addRoute( 62 | path: string, 63 | handler: (req: IncomingMessage, res: ServerResponse) => void, 64 | ) { 65 | if (this.#routes[path]) { 66 | throw new Error(`Route ${path} was already setup.`); 67 | } 68 | this.#routes[path] = handler; 69 | } 70 | 71 | #handle(req: IncomingMessage, res: ServerResponse) { 72 | const url = req.url ?? ''; 73 | const routeHandler = this.#routes[url]; 74 | 75 | if (routeHandler) { 76 | routeHandler(req, res); 77 | } else { 78 | res.writeHead(404, {'Content-Type': 'text/html'}); 79 | res.end( 80 | html`

404 - Not Found

The requested page does not exist.

`, 81 | ); 82 | } 83 | } 84 | 85 | restore() { 86 | this.#routes = {}; 87 | } 88 | 89 | start(): Promise { 90 | return new Promise(res => { 91 | this.#server.listen(this.#port, res); 92 | }); 93 | } 94 | 95 | stop(): Promise { 96 | return new Promise((res, rej) => { 97 | this.#server.close(err => { 98 | if (err) { 99 | rej(err); 100 | } else { 101 | res(); 102 | } 103 | }); 104 | }); 105 | } 106 | } 107 | 108 | export function serverHooks() { 109 | const server = new TestServer(TestServer.randomPort()); 110 | before(async () => { 111 | await server.start(); 112 | }); 113 | after(async () => { 114 | await server.stop(); 115 | }); 116 | afterEach(() => { 117 | server.restore(); 118 | }); 119 | 120 | return server; 121 | } 122 | -------------------------------------------------------------------------------- /tests/formatters/networkFormatter.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 | getFormattedHeaderValue, 12 | getShortDescriptionForRequest, 13 | } from '../../src/formatters/networkFormatter.js'; 14 | import {getMockRequest, getMockResponse} from '../utils.js'; 15 | 16 | describe('networkFormatter', () => { 17 | describe('getShortDescriptionForRequest', () => { 18 | it('works', async () => { 19 | const request = getMockRequest(); 20 | const result = getShortDescriptionForRequest(request); 21 | 22 | assert.equal(result, 'http://example.com GET [pending]'); 23 | }); 24 | it('shows correct method', async () => { 25 | const request = getMockRequest({method: 'POST'}); 26 | const result = getShortDescriptionForRequest(request); 27 | 28 | assert.equal(result, 'http://example.com POST [pending]'); 29 | }); 30 | it('shows correct status for request with response code in 200', async () => { 31 | const response = getMockResponse(); 32 | const request = getMockRequest({response}); 33 | const result = getShortDescriptionForRequest(request); 34 | 35 | assert.equal(result, 'http://example.com GET [success - 200]'); 36 | }); 37 | 38 | it('shows correct status for request with response code in 100', async () => { 39 | const response = getMockResponse({ 40 | status: 199, 41 | }); 42 | const request = getMockRequest({response}); 43 | const result = getShortDescriptionForRequest(request); 44 | 45 | assert.equal(result, 'http://example.com GET [failed - 199]'); 46 | }); 47 | it('shows correct status for request with response code above 200', async () => { 48 | const response = getMockResponse({ 49 | status: 300, 50 | }); 51 | const request = getMockRequest({response}); 52 | const result = getShortDescriptionForRequest(request); 53 | 54 | assert.equal(result, 'http://example.com GET [failed - 300]'); 55 | }); 56 | 57 | it('shows correct status for request that failed', async () => { 58 | const request = getMockRequest({ 59 | failure() { 60 | return { 61 | errorText: 'Error in Network', 62 | }; 63 | }, 64 | }); 65 | const result = getShortDescriptionForRequest(request); 66 | 67 | assert.equal( 68 | result, 69 | 'http://example.com GET [failed - Error in Network]', 70 | ); 71 | }); 72 | }); 73 | 74 | describe('getFormattedHeaderValue', () => { 75 | it('works', () => { 76 | const result = getFormattedHeaderValue({ 77 | key: 'value', 78 | }); 79 | 80 | assert.deepEqual(result, ['- key:value']); 81 | }); 82 | it('with multiple', () => { 83 | const result = getFormattedHeaderValue({ 84 | key: 'value', 85 | key2: 'value2', 86 | key3: 'value3', 87 | key4: 'value4', 88 | }); 89 | 90 | assert.deepEqual(result, [ 91 | '- key:value', 92 | '- key2:value2', 93 | '- key3:value3', 94 | '- key4:value4', 95 | ]); 96 | }); 97 | it('with non', () => { 98 | const result = getFormattedHeaderValue({}); 99 | 100 | assert.deepEqual(result, []); 101 | }); 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /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: ['.prettierrc.cjs', 'eslint.config.mjs'], 31 | }, 32 | }, 33 | 34 | parser: tseslint.parser, 35 | }, 36 | 37 | plugins: { 38 | js, 39 | '@local': localPlugin, 40 | '@typescript-eslint': tseslint.plugin, 41 | '@stylistic': stylisticPlugin, 42 | }, 43 | 44 | settings: { 45 | 'import/resolver': { 46 | typescript: true, 47 | }, 48 | }, 49 | 50 | extends: ['js/recommended'], 51 | }, 52 | tseslint.configs.recommended, 53 | tseslint.configs.stylistic, 54 | { 55 | name: 'TypeScript rules', 56 | rules: { 57 | '@local/check-license': 'error', 58 | 59 | 'no-undef': 'off', 60 | 'no-unused-vars': 'off', 61 | '@typescript-eslint/no-unused-vars': [ 62 | 'error', 63 | { 64 | argsIgnorePattern: '^_', 65 | varsIgnorePattern: '^_', 66 | }, 67 | ], 68 | '@typescript-eslint/no-explicit-any': [ 69 | 'error', 70 | { 71 | ignoreRestArgs: true, 72 | }, 73 | ], 74 | // This optimizes the dependency tracking for type-only files. 75 | '@typescript-eslint/consistent-type-imports': 'error', 76 | // So type-only exports get elided. 77 | '@typescript-eslint/consistent-type-exports': 'error', 78 | // Prefer interfaces over types for shape like. 79 | '@typescript-eslint/consistent-type-definitions': ['error', 'interface'], 80 | '@typescript-eslint/array-type': [ 81 | 'error', 82 | { 83 | default: 'array-simple', 84 | }, 85 | ], 86 | '@typescript-eslint/no-floating-promises': 'error', 87 | 88 | 'import/order': [ 89 | 'error', 90 | { 91 | 'newlines-between': 'always', 92 | 93 | alphabetize: { 94 | order: 'asc', 95 | caseInsensitive: true, 96 | }, 97 | }, 98 | ], 99 | 100 | 'import/no-cycle': [ 101 | 'error', 102 | { 103 | maxDepth: Infinity, 104 | }, 105 | ], 106 | 107 | 'import/enforce-node-protocol-usage': ['error', 'always'], 108 | 109 | '@stylistic/function-call-spacing': 'error', 110 | '@stylistic/semi': 'error', 111 | }, 112 | }, 113 | { 114 | name: 'Tests', 115 | files: ['**/*.test.ts'], 116 | rules: { 117 | // With the Node.js test runner, `describe` and `it` are technically 118 | // promises, but we don't need to await them. 119 | '@typescript-eslint/no-floating-promises': 'off', 120 | }, 121 | }, 122 | ]); 123 | -------------------------------------------------------------------------------- /tests/PageCollector.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | import assert from 'node:assert'; 7 | import {describe, it} from 'node:test'; 8 | 9 | import type {Browser, Frame, Page, PageEvents} from 'puppeteer-core'; 10 | 11 | import {PageCollector} from '../src/PageCollector.js'; 12 | 13 | import {getMockRequest} from './utils.js'; 14 | 15 | function getMockPage(): Page { 16 | const listeners: Record void> = {}; 17 | const mainFrame = {} as Frame; 18 | return { 19 | on(eventName, listener) { 20 | listeners[eventName] = listener; 21 | }, 22 | emit(eventName, data) { 23 | listeners[eventName]?.(data); 24 | }, 25 | mainFrame() { 26 | return mainFrame; 27 | }, 28 | } as Page; 29 | } 30 | 31 | function getMockBrowser(): Browser { 32 | const pages = [getMockPage()]; 33 | return { 34 | pages() { 35 | return Promise.resolve(pages); 36 | }, 37 | on(_type, _handler) { 38 | // Mock 39 | }, 40 | } as Browser; 41 | } 42 | 43 | describe('PageCollector', () => { 44 | it('works', async () => { 45 | const browser = getMockBrowser(); 46 | const page = (await browser.pages())[0]; 47 | const request = getMockRequest(); 48 | const collector = new PageCollector(browser, (page, collect) => { 49 | page.on('request', req => { 50 | collect(req); 51 | }); 52 | }); 53 | await collector.init(); 54 | page.emit('request', request); 55 | 56 | assert.equal(collector.getData(page)[0], request); 57 | }); 58 | 59 | it('clean up after navigation', async () => { 60 | const browser = getMockBrowser(); 61 | const page = (await browser.pages())[0]; 62 | const mainFrame = page.mainFrame(); 63 | const request = getMockRequest(); 64 | const collector = new PageCollector(browser, (page, collect) => { 65 | page.on('request', req => { 66 | collect(req); 67 | }); 68 | }); 69 | await collector.init(); 70 | page.emit('request', request); 71 | 72 | assert.equal(collector.getData(page)[0], request); 73 | page.emit('framenavigated', mainFrame); 74 | 75 | assert.equal(collector.getData(page).length, 0); 76 | }); 77 | 78 | it('does not clean up after sub frame navigation', async () => { 79 | const browser = getMockBrowser(); 80 | const page = (await browser.pages())[0]; 81 | const request = getMockRequest(); 82 | const collector = new PageCollector(browser, (page, collect) => { 83 | page.on('request', req => { 84 | collect(req); 85 | }); 86 | }); 87 | await collector.init(); 88 | page.emit('request', request); 89 | page.emit('framenavigated', {} as Frame); 90 | 91 | assert.equal(collector.getData(page).length, 1); 92 | }); 93 | 94 | it('clean up after navigation and be able to add data after', async () => { 95 | const browser = getMockBrowser(); 96 | const page = (await browser.pages())[0]; 97 | const mainFrame = page.mainFrame(); 98 | const request = getMockRequest(); 99 | const collector = new PageCollector(browser, (page, collect) => { 100 | page.on('request', req => { 101 | collect(req); 102 | }); 103 | }); 104 | await collector.init(); 105 | page.emit('request', request); 106 | 107 | assert.equal(collector.getData(page)[0], request); 108 | page.emit('framenavigated', mainFrame); 109 | 110 | assert.equal(collector.getData(page).length, 0); 111 | 112 | page.emit('request', request); 113 | 114 | assert.equal(collector.getData(page).length, 1); 115 | }); 116 | }); 117 | -------------------------------------------------------------------------------- /tests/tools/screenshot.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | import assert from 'node:assert'; 7 | import {describe, it} from 'node:test'; 8 | 9 | import {screenshot} from '../../src/tools/screenshot.js'; 10 | import {screenshots} from '../snapshot.js'; 11 | import {withBrowser} from '../utils.js'; 12 | 13 | describe('screenshot', () => { 14 | describe('browser_take_screenshot', () => { 15 | it('with default options', async () => { 16 | await withBrowser(async (response, context) => { 17 | const fixture = screenshots.basic; 18 | const page = context.getSelectedPage(); 19 | await page.setContent(fixture.html); 20 | await screenshot.handler({params: {format: 'png'}}, response, context); 21 | 22 | assert.equal(response.images.length, 1); 23 | assert.equal(response.images[0].mimeType, 'image/png'); 24 | assert.equal( 25 | response.responseLines.at(0), 26 | "Took a screenshot of the current page's viewport.", 27 | ); 28 | }); 29 | }); 30 | it('with jpeg', async () => { 31 | await withBrowser(async (response, context) => { 32 | await screenshot.handler({params: {format: 'jpeg'}}, response, context); 33 | 34 | assert.equal(response.images.length, 1); 35 | assert.equal(response.images[0].mimeType, 'image/jpeg'); 36 | assert.equal( 37 | response.responseLines.at(0), 38 | "Took a screenshot of the current page's viewport.", 39 | ); 40 | }); 41 | }); 42 | it('with full page', async () => { 43 | await withBrowser(async (response, context) => { 44 | const fixture = screenshots.viewportOverflow; 45 | const page = context.getSelectedPage(); 46 | await page.setContent(fixture.html); 47 | await screenshot.handler( 48 | {params: {format: 'png', fullPage: true}}, 49 | response, 50 | context, 51 | ); 52 | 53 | assert.equal(response.images.length, 1); 54 | assert.equal(response.images[0].mimeType, 'image/png'); 55 | assert.equal( 56 | response.responseLines.at(0), 57 | 'Took a screenshot of the full current page.', 58 | ); 59 | }); 60 | }); 61 | 62 | it('with full page resulting in a large screenshot', async () => { 63 | await withBrowser(async (response, context) => { 64 | const page = context.getSelectedPage(); 65 | await page.setContent( 66 | `
test
`.repeat(7_000), 67 | ); 68 | await screenshot.handler( 69 | {params: {format: 'png', fullPage: true}}, 70 | response, 71 | context, 72 | ); 73 | 74 | assert.equal(response.images.length, 0); 75 | assert.equal( 76 | response.responseLines.at(0), 77 | 'Took a screenshot of the full current page.', 78 | ); 79 | assert.ok( 80 | response.responseLines.at(1)?.match(/Saved screenshot to.*\.png/), 81 | ); 82 | }); 83 | }); 84 | 85 | it('with element uid', async () => { 86 | await withBrowser(async (response, context) => { 87 | const fixture = screenshots.button; 88 | 89 | const page = context.getSelectedPage(); 90 | await page.setContent(fixture.html); 91 | await context.createTextSnapshot(); 92 | await screenshot.handler( 93 | { 94 | params: { 95 | format: 'png', 96 | uid: '1_1', 97 | }, 98 | }, 99 | response, 100 | context, 101 | ); 102 | 103 | assert.equal(response.images.length, 1); 104 | assert.equal(response.images[0].mimeType, 'image/png'); 105 | assert.equal( 106 | response.responseLines.at(0), 107 | 'Took a screenshot of node with uid "1_1".', 108 | ); 109 | }); 110 | }); 111 | }); 112 | }); 113 | -------------------------------------------------------------------------------- /tests/tools/emulation.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | import assert from 'node:assert'; 7 | import {describe, it} from 'node:test'; 8 | 9 | import {emulateCpu, emulateNetwork} from '../../src/tools/emulation.js'; 10 | import {withBrowser} from '../utils.js'; 11 | 12 | describe('emulation', () => { 13 | describe('network', () => { 14 | it('emulates network throttling when the throttling option is valid ', async () => { 15 | await withBrowser(async (response, context) => { 16 | await emulateNetwork.handler( 17 | { 18 | params: { 19 | throttlingOption: 'Slow 3G', 20 | }, 21 | }, 22 | response, 23 | context, 24 | ); 25 | 26 | assert.strictEqual(context.getNetworkConditions(), 'Slow 3G'); 27 | }); 28 | }); 29 | 30 | it('disables network emulation', async () => { 31 | await withBrowser(async (response, context) => { 32 | await emulateNetwork.handler( 33 | { 34 | params: { 35 | throttlingOption: 'No emulation', 36 | }, 37 | }, 38 | response, 39 | context, 40 | ); 41 | 42 | assert.strictEqual(context.getNetworkConditions(), null); 43 | }); 44 | }); 45 | 46 | it('does not set throttling when the network throttling is not one of the predefined options', async () => { 47 | await withBrowser(async (response, context) => { 48 | await emulateNetwork.handler( 49 | { 50 | params: { 51 | throttlingOption: 'Slow 11G', 52 | }, 53 | }, 54 | response, 55 | context, 56 | ); 57 | 58 | assert.strictEqual(context.getNetworkConditions(), null); 59 | }); 60 | }); 61 | 62 | it('report correctly for the currently selected page', async () => { 63 | await withBrowser(async (response, context) => { 64 | await context.newPage(); 65 | await emulateNetwork.handler( 66 | { 67 | params: { 68 | throttlingOption: 'Slow 3G', 69 | }, 70 | }, 71 | response, 72 | context, 73 | ); 74 | 75 | assert.strictEqual(context.getNetworkConditions(), 'Slow 3G'); 76 | 77 | context.setSelectedPageIdx(0); 78 | 79 | assert.strictEqual(context.getNetworkConditions(), null); 80 | }); 81 | }); 82 | }); 83 | 84 | describe('cpu', () => { 85 | it('emulates cpu throttling when the rate is valid (1-20x)', async () => { 86 | await withBrowser(async (response, context) => { 87 | await emulateCpu.handler( 88 | { 89 | params: { 90 | throttlingRate: 4, 91 | }, 92 | }, 93 | response, 94 | context, 95 | ); 96 | 97 | assert.strictEqual(context.getCpuThrottlingRate(), 4); 98 | }); 99 | }); 100 | 101 | it('disables cpu throttling', async () => { 102 | await withBrowser(async (response, context) => { 103 | context.setCpuThrottlingRate(4); // Set it to something first. 104 | await emulateCpu.handler( 105 | { 106 | params: { 107 | throttlingRate: 1, 108 | }, 109 | }, 110 | response, 111 | context, 112 | ); 113 | 114 | assert.strictEqual(context.getCpuThrottlingRate(), 1); 115 | }); 116 | }); 117 | 118 | it('report correctly for the currently selected page', async () => { 119 | await withBrowser(async (response, context) => { 120 | await context.newPage(); 121 | await emulateCpu.handler( 122 | { 123 | params: { 124 | throttlingRate: 4, 125 | }, 126 | }, 127 | response, 128 | context, 129 | ); 130 | 131 | assert.strictEqual(context.getCpuThrottlingRate(), 4); 132 | 133 | context.setSelectedPageIdx(0); 134 | 135 | assert.strictEqual(context.getCpuThrottlingRate(), 1); 136 | }); 137 | }); 138 | }); 139 | }); 140 | -------------------------------------------------------------------------------- /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 {formatA11ySnapshot} 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 snapshot: 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 = formatA11ySnapshot(snapshot); 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 snapshot: 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 = formatA11ySnapshot(snapshot); 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 snapshot: 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 = formatA11ySnapshot(snapshot); 101 | assert.strictEqual( 102 | formatted, 103 | `uid=1_1 checkbox "checkbox" checked checked="true" 104 | uid=1_2 statictext "text" 105 | `, 106 | ); 107 | }); 108 | 109 | it('formats a snapshot with multiple different type attributes', () => { 110 | const snapshot: 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 = formatA11ySnapshot(snapshot); 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 | -------------------------------------------------------------------------------- /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 type { 12 | Browser, 13 | ChromeReleaseChannel, 14 | ConnectOptions, 15 | LaunchOptions, 16 | Target, 17 | } from 'puppeteer-core'; 18 | import puppeteer from 'puppeteer-core'; 19 | 20 | let browser: Browser | undefined; 21 | 22 | const ignoredPrefixes = new Set([ 23 | 'chrome://', 24 | 'chrome-extension://', 25 | 'chrome-untrusted://', 26 | 'devtools://', 27 | ]); 28 | 29 | function targetFilter(target: Target): boolean { 30 | if (target.url() === 'chrome://newtab/') { 31 | return true; 32 | } 33 | for (const prefix of ignoredPrefixes) { 34 | if (target.url().startsWith(prefix)) { 35 | return false; 36 | } 37 | } 38 | return true; 39 | } 40 | 41 | const connectOptions: ConnectOptions = { 42 | targetFilter, 43 | // We do not expect any single CDP command to take more than 10sec. 44 | protocolTimeout: 10_000, 45 | }; 46 | 47 | async function ensureBrowserConnected(browserURL: string) { 48 | if (browser?.connected) { 49 | return browser; 50 | } 51 | browser = await puppeteer.connect({ 52 | ...connectOptions, 53 | browserURL, 54 | defaultViewport: null, 55 | }); 56 | return browser; 57 | } 58 | 59 | interface McpLaunchOptions { 60 | executablePath?: string; 61 | customDevTools?: string; 62 | channel?: Channel; 63 | userDataDir?: string; 64 | headless: boolean; 65 | isolated: boolean; 66 | logFile?: fs.WriteStream; 67 | } 68 | 69 | export async function launch(options: McpLaunchOptions): Promise { 70 | const {channel, executablePath, customDevTools, headless, isolated} = options; 71 | const profileDirName = 72 | channel && channel !== 'stable' 73 | ? `chrome-profile-${channel}` 74 | : 'chrome-profile'; 75 | 76 | let userDataDir = options.userDataDir; 77 | if (!isolated && !userDataDir) { 78 | userDataDir = path.join( 79 | os.homedir(), 80 | '.cache', 81 | 'chrome-devtools-mcp', 82 | profileDirName, 83 | ); 84 | await fs.promises.mkdir(userDataDir, { 85 | recursive: true, 86 | }); 87 | } 88 | 89 | const args: LaunchOptions['args'] = ['--hide-crash-restore-bubble']; 90 | if (customDevTools) { 91 | args.push(`--custom-devtools-frontend=file://${customDevTools}`); 92 | } 93 | let puppeterChannel: ChromeReleaseChannel | undefined; 94 | if (!executablePath) { 95 | puppeterChannel = 96 | channel && channel !== 'stable' 97 | ? (`chrome-${channel}` as ChromeReleaseChannel) 98 | : 'chrome'; 99 | } 100 | 101 | try { 102 | const browser = await puppeteer.launch({ 103 | ...connectOptions, 104 | channel: puppeterChannel, 105 | executablePath, 106 | defaultViewport: null, 107 | userDataDir, 108 | pipe: true, 109 | headless, 110 | args, 111 | }); 112 | if (options.logFile) { 113 | // FIXME: we are probably subscribing too late to catch startup logs. We 114 | // should expose the process earlier or expose the getRecentLogs() getter. 115 | browser.process()?.stderr?.pipe(options.logFile); 116 | browser.process()?.stdout?.pipe(options.logFile); 117 | } 118 | return browser; 119 | } catch (error) { 120 | if ( 121 | userDataDir && 122 | ((error as Error).message.includes('The browser is already running') || 123 | (error as Error).message.includes('Target closed') || 124 | (error as Error).message.includes('Connection closed')) 125 | ) { 126 | throw new Error( 127 | `The browser is already running for ${userDataDir}. Use --isolated to run multiple browser instances.`, 128 | { 129 | cause: error, 130 | }, 131 | ); 132 | } 133 | throw error; 134 | } 135 | } 136 | 137 | async function ensureBrowserLaunched( 138 | options: McpLaunchOptions, 139 | ): Promise { 140 | if (browser?.connected) { 141 | return browser; 142 | } 143 | browser = await launch(options); 144 | return browser; 145 | } 146 | 147 | export async function resolveBrowser(options: { 148 | browserUrl?: string; 149 | executablePath?: string; 150 | customDevTools?: string; 151 | channel?: Channel; 152 | headless: boolean; 153 | isolated: boolean; 154 | logFile?: fs.WriteStream; 155 | }) { 156 | const browser = options.browserUrl 157 | ? await ensureBrowserConnected(options.browserUrl) 158 | : await ensureBrowserLaunched(options); 159 | 160 | return browser; 161 | } 162 | 163 | export type Channel = 'stable' | 'canary' | 'beta' | 'dev'; 164 | -------------------------------------------------------------------------------- /src/trace-processing/parse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import {PerformanceInsightFormatter} from '../../node_modules/chrome-devtools-frontend/front_end/models/ai_assistance/data_formatters/PerformanceInsightFormatter.js'; 8 | import {PerformanceTraceFormatter} from '../../node_modules/chrome-devtools-frontend/front_end/models/ai_assistance/data_formatters/PerformanceTraceFormatter.js'; 9 | import {AgentFocus} from '../../node_modules/chrome-devtools-frontend/front_end/models/ai_assistance/performance/AIContext.js'; 10 | import * as TraceEngine from '../../node_modules/chrome-devtools-frontend/front_end/models/trace/trace.js'; 11 | import {logger} from '../logger.js'; 12 | 13 | const engine = TraceEngine.TraceModel.Model.createWithAllHandlers(); 14 | 15 | export interface TraceResult { 16 | parsedTrace: TraceEngine.TraceModel.ParsedTrace; 17 | insights: TraceEngine.Insights.Types.TraceInsightSets | null; 18 | } 19 | 20 | export function traceResultIsSuccess( 21 | x: TraceResult | TraceParseError, 22 | ): x is TraceResult { 23 | return 'parsedTrace' in x; 24 | } 25 | 26 | export interface TraceParseError { 27 | error: string; 28 | } 29 | 30 | export async function parseRawTraceBuffer( 31 | buffer: Uint8Array | undefined, 32 | ): Promise { 33 | engine.resetProcessor(); 34 | if (!buffer) { 35 | return { 36 | error: 'No buffer was provided.', 37 | }; 38 | } 39 | const asString = new TextDecoder().decode(buffer); 40 | if (!asString) { 41 | return { 42 | error: 'Decoding the trace buffer returned an empty string.', 43 | }; 44 | } 45 | try { 46 | const data = JSON.parse(asString) as 47 | | { 48 | traceEvents: TraceEngine.Types.Events.Event[]; 49 | } 50 | | TraceEngine.Types.Events.Event[]; 51 | 52 | const events = Array.isArray(data) ? data : data.traceEvents; 53 | await engine.parse(events); 54 | const parsedTrace = engine.parsedTrace(); 55 | if (!parsedTrace) { 56 | return { 57 | error: 'No parsed trace was returned from the trace engine.', 58 | }; 59 | } 60 | 61 | const insights = parsedTrace?.insights ?? null; 62 | 63 | return { 64 | parsedTrace, 65 | insights, 66 | }; 67 | } catch (e) { 68 | const errorText = e instanceof Error ? e.message : JSON.stringify(e); 69 | logger(`Unexpeced error parsing trace: ${errorText}`); 70 | return { 71 | error: errorText, 72 | }; 73 | } 74 | } 75 | 76 | const extraFormatDescriptions = `Information on performance traces may contain main thread activity represented as call frames and network requests. 77 | 78 | ${PerformanceTraceFormatter.callFrameDataFormatDescription} 79 | 80 | ${PerformanceTraceFormatter.networkDataFormatDescription} 81 | `; 82 | export function getTraceSummary(result: TraceResult): string { 83 | const focus = AgentFocus.fromParsedTrace(result.parsedTrace); 84 | const formatter = new PerformanceTraceFormatter(focus); 85 | const output = formatter.formatTraceSummary(); 86 | return `${extraFormatDescriptions} 87 | 88 | ${output}`; 89 | } 90 | 91 | export type InsightName = keyof TraceEngine.Insights.Types.InsightModels; 92 | export type InsightOutput = {output: string} | {error: string}; 93 | 94 | export function getInsightOutput( 95 | result: TraceResult, 96 | insightName: InsightName, 97 | ): InsightOutput { 98 | if (!result.insights) { 99 | return { 100 | error: 'No Performance insights are available for this trace.', 101 | }; 102 | } 103 | 104 | // Currently, we do not support inspecting traces with multiple navigations. We either: 105 | // 1. Find Insights from the first navigation (common case: user records a trace with a page reload to test load performance) 106 | // 2. Fall back to finding Insights not associated with a navigation (common case: user tests an interaction without a page load). 107 | const mainNavigationId = 108 | result.parsedTrace.data.Meta.mainFrameNavigations.at(0)?.args.data 109 | ?.navigationId; 110 | 111 | const insightsForNav = result.insights.get( 112 | mainNavigationId ?? TraceEngine.Types.Events.NO_NAVIGATION, 113 | ); 114 | 115 | if (!insightsForNav) { 116 | return { 117 | error: 'No Performance Insights for this trace.', 118 | }; 119 | } 120 | 121 | const matchingInsight = 122 | insightName in insightsForNav.model 123 | ? insightsForNav.model[insightName] 124 | : null; 125 | if (!matchingInsight) { 126 | return { 127 | error: `No Insight with the name ${insightName} found. Double check the name you provided is accurate and try again.`, 128 | }; 129 | } 130 | 131 | const formatter = new PerformanceInsightFormatter( 132 | AgentFocus.fromParsedTrace(result.parsedTrace), 133 | matchingInsight, 134 | ); 135 | return {output: formatter.formatInsight()}; 136 | } 137 | -------------------------------------------------------------------------------- /src/WaitForHelper.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | import type {Page, Protocol} from 'puppeteer-core'; 7 | import type {CdpPage} from 'puppeteer-core/internal/cdp/Page.js'; 8 | 9 | import {logger} from './logger.js'; 10 | 11 | export class WaitForHelper { 12 | #abortController = new AbortController(); 13 | #page: CdpPage; 14 | #stableDomTimeout: number; 15 | #stableDomFor: number; 16 | #expectNavigationIn: number; 17 | #navigationTimeout: number; 18 | 19 | constructor( 20 | page: Page, 21 | cpuTimeoutMultiplier: number, 22 | networkTimeoutMultiplier: number, 23 | ) { 24 | this.#stableDomTimeout = 3000 * cpuTimeoutMultiplier; 25 | this.#stableDomFor = 100 * cpuTimeoutMultiplier; 26 | this.#expectNavigationIn = 100 * cpuTimeoutMultiplier; 27 | this.#navigationTimeout = 3000 * networkTimeoutMultiplier; 28 | this.#page = page as unknown as CdpPage; 29 | } 30 | 31 | /** 32 | * A wrapper that executes a action and waits for 33 | * a potential navigation, after which it waits 34 | * for the DOM to be stable before returning. 35 | */ 36 | async waitForStableDom(): Promise { 37 | const stableDomObserver = await this.#page.evaluateHandle(timeout => { 38 | let timeoutId: ReturnType; 39 | function callback() { 40 | clearTimeout(timeoutId); 41 | timeoutId = setTimeout(() => { 42 | domObserver.resolver.resolve(); 43 | domObserver.observer.disconnect(); 44 | }, timeout); 45 | } 46 | const domObserver = { 47 | resolver: Promise.withResolvers(), 48 | observer: new MutationObserver(callback), 49 | }; 50 | // It's possible that the DOM is not gonna change so we 51 | // need to start the timeout initially. 52 | callback(); 53 | 54 | domObserver.observer.observe(document.body, { 55 | childList: true, 56 | subtree: true, 57 | attributes: true, 58 | }); 59 | 60 | return domObserver; 61 | }, this.#stableDomFor); 62 | 63 | this.#abortController.signal.addEventListener('abort', async () => { 64 | try { 65 | await stableDomObserver.evaluate(observer => { 66 | observer.observer.disconnect(); 67 | observer.resolver.resolve(); 68 | }); 69 | await stableDomObserver.dispose(); 70 | } catch { 71 | // Ignored cleanup errors 72 | } 73 | }); 74 | 75 | return Promise.race([ 76 | stableDomObserver.evaluate(async observer => { 77 | return await observer.resolver.promise; 78 | }), 79 | this.timeout(this.#stableDomTimeout).then(() => { 80 | throw new Error('Timeout'); 81 | }), 82 | ]); 83 | } 84 | 85 | async waitForNavigationStarted() { 86 | // Currently Puppeteer does not have API 87 | // For when a navigation is about to start 88 | const navigationStartedPromise = new Promise(resolve => { 89 | const listener = (event: Protocol.Page.FrameStartedNavigatingEvent) => { 90 | if ( 91 | [ 92 | 'historySameDocument', 93 | 'historyDifferentDocument', 94 | 'sameDocument', 95 | ].includes(event.navigationType) 96 | ) { 97 | resolve(false); 98 | return; 99 | } 100 | 101 | resolve(true); 102 | }; 103 | 104 | this.#page._client().on('Page.frameStartedNavigating', listener); 105 | this.#abortController.signal.addEventListener('abort', () => { 106 | resolve(false); 107 | this.#page._client().off('Page.frameStartedNavigating', listener); 108 | }); 109 | }); 110 | 111 | return await Promise.race([ 112 | navigationStartedPromise, 113 | this.timeout(this.#expectNavigationIn).then(() => false), 114 | ]); 115 | } 116 | 117 | timeout(time: number): Promise { 118 | return new Promise(res => { 119 | const id = setTimeout(res, time); 120 | this.#abortController.signal.addEventListener('abort', () => { 121 | res(); 122 | clearTimeout(id); 123 | }); 124 | }); 125 | } 126 | 127 | async waitForEventsAfterAction( 128 | action: () => Promise, 129 | ): Promise { 130 | const navigationFinished = this.waitForNavigationStarted() 131 | .then(navigationStated => { 132 | if (navigationStated) { 133 | return this.#page.waitForNavigation({ 134 | timeout: this.#navigationTimeout, 135 | signal: this.#abortController.signal, 136 | }); 137 | } 138 | return; 139 | }) 140 | .catch(error => logger(error)); 141 | 142 | try { 143 | await action(); 144 | } catch (error) { 145 | // Clear up pending promises 146 | this.#abortController.abort(); 147 | throw error; 148 | } 149 | 150 | try { 151 | await navigationFinished; 152 | 153 | // Wait for stable dom after navigation so we execute in 154 | // the correct context 155 | await this.waitForStableDom(); 156 | } catch (error) { 157 | logger(error); 158 | } finally { 159 | this.#abortController.abort(); 160 | } 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /tests/tools/script.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | import assert from 'node:assert'; 7 | import {describe, it} from 'node:test'; 8 | 9 | import {evaluateScript} from '../../src/tools/script.js'; 10 | import {html, withBrowser} from '../utils.js'; 11 | 12 | describe('script', () => { 13 | describe('browser_evaluate_script', () => { 14 | it('evaluates', async () => { 15 | await withBrowser(async (response, context) => { 16 | await evaluateScript.handler( 17 | {params: {function: String(() => 2 * 5)}}, 18 | response, 19 | context, 20 | ); 21 | const lineEvaluation = response.responseLines.at(2)!; 22 | assert.strictEqual(JSON.parse(lineEvaluation), 10); 23 | }); 24 | }); 25 | it('runs in selected page', async () => { 26 | await withBrowser(async (response, context) => { 27 | await evaluateScript.handler( 28 | {params: {function: String(() => document.title)}}, 29 | response, 30 | context, 31 | ); 32 | 33 | let lineEvaluation = response.responseLines.at(2)!; 34 | assert.strictEqual(JSON.parse(lineEvaluation), ''); 35 | 36 | const page = await context.newPage(); 37 | await page.setContent(` 38 | 39 | New Page 40 | 41 | `); 42 | 43 | response.resetResponseLineForTesting(); 44 | await evaluateScript.handler( 45 | {params: {function: String(() => document.title)}}, 46 | response, 47 | context, 48 | ); 49 | 50 | lineEvaluation = response.responseLines.at(2)!; 51 | assert.strictEqual(JSON.parse(lineEvaluation), 'New Page'); 52 | }); 53 | }); 54 | 55 | it('work for complex objects', async () => { 56 | await withBrowser(async (response, context) => { 57 | const page = context.getSelectedPage(); 58 | 59 | await page.setContent(html` `); 60 | 61 | await evaluateScript.handler( 62 | { 63 | params: { 64 | function: String(() => { 65 | const scripts = Array.from( 66 | document.head.querySelectorAll('script'), 67 | ).map(s => ({src: s.src, async: s.async, defer: s.defer})); 68 | 69 | return {scripts}; 70 | }), 71 | }, 72 | }, 73 | response, 74 | context, 75 | ); 76 | const lineEvaluation = response.responseLines.at(2)!; 77 | assert.deepEqual(JSON.parse(lineEvaluation), { 78 | scripts: [], 79 | }); 80 | }); 81 | }); 82 | 83 | it('work for async functions', async () => { 84 | await withBrowser(async (response, context) => { 85 | const page = context.getSelectedPage(); 86 | 87 | await page.setContent(html` `); 88 | 89 | await evaluateScript.handler( 90 | { 91 | params: { 92 | function: String(async () => { 93 | await new Promise(res => setTimeout(res, 0)); 94 | return 'Works'; 95 | }), 96 | }, 97 | }, 98 | response, 99 | context, 100 | ); 101 | const lineEvaluation = response.responseLines.at(2)!; 102 | assert.strictEqual(JSON.parse(lineEvaluation), 'Works'); 103 | }); 104 | }); 105 | 106 | it('work with one argument', async () => { 107 | await withBrowser(async (response, context) => { 108 | const page = context.getSelectedPage(); 109 | 110 | await page.setContent(html``); 111 | 112 | await context.createTextSnapshot(); 113 | 114 | await evaluateScript.handler( 115 | { 116 | params: { 117 | function: String(async (el: Element) => { 118 | return el.id; 119 | }), 120 | args: [{uid: '1_1'}], 121 | }, 122 | }, 123 | response, 124 | context, 125 | ); 126 | const lineEvaluation = response.responseLines.at(2)!; 127 | assert.strictEqual(JSON.parse(lineEvaluation), 'test'); 128 | }); 129 | }); 130 | 131 | it('work with multiple args', async () => { 132 | await withBrowser(async (response, context) => { 133 | const page = context.getSelectedPage(); 134 | 135 | await page.setContent(html``); 136 | 137 | await context.createTextSnapshot(); 138 | 139 | await evaluateScript.handler( 140 | { 141 | params: { 142 | function: String((container: Element, child: Element) => { 143 | return container.contains(child); 144 | }), 145 | args: [{uid: '1_0'}, {uid: '1_1'}], 146 | }, 147 | }, 148 | response, 149 | context, 150 | ); 151 | const lineEvaluation = response.responseLines.at(2)!; 152 | assert.strictEqual(JSON.parse(lineEvaluation), true); 153 | }); 154 | }); 155 | }); 156 | }); 157 | -------------------------------------------------------------------------------- /src/main.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 path from 'node:path'; 10 | 11 | import {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js'; 12 | import {StdioServerTransport} from '@modelcontextprotocol/sdk/server/stdio.js'; 13 | import type {CallToolResult} from '@modelcontextprotocol/sdk/types.js'; 14 | import {SetLevelRequestSchema} from '@modelcontextprotocol/sdk/types.js'; 15 | 16 | import type {Channel} from './browser.js'; 17 | import {resolveBrowser} from './browser.js'; 18 | import {parseArguments} from './cli.js'; 19 | import {logger, saveLogsToFile} from './logger.js'; 20 | import {McpContext} from './McpContext.js'; 21 | import {McpResponse} from './McpResponse.js'; 22 | import {Mutex} from './Mutex.js'; 23 | import * as consoleTools from './tools/console.js'; 24 | import * as emulationTools from './tools/emulation.js'; 25 | import * as inputTools from './tools/input.js'; 26 | import * as networkTools from './tools/network.js'; 27 | import * as pagesTools from './tools/pages.js'; 28 | import * as performanceTools from './tools/performance.js'; 29 | import * as screenshotTools from './tools/screenshot.js'; 30 | import * as scriptTools from './tools/script.js'; 31 | import * as snapshotTools from './tools/snapshot.js'; 32 | import type {ToolDefinition} from './tools/ToolDefinition.js'; 33 | 34 | function readPackageJson(): {version?: string} { 35 | const currentDir = import.meta.dirname; 36 | const packageJsonPath = path.join(currentDir, '..', '..', 'package.json'); 37 | if (!fs.existsSync(packageJsonPath)) { 38 | return {}; 39 | } 40 | try { 41 | const json = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); 42 | assert.strict(json['name'], 'chrome-devtools-mcp'); 43 | return json; 44 | } catch { 45 | return {}; 46 | } 47 | } 48 | 49 | const version = readPackageJson().version ?? 'unknown'; 50 | 51 | export const args = parseArguments(version); 52 | 53 | const logFile = args.logFile ? saveLogsToFile(args.logFile) : undefined; 54 | 55 | logger(`Starting Chrome DevTools MCP Server v${version}`); 56 | const server = new McpServer( 57 | { 58 | name: 'chrome_devtools', 59 | title: 'Chrome DevTools MCP server', 60 | version, 61 | }, 62 | {capabilities: {logging: {}}}, 63 | ); 64 | server.server.setRequestHandler(SetLevelRequestSchema, () => { 65 | return {}; 66 | }); 67 | 68 | let context: McpContext; 69 | async function getContext(): Promise { 70 | const browser = await resolveBrowser({ 71 | browserUrl: args.browserUrl, 72 | headless: args.headless, 73 | executablePath: args.executablePath, 74 | customDevTools: args.customDevtools, 75 | channel: args.channel as Channel, 76 | isolated: args.isolated, 77 | logFile, 78 | }); 79 | if (context?.browser !== browser) { 80 | context = await McpContext.from(browser, logger); 81 | } 82 | return context; 83 | } 84 | 85 | const logDisclaimers = () => { 86 | console.error( 87 | `chrome-devtools-mcp exposes content of the browser instance to the MCP clients allowing them to inspect, 88 | debug, and modify any data in the browser or DevTools. 89 | Avoid sharing sensitive or personal information that you do not want to share with MCP clients.`, 90 | ); 91 | }; 92 | 93 | const toolMutex = new Mutex(); 94 | 95 | function registerTool(tool: ToolDefinition): void { 96 | server.registerTool( 97 | tool.name, 98 | { 99 | description: tool.description, 100 | inputSchema: tool.schema, 101 | annotations: tool.annotations, 102 | }, 103 | async (params): Promise => { 104 | const guard = await toolMutex.acquire(); 105 | try { 106 | logger(`${tool.name} request: ${JSON.stringify(params, null, ' ')}`); 107 | const context = await getContext(); 108 | const response = new McpResponse(); 109 | await tool.handler( 110 | { 111 | params, 112 | }, 113 | response, 114 | context, 115 | ); 116 | try { 117 | const content = await response.handle(tool.name, context); 118 | return { 119 | content, 120 | }; 121 | } catch (error) { 122 | const errorText = 123 | error instanceof Error ? error.message : String(error); 124 | 125 | return { 126 | content: [ 127 | { 128 | type: 'text', 129 | text: errorText, 130 | }, 131 | ], 132 | isError: true, 133 | }; 134 | } 135 | } finally { 136 | guard.dispose(); 137 | } 138 | }, 139 | ); 140 | } 141 | 142 | const tools = [ 143 | ...Object.values(consoleTools), 144 | ...Object.values(emulationTools), 145 | ...Object.values(inputTools), 146 | ...Object.values(networkTools), 147 | ...Object.values(pagesTools), 148 | ...Object.values(performanceTools), 149 | ...Object.values(screenshotTools), 150 | ...Object.values(scriptTools), 151 | ...Object.values(snapshotTools), 152 | ]; 153 | for (const tool of tools) { 154 | registerTool(tool as unknown as ToolDefinition); 155 | } 156 | 157 | const transport = new StdioServerTransport(); 158 | await server.connect(transport); 159 | logger('Chrome DevTools MCP Server connected'); 160 | logDisclaimers(); 161 | -------------------------------------------------------------------------------- /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 tsConfig from '../tsconfig.json' with {type: 'json'}; 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 | /** 24 | * Replaces content in a file. 25 | * @param filePath The path to the file. 26 | * @param find The regex to find. 27 | * @param replace The string to replace with. 28 | */ 29 | function sed(filePath: string, find: RegExp, replace: string): void { 30 | if (!fs.existsSync(filePath)) { 31 | console.warn(`File not found for sed operation: ${filePath}`); 32 | return; 33 | } 34 | const content = fs.readFileSync(filePath, 'utf-8'); 35 | const newContent = content.replace(find, replace); 36 | fs.writeFileSync(filePath, newContent, 'utf-8'); 37 | } 38 | 39 | /** 40 | * Ensures that licenses for third party files we use gets copied into the build/ dir. 41 | */ 42 | function copyThirdPartyLicenseFiles() { 43 | const thirdPartyDirectories = tsConfig.include.filter(location => { 44 | return location.includes( 45 | 'node_modules/chrome-devtools-frontend/front_end/third_party', 46 | ); 47 | }); 48 | 49 | for (const thirdPartyDir of thirdPartyDirectories) { 50 | const fullPath = path.join(process.cwd(), thirdPartyDir); 51 | const licenseFile = path.join(fullPath, 'LICENSE'); 52 | if (!fs.existsSync(licenseFile)) { 53 | console.error('No LICENSE for', path.basename(thirdPartyDir)); 54 | } 55 | 56 | const destinationDir = path.join(BUILD_DIR, thirdPartyDir); 57 | const destinationFile = path.join(destinationDir, 'LICENSE'); 58 | fs.copyFileSync(licenseFile, destinationFile); 59 | } 60 | } 61 | 62 | function main(): void { 63 | const devtoolsThirdPartyPath = 64 | 'node_modules/chrome-devtools-frontend/front_end/third_party'; 65 | const devtoolsFrontEndCorePath = 66 | 'node_modules/chrome-devtools-frontend/front_end/core'; 67 | 68 | // Create i18n mock 69 | const i18nDir = path.join(BUILD_DIR, devtoolsFrontEndCorePath, 'i18n'); 70 | fs.mkdirSync(i18nDir, {recursive: true}); 71 | const i18nFile = path.join(i18nDir, 'i18n.js'); 72 | const i18nContent = ` 73 | export const i18n = { 74 | registerUIStrings: () => {}, 75 | getLocalizedString: (_, str) => { 76 | // So that the string passed in gets output verbatim. 77 | return str; 78 | }, 79 | lockedLazyString: () => {}, 80 | getLazilyComputedLocalizedString: () => {}, 81 | }; 82 | 83 | // TODO(jacktfranklin): once the DocumentLatency insight does not depend on 84 | // this method, we can remove this stub. 85 | export const TimeUtilities = { 86 | millisToString(x) { 87 | const separator = '\xA0'; 88 | const formatter = new Intl.NumberFormat('en-US', { 89 | style: 'unit', 90 | unitDisplay: 'narrow', 91 | minimumFractionDigits: 0, 92 | maximumFractionDigits: 1, 93 | unit: 'millisecond', 94 | }); 95 | 96 | const parts = formatter.formatToParts(x); 97 | for (const part of parts) { 98 | if (part.type === 'literal') { 99 | if (part.value === ' ') { 100 | part.value = separator; 101 | } 102 | } 103 | } 104 | 105 | return parts.map(part => part.value).join(''); 106 | } 107 | }; 108 | 109 | // TODO(jacktfranklin): once the ImageDelivery insight does not depend on this method, we can remove this stub. 110 | export const ByteUtilities = { 111 | bytesToString(x) { 112 | const separator = '\xA0'; 113 | const formatter = new Intl.NumberFormat('en-US', { 114 | style: 'unit', 115 | unit: 'kilobyte', 116 | unitDisplay: 'narrow', 117 | minimumFractionDigits: 1, 118 | maximumFractionDigits: 1, 119 | }); 120 | const parts = formatter.formatToParts(x / 1000); 121 | for (const part of parts) { 122 | if (part.type === 'literal') { 123 | if (part.value === ' ') { 124 | part.value = separator; 125 | } 126 | } 127 | } 128 | 129 | return parts.map(part => part.value).join(''); 130 | } 131 | };`; 132 | writeFile(i18nFile, i18nContent); 133 | 134 | // Create codemirror.next mock. 135 | const codeMirrorDir = path.join( 136 | BUILD_DIR, 137 | devtoolsThirdPartyPath, 138 | 'codemirror.next', 139 | ); 140 | fs.mkdirSync(codeMirrorDir, {recursive: true}); 141 | const codeMirrorFile = path.join(codeMirrorDir, 'codemirror.next.js'); 142 | const codeMirrorContent = `export default {}`; 143 | writeFile(codeMirrorFile, codeMirrorContent); 144 | 145 | // Create root mock 146 | const rootDir = path.join(BUILD_DIR, devtoolsFrontEndCorePath, 'root'); 147 | fs.mkdirSync(rootDir, {recursive: true}); 148 | const runtimeFile = path.join(rootDir, 'Runtime.js'); 149 | const runtimeContent = ` 150 | export function getChromeVersion() { return ''; }; 151 | export const hostConfig = {}; 152 | `; 153 | writeFile(runtimeFile, runtimeContent); 154 | 155 | // Update protocol_client to remove: 156 | // 1. self.Protocol assignment 157 | // 2. Call to register backend commands. 158 | const protocolClientDir = path.join( 159 | BUILD_DIR, 160 | devtoolsFrontEndCorePath, 161 | 'protocol_client', 162 | ); 163 | const clientFile = path.join(protocolClientDir, 'protocol_client.js'); 164 | const globalAssignment = /self\.Protocol = self\.Protocol \|\| \{\};/; 165 | const registerCommands = 166 | /InspectorBackendCommands\.registerCommands\(InspectorBackend\.inspectorBackend\);/; 167 | sed(clientFile, globalAssignment, ''); 168 | sed(clientFile, registerCommands, ''); 169 | 170 | const devtoolsLicensePath = path.join( 171 | 'node_modules', 172 | 'chrome-devtools-frontend', 173 | 'LICENSE', 174 | ); 175 | const devtoolsLicenseFileSource = path.join( 176 | process.cwd(), 177 | devtoolsLicensePath, 178 | ); 179 | const devtoolsLicenseFileDestination = path.join( 180 | BUILD_DIR, 181 | devtoolsLicensePath, 182 | ); 183 | fs.copyFileSync(devtoolsLicenseFileSource, devtoolsLicenseFileDestination); 184 | 185 | copyThirdPartyLicenseFiles(); 186 | } 187 | 188 | main(); 189 | -------------------------------------------------------------------------------- /src/tools/pages.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import z from 'zod'; 8 | 9 | import {ToolCategories} from './categories.js'; 10 | import {CLOSE_PAGE_ERROR, defineTool} from './ToolDefinition.js'; 11 | 12 | export const listPages = defineTool({ 13 | name: 'list_pages', 14 | description: `Get a list of pages open in the browser.`, 15 | annotations: { 16 | category: ToolCategories.NAVIGATION_AUTOMATION, 17 | readOnlyHint: true, 18 | }, 19 | schema: {}, 20 | handler: async (_request, response) => { 21 | response.setIncludePages(true); 22 | }, 23 | }); 24 | 25 | export const selectPage = defineTool({ 26 | name: 'select_page', 27 | description: `Select a page as a context for future tool calls.`, 28 | annotations: { 29 | category: ToolCategories.NAVIGATION_AUTOMATION, 30 | readOnlyHint: true, 31 | }, 32 | schema: { 33 | pageIdx: z 34 | .number() 35 | .describe( 36 | 'The index of the page to select. Call list_pages to list pages.', 37 | ), 38 | }, 39 | handler: async (request, response, context) => { 40 | const page = context.getPageByIdx(request.params.pageIdx); 41 | await page.bringToFront(); 42 | context.setSelectedPageIdx(request.params.pageIdx); 43 | response.setIncludePages(true); 44 | }, 45 | }); 46 | 47 | export const closePage = defineTool({ 48 | name: 'close_page', 49 | description: `Closes the page by its index. The last open page cannot be closed.`, 50 | annotations: { 51 | category: ToolCategories.NAVIGATION_AUTOMATION, 52 | readOnlyHint: false, 53 | }, 54 | schema: { 55 | pageIdx: z 56 | .number() 57 | .describe( 58 | 'The index of the page to close. Call list_pages to list pages.', 59 | ), 60 | }, 61 | handler: async (request, response, context) => { 62 | try { 63 | await context.closePage(request.params.pageIdx); 64 | } catch (err) { 65 | if (err.message === CLOSE_PAGE_ERROR) { 66 | response.appendResponseLine(err.message); 67 | } else { 68 | throw err; 69 | } 70 | } 71 | response.setIncludePages(true); 72 | }, 73 | }); 74 | 75 | export const newPage = defineTool({ 76 | name: 'new_page', 77 | description: `Creates a new page`, 78 | annotations: { 79 | category: ToolCategories.NAVIGATION_AUTOMATION, 80 | readOnlyHint: false, 81 | }, 82 | schema: { 83 | url: z.string().describe('URL to load in a new page.'), 84 | }, 85 | handler: async (request, response, context) => { 86 | const page = await context.newPage(); 87 | 88 | await context.waitForEventsAfterAction(async () => { 89 | await page.goto(request.params.url); 90 | }); 91 | 92 | response.setIncludePages(true); 93 | }, 94 | }); 95 | 96 | export const navigatePage = defineTool({ 97 | name: 'navigate_page', 98 | description: `Navigates the currently selected page to a URL.`, 99 | annotations: { 100 | category: ToolCategories.NAVIGATION_AUTOMATION, 101 | readOnlyHint: false, 102 | }, 103 | schema: { 104 | url: z.string().describe('URL to navigate the page to'), 105 | }, 106 | handler: async (request, response, context) => { 107 | const page = context.getSelectedPage(); 108 | 109 | await context.waitForEventsAfterAction(async () => { 110 | await page.goto(request.params.url); 111 | }); 112 | 113 | response.setIncludePages(true); 114 | }, 115 | }); 116 | 117 | export const navigatePageHistory = defineTool({ 118 | name: 'navigate_page_history', 119 | description: `Navigates the currently selected page.`, 120 | annotations: { 121 | category: ToolCategories.NAVIGATION_AUTOMATION, 122 | readOnlyHint: false, 123 | }, 124 | schema: { 125 | navigate: z 126 | .enum(['back', 'forward']) 127 | .describe( 128 | 'Whether to navigate back or navigate forward in the selected pages history', 129 | ), 130 | }, 131 | handler: async (request, response, context) => { 132 | const page = context.getSelectedPage(); 133 | 134 | try { 135 | if (request.params.navigate === 'back') { 136 | await page.goBack(); 137 | } else { 138 | await page.goForward(); 139 | } 140 | } catch { 141 | response.appendResponseLine( 142 | `Unable to navigate ${request.params.navigate} in currently selected page.`, 143 | ); 144 | } 145 | 146 | response.setIncludePages(true); 147 | }, 148 | }); 149 | 150 | export const resizePage = defineTool({ 151 | name: 'resize_page', 152 | description: `Resizes the selected page's window so that the page has specified dimension`, 153 | annotations: { 154 | category: ToolCategories.EMULATION, 155 | readOnlyHint: false, 156 | }, 157 | schema: { 158 | width: z.number().describe('Page width'), 159 | height: z.number().describe('Page height'), 160 | }, 161 | handler: async (request, response, context) => { 162 | const page = context.getSelectedPage(); 163 | 164 | // @ts-expect-error internal API for now. 165 | await page.resize({ 166 | contentWidth: request.params.width, 167 | contentHeight: request.params.height, 168 | }); 169 | 170 | response.setIncludePages(true); 171 | }, 172 | }); 173 | 174 | export const handleDialog = defineTool({ 175 | name: 'handle_dialog', 176 | description: `If a browser dialog was opened, use this command to handle it`, 177 | annotations: { 178 | category: ToolCategories.INPUT_AUTOMATION, 179 | readOnlyHint: false, 180 | }, 181 | schema: { 182 | action: z 183 | .enum(['accept', 'dismiss']) 184 | .describe('Whether to dismiss or accept the dialog'), 185 | promptText: z 186 | .string() 187 | .optional() 188 | .describe('Optional prompt text to enter into the dialog.'), 189 | }, 190 | handler: async (request, response, context) => { 191 | const dialog = context.getDialog(); 192 | if (!dialog) { 193 | throw new Error('No open dialog found'); 194 | } 195 | 196 | switch (request.params.action) { 197 | case 'accept': { 198 | await dialog.accept(request.params.promptText); 199 | response.appendResponseLine('Successfully accepted the dialog'); 200 | break; 201 | } 202 | case 'dismiss': { 203 | await dialog.dismiss(); 204 | response.appendResponseLine('Successfully dismissed the dialog'); 205 | break; 206 | } 207 | } 208 | 209 | context.clearDialog(); 210 | response.setIncludePages(true); 211 | }, 212 | }); 213 | -------------------------------------------------------------------------------- /src/tools/performance.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import type {Page} from 'puppeteer-core'; 8 | import z from 'zod'; 9 | 10 | import {logger} from '../logger.js'; 11 | import type {InsightName} from '../trace-processing/parse.js'; 12 | import { 13 | getInsightOutput, 14 | getTraceSummary, 15 | parseRawTraceBuffer, 16 | traceResultIsSuccess, 17 | } from '../trace-processing/parse.js'; 18 | 19 | import {ToolCategories} from './categories.js'; 20 | import type {Context, Response} from './ToolDefinition.js'; 21 | import {defineTool} from './ToolDefinition.js'; 22 | 23 | export const startTrace = defineTool({ 24 | name: 'performance_start_trace', 25 | description: 26 | '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.', 27 | annotations: { 28 | category: ToolCategories.PERFORMANCE, 29 | readOnlyHint: true, 30 | }, 31 | schema: { 32 | reload: z 33 | .boolean() 34 | .describe( 35 | 'Determines if, once tracing has started, the page should be automatically reloaded.', 36 | ), 37 | autoStop: z 38 | .boolean() 39 | .describe( 40 | 'Determines if the trace recording should be automatically stopped.', 41 | ), 42 | }, 43 | handler: async (request, response, context) => { 44 | if (context.isRunningPerformanceTrace()) { 45 | response.appendResponseLine( 46 | 'Error: a performance trace is already running. Use performance_stop_trace to stop it. Only one trace can be running at any given time.', 47 | ); 48 | return; 49 | } 50 | context.setIsRunningPerformanceTrace(true); 51 | 52 | const page = context.getSelectedPage(); 53 | const pageUrlForTracing = page.url(); 54 | 55 | if (request.params.reload) { 56 | // Before starting the recording, navigate to about:blank to clear out any state. 57 | await page.goto('about:blank', { 58 | waitUntil: ['networkidle0'], 59 | }); 60 | } 61 | 62 | // Keep in sync with the categories arrays in: 63 | // https://source.chromium.org/chromium/chromium/src/+/main:third_party/devtools-frontend/src/front_end/panels/timeline/TimelineController.ts 64 | // https://github.com/GoogleChrome/lighthouse/blob/master/lighthouse-core/gather/gatherers/trace.js 65 | const categories = [ 66 | '-*', 67 | 'blink.console', 68 | 'blink.user_timing', 69 | 'devtools.timeline', 70 | 'disabled-by-default-devtools.screenshot', 71 | 'disabled-by-default-devtools.timeline', 72 | 'disabled-by-default-devtools.timeline.invalidationTracking', 73 | 'disabled-by-default-devtools.timeline.frame', 74 | 'disabled-by-default-devtools.timeline.stack', 75 | 'disabled-by-default-v8.cpu_profiler', 76 | 'disabled-by-default-v8.cpu_profiler.hires', 77 | 'latencyInfo', 78 | 'loading', 79 | 'disabled-by-default-lighthouse', 80 | 'v8.execute', 81 | 'v8', 82 | ]; 83 | await page.tracing.start({ 84 | categories, 85 | }); 86 | 87 | if (request.params.reload) { 88 | await page.goto(pageUrlForTracing, { 89 | waitUntil: ['load'], 90 | }); 91 | } 92 | 93 | if (request.params.autoStop) { 94 | await new Promise(resolve => setTimeout(resolve, 5_000)); 95 | await stopTracingAndAppendOutput(page, response, context); 96 | } else { 97 | response.appendResponseLine( 98 | `The performance trace is being recorded. Use performance_stop_trace to stop it.`, 99 | ); 100 | } 101 | }, 102 | }); 103 | 104 | export const stopTrace = defineTool({ 105 | name: 'performance_stop_trace', 106 | description: 107 | 'Stops the active performance trace recording on the selected page.', 108 | annotations: { 109 | category: ToolCategories.PERFORMANCE, 110 | readOnlyHint: true, 111 | }, 112 | schema: {}, 113 | handler: async (_request, response, context) => { 114 | if (!context.isRunningPerformanceTrace) { 115 | return; 116 | } 117 | const page = context.getSelectedPage(); 118 | await stopTracingAndAppendOutput(page, response, context); 119 | }, 120 | }); 121 | 122 | export const analyzeInsight = defineTool({ 123 | name: 'performance_analyze_insight', 124 | description: 125 | 'Provides more detailed information on a specific Performance Insight that was highlighed in the results of a trace recording.', 126 | annotations: { 127 | category: ToolCategories.PERFORMANCE, 128 | readOnlyHint: true, 129 | }, 130 | schema: { 131 | insightName: z 132 | .string() 133 | .describe( 134 | 'The name of the Insight you want more information on. For example: "DocumentLatency" or "LCPBreakdown"', 135 | ), 136 | }, 137 | handler: async (request, response, context) => { 138 | const lastRecording = context.recordedTraces().at(-1); 139 | if (!lastRecording) { 140 | response.appendResponseLine( 141 | 'No recorded traces found. Record a performance trace so you have Insights to analyze.', 142 | ); 143 | return; 144 | } 145 | 146 | const insightOutput = getInsightOutput( 147 | lastRecording, 148 | request.params.insightName as InsightName, 149 | ); 150 | if ('error' in insightOutput) { 151 | response.appendResponseLine(insightOutput.error); 152 | return; 153 | } 154 | 155 | response.appendResponseLine(insightOutput.output); 156 | }, 157 | }); 158 | 159 | async function stopTracingAndAppendOutput( 160 | page: Page, 161 | response: Response, 162 | context: Context, 163 | ): Promise { 164 | try { 165 | const traceEventsBuffer = await page.tracing.stop(); 166 | const result = await parseRawTraceBuffer(traceEventsBuffer); 167 | response.appendResponseLine('The performance trace has been stopped.'); 168 | if (traceResultIsSuccess(result)) { 169 | context.storeTraceRecording(result); 170 | response.appendResponseLine( 171 | 'Here is a high level summary of the trace and the Insights that were found:', 172 | ); 173 | const traceSummaryText = getTraceSummary(result); 174 | response.appendResponseLine(traceSummaryText); 175 | } else { 176 | response.appendResponseLine( 177 | 'There was an unexpected error parsing the trace:', 178 | ); 179 | response.appendResponseLine(result.error); 180 | } 181 | } catch (e) { 182 | const errorText = e instanceof Error ? e.message : JSON.stringify(e); 183 | logger(`Error stopping performance trace: ${errorText}`); 184 | response.appendResponseLine( 185 | 'An error occured generating the response for this trace:', 186 | ); 187 | response.appendResponseLine(errorText); 188 | } finally { 189 | context.setIsRunningPerformanceTrace(false); 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /tests/formatters/consoleFormatter.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 {ConsoleMessage} from 'puppeteer-core'; 11 | 12 | import {formatConsoleEvent} from '../../src/formatters/consoleFormatter.js'; 13 | 14 | function getMockConsoleMessage(options: { 15 | type: string; 16 | text: string; 17 | location?: { 18 | url?: string; 19 | lineNumber?: number; 20 | columnNumber?: number; 21 | }; 22 | stackTrace?: Array<{ 23 | url: string; 24 | lineNumber: number; 25 | columnNumber: number; 26 | }>; 27 | args?: unknown[]; 28 | }): ConsoleMessage { 29 | return { 30 | type() { 31 | return options.type; 32 | }, 33 | text() { 34 | return options.text; 35 | }, 36 | location() { 37 | return options.location ?? {}; 38 | }, 39 | stackTrace() { 40 | return options.stackTrace ?? []; 41 | }, 42 | args() { 43 | return ( 44 | options.args?.map(arg => { 45 | return { 46 | evaluate(fn: (arg: unknown) => unknown) { 47 | return Promise.resolve(fn(arg)); 48 | }, 49 | jsonValue() { 50 | return Promise.resolve(arg); 51 | }, 52 | dispose() { 53 | return Promise.resolve(); 54 | }, 55 | }; 56 | }) ?? [] 57 | ); 58 | }, 59 | } as ConsoleMessage; 60 | } 61 | 62 | describe('consoleFormatter', () => { 63 | describe('formatConsoleEvent', () => { 64 | it('formats a console.log message', async () => { 65 | const message = getMockConsoleMessage({ 66 | type: 'log', 67 | text: 'Hello, world!', 68 | location: { 69 | url: 'http://example.com/script.js', 70 | lineNumber: 10, 71 | columnNumber: 5, 72 | }, 73 | }); 74 | const result = await formatConsoleEvent(message); 75 | assert.equal(result, 'Log> script.js:10:5: Hello, world!'); 76 | }); 77 | 78 | it('formats a console.log message with arguments', async () => { 79 | const message = getMockConsoleMessage({ 80 | type: 'log', 81 | text: 'Processing file:', 82 | args: ['file.txt', {id: 1, status: 'done'}], 83 | location: { 84 | url: 'http://example.com/script.js', 85 | lineNumber: 10, 86 | columnNumber: 5, 87 | }, 88 | }); 89 | const result = await formatConsoleEvent(message); 90 | assert.equal( 91 | result, 92 | 'Log> script.js:10:5: Processing file: file.txt {"id":1,"status":"done"}', 93 | ); 94 | }); 95 | 96 | it('formats a console.error message', async () => { 97 | const message = getMockConsoleMessage({ 98 | type: 'error', 99 | text: 'Something went wrong', 100 | }); 101 | const result = await formatConsoleEvent(message); 102 | assert.equal(result, 'Error> Something went wrong'); 103 | }); 104 | 105 | it('formats a console.error message with arguments', async () => { 106 | const message = getMockConsoleMessage({ 107 | type: 'error', 108 | text: 'Something went wrong:', 109 | args: ['details', {code: 500}], 110 | }); 111 | const result = await formatConsoleEvent(message); 112 | assert.equal(result, 'Error> Something went wrong: details {"code":500}'); 113 | }); 114 | 115 | it('formats a console.error message with a stack trace', async () => { 116 | const message = getMockConsoleMessage({ 117 | type: 'error', 118 | text: 'Something went wrong', 119 | stackTrace: [ 120 | { 121 | url: 'http://example.com/script.js', 122 | lineNumber: 10, 123 | columnNumber: 5, 124 | }, 125 | { 126 | url: 'http://example.com/script2.js', 127 | lineNumber: 20, 128 | columnNumber: 10, 129 | }, 130 | ], 131 | }); 132 | const result = await formatConsoleEvent(message); 133 | assert.equal( 134 | result, 135 | 'Error> Something went wrong\nscript.js:10:5\nscript2.js:20:10', 136 | ); 137 | }); 138 | 139 | it('formats a console.error message with a JSHandle@error', async () => { 140 | const message = getMockConsoleMessage({ 141 | type: 'error', 142 | text: 'JSHandle@error', 143 | args: [new Error('mock stack')], 144 | }); 145 | const result = await formatConsoleEvent(message); 146 | assert.ok(result.startsWith('Error> Error: mock stack')); 147 | }); 148 | 149 | it('formats a console.warn message', async () => { 150 | const message = getMockConsoleMessage({ 151 | type: 'warning', 152 | text: 'This is a warning', 153 | location: { 154 | url: 'http://example.com/script.js', 155 | lineNumber: 10, 156 | columnNumber: 5, 157 | }, 158 | }); 159 | const result = await formatConsoleEvent(message); 160 | assert.equal(result, 'Warning> script.js:10:5: This is a warning'); 161 | }); 162 | 163 | it('formats a console.info message', async () => { 164 | const message = getMockConsoleMessage({ 165 | type: 'info', 166 | text: 'This is an info message', 167 | location: { 168 | url: 'http://example.com/script.js', 169 | lineNumber: 10, 170 | columnNumber: 5, 171 | }, 172 | }); 173 | const result = await formatConsoleEvent(message); 174 | assert.equal(result, 'Info> script.js:10:5: This is an info message'); 175 | }); 176 | 177 | it('formats a page error', async () => { 178 | const error = new Error('Page crashed'); 179 | error.stack = 'Error: Page crashed\n at :1:1'; 180 | const result = await formatConsoleEvent(error); 181 | assert.equal(result, 'Error: Page crashed'); 182 | }); 183 | 184 | it('formats a page error without a stack', async () => { 185 | const error = new Error('Page crashed'); 186 | error.stack = undefined; 187 | const result = await formatConsoleEvent(error); 188 | assert.equal(result, 'Error: Page crashed'); 189 | }); 190 | 191 | it('formats a console.log message from a removed iframe - no location', async () => { 192 | const message = getMockConsoleMessage({ 193 | type: 'log', 194 | text: 'Hello from iframe', 195 | location: {}, 196 | }); 197 | const result = await formatConsoleEvent(message); 198 | assert.equal(result, 'Log> : Hello from iframe'); 199 | }); 200 | 201 | it('formats a console.log message from a removed iframe with partial location', async () => { 202 | const message = getMockConsoleMessage({ 203 | type: 'log', 204 | text: 'Hello from iframe', 205 | location: { 206 | lineNumber: 10, 207 | columnNumber: 5, 208 | }, 209 | }); 210 | const result = await formatConsoleEvent(message); 211 | assert.equal(result, 'Log> : Hello from iframe'); 212 | }); 213 | }); 214 | }); 215 | -------------------------------------------------------------------------------- /src/tools/input.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import type {ElementHandle} from 'puppeteer-core'; 8 | import z from 'zod'; 9 | 10 | import {ToolCategories} from './categories.js'; 11 | import {defineTool} from './ToolDefinition.js'; 12 | 13 | export const click = defineTool({ 14 | name: 'click', 15 | description: `Clicks on the provided element`, 16 | annotations: { 17 | category: ToolCategories.INPUT_AUTOMATION, 18 | readOnlyHint: false, 19 | }, 20 | schema: { 21 | uid: z 22 | .string() 23 | .describe( 24 | 'The uid of an element on the page from the page content snapshot', 25 | ), 26 | dblClick: z 27 | .boolean() 28 | .optional() 29 | .describe('Set to true for double clicks. Default is false.'), 30 | }, 31 | handler: async (request, response, context) => { 32 | const uid = request.params.uid; 33 | const handle = await context.getElementByUid(uid); 34 | try { 35 | await context.waitForEventsAfterAction(async () => { 36 | await handle.asLocator().click({ 37 | count: request.params.dblClick ? 2 : 1, 38 | }); 39 | }); 40 | response.appendResponseLine( 41 | request.params.dblClick 42 | ? `Successfully double clicked on the element` 43 | : `Successfully clicked on the element`, 44 | ); 45 | response.setIncludeSnapshot(true); 46 | } finally { 47 | void handle.dispose(); 48 | } 49 | }, 50 | }); 51 | 52 | export const hover = defineTool({ 53 | name: 'hover', 54 | description: `Hover over the provided element`, 55 | annotations: { 56 | category: ToolCategories.INPUT_AUTOMATION, 57 | readOnlyHint: false, 58 | }, 59 | schema: { 60 | uid: z 61 | .string() 62 | .describe( 63 | 'The uid of an element on the page from the page content snapshot', 64 | ), 65 | }, 66 | handler: async (request, response, context) => { 67 | const uid = request.params.uid; 68 | const handle = await context.getElementByUid(uid); 69 | try { 70 | await context.waitForEventsAfterAction(async () => { 71 | await handle.asLocator().hover(); 72 | }); 73 | response.appendResponseLine(`Successfully hovered over the element`); 74 | response.setIncludeSnapshot(true); 75 | } finally { 76 | void handle.dispose(); 77 | } 78 | }, 79 | }); 80 | 81 | export const fill = defineTool({ 82 | name: 'fill', 83 | description: `Type text into a input, text area or select an option from a