chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "chrome-devtools-plugins",
|
||||
"version": "1.5.0",
|
||||
"description": "Bundled plugins for actuating and debugging the Chrome browser.",
|
||||
"repository": "https://github.com/ChromeDevTools/chrome-devtools-mcp",
|
||||
"owner": {
|
||||
"name": "Chrome DevTools Team",
|
||||
"email": "devtools-dev@chromium.org"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "chrome-devtools-mcp",
|
||||
"source": "./",
|
||||
"description": "Reliable automation, in-depth debugging, and performance analysis in Chrome using Chrome DevTools and Puppeteer"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "chrome-devtools-mcp",
|
||||
"version": "1.5.0",
|
||||
"description": "Reliable automation, in-depth debugging, and performance analysis in Chrome using Chrome DevTools and Puppeteer",
|
||||
"mcpServers": {
|
||||
"chrome-devtools": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"chrome-devtools-mcp@1.5.0"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "devtools-for-agents",
|
||||
"description": "Help your agent build, debug, and verify your code correctly. With Chrome DevTools for agents, your AI agent can interact with the Chrome browser to test code, emulate users, and catch bugs using Chrome DevTools’ capabilities before shipping.",
|
||||
"version": "1.5.0",
|
||||
"author": {
|
||||
"name": "Google Chrome"
|
||||
},
|
||||
"logo": "https://github.com/ChromeDevTools/devtools-logo/blob/master/logos/svg/chrome-devtools-square-responsive.svg",
|
||||
"homepage": "https://developer.chrome.com/docs/devtools/agents",
|
||||
"repository": "https://github.com/ChromeDevTools/chrome-devtools-mcp",
|
||||
"mcpServers": {
|
||||
"chrome-devtools": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"chrome-devtools-mcp@1.5.0"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"context": {
|
||||
"fileName": ["AGENTS.md", "GEMINI.md"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
@@ -0,0 +1,87 @@
|
||||
name: Bug report
|
||||
description: File a bug report for chrome-devtools-mcp
|
||||
title: '<short description of the bug>'
|
||||
type: 'Bug'
|
||||
body:
|
||||
- id: description
|
||||
type: textarea
|
||||
attributes:
|
||||
label: Description of the bug
|
||||
description: >
|
||||
A clear and concise description of what the bug is.
|
||||
placeholder:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- id: reproduce
|
||||
type: textarea
|
||||
attributes:
|
||||
label: Reproduction
|
||||
description: >
|
||||
Steps to reproduce the behavior:
|
||||
placeholder: |
|
||||
1. Use tool '...'
|
||||
2. Then use tool '...'
|
||||
|
||||
- id: expectation
|
||||
type: textarea
|
||||
attributes:
|
||||
label: Expectation
|
||||
description: A clear and concise description of what you expected to happen.
|
||||
|
||||
- id: mcp-configuration
|
||||
type: textarea
|
||||
attributes:
|
||||
label: MCP configuration
|
||||
|
||||
- id: chrome-devtools-mcp-version
|
||||
type: input
|
||||
attributes:
|
||||
label: Chrome DevTools MCP version
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- id: chrome-version
|
||||
type: input
|
||||
attributes:
|
||||
label: Chrome version
|
||||
|
||||
- id: coding-agent-version
|
||||
type: input
|
||||
attributes:
|
||||
label: Coding agent version
|
||||
|
||||
- id: model-version
|
||||
type: input
|
||||
attributes:
|
||||
label: Model version
|
||||
|
||||
- id: chat-log
|
||||
type: input
|
||||
attributes:
|
||||
label: Chat log
|
||||
|
||||
- id: node-version
|
||||
type: input
|
||||
attributes:
|
||||
label: Node version
|
||||
description: >
|
||||
Please verify you have the minimal supported version listed in the README.md
|
||||
|
||||
- id: operating-system
|
||||
type: dropdown
|
||||
attributes:
|
||||
label: Operating system
|
||||
description: What supported operating system are you running?
|
||||
options:
|
||||
- Windows
|
||||
- Windows Subsystem for Linux (WSL)
|
||||
- macOS
|
||||
- Linux
|
||||
|
||||
- type: checkboxes
|
||||
id: provide-pr
|
||||
attributes:
|
||||
label: Extra checklist
|
||||
options:
|
||||
- label: I want to provide a PR to fix this bug
|
||||
@@ -0,0 +1,44 @@
|
||||
name: Feature
|
||||
description: Suggest an idea for chrome-devtools-mcp
|
||||
title: '<short description of the feature request>'
|
||||
type: 'Feature'
|
||||
labels:
|
||||
- feature
|
||||
body:
|
||||
- id: description
|
||||
type: textarea
|
||||
attributes:
|
||||
label: Is your feature request related to a problem? Please describe.
|
||||
description: >
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
placeholder:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- id: solution
|
||||
type: textarea
|
||||
attributes:
|
||||
label: Describe the solution you'd like
|
||||
description: >
|
||||
A clear and concise description of what you want to happen.
|
||||
placeholder:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- id: alternatives
|
||||
type: textarea
|
||||
attributes:
|
||||
label: Describe alternatives you've considered
|
||||
description: >
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
placeholder:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- id: additional-context
|
||||
type: textarea
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: >
|
||||
Add any other context or screenshots about the feature request here.
|
||||
placeholder:
|
||||
@@ -0,0 +1,17 @@
|
||||
name: Task
|
||||
description: Work tracking for maintainers only!
|
||||
title: '[Task]:'
|
||||
type: 'Task'
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
### This issue type should be used only by mainainers!
|
||||
Task are to track small non user facing issue or improvements.
|
||||
The issue will be closed if it does not follow those rules.
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: 'Task to do:'
|
||||
id: task
|
||||
validations:
|
||||
required: true
|
||||
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
@@ -0,0 +1,58 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: npm
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: 'sunday'
|
||||
time: '02:00'
|
||||
timezone: Europe/Berlin
|
||||
cooldown:
|
||||
default-days: 7
|
||||
exclude:
|
||||
- 'chrome-devtools-frontend'
|
||||
- 'puppeteer*'
|
||||
- 'lighthouse'
|
||||
- '@google/genai'
|
||||
groups:
|
||||
dependencies:
|
||||
dependency-type: production
|
||||
patterns:
|
||||
- '*'
|
||||
dev-dependencies:
|
||||
dependency-type: development
|
||||
exclude-patterns:
|
||||
- 'puppeteer*'
|
||||
- 'chrome-devtools-frontend'
|
||||
- '@modelcontextprotocol/sdk'
|
||||
- 'yargs'
|
||||
- 'debug'
|
||||
- 'core-js'
|
||||
patterns:
|
||||
- '*'
|
||||
# breaks often so better to roll separetely.
|
||||
bundled-devtools:
|
||||
patterns:
|
||||
- 'chrome-devtools-frontend'
|
||||
bundled:
|
||||
patterns:
|
||||
- 'puppeteer*'
|
||||
- '@modelcontextprotocol/sdk'
|
||||
- 'yargs'
|
||||
- 'debug'
|
||||
- 'core-js'
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: 'sunday'
|
||||
time: '04:00'
|
||||
timezone: Europe/Berlin
|
||||
cooldown:
|
||||
default-days: 7
|
||||
exclude:
|
||||
- 'googleapis/*'
|
||||
groups:
|
||||
all:
|
||||
patterns:
|
||||
- '*'
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "chrome-devtools-mcp",
|
||||
"version": "1.5.0",
|
||||
"description": "Reliable automation, in-depth debugging, and performance analysis in Chrome using Chrome DevTools and Puppeteer",
|
||||
"mcpServers": {
|
||||
"chrome-devtools": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"chrome-devtools-mcp@1.5.0"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
name: 'Conventional Commit'
|
||||
|
||||
on:
|
||||
merge_group:
|
||||
pull_request_target:
|
||||
types:
|
||||
# Defaults
|
||||
# https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#pull_request_target
|
||||
- opened
|
||||
- reopened
|
||||
- synchronize
|
||||
# Tracks editing PR title or description, or base branch changes
|
||||
# https://docs.github.com/en/webhooks/webhook-events-and-payloads?actionType=edited#pull_request
|
||||
- edited
|
||||
|
||||
jobs:
|
||||
main:
|
||||
name: '[Required] Validate PR title'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: read
|
||||
steps:
|
||||
- if: github.event_name != 'merge_group'
|
||||
uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -0,0 +1,44 @@
|
||||
name: Pre-release
|
||||
|
||||
permissions: read-all
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- release-please-*
|
||||
|
||||
jobs:
|
||||
pre-release:
|
||||
name: 'Verify artifacts before release'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
cache: npm
|
||||
node-version-file: '.nvmrc'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
# Ensure npm 11.5.1 or later is installed
|
||||
- name: Update npm
|
||||
run: npm install -g npm@latest
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build and bundle
|
||||
run: npm run bundle
|
||||
env:
|
||||
NODE_ENV: 'production'
|
||||
|
||||
- name: Verify server.json
|
||||
run: npm run verify-server-json-version
|
||||
|
||||
- name: Verify npm package
|
||||
run: npm run verify-npm-package
|
||||
@@ -0,0 +1,67 @@
|
||||
name: Check code before submitting
|
||||
|
||||
permissions: read-all
|
||||
|
||||
on:
|
||||
merge_group:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
check-format:
|
||||
name: '[Required] Check correct format'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
cache: npm
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run format check
|
||||
run: npm run check-format
|
||||
|
||||
check-docs:
|
||||
name: '[Required] Check docs updated'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
cache: npm
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Generate
|
||||
run: npm run gen
|
||||
|
||||
- name: Check if autogenerated code and docs are out of date
|
||||
run: |
|
||||
diff_file=$(mktemp doc_diff_XXXXXX)
|
||||
git diff --color > $diff_file
|
||||
if [[ -s $diff_file ]]; then
|
||||
echo "Please update the documentation by running 'npm run gen'. The following was the diff"
|
||||
cat $diff_file
|
||||
rm $diff_file
|
||||
exit 1
|
||||
fi
|
||||
rm $diff_file
|
||||
@@ -0,0 +1,101 @@
|
||||
name: publish-on-tag
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'chrome-devtools-mcp-v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
npm-publish:
|
||||
description: 'Try to publish to npm'
|
||||
default: false
|
||||
type: boolean
|
||||
mcp-publish:
|
||||
description: 'Try to publish to MCP registry'
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
id-token: write # Required for OIDC
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
publish-to-npm:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ (github.event_name != 'workflow_dispatch') || (inputs.npm-publish && always()) }}
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
cache: npm
|
||||
node-version-file: '.nvmrc'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build and bundle
|
||||
run: npm run bundle
|
||||
env:
|
||||
NODE_ENV: 'production'
|
||||
|
||||
- name: Publish chrome-devtools-mcp
|
||||
run: |
|
||||
npm publish --provenance --access public
|
||||
|
||||
- name: Publish chrome-devtools
|
||||
run: |
|
||||
node --input-type=module --eval '
|
||||
import * as fs from "node:fs/promises";
|
||||
const json = await fs.readFile("package.json", "utf8");
|
||||
const pkg = JSON.parse(json);
|
||||
pkg.name = "chrome-devtools";
|
||||
await fs.writeFile("package.json", JSON.stringify(pkg, null, 2) + "\n");
|
||||
'
|
||||
echo 'This is the **Chrome DevTools for agents** package. Docs: https://github.com/ChromeDevTools/chrome-devtools-mcp' > README.md
|
||||
npm publish --provenance --access public
|
||||
|
||||
publish-to-mcp-registry:
|
||||
runs-on: ubuntu-latest
|
||||
needs: publish-to-npm
|
||||
if: ${{ (github.event_name != 'workflow_dispatch' && needs.publish-to-npm.result == 'success') || (inputs.mcp-publish && always()) }}
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
cache: npm
|
||||
node-version-file: '.nvmrc'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
# Ensure npm 11.5.1 or later is installed
|
||||
- name: Update npm
|
||||
run: npm install -g npm@latest
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build and bundle
|
||||
run: npm run bundle
|
||||
env:
|
||||
NODE_ENV: 'production'
|
||||
|
||||
- name: Install MCP Publisher
|
||||
run: |
|
||||
export OS=$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')
|
||||
curl -L "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_${OS}.tar.gz" | tar xz mcp-publisher
|
||||
|
||||
- name: Login to MCP Registry
|
||||
run: ./mcp-publisher login github-oidc
|
||||
|
||||
- name: Publish to MCP Registry
|
||||
run: ./mcp-publisher publish
|
||||
@@ -0,0 +1,18 @@
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions: read-all
|
||||
name: release-please
|
||||
|
||||
jobs:
|
||||
release-please:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: googleapis/release-please-action@v5
|
||||
with:
|
||||
token: ${{ secrets.BROWSER_AUTOMATION_BOT_TOKEN }}
|
||||
target-branch: main
|
||||
config-file: release-please-config.json
|
||||
manifest-file: .release-please-manifest.json
|
||||
@@ -0,0 +1,83 @@
|
||||
name: Compile and run tests
|
||||
|
||||
permissions: read-all
|
||||
|
||||
on:
|
||||
merge_group:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
run-tests:
|
||||
name: Tests on ${{ matrix.os }} with node ${{ matrix.node }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-latest
|
||||
- windows-latest
|
||||
- macos-latest
|
||||
node:
|
||||
- 22
|
||||
- 24
|
||||
- 26
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
cache: npm
|
||||
node-version: 22 # build works only with 22+.
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: npm ci
|
||||
env:
|
||||
PUPPETEER_SKIP_DOWNLOAD: true
|
||||
|
||||
- name: Install browser
|
||||
shell: bash
|
||||
run: npx puppeteer browsers install chrome
|
||||
|
||||
- name: Build
|
||||
run: npm run bundle
|
||||
env:
|
||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
cache: npm
|
||||
node-version: ${{ matrix.node }}
|
||||
|
||||
- name: Disable AppArmor
|
||||
if: ${{ matrix.os == 'ubuntu-latest' }}
|
||||
shell: bash
|
||||
run: echo 0 | sudo tee /proc/sys/kernel/apparmor_restrict_unprivileged_userns
|
||||
|
||||
- name: Run tests
|
||||
shell: bash
|
||||
# Retry tests if they fail in the merge queue.
|
||||
run: >
|
||||
npm run test:no-build --
|
||||
${{ matrix.os == 'windows-latest' && '--test-concurrency=1' || '' }}
|
||||
${{ github.event_name == 'merge_group' && '--retry' || '' }}
|
||||
|
||||
# Gating job for branch protection.
|
||||
test-success:
|
||||
name: '[Required] Tests passed'
|
||||
runs-on: ubuntu-latest
|
||||
needs: run-tests
|
||||
if: ${{ !cancelled() }}
|
||||
steps:
|
||||
- if: ${{ needs.run-tests.result != 'success' }}
|
||||
run: 'exit 1'
|
||||
- run: 'exit 0'
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
trace.json
|
||||
trace.json.gz
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# vitepress build output
|
||||
**/.vitepress/dist
|
||||
|
||||
# vitepress cache directory
|
||||
**/.vitepress/cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# Stores VSCode specific settings
|
||||
.vscode
|
||||
!.vscode/*.template.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
# Build output directory
|
||||
build/
|
||||
|
||||
log.txt
|
||||
|
||||
.DS_Store
|
||||
@@ -0,0 +1,9 @@
|
||||
# Prettier-only ignores.
|
||||
CHANGELOG.md
|
||||
src/third_party/lighthouse-devtools-mcp-bundle.js
|
||||
|
||||
# Release-please formatting brakes CI checks
|
||||
.claude-plugin/plugin.json
|
||||
.cursor-plugin/plugin.json
|
||||
.github/plugin/plugin.json
|
||||
gemini-extension.json
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* @type {import('prettier').Config}
|
||||
*/
|
||||
module.exports = {
|
||||
bracketSpacing: false,
|
||||
singleQuote: true,
|
||||
trailingComma: 'all',
|
||||
arrowParens: 'avoid',
|
||||
singleAttributePerLine: true,
|
||||
htmlWhitespaceSensitivity: 'strict',
|
||||
endOfLine: 'lf',
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
".": "1.5.0"
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
This repository contains an MCP server and CLI for Chrome DevTools.
|
||||
|
||||
# Instructions
|
||||
|
||||
- Use only scripts from `package.json` to run commands.
|
||||
- Use `npm run build` to run tsc and test build.
|
||||
- Use `npm run test` to build and run tests, run all tests to verify correctness.
|
||||
- Use `npm run test path-to-test.ts` to build and run a single test file, for example, `npm run test tests/McpContext.test.ts`.
|
||||
- Use `npm run format` to fix formatting and get linting errors.
|
||||
|
||||
## Rules for TypeScript
|
||||
|
||||
- Do not use `any` type.
|
||||
- Do not use `as` keyword for type casting.
|
||||
- Do not use `!` operator for type assertion.
|
||||
- Do not use `// @ts-ignore` comments.
|
||||
- Do not use `// @ts-nocheck` comments.
|
||||
- Do not use `// @ts-expect-error` comments.
|
||||
- Prefer `for..of` instead of `forEach`.
|
||||
+1010
File diff suppressed because it is too large
Load Diff
+156
@@ -0,0 +1,156 @@
|
||||
# How to contribute
|
||||
|
||||
We'd love to accept your patches and contributions to this project.
|
||||
|
||||
## Before you begin
|
||||
|
||||
### Sign our Contributor License Agreement
|
||||
|
||||
Contributions to this project must be accompanied by a
|
||||
[Contributor License Agreement](https://cla.developers.google.com/about) (CLA).
|
||||
You (or your employer) retain the copyright to your contribution; this simply
|
||||
gives us permission to use and redistribute your contributions as part of the
|
||||
project.
|
||||
|
||||
If you or your current employer have already signed the Google CLA (even if it
|
||||
was for a different project), you probably don't need to do it again.
|
||||
|
||||
Visit <https://cla.developers.google.com/> to see your current agreements or to
|
||||
sign a new one.
|
||||
|
||||
### Review our community guidelines
|
||||
|
||||
This project follows
|
||||
[Google's Open Source Community Guidelines](https://opensource.google/conduct/).
|
||||
|
||||
## Development process
|
||||
|
||||
### Code reviews
|
||||
|
||||
All submissions, including submissions by project members, require review. We
|
||||
use GitHub pull requests for this purpose. Consult
|
||||
[GitHub Help](https://help.github.com/articles/about-pull-requests/) for more
|
||||
information on using pull requests.
|
||||
|
||||
### Conventional commits
|
||||
|
||||
Please follow [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/)
|
||||
for PR and commit titles.
|
||||
|
||||
### Feature release checklist
|
||||
|
||||
Use `chore:` for commits containing incomplete features that are not available
|
||||
to users yet. Once the feature is ready to be released, create a PR with a
|
||||
`feat:` prefix that enables the feature. The following criteria need to be
|
||||
completed:
|
||||
|
||||
- Documentation for the feature is up to date. For example, README.md and tools
|
||||
reference are updated.
|
||||
- The feature can be used with Chrome stable or version restrictions are
|
||||
documented otherwise.
|
||||
- Corresponding skills are updated or new skills are added if needed.
|
||||
- The feature fulfills the use case by its own or in conjunction with existing
|
||||
features (we want to avoid features that offer some tools but cannot be used
|
||||
successfully to debug things).
|
||||
|
||||
### Release process
|
||||
|
||||
Releasing `chrome-devtools-mcp` is automated by GitHub Actions. To release a new
|
||||
version, [search for a PR titled `chore(main): release chrome-devtools-mcp`](https://github.com/ChromeDevTools/chrome-devtools-mcp/pulls?q=is%3Apr+is%3Aopen+%22chore%28main%29%3A+release+chrome-devtools-mcp%22)
|
||||
and review, test, and land it. The release PR is automatically opened if there
|
||||
are any changes on the main branch that show up in the changelog.
|
||||
|
||||
### How to update the Lighthouse dependency
|
||||
|
||||
- Update the Lighthouse version in package.json and run `npm install`. The npm version is currently used for types.
|
||||
- Check out the corresponding Lighthouse repository revision to a sibling directory (`../lighthouse`).
|
||||
- Run `npm run update-lighthouse` (Note that Lighthouse requires yarn).
|
||||
- Commit the bundle. If new dependencies are added via the bundle, update `tests/third_party_notices.test.ts`.
|
||||
|
||||
## Installation
|
||||
|
||||
Check that you are using node version specified in .nvmrc, then run following commands:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/ChromeDevTools/chrome-devtools-mcp.git
|
||||
cd chrome-devtools-mcp
|
||||
npm ci
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Testing with @modelcontextprotocol/inspector
|
||||
|
||||
```sh
|
||||
npx @modelcontextprotocol/inspector node /build/src/bin/chrome-devtools-mcp.js
|
||||
```
|
||||
|
||||
### Testing with an MCP client
|
||||
|
||||
Add the MCP server to your client's config.
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"chrome-devtools": {
|
||||
"command": "node",
|
||||
"args": ["/path-to/build/src/bin/chrome-devtools-mcp.js"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Using with VS Code SSH
|
||||
|
||||
When running the `@modelcontextprotocol/inspector` it spawns 2 services - one on port `6274` and one on `6277`.
|
||||
Usually VS Code automatically detects and forwards `6274` but fails to detect `6277` so you need to manually forward it.
|
||||
|
||||
### Debugging
|
||||
|
||||
To write debug logs to `log.txt` in the working directory, run with the following commands:
|
||||
|
||||
```sh
|
||||
npx @modelcontextprotocol/inspector node /build/src/bin/chrome-devtools-mcp.js --log-file=/your/desired/path/log.txt
|
||||
```
|
||||
|
||||
You can use the `DEBUG` environment variable as usual to control categories that are logged.
|
||||
|
||||
### Updating documentation
|
||||
|
||||
When adding a new tool or updating a tool name or description, make sure to run `npm run gen` to generate the tool reference documentation.
|
||||
|
||||
### Contributing to Evals
|
||||
|
||||
We use Gemini to evaluate the MCP server tools in `scripts/eval_scenarios`.
|
||||
Each scenario is a TypeScript file that exports a `scenario` object implementing `TestScenario`.
|
||||
|
||||
- **prompt**: The prompt to send to the model.
|
||||
- **maxTurns**: Maximum number of conversation turns.
|
||||
- **expectations**: A function that verifies the tool calls made by the model.
|
||||
- **htmlRoute** (Optional): Serve custom HTML content for the test at a specific path.
|
||||
|
||||
We look to test that the tools are used correctly without too rigid assertions. Avoid asserting exact argument values if they can vary (e.g., natural language reasoning), but ensure the core parameters (like URLs or selectors) were correct.
|
||||
|
||||
Example:
|
||||
|
||||
```ts
|
||||
import {TestScenario} from '../eval_gemini.js';
|
||||
|
||||
export const scenario: TestScenario = {
|
||||
prompt: 'Navigate to example.com',
|
||||
maxTurns: 2,
|
||||
expectations: calls => {
|
||||
// Check that at least one call was 'browse_page'
|
||||
const navigation = calls.find(c => c.name === 'browse_page');
|
||||
if (!navigation) throw new Error('Model did not browse the page');
|
||||
// Verify essential args
|
||||
if (navigation.args.url !== 'http://example.com') {
|
||||
throw new Error(`Wrong URL: ${navigation.args.url}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Restrictions on JSON schema
|
||||
|
||||
- no .nullable(), no .object() types. Enforced by the `@local/enforce-zod-schema` ESLint rule.
|
||||
- represent complex object as a short formatted string.
|
||||
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,983 @@
|
||||
# Chrome DevTools for agents
|
||||
|
||||
[](https://npmjs.org/package/chrome-devtools-mcp)
|
||||
|
||||
Chrome DevTools for agents (`chrome-devtools-mcp`) lets your coding agent (such as Antigravity, Claude, Cursor or Copilot)
|
||||
control and inspect a live Chrome browser. It acts as a Model-Context-Protocol
|
||||
(MCP) server, giving your AI coding assistant access to the full power of
|
||||
Chrome DevTools for reliable automation, in-depth debugging, and performance analysis.
|
||||
A [CLI](docs/cli.md) is also provided for use without MCP.
|
||||
|
||||
## [Tool reference](./docs/tool-reference.md) | [Changelog](./CHANGELOG.md) | [Contributing](./CONTRIBUTING.md) | [Troubleshooting](./docs/troubleshooting.md) | [Design Principles](./docs/design-principles.md)
|
||||
|
||||
## Key features
|
||||
|
||||
- **Get performance insights**: Uses [Chrome
|
||||
DevTools](https://github.com/ChromeDevTools/devtools-frontend) to record
|
||||
traces and extract actionable performance insights.
|
||||
- **Advanced browser debugging**: Analyze network requests, take screenshots and
|
||||
check browser console messages (with source-mapped stack traces).
|
||||
- **Reliable automation**. Uses
|
||||
[puppeteer](https://github.com/puppeteer/puppeteer) to automate actions in
|
||||
Chrome and automatically wait for action results.
|
||||
|
||||
## Disclaimers
|
||||
|
||||
`chrome-devtools-mcp` exposes content of the browser instance to the MCP clients
|
||||
allowing them to inspect, debug, and modify any data in the browser or DevTools.
|
||||
Avoid sharing sensitive or personal information that you don't want to share with
|
||||
MCP clients.
|
||||
|
||||
`chrome-devtools-mcp` officially supports Google Chrome and [Chrome for Testing](https://developer.chrome.com/blog/chrome-for-testing/) only.
|
||||
Other Chromium-based browsers may work, but this is not guaranteed, and you may encounter unexpected behavior. Use at your own discretion.
|
||||
We are committed to providing fixes and support for the latest version of [Extended Stable Chrome](https://chromiumdash.appspot.com/schedule).
|
||||
|
||||
Performance tools may send trace URLs to the Google CrUX API to fetch real-user
|
||||
experience data. This helps provide a holistic performance picture by
|
||||
presenting field data alongside lab data. This data is collected by the [Chrome
|
||||
User Experience Report (CrUX)](https://developer.chrome.com/docs/crux). To disable
|
||||
this, run with the `--no-performance-crux` flag.
|
||||
|
||||
## **Usage statistics**
|
||||
|
||||
Google collects usage statistics (such as tool invocation success rates, latency, and environment information) to improve the reliability and performance of Chrome DevTools MCP.
|
||||
|
||||
Data collection is **enabled by default**. You can opt-out by passing the `--no-usage-statistics` flag when starting the server:
|
||||
|
||||
```json
|
||||
"args": ["-y", "chrome-devtools-mcp@latest", "--no-usage-statistics"]
|
||||
```
|
||||
|
||||
Google handles this data in accordance with the [Google Privacy Policy](https://policies.google.com/privacy).
|
||||
|
||||
Google's collection of usage statistics for Chrome DevTools MCP is independent from the Chrome browser's usage statistics. Opting out of Chrome metrics does not automatically opt you out of this tool, and vice-versa.
|
||||
|
||||
Collection is disabled if `CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS` or `CI` env variables are set.
|
||||
|
||||
## Update checks
|
||||
|
||||
By default, the server periodically checks the npm registry for updates and logs a notification when a newer version is available.
|
||||
You can disable these update checks by setting the `CHROME_DEVTOOLS_MCP_NO_UPDATE_CHECKS` environment variable.
|
||||
|
||||
## Requirements
|
||||
|
||||
- [Node.js](https://nodejs.org/) [LTS](https://github.com/nodejs/Release#release-schedule) version.
|
||||
- [Chrome](https://www.google.com/chrome/) current stable version or newer.
|
||||
- [npm](https://www.npmjs.com/)
|
||||
|
||||
## Getting started
|
||||
|
||||
Add the following config to your MCP client:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"chrome-devtools": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "chrome-devtools-mcp@latest"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> Using `chrome-devtools-mcp@latest` ensures that your MCP client will always use the latest version of the Chrome DevTools MCP server.
|
||||
|
||||
If you are interested in doing only basic browser tasks, use the `--slim` mode:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"chrome-devtools": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "chrome-devtools-mcp@latest", "--slim", "--headless"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See [Slim tool reference](./docs/slim-tool-reference.md).
|
||||
|
||||
### MCP Client configuration
|
||||
|
||||
<details>
|
||||
<summary>Amp</summary>
|
||||
Follow https://ampcode.com/manual#mcp and use the config provided above. You can also install the Chrome DevTools MCP server using the CLI:
|
||||
|
||||
```bash
|
||||
amp mcp add chrome-devtools -- npx chrome-devtools-mcp@latest
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Antigravity</summary>
|
||||
|
||||
To use the Chrome DevTools MCP server follow the instructions from <a href="https://antigravity.google/docs/mcp">Antigravity's docs</a> to install a custom MCP server. Add the following config to the MCP servers config:
|
||||
|
||||
```bash
|
||||
{
|
||||
"mcpServers": {
|
||||
"chrome-devtools": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"chrome-devtools-mcp@latest",
|
||||
"--browser-url=http://127.0.0.1:9222"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This will make the Chrome DevTools MCP server automatically connect to the browser that Antigravity is using. If you are not using port 9222, make sure to adjust accordingly.
|
||||
|
||||
Chrome DevTools MCP will not start the browser instance automatically using this approach because the Chrome DevTools MCP server connects to Antigravity's built-in browser. If the browser is not already running, you have to start it first by clicking the Chrome icon at the top right corner.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Claude Code</summary>
|
||||
|
||||
**Install via CLI (MCP only)**
|
||||
|
||||
Use the Claude Code CLI to add the Chrome DevTools MCP server (<a href="https://code.claude.com/docs/en/mcp">guide</a>):
|
||||
|
||||
```bash
|
||||
claude mcp add chrome-devtools --scope user npx chrome-devtools-mcp@latest
|
||||
```
|
||||
|
||||
**Install as a Plugin (MCP + Skills)**
|
||||
|
||||
> [!NOTE]
|
||||
> If you already had Chrome DevTools MCP installed previously for Claude Code, make sure to remove it first from your installation and configuration files.
|
||||
|
||||
To install Chrome DevTools MCP with skills, add the marketplace registry in Claude Code:
|
||||
|
||||
```sh
|
||||
/plugin marketplace add ChromeDevTools/chrome-devtools-mcp
|
||||
```
|
||||
|
||||
Then, install the plugin:
|
||||
|
||||
```sh
|
||||
/plugin install chrome-devtools-mcp@chrome-devtools-plugins
|
||||
```
|
||||
|
||||
Restart Claude Code to have the MCP server and skills load (check with `/skills`).
|
||||
|
||||
> [!TIP]
|
||||
> If the plugin installation fails with a `Failed to clone repository` error (e.g., HTTPS connectivity issues behind a corporate firewall), see the [troubleshooting guide](./docs/troubleshooting.md#claude-code-plugin-installation-fails-with-failed-to-clone-repository) for workarounds, or use the CLI installation method above instead.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Cline</summary>
|
||||
Follow https://docs.cline.bot/mcp/configuring-mcp-servers and use the config provided above.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Codex</summary>
|
||||
Follow the <a href="https://developers.openai.com/codex/mcp/#configure-with-the-cli">configure MCP guide</a>
|
||||
using the standard config from above. You can also install the Chrome DevTools MCP server using the Codex CLI:
|
||||
|
||||
```bash
|
||||
codex mcp add chrome-devtools -- npx chrome-devtools-mcp@latest
|
||||
```
|
||||
|
||||
**On Windows 11**
|
||||
|
||||
Configure the Chrome install location and increase the startup timeout by updating `.codex/config.toml` and adding the following `env` and `startup_timeout_ms` parameters:
|
||||
|
||||
```
|
||||
[mcp_servers.chrome-devtools]
|
||||
command = "cmd"
|
||||
args = [
|
||||
"/c",
|
||||
"npx",
|
||||
"-y",
|
||||
"chrome-devtools-mcp@latest",
|
||||
]
|
||||
env = { SystemRoot="C:\\Windows", PROGRAMFILES="C:\\Program Files" }
|
||||
startup_timeout_ms = 20_000
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Command Code</summary>
|
||||
|
||||
Use the Command Code CLI to add the Chrome DevTools MCP server (<a href="https://commandcode.ai/docs/mcp">MCP guide</a>):
|
||||
|
||||
```bash
|
||||
cmd mcp add chrome-devtools --scope user npx chrome-devtools-mcp@latest
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Copilot CLI</summary>
|
||||
|
||||
Start Copilot CLI:
|
||||
|
||||
```
|
||||
copilot
|
||||
```
|
||||
|
||||
Start the dialog to add a new MCP server by running:
|
||||
|
||||
```
|
||||
/mcp add
|
||||
```
|
||||
|
||||
Configure the following fields and press `CTRL+S` to save the configuration:
|
||||
|
||||
- **Server name:** `chrome-devtools`
|
||||
- **Server Type:** `[1] Local`
|
||||
- **Command:** `npx -y chrome-devtools-mcp@latest`
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Copilot / VS Code</summary>
|
||||
|
||||
**Install as a Plugin (Recommended)**
|
||||
|
||||
The easiest way to get up and running is to install `chrome-devtools-mcp` as an agent plugin.
|
||||
This bundles the **MCP server** and all **skills** together, so your agent gets both the tools
|
||||
and the expert guidance it needs to use them effectively.
|
||||
|
||||
1. Open the **Command Palette** (`Cmd+Shift+P` on macOS or `Ctrl+Shift+P` on Windows/Linux).
|
||||
2. Search for and run the **Chat: Install Plugin From Source** command.
|
||||
3. Paste in our repository name: `ChromeDevTools/chrome-devtools-mcp`.
|
||||
|
||||
That's it! Your agent is now supercharged with Chrome DevTools capabilities.
|
||||
|
||||
---
|
||||
|
||||
**Install as an MCP Server (MCP only)**
|
||||
|
||||
**Click the button to install:**
|
||||
|
||||
[<img src="https://img.shields.io/badge/VS_Code-VS_Code?style=flat-square&label=Install%20Server&color=0098FF" alt="Install in VS Code">](https://vscode.dev/redirect/mcp/install?name=io.github.ChromeDevTools%2Fchrome-devtools-mcp&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22chrome-devtools-mcp%22%5D%2C%22env%22%3A%7B%7D%7D)
|
||||
|
||||
[<img src="https://img.shields.io/badge/VS_Code_Insiders-VS_Code_Insiders?style=flat-square&label=Install%20Server&color=24bfa5" alt="Install in VS Code Insiders">](https://insiders.vscode.dev/redirect?url=vscode-insiders%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522io.github.ChromeDevTools%252Fchrome-devtools-mcp%2522%252C%2522config%2522%253A%257B%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522-y%2522%252C%2522chrome-devtools-mcp%2522%255D%252C%2522env%2522%253A%257B%257D%257D%257D)
|
||||
|
||||
**Or install manually:**
|
||||
|
||||
Follow the VS Code [MCP configuration guide](https://code.visualstudio.com/docs/copilot/chat/mcp-servers#_add-an-mcp-server) using the standard config from above, or use the CLI:
|
||||
|
||||
For macOS and Linux:
|
||||
|
||||
```bash
|
||||
code --add-mcp '{"name":"io.github.ChromeDevTools/chrome-devtools-mcp","command":"npx","args":["-y","chrome-devtools-mcp"],"env":{}}'
|
||||
```
|
||||
|
||||
For Windows (PowerShell):
|
||||
|
||||
```powershell
|
||||
code --add-mcp '{"""name""":"""io.github.ChromeDevTools/chrome-devtools-mcp""","""command""":"""npx""","""args""":["""-y""","""chrome-devtools-mcp"""]}'
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Cursor</summary>
|
||||
|
||||
**Click the button to install:**
|
||||
|
||||
[<img src="https://cursor.com/deeplink/mcp-install-dark.svg" alt="Install in Cursor">](https://cursor.com/en/install-mcp?name=chrome-devtools&config=eyJjb21tYW5kIjoibnB4IC15IGNocm9tZS1kZXZ0b29scy1tY3BAbGF0ZXN0In0%3D)
|
||||
|
||||
**Or install manually:**
|
||||
|
||||
Go to `Cursor Settings` -> `MCP` -> `New MCP Server`. Use the config provided above.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Factory CLI</summary>
|
||||
Use the Factory CLI to add the Chrome DevTools MCP server (<a href="https://docs.factory.ai/cli/configuration/mcp">guide</a>):
|
||||
|
||||
```bash
|
||||
droid mcp add chrome-devtools "npx -y chrome-devtools-mcp@latest"
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Gemini CLI</summary>
|
||||
Install the Chrome DevTools MCP server using the Gemini CLI.
|
||||
|
||||
**Project wide:**
|
||||
|
||||
```bash
|
||||
# Either MCP only:
|
||||
gemini mcp add chrome-devtools npx chrome-devtools-mcp@latest
|
||||
# Or as a Gemini extension (MCP+Skills):
|
||||
gemini extensions install --auto-update https://github.com/ChromeDevTools/chrome-devtools-mcp
|
||||
```
|
||||
|
||||
**Globally:**
|
||||
|
||||
```bash
|
||||
gemini mcp add -s user chrome-devtools npx chrome-devtools-mcp@latest
|
||||
```
|
||||
|
||||
Alternatively, follow the <a href="https://github.com/google-gemini/gemini-cli/blob/main/docs/tools/mcp-server.md#how-to-set-up-your-mcp-server">MCP guide</a> and use the standard config from above.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Gemini Code Assist</summary>
|
||||
Follow the <a href="https://cloud.google.com/gemini/docs/codeassist/use-agentic-chat-pair-programmer#configure-mcp-servers">configure MCP guide</a>
|
||||
using the standard config from above.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Grok Build CLI</summary>
|
||||
|
||||
```bash
|
||||
grok mcp add chrome-devtools npx chrome-devtools-mcp@latest
|
||||
```
|
||||
|
||||
See the <a href="https://docs.x.ai/build/features/skills-plugins-marketplaces">docs</a> for more options
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>JetBrains AI Assistant & Junie</summary>
|
||||
|
||||
Go to `Settings | Tools | AI Assistant | Model Context Protocol (MCP)` -> `Add`. Use the config provided above.
|
||||
The same way chrome-devtools-mcp can be configured for JetBrains Junie in `Settings | Tools | Junie | MCP Settings` -> `Add`. Use the config provided above.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Kiro</summary>
|
||||
|
||||
In **Kiro Settings**, go to `Configure MCP` > `Open Workspace or User MCP Config` > Use the configuration snippet provided above.
|
||||
|
||||
Or, from the IDE **Activity Bar** > `Kiro` > `MCP Servers` > `Click Open MCP Config`. Use the configuration snippet provided above.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Katalon Studio</summary>
|
||||
|
||||
The Chrome DevTools MCP server can be used with <a href="https://docs.katalon.com/katalon-studio/studioassist/mcp-servers/setting-up-chrome-devtools-mcp-server-for-studioassist">Katalon StudioAssist</a> via an MCP proxy.
|
||||
|
||||
**Step 1:** Install the MCP proxy by following the <a href="https://docs.katalon.com/katalon-studio/studioassist/mcp-servers/setting-up-mcp-proxy-for-stdio-mcp-servers">MCP proxy setup guide</a>.
|
||||
|
||||
**Step 2:** Start the Chrome DevTools MCP server with the proxy:
|
||||
|
||||
```bash
|
||||
mcp-proxy --transport streamablehttp --port 8080 -- npx -y chrome-devtools-mcp@latest
|
||||
```
|
||||
|
||||
**Note:** You may need to pick another port if 8080 is already in use.
|
||||
|
||||
**Step 3:** In Katalon Studio, add the server to StudioAssist with the following settings:
|
||||
|
||||
- **Connection URL:** `http://127.0.0.1:8080/mcp`
|
||||
- **Transport type:** `HTTP`
|
||||
|
||||
Once connected, the Chrome DevTools MCP tools will be available in StudioAssist.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Mistral Vibe</summary>
|
||||
|
||||
Add in ~/.vibe/config.toml:
|
||||
|
||||
```toml
|
||||
[[mcp_servers]]
|
||||
name = "chrome-devtools"
|
||||
transport = "stdio"
|
||||
command = "npx"
|
||||
args = ["chrome-devtools-mcp@latest"]
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>OpenCode</summary>
|
||||
|
||||
Add the following configuration to your `opencode.json` file. If you don't have one, create it at `~/.config/opencode/opencode.json` (<a href="https://opencode.ai/docs/mcp-servers">guide</a>):
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"mcp": {
|
||||
"chrome-devtools": {
|
||||
"type": "local",
|
||||
"command": ["npx", "-y", "chrome-devtools-mcp@latest"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Qoder</summary>
|
||||
|
||||
In **Qoder Settings**, go to `MCP Server` > `+ Add` > Use the configuration snippet provided above.
|
||||
|
||||
Alternatively, follow the <a href="https://docs.qoder.com/user-guide/chat/model-context-protocol">MCP guide</a> and use the standard config from above.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Qoder CLI</summary>
|
||||
|
||||
Install the Chrome DevTools MCP server using the Qoder CLI (<a href="https://docs.qoder.com/cli/using-cli#mcp-servers">guide</a>):
|
||||
|
||||
**Project wide:**
|
||||
|
||||
```bash
|
||||
qodercli mcp add chrome-devtools -- npx chrome-devtools-mcp@latest
|
||||
```
|
||||
|
||||
**Globally:**
|
||||
|
||||
```bash
|
||||
qodercli mcp add -s user chrome-devtools -- npx chrome-devtools-mcp@latest
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Visual Studio</summary>
|
||||
|
||||
**Click the button to install:**
|
||||
|
||||
[<img src="https://img.shields.io/badge/Visual_Studio-Install-C16FDE?logo=visualstudio&logoColor=white" alt="Install in Visual Studio">](https://vs-open.link/mcp-install?%7B%22name%22%3A%22chrome-devtools%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22chrome-devtools-mcp%40latest%22%5D%7D)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Warp</summary>
|
||||
|
||||
Go to `Settings | AI | Manage MCP Servers` -> `+ Add` to [add an MCP Server](https://docs.warp.dev/knowledge-and-collaboration/mcp#adding-an-mcp-server). Use the config provided above.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Windsurf</summary>
|
||||
Follow the <a href="https://docs.windsurf.com/windsurf/cascade/mcp#mcp-config-json">configure MCP guide</a>
|
||||
using the standard config from above.
|
||||
</details>
|
||||
|
||||
### Your first prompt
|
||||
|
||||
Enter the following prompt in your MCP Client to check if everything is working:
|
||||
|
||||
```
|
||||
Check the performance of https://developers.chrome.com
|
||||
```
|
||||
|
||||
Your MCP client should open the browser and record a performance trace.
|
||||
|
||||
> [!NOTE]
|
||||
> The MCP server will start the browser automatically once the MCP client uses a tool that requires a running browser instance. Connecting to the Chrome DevTools MCP server on its own will not automatically start the browser.
|
||||
|
||||
## Tools
|
||||
|
||||
If you run into any issues, checkout our [troubleshooting guide](./docs/troubleshooting.md).
|
||||
|
||||
<!-- BEGIN AUTO GENERATED TOOLS -->
|
||||
|
||||
- **Input automation** (10 tools)
|
||||
- [`click`](docs/tool-reference.md#click)
|
||||
- [`drag`](docs/tool-reference.md#drag)
|
||||
- [`fill`](docs/tool-reference.md#fill)
|
||||
- [`fill_form`](docs/tool-reference.md#fill_form)
|
||||
- [`handle_dialog`](docs/tool-reference.md#handle_dialog)
|
||||
- [`hover`](docs/tool-reference.md#hover)
|
||||
- [`press_key`](docs/tool-reference.md#press_key)
|
||||
- [`type_text`](docs/tool-reference.md#type_text)
|
||||
- [`upload_file`](docs/tool-reference.md#upload_file)
|
||||
- [`click_at`](docs/tool-reference.md#click_at)
|
||||
- **Navigation automation** (6 tools)
|
||||
- [`close_page`](docs/tool-reference.md#close_page)
|
||||
- [`list_pages`](docs/tool-reference.md#list_pages)
|
||||
- [`navigate_page`](docs/tool-reference.md#navigate_page)
|
||||
- [`new_page`](docs/tool-reference.md#new_page)
|
||||
- [`select_page`](docs/tool-reference.md#select_page)
|
||||
- [`wait_for`](docs/tool-reference.md#wait_for)
|
||||
- **Emulation** (2 tools)
|
||||
- [`emulate`](docs/tool-reference.md#emulate)
|
||||
- [`resize_page`](docs/tool-reference.md#resize_page)
|
||||
- **Performance** (3 tools)
|
||||
- [`performance_analyze_insight`](docs/tool-reference.md#performance_analyze_insight)
|
||||
- [`performance_start_trace`](docs/tool-reference.md#performance_start_trace)
|
||||
- [`performance_stop_trace`](docs/tool-reference.md#performance_stop_trace)
|
||||
- **Network** (2 tools)
|
||||
- [`get_network_request`](docs/tool-reference.md#get_network_request)
|
||||
- [`list_network_requests`](docs/tool-reference.md#list_network_requests)
|
||||
- **Debugging** (8 tools)
|
||||
- [`evaluate_script`](docs/tool-reference.md#evaluate_script)
|
||||
- [`get_console_message`](docs/tool-reference.md#get_console_message)
|
||||
- [`lighthouse_audit`](docs/tool-reference.md#lighthouse_audit)
|
||||
- [`list_console_messages`](docs/tool-reference.md#list_console_messages)
|
||||
- [`take_screenshot`](docs/tool-reference.md#take_screenshot)
|
||||
- [`take_snapshot`](docs/tool-reference.md#take_snapshot)
|
||||
- [`screencast_start`](docs/tool-reference.md#screencast_start)
|
||||
- [`screencast_stop`](docs/tool-reference.md#screencast_stop)
|
||||
- **Memory** (11 tools)
|
||||
- [`take_heapsnapshot`](docs/tool-reference.md#take_heapsnapshot)
|
||||
- [`close_heapsnapshot`](docs/tool-reference.md#close_heapsnapshot)
|
||||
- [`compare_heapsnapshots`](docs/tool-reference.md#compare_heapsnapshots)
|
||||
- [`get_heapsnapshot_class_nodes`](docs/tool-reference.md#get_heapsnapshot_class_nodes)
|
||||
- [`get_heapsnapshot_details`](docs/tool-reference.md#get_heapsnapshot_details)
|
||||
- [`get_heapsnapshot_dominators`](docs/tool-reference.md#get_heapsnapshot_dominators)
|
||||
- [`get_heapsnapshot_duplicate_strings`](docs/tool-reference.md#get_heapsnapshot_duplicate_strings)
|
||||
- [`get_heapsnapshot_edges`](docs/tool-reference.md#get_heapsnapshot_edges)
|
||||
- [`get_heapsnapshot_retainers`](docs/tool-reference.md#get_heapsnapshot_retainers)
|
||||
- [`get_heapsnapshot_retaining_paths`](docs/tool-reference.md#get_heapsnapshot_retaining_paths)
|
||||
- [`get_heapsnapshot_summary`](docs/tool-reference.md#get_heapsnapshot_summary)
|
||||
- **Extensions** (5 tools)
|
||||
- [`install_extension`](docs/tool-reference.md#install_extension)
|
||||
- [`list_extensions`](docs/tool-reference.md#list_extensions)
|
||||
- [`reload_extension`](docs/tool-reference.md#reload_extension)
|
||||
- [`trigger_extension_action`](docs/tool-reference.md#trigger_extension_action)
|
||||
- [`uninstall_extension`](docs/tool-reference.md#uninstall_extension)
|
||||
- **Third-party** (2 tools)
|
||||
- [`execute_3p_developer_tool`](docs/tool-reference.md#execute_3p_developer_tool)
|
||||
- [`list_3p_developer_tools`](docs/tool-reference.md#list_3p_developer_tools)
|
||||
- **WebMCP** (2 tools)
|
||||
- [`execute_webmcp_tool`](docs/tool-reference.md#execute_webmcp_tool)
|
||||
- [`list_webmcp_tools`](docs/tool-reference.md#list_webmcp_tools)
|
||||
|
||||
<!-- END AUTO GENERATED TOOLS -->
|
||||
|
||||
## Configuration
|
||||
|
||||
The Chrome DevTools MCP server supports the following configuration option:
|
||||
|
||||
<!-- BEGIN AUTO GENERATED OPTIONS -->
|
||||
|
||||
- **`--autoConnect`/ `--auto-connect`**
|
||||
If specified, automatically connects to a browser (Chrome 144+) running locally from the user data directory identified by the channel param (default channel is stable). Requires the remote debugging server to be started in the Chrome instance via chrome://inspect/#remote-debugging.
|
||||
- **Type:** boolean
|
||||
- **Default:** `false`
|
||||
|
||||
- **`--browserUrl`/ `--browser-url`, `-u`**
|
||||
Connect to a running, debuggable Chrome instance (e.g. `http://127.0.0.1:9222`). For more details see: https://github.com/ChromeDevTools/chrome-devtools-mcp#connecting-to-a-running-chrome-instance.
|
||||
- **Type:** string
|
||||
- **Default:** `false`
|
||||
|
||||
- **`--wsEndpoint`/ `--ws-endpoint`, `-w`**
|
||||
WebSocket endpoint to connect to a running Chrome instance (e.g., ws://127.0.0.1:9222/devtools/browser/<id>). Alternative to --browserUrl.
|
||||
- **Type:** string
|
||||
- **Default:** `false`
|
||||
|
||||
- **`--wsHeaders`/ `--ws-headers`**
|
||||
Custom headers for WebSocket connection in JSON format (e.g., '{"Authorization":"Bearer token"}'). Only works with --wsEndpoint.
|
||||
- **Type:** string
|
||||
- **Default:** `false`
|
||||
|
||||
- **`--headless`**
|
||||
Whether to run in headless (no UI) mode.
|
||||
- **Type:** boolean
|
||||
- **Default:** `false`
|
||||
|
||||
- **`--executablePath`/ `--executable-path`, `-e`**
|
||||
Path to custom Chrome executable.
|
||||
- **Type:** string
|
||||
- **Default:** `false`
|
||||
|
||||
- **`--isolated`**
|
||||
If specified, creates a temporary user-data-dir that is automatically cleaned up after the browser is closed. Defaults to false.
|
||||
- **Type:** boolean
|
||||
- **Default:** `false`
|
||||
|
||||
- **`--userDataDir`/ `--user-data-dir`**
|
||||
Path to the user data directory for Chrome. Default is $HOME/.cache/chrome-devtools-mcp/chrome-profile$CHANNEL_SUFFIX_IF_NON_STABLE
|
||||
- **Type:** string
|
||||
- **Default:** `false`
|
||||
|
||||
- **`--channel`**
|
||||
Specify a different Chrome channel that should be used. The default is the stable channel version.
|
||||
- **Type:** string
|
||||
- **Choices:** `canary`, `dev`, `beta`, `stable`
|
||||
- **Default:** `false`
|
||||
|
||||
- **`--logFile`/ `--log-file`**
|
||||
Path to a file to write debug logs to. Set the env variable `DEBUG` to `*` to enable verbose logs. Useful for submitting bug reports.
|
||||
- **Type:** string
|
||||
- **Default:** `false`
|
||||
|
||||
- **`--viewport`**
|
||||
Initial viewport size for the Chrome instances started by the server. For example, `1280x720`. In headless mode, max size is 3840x2160px.
|
||||
- **Type:** string
|
||||
- **Default:** `false`
|
||||
|
||||
- **`--proxyServer`/ `--proxy-server`**
|
||||
Proxy server configuration for Chrome passed as --proxy-server when launching the browser. See https://www.chromium.org/developers/design-documents/network-settings/ for details.
|
||||
- **Type:** string
|
||||
- **Default:** `false`
|
||||
|
||||
- **`--acceptInsecureCerts`/ `--accept-insecure-certs`**
|
||||
If enabled, ignores errors relative to self-signed and expired certificates. Use with caution.
|
||||
- **Type:** boolean
|
||||
- **Default:** `false`
|
||||
|
||||
- **`--experimentalPageIdRouting`/ `--experimental-page-id-routing`**
|
||||
Whether to expose pageId on page-scoped tools and route requests by page ID (useful for concurrent agent sessions).
|
||||
- **Type:** boolean
|
||||
- **Default:** `false`
|
||||
|
||||
- **`--experimentalDevtools`/ `--experimental-devtools`**
|
||||
Whether to enable automation over DevTools targets
|
||||
- **Type:** boolean
|
||||
- **Default:** `false`
|
||||
|
||||
- **`--experimentalVision`/ `--experimental-vision`**
|
||||
Whether to enable coordinate-based tools such as click_at(x,y). Usually requires a computer-use model able to produce accurate coordinates by looking at screenshots.
|
||||
- **Type:** boolean
|
||||
- **Default:** `false`
|
||||
|
||||
- **`--memoryDebugging`/ `--memory-debugging`, `-experimentalMemory`**
|
||||
Whether to enable memory debugging tools.
|
||||
- **Type:** boolean
|
||||
- **Default:** `false`
|
||||
|
||||
- **`--experimentalStructuredContent`/ `--experimental-structured-content`**
|
||||
Whether to output structured formatted content.
|
||||
- **Type:** boolean
|
||||
- **Default:** `false`
|
||||
|
||||
- **`--experimentalIncludeAllPages`/ `--experimental-include-all-pages`**
|
||||
Whether to include all kinds of pages such as webviews or background pages as pages.
|
||||
- **Type:** boolean
|
||||
- **Default:** `false`
|
||||
|
||||
- **`--experimentalScreencast`/ `--experimental-screencast`**
|
||||
Exposes experimental screencast tools (requires ffmpeg). Install ffmpeg https://www.ffmpeg.org/download.html and ensure it is available in the MCP server PATH.
|
||||
- **Type:** boolean
|
||||
- **Default:** `false`
|
||||
|
||||
- **`--experimentalFfmpegPath`/ `--experimental-ffmpeg-path`**
|
||||
Path to ffmpeg executable for screencast recording.
|
||||
- **Type:** string
|
||||
- **Default:** `false`
|
||||
|
||||
- **`--categoryExperimentalWebmcp`/ `--category-experimental-webmcp`**
|
||||
Set to true to enable debugging WebMCP tools. Requires Chrome 149+ with the following flags: `--enable-features=WebMCP,DevToolsWebMCPSupport`
|
||||
- **Type:** boolean
|
||||
- **Default:** `false`
|
||||
|
||||
- **`--chromeArg`/ `--chrome-arg`**
|
||||
Additional arguments for Chrome. Only applies when Chrome is launched by chrome-devtools-mcp.
|
||||
- **Type:** array
|
||||
- **Default:** `false`
|
||||
|
||||
- **`--blockedUrlPattern`/ `--blocked-url-pattern`**
|
||||
Restricts browser's network access by blocking specified URL patterns (uses https://urlpattern.spec.whatwg.org/). Silently detaches from targets with blocked URLs upon connection, and blocks runtime requests (including navigations and subresources). Accepts an array of patterns.
|
||||
- **Type:** array
|
||||
- **Default:** `false`
|
||||
|
||||
- **`--allowedUrlPattern`/ `--allowed-url-pattern`**
|
||||
Restricts browser's network access by allowing only specified URL patterns (uses https://urlpattern.spec.whatwg.org/). Requires Chrome 149+. Silently detaches from targets with unallowed URLs upon connection, and blocks runtime requests (including navigations and subresources). Accepts an array of patterns.
|
||||
- **Type:** array
|
||||
- **Default:** `false`
|
||||
|
||||
- **`--ignoreDefaultChromeArg`/ `--ignore-default-chrome-arg`**
|
||||
Explicitly disable default arguments for Chrome. Only applies when Chrome is launched by chrome-devtools-mcp.
|
||||
- **Type:** array
|
||||
- **Default:** `false`
|
||||
|
||||
- **`--categoryEmulation`/ `--category-emulation`**
|
||||
Set to false to exclude tools related to emulation.
|
||||
- **Type:** boolean
|
||||
- **Default:** `true`
|
||||
|
||||
- **`--categoryPerformance`/ `--category-performance`**
|
||||
Set to false to exclude tools related to performance.
|
||||
- **Type:** boolean
|
||||
- **Default:** `true`
|
||||
|
||||
- **`--categoryNetwork`/ `--category-network`**
|
||||
Set to false to exclude tools related to network.
|
||||
- **Type:** boolean
|
||||
- **Default:** `true`
|
||||
|
||||
- **`--categoryExtensions`/ `--category-extensions`**
|
||||
Set to true to include tools related to extensions. Note: This feature is currently only supported with a pipe connection. autoConnect, browserUrl, and wsEndpoint are not supported with this feature until 149 will be released.
|
||||
- **Type:** boolean
|
||||
- **Default:** `false`
|
||||
|
||||
- **`--categoryExperimentalThirdParty`/ `--category-experimental-third-party`**
|
||||
Set to true to enable third-party developer tools exposed by the inspected page itself
|
||||
- **Type:** boolean
|
||||
- **Default:** `false`
|
||||
|
||||
- **`--performanceCrux`/ `--performance-crux`**
|
||||
Set to false to disable sending URLs from performance traces to CrUX API to get field performance data.
|
||||
- **Type:** boolean
|
||||
- **Default:** `true`
|
||||
|
||||
- **`--usageStatistics`/ `--usage-statistics`**
|
||||
Set to false to opt-out of usage statistics collection. Google collects usage data to improve the tool, handled under the Google Privacy Policy (https://policies.google.com/privacy). This is independent from Chrome browser metrics. Disabled if `CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS` or `CI` env variables are set.
|
||||
- **Type:** boolean
|
||||
- **Default:** `true`
|
||||
|
||||
- **`--screenshotFormat`/ `--screenshot-format`**
|
||||
Override the default output format used by take_screenshot when the caller does not specify one. JPEG and WebP are ~3-5x smaller than PNG, which helps reduce context size in AI conversations. Unset preserves the existing default ("png").
|
||||
- **Type:** string
|
||||
- **Choices:** `jpeg`, `png`, `webp`
|
||||
- **Default:** `false`
|
||||
|
||||
- **`--screenshotQuality`/ `--screenshot-quality`**
|
||||
Override the default compression quality (0-100) used by take_screenshot for JPEG and WebP when the caller does not specify one. Lower values mean smaller files. Ignored for PNG. Unset preserves the Puppeteer default.
|
||||
- **Type:** number
|
||||
- **Default:** `false`
|
||||
|
||||
- **`--screenshotMaxWidth`/ `--screenshot-max-width`**
|
||||
Maximum width in pixels for screenshots. If the captured image is wider, it is downscaled (preserving aspect ratio) before being returned. Reduces context size in AI conversations. Unset means no resize.
|
||||
- **Type:** number
|
||||
- **Default:** `false`
|
||||
|
||||
- **`--screenshotMaxHeight`/ `--screenshot-max-height`**
|
||||
Maximum height in pixels for screenshots. If the captured image is taller, it is downscaled (preserving aspect ratio) before being returned. Can be combined with --screenshot-max-width; the smaller scale factor wins. Unset means no resize.
|
||||
- **Type:** number
|
||||
- **Default:** `false`
|
||||
|
||||
- **`--slim`**
|
||||
Exposes a "slim" set of 3 tools covering navigation, script execution and screenshots only. Useful for basic browser tasks.
|
||||
- **Type:** boolean
|
||||
- **Default:** `false`
|
||||
|
||||
- **`--redactNetworkHeaders`/ `--redact-network-headers`**
|
||||
If true, redacts some of the network headers considered sensitive before returning to the client.
|
||||
- **Type:** boolean
|
||||
- **Default:** `false`
|
||||
|
||||
- **`--allowUnrestrictedPaths`/ `--allow-unrestricted-paths`**
|
||||
If set, disables the default path restriction that applies when the MCP client does not negotiate the roots capability. By default, file-writing tools are restricted to the OS temp directory when no roots are configured. Use this only when connecting a trusted local client that does not implement MCP roots and requires access to paths outside the temp directory.
|
||||
- **Type:** boolean
|
||||
- **Default:** `false`
|
||||
|
||||
<!-- END AUTO GENERATED OPTIONS -->
|
||||
|
||||
Pass them via the `args` property in the JSON configuration. For example:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"chrome-devtools": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"chrome-devtools-mcp@latest",
|
||||
"--channel=canary",
|
||||
"--headless=true",
|
||||
"--isolated=true"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Connecting via WebSocket with custom headers
|
||||
|
||||
You can connect directly to a Chrome WebSocket endpoint and include custom headers (e.g., for authentication):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"chrome-devtools": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"chrome-devtools-mcp@latest",
|
||||
"--wsEndpoint=ws://127.0.0.1:9222/devtools/browser/<id>",
|
||||
"--wsHeaders={\"Authorization\":\"Bearer YOUR_TOKEN\"}"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
To get the WebSocket endpoint from a running Chrome instance, visit `http://127.0.0.1:9222/json/version` and look for the `webSocketDebuggerUrl` field.
|
||||
|
||||
You can also run `npx chrome-devtools-mcp@latest --help` to see all available configuration options.
|
||||
|
||||
## Concepts
|
||||
|
||||
### Concurrent sessions
|
||||
|
||||
Most MCP clients start one Chrome DevTools MCP server per conversation. If your
|
||||
client shares a single server instance across concurrent agents or subagents,
|
||||
start the server with `--experimentalPageIdRouting`. This exposes `pageId` on
|
||||
page-scoped tools so each agent can route tool calls to the tab it is working
|
||||
with.
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"chrome-devtools": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"chrome-devtools-mcp@latest",
|
||||
"--experimentalPageIdRouting"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If you run multiple independent MCP client sessions and want each session to
|
||||
launch its own temporary Chrome profile, also pass `--isolated`. This avoids
|
||||
sharing the default Chrome DevTools MCP user data directory between those
|
||||
server instances.
|
||||
|
||||
### User data directory
|
||||
|
||||
`chrome-devtools-mcp` starts a Chrome's stable channel instance using the following user
|
||||
data directory:
|
||||
|
||||
- Linux / macOS: `$HOME/.cache/chrome-devtools-mcp/chrome-profile-$CHANNEL`
|
||||
- Windows: `%HOMEPATH%/.cache/chrome-devtools-mcp/chrome-profile-$CHANNEL`
|
||||
|
||||
The user data directory is not cleared between runs and shared across
|
||||
all instances of `chrome-devtools-mcp`. Set the `isolated` option to `true`
|
||||
to use a temporary user data dir instead which will be cleared automatically after
|
||||
the browser is closed.
|
||||
|
||||
### Connecting to a running Chrome instance
|
||||
|
||||
By default, the Chrome DevTools MCP server will start a new Chrome instance with a dedicated profile. This might not be ideal in all situations:
|
||||
|
||||
- If you would like to maintain the same application state when alternating between manual site testing and agent-driven testing.
|
||||
- When the MCP needs to sign into a website. Some accounts may prevent sign-in when the browser is controlled via WebDriver (the default launch mechanism for the Chrome DevTools MCP server).
|
||||
- If you're running your LLM inside a sandboxed environment, but you would like to connect to a Chrome instance that runs outside the sandbox.
|
||||
|
||||
In these cases, start Chrome first and let the Chrome DevTools MCP server connect to it. There are two ways to do so:
|
||||
|
||||
- **Automatic connection (available in Chrome 144)**: best for sharing state between manual and agent-driven testing.
|
||||
- **Manual connection via remote debugging port**: best when running inside a sandboxed environment.
|
||||
|
||||
#### Automatically connecting to a running Chrome instance
|
||||
|
||||
**Step 1:** Set up remote debugging in Chrome
|
||||
|
||||
In Chrome (\>= M144), do the following to set up remote debugging:
|
||||
|
||||
1. Navigate to `chrome://inspect/#remote-debugging` to enable remote debugging.
|
||||
2. Follow the dialog UI to allow or disallow incoming debugging connections.
|
||||
|
||||
**Step 2:** Configure Chrome DevTools MCP server to automatically connect to a running Chrome Instance
|
||||
|
||||
To connect the `chrome-devtools-mcp` server to the running Chrome instance, use
|
||||
`--autoConnect` command line argument for the MCP server.
|
||||
|
||||
The following code snippet is an example configuration for gemini-cli:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"chrome-devtools": {
|
||||
"command": "npx",
|
||||
"args": ["chrome-devtools-mcp@latest", "--autoConnect"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3:** Test your setup
|
||||
|
||||
Make sure your browser is running. Open gemini-cli and run the following prompt:
|
||||
|
||||
```none
|
||||
Check the performance of https://developers.chrome.com
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> The <code>autoConnect</code> option requires the user to start Chrome. If the user has multiple active profiles, the MCP server will connect to the default profile (as determined by Chrome). The MCP server has access to all open windows for the selected profile.
|
||||
|
||||
The Chrome DevTools MCP server will try to connect to your running Chrome
|
||||
instance. It shows a dialog asking for user permission.
|
||||
|
||||
Clicking **Allow** results in the Chrome DevTools MCP server opening
|
||||
[developers.chrome.com](http://developers.chrome.com) and taking a performance
|
||||
trace.
|
||||
|
||||
#### Manual connection using port forwarding
|
||||
|
||||
You can connect to a running Chrome instance by using the `--browser-url` option. This is useful if you are running the MCP server in a sandboxed environment that does not allow starting a new Chrome instance.
|
||||
|
||||
Here is a step-by-step guide on how to connect to a running Chrome instance:
|
||||
|
||||
**Step 1: Configure the MCP client**
|
||||
|
||||
Add the `--browser-url` option to your MCP client configuration. The value of this option should be the URL of the running Chrome instance. `http://127.0.0.1:9222` is a common default.
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"chrome-devtools": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"chrome-devtools-mcp@latest",
|
||||
"--browser-url=http://127.0.0.1:9222"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Start the Chrome browser**
|
||||
|
||||
> [!WARNING]
|
||||
> Enabling the remote debugging port opens up a debugging port on the running browser instance. Any application on your machine can connect to this port and control the browser. Make sure that you are not browsing any sensitive websites while the debugging port is open.
|
||||
|
||||
Start the Chrome browser with the remote debugging port enabled. Make sure to close any running Chrome instances before starting a new one with the debugging port enabled. The port number you choose must be the same as the one you specified in the `--browser-url` option in your MCP client configuration.
|
||||
|
||||
For security reasons, [Chrome requires you to use a non-default user data directory](https://developer.chrome.com/blog/remote-debugging-port) when enabling the remote debugging port. You can specify a custom directory using the `--user-data-dir` flag. This ensures that your regular browsing profile and data are not exposed to the debugging session.
|
||||
|
||||
**macOS**
|
||||
|
||||
```bash
|
||||
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-profile-stable
|
||||
```
|
||||
|
||||
**Linux**
|
||||
|
||||
```bash
|
||||
/usr/bin/google-chrome --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-profile-stable
|
||||
```
|
||||
|
||||
**Windows**
|
||||
|
||||
```bash
|
||||
"C:\Program Files\Google\Chrome\Application\chrome.exe" --remote-debugging-port=9222 --user-data-dir="%TEMP%\chrome-profile-stable"
|
||||
```
|
||||
|
||||
**Step 3: Test your setup**
|
||||
|
||||
After configuring the MCP client and starting the Chrome browser, you can test your setup by running a simple prompt in your MCP client:
|
||||
|
||||
```
|
||||
Check the performance of https://developers.chrome.com
|
||||
```
|
||||
|
||||
Your MCP client should connect to the running Chrome instance and receive a performance report.
|
||||
|
||||
If you hit VM-to-host port forwarding issues, see the “Remote debugging between virtual machine (VM) and host fails” section in [`docs/troubleshooting.md`](./docs/troubleshooting.md#remote-debugging-between-virtual-machine-vm-and-host-fails).
|
||||
|
||||
For more details on remote debugging, see the [Chrome DevTools documentation](https://developer.chrome.com/docs/devtools/remote-debugging/).
|
||||
|
||||
### Debugging Chrome on Android
|
||||
|
||||
Please consult [these instructions](./docs/debugging-android.md).
|
||||
|
||||
## Known limitations
|
||||
|
||||
See [Troubleshooting](./docs/troubleshooting.md).
|
||||
|
||||
## Integrating as a browser subagent
|
||||
|
||||
If you are developing agentic tooling and want to provide an integrated browser subagent as part of your product, we recommend building on top of Chrome DevTools for agents.
|
||||
|
||||
For a reference implementation, see the [Gemini CLI browser agent documentation](https://geminicli.com/docs/core/subagents/#browser-agent).
|
||||
@@ -0,0 +1,7 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- 原始项目:`ChromeDevTools/chrome-devtools-mcp`
|
||||
- 原始仓库:https://github.com/ChromeDevTools/chrome-devtools-mcp
|
||||
- 导入方式:上游默认分支的最新快照
|
||||
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
|
||||
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
## Security policy
|
||||
|
||||
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/).
|
||||
|
||||
### Scope
|
||||
|
||||
In general, it is the expectation that the AI agent or client using this MCP server validates any input (including tool calls and parameters) before sending it. The server provides powerful capabilities for browser automation and inspection, and it is the responsibility of the calling agent to ensure these are used safely and as intended.
|
||||
|
||||
Several tools in this project have the ability to perform actions such as writing files to disk (e.g., via browser downloads or screenshots) or dynamically loading Chrome extensions. These are intentional, documented features and are not vulnerabilities.
|
||||
|
||||
We appreciate feedback and suggestions from developers on how this tool can make it easier for them to build a more secure user experience, but will treat these exclusively as feature requests, and not vulnerabilities in chrome-devtools-mcp itself.
|
||||
|
||||
### MCP roots
|
||||
|
||||
`chrome-devtools-mcp` supports [MCP roots](https://modelcontextprotocol.io/specification/2025-06-18/client/roots). If the client specifies them, the MCP server will check the roots when accessing files.
|
||||
Note that the MCP server always retains access to the OS-provided tmp directory. We treat security issues in the MCP roots implementation as low-severity issues because it is an optional configuration. To have full
|
||||
filesystem sandboxing, we recommend using OS sandbox mechanisms.
|
||||
|
||||
### Network guardrails
|
||||
|
||||
Optional `--allowed-url-pattern` and `--blocked-url-pattern` arguments configure the browser to reject access to the identified URLs. Note that this is not a complete network sandbox and it only applies to Chrome DevTools targets while `chrome-devtools-mcp` is attached to them.
|
||||
To have a full network sandbox, we recommend using a separate OS/VM sandbox mechanism.
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
# Chrome DevTools CLI
|
||||
|
||||
The `chrome-devtools-mcp` package includes an **experimental** CLI interface that allows you to interact with the browser directly from your terminal. This is particularly useful for debugging or when you want an agent to generate scripts that automate browser actions.
|
||||
|
||||
## Getting started
|
||||
|
||||
Install the package globally to make the `chrome-devtools` command available:
|
||||
|
||||
```sh
|
||||
npm i chrome-devtools-mcp@latest -g
|
||||
chrome-devtools status # check if install worked.
|
||||
```
|
||||
|
||||
## How it works
|
||||
|
||||
The CLI acts as a client to a background `chrome-devtools-mcp` daemon (uses Unix sockets on Linux/Mac and named pipes on Windows).
|
||||
|
||||
- **Automatic Start**: The first time you call a tool (e.g., `list_pages`), the CLI automatically starts the MCP server and the browser in the background if they aren't already running.
|
||||
- **Persistence**: The same background instance is reused for subsequent commands, preserving the browser state (open pages, cookies, etc.).
|
||||
- **Manual Control**: You can explicitly manage the background process using `start`, `stop`, and `status`. The `start` command forwards all subsequent arguments to the underlying MCP server (e.g., `--headless`, `--userDataDir`) but not all args are supported. Run `chrome-devtools start --help` for supported args. Headless is enabled by default. Isolated is enabled by default unless `--userDataDir` is provided.
|
||||
|
||||
```sh
|
||||
# Check if the daemon is running
|
||||
chrome-devtools status
|
||||
|
||||
# Navigate the current page to a URL
|
||||
chrome-devtools navigate_page "https://google.com"
|
||||
|
||||
# Take a screenshot and save it to a file
|
||||
chrome-devtools take_screenshot --filePath screenshot.png
|
||||
|
||||
# Stop the background daemon when finished
|
||||
chrome-devtools stop
|
||||
```
|
||||
|
||||
## Command Usage
|
||||
|
||||
The CLI only supports tools available in the MCP server without additional arguments (see [Tool reference](./tool-reference.md)).
|
||||
Thus, `--categoryExtensions` tools are currently not available in the CLI.
|
||||
|
||||
```sh
|
||||
chrome-devtools <tool> [arguments] [flags]
|
||||
```
|
||||
|
||||
- **Required Arguments**: Passed as positional arguments.
|
||||
- **Optional Arguments**: Passed as flags (e.g., `--filePath`, `--fullPage`).
|
||||
|
||||
### Examples
|
||||
|
||||
**New Page and Navigation:**
|
||||
|
||||
```sh
|
||||
chrome-devtools new_page "https://example.com"
|
||||
chrome-devtools navigate_page "https://web.dev" --type url
|
||||
```
|
||||
|
||||
**Interaction:**
|
||||
|
||||
```sh
|
||||
# Click an element by its UID from a snapshot
|
||||
chrome-devtools click "element-uid-123"
|
||||
|
||||
# Fill a form field
|
||||
chrome-devtools fill "input-uid-456" "search query"
|
||||
```
|
||||
|
||||
**Analysis:**
|
||||
|
||||
```sh
|
||||
# Run a Lighthouse audit (defaults to navigation mode)
|
||||
chrome-devtools lighthouse_audit --mode snapshot
|
||||
```
|
||||
|
||||
## Output format
|
||||
|
||||
By default, the CLI outputs a human-readable summary of the tool's result. For programmatic use, you can request raw JSON:
|
||||
|
||||
```sh
|
||||
chrome-devtools list_pages --output-format=json
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If the CLI hangs or fails to connect, try stopping the background process:
|
||||
|
||||
```sh
|
||||
chrome-devtools stop
|
||||
```
|
||||
|
||||
For more verbose logs, set the `DEBUG` environment variable:
|
||||
|
||||
```sh
|
||||
DEBUG=* chrome-devtools list_pages
|
||||
```
|
||||
|
||||
## CLI generation
|
||||
|
||||
Implemented in `scripts/generate-cli.ts`. Some commands are excluded from CLI
|
||||
generation such as `wait_for` and `fill_form`.
|
||||
|
||||
`chrome-devtools-mcp` args are also filtered in `src/bin/chrome-devtools.ts`
|
||||
because not all args make sense in a CLI interface.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Experimental: Debugging Chrome on Android
|
||||
|
||||
This is an experimental feature as Puppeteer does not officially support Chrome on Android as a target.
|
||||
|
||||
The workflow below works for most users. See [Troubleshooting: DevTools is not detecting the Android device for more help](https://developer.chrome.com/docs/devtools/remote-debugging#troubleshooting) for more help.
|
||||
|
||||
1. Open the Developer Options screen on your Android. See [Configure on-device developer Options](https://developer.android.com/studio/debug/dev-options.html).
|
||||
2. Select Enable USB Debugging.
|
||||
3. Connect your Android device directly to your development machine using a USB cable.
|
||||
4. On your development machine setup port forwarding from your development machine to your android device:
|
||||
```shell
|
||||
adb forward tcp:9222 localabstract:chrome_devtools_remote
|
||||
```
|
||||
5. Configure your MCP server to connect to the Chrome
|
||||
```json
|
||||
"chrome-devtools": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"chrome-devtools-mcp@latest",
|
||||
"--wsEndpoint=ws://127.0.0.1:9222/devtools/browser/"
|
||||
],
|
||||
"trust": true
|
||||
}
|
||||
```
|
||||
6. Test your setup by running the following prompt in your coding agent:
|
||||
```none
|
||||
Check the performance of developers.chrome.com
|
||||
```
|
||||
|
||||
The Chrome DevTools MCP server should now control Chrome on your Android device.
|
||||
@@ -0,0 +1,12 @@
|
||||
# Design Principles
|
||||
|
||||
These are rough guidelines to follow when shipping features for the MCP server.
|
||||
Apply them with nuance.
|
||||
|
||||
- **Agent-Agnostic API**: Use standards like MCP. Don't lock in to one LLM. Interoperability is key.
|
||||
- **Token-Optimized**: Return semantic summaries. "LCP was 3.2s" is better than 50k lines of JSON. Files are the right location for large amounts of data.
|
||||
- **Small, Deterministic Blocks**: Give agents composable tools (Click, Screenshot), not magic buttons.
|
||||
- **Self-Healing Errors**: Return actionable errors that include context and potential fixes.
|
||||
- **Human-Agent Collaboration**: Output must be readable by machines (structured) AND humans (summaries).
|
||||
- **Progressive Complexity**: Tools should be simple by default (high-level actions) but offer advanced optional arguments for power users.
|
||||
- **Reference over Value**: for heavy assets (screenshots, traces, videos), return a file path or resource URI, never the raw data stream. Some MCP clients support a built-in handling of heavy assets e.g. directly displaying images. This _could_ be an exception.
|
||||
@@ -0,0 +1,41 @@
|
||||
<!-- AUTO GENERATED DO NOT EDIT - run 'npm run gen' to update-->
|
||||
|
||||
# Chrome DevTools MCP Slim Tool Reference
|
||||
|
||||
- **[Navigation automation](#navigation-automation)** (1 tools)
|
||||
- [`navigate`](#navigate)
|
||||
- **[Debugging](#debugging)** (2 tools)
|
||||
- [`evaluate`](#evaluate)
|
||||
- [`screenshot`](#screenshot)
|
||||
|
||||
## Navigation automation
|
||||
|
||||
### `navigate`
|
||||
|
||||
**Description:** Loads a URL
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- **url** (string) **(required)**: URL to [`navigate`](#navigate) to
|
||||
|
||||
---
|
||||
|
||||
## Debugging
|
||||
|
||||
### `evaluate`
|
||||
|
||||
**Description:** Evaluates a JavaScript script
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- **script** (string) **(required)**: JS script to run on the page
|
||||
|
||||
---
|
||||
|
||||
### `screenshot`
|
||||
|
||||
**Description:** Takes a [`screenshot`](#screenshot)
|
||||
|
||||
**Parameters:** None
|
||||
|
||||
---
|
||||
@@ -0,0 +1,87 @@
|
||||
# Developer Guide: Building third-party developer tools
|
||||
|
||||
This documentation outlines how to expose custom runtime data and tools from your web application to Chrome DevTools for Agents.
|
||||
|
||||
## Overview
|
||||
|
||||
Third-party developer tools enable your web application to expose internal state, component hierarchies, or specific debug data that cannot be deduced through static analysis. This allows Chrome DevTools for Agents to provide richer, more actionable context to AI agents during debugging sessions.
|
||||
|
||||
## How It Works: Tool Discovery
|
||||
|
||||
Chrome DevTools for Agents uses an event-based mechanism to discover tools exposed by the page. The process follows these steps:
|
||||
|
||||
1. **Event Dispatch:** Chrome DevTools for Agents dispatches a `devtoolstooldiscovery` event on the global `window` object.
|
||||
2. **Listener:** Your application listens for this event and provides the tool definitions.
|
||||
3. **Response:** Your application must call `event.respondWith()` to register a `ToolGroup` object.
|
||||
|
||||
_Note: Chrome DevTools for Agents requests this list automatically after page navigations (e.g., `new_page`, `navigate_page`) or when explicitly requested via the `list_3p_developer_tools()` MCP tool._
|
||||
|
||||
## Implementation
|
||||
|
||||
To expose tools, implement a listener for the `devtoolstooldiscovery` event and provide a `ToolGroup` containing your tool definitions.
|
||||
|
||||
### Type Definitions
|
||||
|
||||
Your tools must follow the `ToolDefinition` and `ToolGroup` interfaces:
|
||||
|
||||
```typescript
|
||||
export interface ToolDefinition {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: JSONSchema7;
|
||||
execute: (args: Record<string, unknown>) => unknown;
|
||||
}
|
||||
|
||||
export interface ToolGroup {
|
||||
name: string;
|
||||
description: string;
|
||||
tools: ToolDefinition[];
|
||||
}
|
||||
```
|
||||
|
||||
### Example Implementation
|
||||
|
||||
```typescript
|
||||
window.addEventListener(
|
||||
'devtoolstooldiscovery',
|
||||
(event: DevtoolsToolDiscoveryEvent) => {
|
||||
event.respondWith({
|
||||
name: 'Page-specific DevTools',
|
||||
description: "Provide runtime info directly from the page's JavaScript",
|
||||
tools: [
|
||||
{
|
||||
name: 'add',
|
||||
description: 'Calculates the sum of two numbers.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
a: {type: 'number'},
|
||||
b: {type: 'number'},
|
||||
},
|
||||
required: ['a', 'b'],
|
||||
},
|
||||
execute: async (input: {a: number; b: number}) => {
|
||||
return input.a + input.b;
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## Tool Invocation
|
||||
|
||||
Once discovered, MCP clients can execute your tools through Chrome DevTools for Agents using:
|
||||
|
||||
- **`execute_3p_developer_tool`**: The standard way to invoke a specific registered tool by name with validated parameters.
|
||||
- **`evaluate_script`**: Allows for more complex interactions by running a custom script that calls `window.__dtmcp.executeTool()` directly, enabling you to compose functionality.
|
||||
|
||||
## Important Considerations
|
||||
|
||||
- **Experimental Status:** This feature is currently experimental. APIs may change, and there are no guarantees regarding stability.
|
||||
- **Security & Scope:**
|
||||
- **Context:** Third-party developer tools execute only within the context of the page that defines them. They do not persist across origins.
|
||||
- **Capabilities:** These tools do not grant expanded privileges; they can only execute code that an attacker would already be able to run on that page.
|
||||
- **DOM Elements:** If your tools require DOM elements as inputs or outputs, they are handled via special UIDs referenced in the accessibility tree.
|
||||
- **Flags:** The implementation is gated behind the `--categoryExperimentalThirdParty=true` command-line flag.
|
||||
@@ -0,0 +1,681 @@
|
||||
<!-- AUTO GENERATED DO NOT EDIT - run 'npm run gen' to update-->
|
||||
|
||||
# Chrome DevTools MCP Tool Reference
|
||||
|
||||
- **[Input automation](#input-automation)** (10 tools)
|
||||
- [`click`](#click)
|
||||
- [`drag`](#drag)
|
||||
- [`fill`](#fill)
|
||||
- [`fill_form`](#fill_form)
|
||||
- [`handle_dialog`](#handle_dialog)
|
||||
- [`hover`](#hover)
|
||||
- [`press_key`](#press_key)
|
||||
- [`type_text`](#type_text)
|
||||
- [`upload_file`](#upload_file)
|
||||
- [`click_at`](#click_at)
|
||||
- **[Navigation automation](#navigation-automation)** (6 tools)
|
||||
- [`close_page`](#close_page)
|
||||
- [`list_pages`](#list_pages)
|
||||
- [`navigate_page`](#navigate_page)
|
||||
- [`new_page`](#new_page)
|
||||
- [`select_page`](#select_page)
|
||||
- [`wait_for`](#wait_for)
|
||||
- **[Emulation](#emulation)** (2 tools)
|
||||
- [`emulate`](#emulate)
|
||||
- [`resize_page`](#resize_page)
|
||||
- **[Performance](#performance)** (3 tools)
|
||||
- [`performance_analyze_insight`](#performance_analyze_insight)
|
||||
- [`performance_start_trace`](#performance_start_trace)
|
||||
- [`performance_stop_trace`](#performance_stop_trace)
|
||||
- **[Network](#network)** (2 tools)
|
||||
- [`get_network_request`](#get_network_request)
|
||||
- [`list_network_requests`](#list_network_requests)
|
||||
- **[Debugging](#debugging)** (8 tools)
|
||||
- [`evaluate_script`](#evaluate_script)
|
||||
- [`get_console_message`](#get_console_message)
|
||||
- [`lighthouse_audit`](#lighthouse_audit)
|
||||
- [`list_console_messages`](#list_console_messages)
|
||||
- [`take_screenshot`](#take_screenshot)
|
||||
- [`take_snapshot`](#take_snapshot)
|
||||
- [`screencast_start`](#screencast_start)
|
||||
- [`screencast_stop`](#screencast_stop)
|
||||
- **[Memory](#memory)** (11 tools)
|
||||
- [`take_heapsnapshot`](#take_heapsnapshot)
|
||||
- [`close_heapsnapshot`](#close_heapsnapshot)
|
||||
- [`compare_heapsnapshots`](#compare_heapsnapshots)
|
||||
- [`get_heapsnapshot_class_nodes`](#get_heapsnapshot_class_nodes)
|
||||
- [`get_heapsnapshot_details`](#get_heapsnapshot_details)
|
||||
- [`get_heapsnapshot_dominators`](#get_heapsnapshot_dominators)
|
||||
- [`get_heapsnapshot_duplicate_strings`](#get_heapsnapshot_duplicate_strings)
|
||||
- [`get_heapsnapshot_edges`](#get_heapsnapshot_edges)
|
||||
- [`get_heapsnapshot_retainers`](#get_heapsnapshot_retainers)
|
||||
- [`get_heapsnapshot_retaining_paths`](#get_heapsnapshot_retaining_paths)
|
||||
- [`get_heapsnapshot_summary`](#get_heapsnapshot_summary)
|
||||
- **[Extensions](#extensions)** (5 tools)
|
||||
- [`install_extension`](#install_extension)
|
||||
- [`list_extensions`](#list_extensions)
|
||||
- [`reload_extension`](#reload_extension)
|
||||
- [`trigger_extension_action`](#trigger_extension_action)
|
||||
- [`uninstall_extension`](#uninstall_extension)
|
||||
- **[Third-party](#third-party)** (2 tools)
|
||||
- [`execute_3p_developer_tool`](#execute_3p_developer_tool)
|
||||
- [`list_3p_developer_tools`](#list_3p_developer_tools)
|
||||
- **[WebMCP](#webmcp)** (2 tools)
|
||||
- [`execute_webmcp_tool`](#execute_webmcp_tool)
|
||||
- [`list_webmcp_tools`](#list_webmcp_tools)
|
||||
|
||||
## Input automation
|
||||
|
||||
### `click`
|
||||
|
||||
**Description:** Clicks on the provided element
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- **uid** (string) **(required)**: The uid of an element on the page from the page content snapshot
|
||||
- **dblClick** (boolean) _(optional)_: Set to true for double clicks. Default is false.
|
||||
- **includeSnapshot** (boolean) _(optional)_: Whether to include a snapshot in the response. Default is false.
|
||||
|
||||
---
|
||||
|
||||
### `drag`
|
||||
|
||||
**Description:** [`Drag`](#drag) an element onto another element
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- **from_uid** (string) **(required)**: The uid of the element to [`drag`](#drag)
|
||||
- **to_uid** (string) **(required)**: The uid of the element to drop into
|
||||
- **includeSnapshot** (boolean) _(optional)_: Whether to include a snapshot in the response. Default is false.
|
||||
|
||||
---
|
||||
|
||||
### `fill`
|
||||
|
||||
**Description:** Type text into an input, text area or select an option from a <select> element.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- **uid** (string) **(required)**: The uid of an element on the page from the page content snapshot
|
||||
- **value** (string) **(required)**: The value to [`fill`](#fill) in. "true" or "false" for checkboxes and toggles, "true" for radio buttons.
|
||||
- **includeSnapshot** (boolean) _(optional)_: Whether to include a snapshot in the response. Default is false.
|
||||
|
||||
---
|
||||
|
||||
### `fill_form`
|
||||
|
||||
**Description:** [`Fill`](#fill) out multiple form elements (inputs, selects, checkboxes, radios) at once. ALWAYS prefer this tool over multiple individual '[`fill`](#fill)' or '[`click`](#click)' calls when interacting with forms. It is significantly faster, more reliable, and reduces turn count. Example: [`Fill`](#fill) username, password, and check "Remember Me" in one call.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- **elements** (array) **(required)**: Elements from snapshot to [`fill`](#fill) out.
|
||||
- **includeSnapshot** (boolean) _(optional)_: Whether to include a snapshot in the response. Default is false.
|
||||
|
||||
---
|
||||
|
||||
### `handle_dialog`
|
||||
|
||||
**Description:** If a browser dialog was opened, use this command to handle it
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- **action** (enum: "accept", "dismiss") **(required)**: Whether to dismiss or accept the dialog
|
||||
- **promptText** (string) _(optional)_: Optional prompt text to enter into the dialog.
|
||||
|
||||
---
|
||||
|
||||
### `hover`
|
||||
|
||||
**Description:** [`Hover`](#hover) over the provided element
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- **uid** (string) **(required)**: The uid of an element on the page from the page content snapshot
|
||||
- **includeSnapshot** (boolean) _(optional)_: Whether to include a snapshot in the response. Default is false.
|
||||
|
||||
---
|
||||
|
||||
### `press_key`
|
||||
|
||||
**Description:** Press a key or key combination. Use this when other input methods like [`fill`](#fill)() cannot be used (e.g., keyboard shortcuts, navigation keys, or special key combinations).
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- **key** (string) **(required)**: A key or a combination (e.g., "Enter", "Control+A", "Control++", "Control+Shift+R"). Modifiers: Control, Shift, Alt, Meta
|
||||
- **includeSnapshot** (boolean) _(optional)_: Whether to include a snapshot in the response. Default is false.
|
||||
|
||||
---
|
||||
|
||||
### `type_text`
|
||||
|
||||
**Description:** Type text using keyboard into a previously focused input
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- **text** (string) **(required)**: The text to type
|
||||
- **submitKey** (string) _(optional)_: Optional key to press after typing. E.g., "Enter", "Tab", "Escape"
|
||||
|
||||
---
|
||||
|
||||
### `upload_file`
|
||||
|
||||
**Description:** Upload a file through a provided element.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- **filePath** (string) **(required)**: The local path of the file to upload
|
||||
- **uid** (string) **(required)**: The uid of the file input element or an element that will open file chooser on the page from the page content snapshot
|
||||
- **includeSnapshot** (boolean) _(optional)_: Whether to include a snapshot in the response. Default is false.
|
||||
|
||||
---
|
||||
|
||||
### `click_at`
|
||||
|
||||
**Description:** Clicks at the provided coordinates (requires flag: --experimentalVision=true)
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- **x** (number) **(required)**: The x coordinate
|
||||
- **y** (number) **(required)**: The y coordinate
|
||||
- **dblClick** (boolean) _(optional)_: Set to true for double clicks. Default is false.
|
||||
- **includeSnapshot** (boolean) _(optional)_: Whether to include a snapshot in the response. Default is false.
|
||||
|
||||
---
|
||||
|
||||
## Navigation automation
|
||||
|
||||
### `close_page`
|
||||
|
||||
**Description:** Closes the page by its index. The last open page cannot be closed.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- **pageId** (number) **(required)**: The ID of the page to close. Call [`list_pages`](#list_pages) to list pages.
|
||||
|
||||
---
|
||||
|
||||
### `list_pages`
|
||||
|
||||
**Description:** Get a list of pages open in the browser.
|
||||
|
||||
**Parameters:** None
|
||||
|
||||
---
|
||||
|
||||
### `navigate_page`
|
||||
|
||||
**Description:** Go to a URL, or back, forward, or reload. Use project URL if not specified otherwise.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- **handleBeforeUnload** (enum: "accept", "dismiss") _(optional)_: Whether to auto accept or beforeunload dialogs triggered by this navigation. Default is accept.
|
||||
- **ignoreCache** (boolean) _(optional)_: Whether to ignore cache on reload.
|
||||
- **initScript** (string) _(optional)_: A JavaScript script to be executed on each new document before any other scripts for the next navigation.
|
||||
- **timeout** (integer) _(optional)_: Maximum wait time in milliseconds. If set to 0, the default timeout will be used.
|
||||
- **type** (enum: "url", "back", "forward", "reload") _(optional)_: Navigate the page by URL, back or forward in history, or reload.
|
||||
- **url** (string) _(optional)_: Target URL (only type=url)
|
||||
|
||||
---
|
||||
|
||||
### `new_page`
|
||||
|
||||
**Description:** Open a new tab and load a URL. Use project URL if not specified otherwise.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- **url** (string) **(required)**: URL to load in a new page.
|
||||
- **background** (boolean) _(optional)_: Whether to open the page in the background without bringing it to the front. Default is false (foreground).
|
||||
- **isolatedContext** (string) _(optional)_: If specified, the page is created in an isolated browser context with the given name. Pages in the same browser context share cookies and storage. Pages in different browser contexts are fully isolated.
|
||||
- **timeout** (integer) _(optional)_: Maximum wait time in milliseconds. If set to 0, the default timeout will be used.
|
||||
|
||||
---
|
||||
|
||||
### `select_page`
|
||||
|
||||
**Description:** Select a page as a context for future tool calls.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- **pageId** (number) **(required)**: The ID of the page to select. Call [`list_pages`](#list_pages) to get available pages.
|
||||
- **bringToFront** (boolean) _(optional)_: Whether to focus the page and bring it to the top.
|
||||
|
||||
---
|
||||
|
||||
### `wait_for`
|
||||
|
||||
**Description:** Wait for the specified text to appear on the selected page.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- **text** (array) **(required)**: Non-empty list of texts. Resolves when any value appears on the page.
|
||||
- **timeout** (integer) _(optional)_: Maximum wait time in milliseconds. If set to 0, the default timeout will be used.
|
||||
|
||||
---
|
||||
|
||||
## Emulation
|
||||
|
||||
### `emulate`
|
||||
|
||||
**Description:** Emulates various features on the selected page.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- **colorScheme** (enum: "dark", "light", "auto") _(optional)_: [`Emulate`](#emulate) the dark or the light mode. Set to "auto" to reset to the default.
|
||||
- **cpuThrottlingRate** (number) _(optional)_: Represents the CPU slowdown factor. Omit or set the rate to 1 to disable throttling
|
||||
- **extraHttpHeaders** (string) _(optional)_: Extra HTTP headers as a JSON string object, e.g. {"X-Custom": "value", "Authorization": "Bearer token"}. Headers are included into every HTTP request originating from the page and persist across navigations until cleared. Pass an empty string to clear all extra headers.
|
||||
- **geolocation** (string) _(optional)_: Geolocation (`<latitude>,<longitude>`) to [`emulate`](#emulate). Latitude between -90 and 90. Longitude between -180 and 180. Omit to clear the geolocation override.
|
||||
- **networkConditions** (enum: "Offline", "Slow 3G", "Fast 3G", "Slow 4G", "Fast 4G") _(optional)_: Throttle network. Omit to disable throttling.
|
||||
- **userAgent** (string) _(optional)_: User agent to [`emulate`](#emulate). Set to empty string to clear the user agent override.
|
||||
- **viewport** (string) _(optional)_: [`Emulate`](#emulate) device viewports '<width>x<height>x<devicePixelRatio>[,mobile][,touch][,landscape]'. 'touch' and 'mobile' to [`emulate`](#emulate) mobile devices. 'landscape' to [`emulate`](#emulate) landscape mode.
|
||||
|
||||
---
|
||||
|
||||
### `resize_page`
|
||||
|
||||
**Description:** Resizes the selected page's window so that the page has specified dimension
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- **height** (number) **(required)**: Page height
|
||||
- **width** (number) **(required)**: Page width
|
||||
|
||||
---
|
||||
|
||||
## Performance
|
||||
|
||||
### `performance_analyze_insight`
|
||||
|
||||
**Description:** Provides more detailed information on a specific Performance Insight of an insight set that was highlighted in the results of a trace recording.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- **insightName** (string) **(required)**: The name of the Insight you want more information on. For example: "DocumentLatency" or "LCPBreakdown"
|
||||
- **insightSetId** (string) **(required)**: The id for the specific insight set. Only use the ids given in the "Available insight sets" list.
|
||||
|
||||
---
|
||||
|
||||
### `performance_start_trace`
|
||||
|
||||
**Description:** Start a performance trace on the selected webpage. Use to find frontend performance issues, Core Web Vitals (LCP, INP, CLS), and improve page load speed.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- **autoStop** (boolean) _(optional)_: Determines if the trace recording should be automatically stopped.
|
||||
- **filePath** (string) _(optional)_: The absolute file path, or a file path relative to the current working directory, to save the raw trace data. For example, trace.json.gz (compressed) or trace.json (uncompressed).
|
||||
- **reload** (boolean) _(optional)_: Determines if, once tracing has started, the current selected page should be automatically reloaded. Navigate the page to the right URL using the [`navigate_page`](#navigate_page) tool BEFORE starting the trace if reload or autoStop is set to true.
|
||||
|
||||
---
|
||||
|
||||
### `performance_stop_trace`
|
||||
|
||||
**Description:** Stop the active performance trace recording on the selected webpage.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- **filePath** (string) _(optional)_: The absolute file path, or a file path relative to the current working directory, to save the raw trace data. For example, trace.json.gz (compressed) or trace.json (uncompressed).
|
||||
|
||||
---
|
||||
|
||||
## Network
|
||||
|
||||
### `get_network_request`
|
||||
|
||||
**Description:** Gets a network request by an optional reqid, if omitted returns the currently selected request in the DevTools Network panel.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- **reqid** (number) _(optional)_: The reqid of the network request. If omitted returns the currently selected request in the DevTools Network panel.
|
||||
- **requestFilePath** (string) _(optional)_: The absolute or relative path to a .network-request file to save the request body to. If omitted, the body is returned inline.
|
||||
- **responseFilePath** (string) _(optional)_: The absolute or relative path to a .network-response file to save the response body to. If omitted, the body is returned inline.
|
||||
|
||||
---
|
||||
|
||||
### `list_network_requests`
|
||||
|
||||
**Description:** List all requests for the currently selected page since the last navigation.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- **includePreservedRequests** (boolean) _(optional)_: Set to true to return the preserved requests over the last 3 navigations.
|
||||
- **pageIdx** (integer) _(optional)_: Page number to return (0-based). When omitted, returns the first page.
|
||||
- **pageSize** (integer) _(optional)_: Maximum number of requests to return. When omitted, returns all requests.
|
||||
- **resourceTypes** (array) _(optional)_: Filter requests to only return requests of the specified resource types. When omitted or empty, returns all requests.
|
||||
|
||||
---
|
||||
|
||||
## Debugging
|
||||
|
||||
### `evaluate_script`
|
||||
|
||||
**Description:** Evaluate a JavaScript function inside the currently selected page. Returns the response as JSON, so returned values have to be JSON-serializable.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- **function** (string) **(required)**: A JavaScript function declaration to be executed by the tool in the currently selected page.
|
||||
Example without arguments: `() => document.title` or `async () => await fetch("example.com")`.
|
||||
Example with arguments: `(el) => el.innerText`
|
||||
|
||||
- **args** (array) _(optional)_: An optional list of arguments to pass to the function.
|
||||
- **dialogAction** (string) _(optional)_: Handle dialogs while execution. "accept", "dismiss", or string for response of window.prompt. Defaults to accept.
|
||||
- **filePath** (string) _(optional)_: The absolute or relative path to a file to save the script output to. If omitted, the output is returned inline.
|
||||
|
||||
---
|
||||
|
||||
### `get_console_message`
|
||||
|
||||
**Description:** Gets a console message by its ID. You can get all messages by calling [`list_console_messages`](#list_console_messages).
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- **msgid** (number) **(required)**: The msgid of a console message on the page from the listed console messages
|
||||
|
||||
---
|
||||
|
||||
### `lighthouse_audit`
|
||||
|
||||
**Description:** Get Lighthouse score and reports for accessibility, SEO, best practices, and agentic browsing. This excludes performance. For performance audits, run [`performance_start_trace`](#performance_start_trace)
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- **device** (enum: "desktop", "mobile") _(optional)_: Device to [`emulate`](#emulate).
|
||||
- **mode** (enum: "navigation", "snapshot") _(optional)_: "navigation" reloads & audits. "snapshot" analyzes current state.
|
||||
- **outputDirPath** (string) _(optional)_: Directory for reports. If omitted, uses temporary files.
|
||||
|
||||
---
|
||||
|
||||
### `list_console_messages`
|
||||
|
||||
**Description:** List all console messages for the currently selected page since the last navigation.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- **includePreservedMessages** (boolean) _(optional)_: Set to true to return the preserved messages over the last 3 navigations.
|
||||
- **pageIdx** (integer) _(optional)_: Page number to return (0-based). When omitted, returns the first page.
|
||||
- **pageSize** (integer) _(optional)_: Maximum number of messages to return. When omitted, returns all messages.
|
||||
- **serviceWorkerId** (string) _(optional)_: Filter messages to only return messages of the specified service worker.
|
||||
- **types** (array) _(optional)_: Filter messages to only return messages of the specified resource types. When omitted or empty, returns all messages.
|
||||
|
||||
---
|
||||
|
||||
### `take_screenshot`
|
||||
|
||||
**Description:** Take a screenshot of the page or element.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- **filePath** (string) _(optional)_: The absolute path, or a path relative to the current working directory, to save the screenshot to instead of attaching it to the response.
|
||||
- **format** (enum: "png", "jpeg", "webp") _(optional)_: Type of format to save the screenshot as. Default is "png"
|
||||
- **fullPage** (boolean) _(optional)_: If set to true takes a screenshot of the full page instead of the currently visible viewport. Incompatible with uid.
|
||||
- **quality** (number) _(optional)_: Compression quality for JPEG and WebP formats (0-100). Higher values mean better quality but larger file sizes. Ignored for PNG format.
|
||||
- **uid** (string) _(optional)_: The uid of an element on the page from the page content snapshot. If omitted, takes a page screenshot.
|
||||
|
||||
---
|
||||
|
||||
### `take_snapshot`
|
||||
|
||||
**Description:** Take a text snapshot of the currently selected page based on the a11y tree. The snapshot lists page elements along with a unique
|
||||
identifier (uid). Always use the latest snapshot. Prefer taking a snapshot over taking a screenshot. The snapshot indicates the element selected
|
||||
in the DevTools Elements panel (if any).
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- **filePath** (string) _(optional)_: The absolute path, or a path relative to the current working directory, to save the snapshot to instead of attaching it to the response.
|
||||
- **verbose** (boolean) _(optional)_: Whether to include all possible information available in the full a11y tree. Default is false.
|
||||
|
||||
---
|
||||
|
||||
### `screencast_start`
|
||||
|
||||
**Description:** Starts recording a screencast (video) of the selected page in specified format. (requires flag: --experimentalScreencast=true)
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- **filePath** (string) _(optional)_: Output file path (.webm,.mp4 are supported). Uses mkdtemp to generate a unique path if not provided.
|
||||
|
||||
---
|
||||
|
||||
### `screencast_stop`
|
||||
|
||||
**Description:** Stops the active screencast recording on the selected page. (requires flag: --experimentalScreencast=true)
|
||||
|
||||
**Parameters:** None
|
||||
|
||||
---
|
||||
|
||||
## Memory
|
||||
|
||||
### `take_heapsnapshot`
|
||||
|
||||
**Description:** Capture a heap snapshot of the currently selected page. Use to analyze the memory distribution of JavaScript objects and debug memory leaks.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- **filePath** (string) **(required)**: A path to a .heapsnapshot file to save the heapsnapshot to.
|
||||
|
||||
---
|
||||
|
||||
### `close_heapsnapshot`
|
||||
|
||||
**Description:** Closes a previously loaded memory heapsnapshot, freeing its memory. (requires flag: --memoryDebugging=true)
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- **filePath** (string) **(required)**: A path to the .heapsnapshot file to close.
|
||||
|
||||
---
|
||||
|
||||
### `compare_heapsnapshots`
|
||||
|
||||
**Description:** Loads two memory heapsnapshots and returns the comparison. If classIndex is provided, returns detailed diff for that class, otherwise returns summary diff. (requires flag: --memoryDebugging=true)
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- **baseFilePath** (string) **(required)**: A path to the base .heapsnapshot file (earlier snapshot).
|
||||
- **currentFilePath** (string) **(required)**: A path to the current .heapsnapshot file (later snapshot).
|
||||
- **classIndex** (number) _(optional)_: Optional 0-based index of the class in the summary list to filter results, showing individual objects.
|
||||
|
||||
---
|
||||
|
||||
### `get_heapsnapshot_class_nodes`
|
||||
|
||||
**Description:** Loads a memory heapsnapshot and returns instances of a specific class with their IDs. (requires flag: --memoryDebugging=true)
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- **filePath** (string) **(required)**: A path to a .heapsnapshot file to read.
|
||||
- **id** (number) **(required)**: The ID for the class, obtained from details.
|
||||
- **filterName** (enum: "objectsRetainedByDetachedDomNodes", "objectsRetainedByConsole", "objectsRetainedByEventHandlers", "objectsRetainedByContexts") _(optional)_: An optional filter to apply to the nodes.
|
||||
- **pageIdx** (number) _(optional)_: The page index for pagination.
|
||||
- **pageSize** (number) _(optional)_: The page size for pagination.
|
||||
|
||||
---
|
||||
|
||||
### `get_heapsnapshot_details`
|
||||
|
||||
**Description:** Loads a memory heapsnapshot and returns all available information including statistics, static data, and aggregated node information. Supports pagination for aggregates. (requires flag: --memoryDebugging=true)
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- **filePath** (string) **(required)**: A path to a .heapsnapshot file to read.
|
||||
- **filterName** (enum: "objectsRetainedByDetachedDomNodes", "objectsRetainedByConsole", "objectsRetainedByEventHandlers", "objectsRetainedByContexts") _(optional)_: An optional filter to apply to the aggregates.
|
||||
- **pageIdx** (number) _(optional)_: The page index for pagination of aggregates.
|
||||
- **pageSize** (number) _(optional)_: The page size for pagination of aggregates.
|
||||
|
||||
---
|
||||
|
||||
### `get_heapsnapshot_dominators`
|
||||
|
||||
**Description:** Loads a memory heapsnapshot and returns the dominator chain for a specific node ID. This helps to identify which objects are keeping the target node alive. (requires flag: --memoryDebugging=true)
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- **filePath** (string) **(required)**: A path to a .heapsnapshot file to read.
|
||||
- **nodeId** (number) **(required)**: The node ID to get the dominator chain for.
|
||||
|
||||
---
|
||||
|
||||
### `get_heapsnapshot_duplicate_strings`
|
||||
|
||||
**Description:** Loads a memory heapsnapshot and returns duplicate strings grouped by their value. (requires flag: --memoryDebugging=true)
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- **filePath** (string) **(required)**: A path to a .heapsnapshot file to read.
|
||||
- **pageIdx** (number) _(optional)_: The page index for pagination.
|
||||
- **pageSize** (number) _(optional)_: The page size for pagination.
|
||||
|
||||
---
|
||||
|
||||
### `get_heapsnapshot_edges`
|
||||
|
||||
**Description:** Loads a memory heapsnapshot and returns outgoing edges (references) for a specific node ID. (requires flag: --memoryDebugging=true)
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- **filePath** (string) **(required)**: A path to a .heapsnapshot file to read.
|
||||
- **nodeId** (number) **(required)**: The node ID to get outgoing edges for.
|
||||
- **pageIdx** (number) _(optional)_: The page index for pagination.
|
||||
- **pageSize** (number) _(optional)_: The page size for pagination.
|
||||
|
||||
---
|
||||
|
||||
### `get_heapsnapshot_retainers`
|
||||
|
||||
**Description:** Loads a memory heapsnapshot and returns retainers for a specific node ID. (requires flag: --memoryDebugging=true)
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- **filePath** (string) **(required)**: A path to a .heapsnapshot file to read.
|
||||
- **nodeId** (number) **(required)**: The node ID to get retainers for.
|
||||
- **pageIdx** (number) _(optional)_: The page index for pagination.
|
||||
- **pageSize** (number) _(optional)_: The page size for pagination.
|
||||
|
||||
---
|
||||
|
||||
### `get_heapsnapshot_retaining_paths`
|
||||
|
||||
**Description:** Loads a memory heapsnapshot and returns retaining paths for a specific node ID. This helps to understand why a node is not being garbage collected. (requires flag: --memoryDebugging=true)
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- **filePath** (string) **(required)**: A path to a .heapsnapshot file to read.
|
||||
- **nodeId** (number) **(required)**: The node ID to get retaining paths for.
|
||||
- **maxDepth** (number) _(optional)_: The maximum depth to search for retaining paths.
|
||||
- **maxNodes** (number) _(optional)_: The maximum number of nodes to return.
|
||||
- **maxSiblings** (number) _(optional)_: The maximum number of siblings to return.
|
||||
|
||||
---
|
||||
|
||||
### `get_heapsnapshot_summary`
|
||||
|
||||
**Description:** Loads a memory heapsnapshot and returns snapshot summary stats. (requires flag: --memoryDebugging=true)
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- **filePath** (string) **(required)**: A path to a .heapsnapshot file to read.
|
||||
|
||||
---
|
||||
|
||||
## Extensions
|
||||
|
||||
> NOTE: The Extensions category is not active by default. Use the '--categoryExtensions' flag.
|
||||
|
||||
### `install_extension`
|
||||
|
||||
**Description:** Installs a Chrome extension from the given path. (requires flag: --categoryExtensions=true)
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- **path** (string) **(required)**: Absolute path to the unpacked extension folder.
|
||||
|
||||
---
|
||||
|
||||
### `list_extensions`
|
||||
|
||||
**Description:** Lists all the Chrome extensions installed in the browser. This includes their name, ID, version, and enabled status. (requires flag: --categoryExtensions=true)
|
||||
|
||||
**Parameters:** None
|
||||
|
||||
---
|
||||
|
||||
### `reload_extension`
|
||||
|
||||
**Description:** Reloads an unpacked Chrome extension by its ID. (requires flag: --categoryExtensions=true)
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- **id** (string) **(required)**: ID of the extension to reload.
|
||||
|
||||
---
|
||||
|
||||
### `trigger_extension_action`
|
||||
|
||||
**Description:** Triggers the default action of an extension by its ID. (requires flag: --categoryExtensions=true)
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- **id** (string) **(required)**: ID of the extension to trigger the action for.
|
||||
|
||||
---
|
||||
|
||||
### `uninstall_extension`
|
||||
|
||||
**Description:** Uninstalls a Chrome extension by its ID. (requires flag: --categoryExtensions=true)
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- **id** (string) **(required)**: ID of the extension to uninstall.
|
||||
|
||||
---
|
||||
|
||||
## Third-party
|
||||
|
||||
> NOTE: The Third-party category is not active by default. Use the '--categoryExperimentalThirdParty' flag.
|
||||
|
||||
### `execute_3p_developer_tool`
|
||||
|
||||
**Description:** Executes a tool exposed by the page. (requires flag: --categoryExperimentalThirdParty=true)
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- **toolName** (string) **(required)**: The name of the tool to execute
|
||||
- **params** (string) _(optional)_: The JSON-stringified parameters to pass to the tool
|
||||
|
||||
---
|
||||
|
||||
### `list_3p_developer_tools`
|
||||
|
||||
**Description:** Lists all third-party developer tools the page exposes for providing runtime information.
|
||||
Third-party developer tools can be called via the '[`execute_3p_developer_tool`](#execute_3p_developer_tool)()' MCP tool.
|
||||
Alternatively, third-party developer tools can be executed by calling '[`evaluate_script`](#evaluate_script)' and adding the
|
||||
following command to the script:
|
||||
`window.__dtmcp.executeTool(toolName, params)`
|
||||
This might be helpful when the third-party developer tools return non-serializable values or when composing
|
||||
third-party developer tools with additional functionality. (requires flag: --categoryExperimentalThirdParty=true)
|
||||
|
||||
**Parameters:** None
|
||||
|
||||
---
|
||||
|
||||
## WebMCP
|
||||
|
||||
> NOTE: The WebMCP category is not active by default. Use the '--categoryExperimentalWebmcp' flag.
|
||||
|
||||
### `execute_webmcp_tool`
|
||||
|
||||
**Description:** Executes a WebMCP tool exposed by the page. (requires flag: --categoryExperimentalWebmcp=true)
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- **toolName** (string) **(required)**: The name of the WebMCP tool to execute
|
||||
- **input** (string) _(optional)_: The JSON-stringified parameters to pass to the WebMCP tool
|
||||
|
||||
---
|
||||
|
||||
### `list_webmcp_tools`
|
||||
|
||||
**Description:** Lists all WebMCP tools the page exposes. (requires flag: --categoryExperimentalWebmcp=true)
|
||||
|
||||
**Parameters:** None
|
||||
|
||||
---
|
||||
@@ -0,0 +1,183 @@
|
||||
# Troubleshooting
|
||||
|
||||
## General tips
|
||||
|
||||
- Run `npx chrome-devtools-mcp@latest --help` to test if the MCP server runs on your machine.
|
||||
- Make sure that your MCP client uses the same npm and node version as your terminal.
|
||||
- When configuring your MCP client, try using the `--yes` argument to `npx` to
|
||||
auto-accept installation prompt.
|
||||
- Find a specific error in the output of the `chrome-devtools-mcp` server.
|
||||
Usually, if your client is an IDE, logs would be in the Output pane.
|
||||
- Search the [GitHub repository issues and discussions](https://github.com/ChromeDevTools/chrome-devtools-mcp) for help or existing similar problems.
|
||||
|
||||
## Debugging
|
||||
|
||||
Start the MCP server with debugging enabled and a log file:
|
||||
|
||||
- `DEBUG=* npx chrome-devtools-mcp@latest --log-file=/path/to/chrome-devtools-mcp.log`
|
||||
|
||||
Using `.mcp.json` to debug while using a client:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"chrome-devtools": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"chrome-devtools-mcp@latest",
|
||||
"--log-file",
|
||||
"/path/to/chrome-devtools-mcp.log"
|
||||
],
|
||||
"env": {
|
||||
"DEBUG": "*"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Specific problems
|
||||
|
||||
### `Error [ERR_MODULE_NOT_FOUND]: Cannot find module ...`
|
||||
|
||||
This usually indicates either a non-supported Node version is in use or that the
|
||||
`npm`/`npx` cache is corrupted. Try clearing the cache, uninstalling
|
||||
`chrome-devtools-mcp` and installing it again. Clear the cache by running:
|
||||
|
||||
```sh
|
||||
rm -rf ~/.npm/_npx # NOTE: this might remove other installed npx executables.
|
||||
npm cache clean --force
|
||||
```
|
||||
|
||||
### `Target closed` error
|
||||
|
||||
This indicates that the browser could not be started. Make sure that no Chrome
|
||||
instances are running or close them. Make sure you have the latest stable Chrome
|
||||
installed and that [your system is able to run Chrome](https://support.google.com/chrome/a/answer/7100626?hl=en).
|
||||
|
||||
### Chrome crashes on macOS when using Web Bluetooth
|
||||
|
||||
On macOS, Chrome launched by an MCP client application (such as Claude Desktop) may crash when a Web Bluetooth prompt appears. This is caused by a macOS privacy permission violation (TCC).
|
||||
|
||||
To resolve this, grant Bluetooth permission to the MCP client application in `System Settings > Privacy & Security > Bluetooth`. After granting permission, restart the client application and start a new MCP session.
|
||||
|
||||
### Remote debugging between virtual machine (VM) and host fails
|
||||
|
||||
When attempting to connect to Chrome running on a host machine from within a virtual machine (VM), Chrome may reject the connection due to 'Host' header validation. You can bypass this restriction by creating an SSH tunnel from the VM to the host. In the VM, run:
|
||||
|
||||
```sh
|
||||
ssh -N -L 127.0.0.1:9222:127.0.0.1:9222 <user>@<host-ip>
|
||||
```
|
||||
|
||||
Point the MCP connection inside the VM to `http://127.0.0.1:9222`. This allows DevTools to reach the host browser without triggering the Host validation error.
|
||||
|
||||
### Operating system sandboxes
|
||||
|
||||
Some MCP clients allow sandboxing the MCP server using macOS Seatbelt or Linux
|
||||
containers. If sandboxes are enabled, `chrome-devtools-mcp` is not able to start
|
||||
Chrome that requires permissions to create its own sandboxes. As a workaround,
|
||||
either disable sandboxing for `chrome-devtools-mcp` in your MCP client or use
|
||||
`--browser-url` to connect to a Chrome instance that you start manually outside
|
||||
of the MCP client sandbox.
|
||||
|
||||
### WSL
|
||||
|
||||
By default, `chrome-devtools-mcp` in WSL requires Chrome to be installed within the Linux environment. While it normally attempts to launch Chrome on the Windows side, this currently fails due to a [known WSL issue](https://github.com/microsoft/WSL/issues/14201). Ensure you are using a [Linux distribution compatible with Chrome](https://support.google.com/chrome/a/answer/7100626).
|
||||
|
||||
Possible workarounds include:
|
||||
|
||||
- **Install Google Chrome in WSL:**
|
||||
- `wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb`
|
||||
- `sudo dpkg -i google-chrome-stable_current_amd64.deb`
|
||||
|
||||
- **Use Mirrored networking:**
|
||||
1. Configure [Mirrored networking for WSL](https://learn.microsoft.com/en-us/windows/wsl/networking).
|
||||
2. Start Chrome on the Windows side with:
|
||||
`chrome.exe --remote-debugging-port=9222 --user-data-dir=C:\path\to\dir`
|
||||
3. Start `chrome-devtools-mcp` with:
|
||||
`npx chrome-devtools-mcp --browser-url http://127.0.0.1:9222`
|
||||
|
||||
- **Use PowerShell or Git Bash** instead of WSL.
|
||||
|
||||
### Windows 10: Error during discovery for MCP server 'chrome-devtools': MCP error -32000: Connection closed
|
||||
|
||||
- **Solution 1** Call using `cmd` (For more info https://github.com/modelcontextprotocol/servers/issues/1082#issuecomment-2791786310)
|
||||
|
||||
```json
|
||||
"mcpServers": {
|
||||
"chrome-devtools": {
|
||||
"command": "cmd",
|
||||
"args": ["/c", "npx", "-y", "chrome-devtools-mcp@latest"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **The Key Change:** On Windows, running a Node.js package via `npx` often requires the `cmd /c` prefix to be executed correctly from within another process like VSCode's extension host. Therefore, `"command": "npx"` was replaced with `"command": "cmd"`, and the actual `npx` command was moved into the `"args"` array, preceded by `"/c"`. This fix allows Windows to interpret the command correctly and launch the server.
|
||||
|
||||
- **Solution 2** Instead of another layer of shell you can write the absolute path to `npx`:
|
||||
> Note: The path below is an example. You must adjust it to match the actual location of `npx` on your machine. Depending on your setup, the file extension might be `.cmd`, `.bat`, or `.exe` rather than `.ps1`. Also, ensure you use double backslashes (`\\`) as path delimiters, as required by the JSON format.
|
||||
```json
|
||||
"mcpServers": {
|
||||
"chrome-devtools": {
|
||||
"command": "C:\\nvm4w\\nodejs\\npx.ps1",
|
||||
"args": ["-y", "chrome-devtools-mcp@latest"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Claude Code plugin installation fails with `Failed to clone repository`
|
||||
|
||||
When installing `chrome-devtools-mcp` as a Claude Code plugin (either from the
|
||||
official marketplace or via `/plugin marketplace add`), the installation may fail
|
||||
with a timeout error if your environment cannot reach `github.com` on port 443
|
||||
(HTTPS):
|
||||
|
||||
```
|
||||
Failed to download/cache plugin chrome-devtools-mcp: Failed to clone repository:
|
||||
Cloning into '...'...
|
||||
fatal: unable to access 'https://github.com/ChromeDevTools/chrome-devtools-mcp.git/':
|
||||
Failed to connect to github.com port 443
|
||||
```
|
||||
|
||||
This can happen in environments with restricted outbound HTTPS connectivity,
|
||||
corporate firewalls, or proxy configurations that block HTTPS git operations.
|
||||
|
||||
**Workaround 1: Use SSH instead of HTTPS**
|
||||
|
||||
If you have SSH access to GitHub configured, you can redirect all GitHub HTTPS
|
||||
URLs to use SSH by running:
|
||||
|
||||
```sh
|
||||
git config --global url."git@github.com:".insteadOf "https://github.com/"
|
||||
```
|
||||
|
||||
Then retry the plugin installation. This tells git to use your SSH key for all
|
||||
GitHub operations instead of HTTPS.
|
||||
|
||||
**Workaround 2: Install via CLI instead**
|
||||
|
||||
If the plugin marketplace approach fails, you can install `chrome-devtools-mcp`
|
||||
as an MCP server directly without cloning the repository:
|
||||
|
||||
```sh
|
||||
claude mcp add chrome-devtools --scope user npx chrome-devtools-mcp@latest
|
||||
```
|
||||
|
||||
This bypasses the git clone entirely and uses npm/npx to fetch the package. Note
|
||||
that this method installs only the MCP server without the bundled skills.
|
||||
|
||||
### Connection timeouts with `--autoConnect`
|
||||
|
||||
If you are using the `--autoConnect` flag and tools like `list_pages`, `new_page`, or `navigate_page` fail with a timeout (e.g., `ProtocolError: Network.enable timed out` or `The socket connection was closed unexpectedly`), this usually means the MCP server cannot handshake with the running Chrome instance correctly. Ensure:
|
||||
|
||||
1. Chrome 144+ is **already** running.
|
||||
2. Remote debugging is enabled in Chrome via `chrome://inspect/#remote-debugging`.
|
||||
3. You have allowed the remote debugging connection prompt in the browser.
|
||||
4. There is no other MCP server or tool trying to connect to the same debugging port.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> In Chrome versions up to 149, connection issues may be caused by frozen or unloaded tabs.
|
||||
> Chrome DevTools MCP forces all tabs to be loaded, so ensure your system has sufficient resources.
|
||||
> It is currently not recommended to use Chrome DevTools MCP with browser instances running hundreds of tabs.
|
||||
> See [Issue #1921](https://github.com/ChromeDevTools/chrome-devtools-mcp/issues/1921) for more details.
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import js from '@eslint/js';
|
||||
import stylisticPlugin from '@stylistic/eslint-plugin';
|
||||
import {defineConfig, globalIgnores} from 'eslint/config';
|
||||
import importPlugin from 'eslint-plugin-import';
|
||||
import globals from 'globals';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
import localPlugin from './scripts/eslint_rules/local-plugin.js';
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores([
|
||||
'**/node_modules',
|
||||
'**/build/',
|
||||
'tests/tools/fixtures/',
|
||||
'tests/fixtures/',
|
||||
'src/third_party/lighthouse-devtools-mcp-bundle.js',
|
||||
]),
|
||||
importPlugin.flatConfigs.typescript,
|
||||
{
|
||||
languageOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
|
||||
globals: {
|
||||
...globals.node,
|
||||
},
|
||||
|
||||
parserOptions: {
|
||||
projectService: {
|
||||
allowDefaultProject: [
|
||||
'.prettierrc.cjs',
|
||||
'puppeteer.config.cjs',
|
||||
'eslint.config.mjs',
|
||||
'rollup.config.mjs',
|
||||
'skills/memory-leak-debugging/references/compare_snapshots.js',
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
parser: tseslint.parser,
|
||||
},
|
||||
|
||||
plugins: {
|
||||
js,
|
||||
'@local': localPlugin,
|
||||
'@typescript-eslint': tseslint.plugin,
|
||||
'@stylistic': stylisticPlugin,
|
||||
},
|
||||
|
||||
settings: {
|
||||
'import/resolver': {
|
||||
typescript: true,
|
||||
},
|
||||
},
|
||||
|
||||
extends: ['js/recommended'],
|
||||
},
|
||||
tseslint.configs.recommended,
|
||||
tseslint.configs.stylistic,
|
||||
{
|
||||
name: 'TypeScript rules',
|
||||
rules: {
|
||||
'@local/check-license': 'error',
|
||||
curly: ['error', 'all'],
|
||||
|
||||
'no-undef': 'off',
|
||||
'no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/no-explicit-any': [
|
||||
'error',
|
||||
{
|
||||
ignoreRestArgs: true,
|
||||
},
|
||||
],
|
||||
// This optimizes the dependency tracking for type-only files.
|
||||
'@typescript-eslint/consistent-type-imports': 'error',
|
||||
// So type-only exports get elided.
|
||||
'@typescript-eslint/consistent-type-exports': 'error',
|
||||
// Prefer interfaces over types for shape like.
|
||||
'@typescript-eslint/consistent-type-definitions': ['error', 'interface'],
|
||||
'@typescript-eslint/array-type': [
|
||||
'error',
|
||||
{
|
||||
default: 'array-simple',
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/no-floating-promises': 'error',
|
||||
|
||||
'import/order': [
|
||||
'error',
|
||||
{
|
||||
'newlines-between': 'always',
|
||||
|
||||
alphabetize: {
|
||||
order: 'asc',
|
||||
caseInsensitive: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
'import/no-cycle': [
|
||||
'error',
|
||||
{
|
||||
maxDepth: Infinity,
|
||||
},
|
||||
],
|
||||
|
||||
'import/enforce-node-protocol-usage': ['error', 'always'],
|
||||
|
||||
'@stylistic/function-call-spacing': 'error',
|
||||
'@stylistic/semi': 'error',
|
||||
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
regex: '.*chrome-devtools-frontend/(?!mcp/mcp.js$).*',
|
||||
message:
|
||||
'Import only the devtools-frontend code exported via node_modules/chrome-devtools-frontend/mcp/mcp.js',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Source files',
|
||||
files: ['src/**/*.ts'],
|
||||
rules: {
|
||||
'@local/no-direct-third-party-imports': 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Tools definitions',
|
||||
files: ['src/tools/**/*.ts'],
|
||||
rules: {
|
||||
'@local/enforce-zod-schema': 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Tests',
|
||||
files: ['**/*.test.ts'],
|
||||
rules: {
|
||||
// With the Node.js test runner, `describe` and `it` are technically
|
||||
// promises, but we don't need to await them.
|
||||
'@typescript-eslint/no-floating-promises': 'off',
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "chrome-devtools-mcp",
|
||||
"version": "1.5.0",
|
||||
"mcpServers": {
|
||||
"chrome-devtools": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"chrome-devtools-mcp@1.5.0"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+8651
File diff suppressed because it is too large
Load Diff
+105
@@ -0,0 +1,105 @@
|
||||
{
|
||||
"name": "chrome-devtools-mcp",
|
||||
"version": "1.5.0",
|
||||
"description": "MCP server for Chrome DevTools",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"chrome-devtools-mcp": "./build/src/bin/chrome-devtools-mcp.js",
|
||||
"chrome-devtools": "./build/src/bin/chrome-devtools.js"
|
||||
},
|
||||
"main": "./build/src/index.js",
|
||||
"scripts": {
|
||||
"cli:generate": "node scripts/generate-cli.ts",
|
||||
"clean": "node -e \"require('fs').rmSync('build', {recursive: true, force: true})\"",
|
||||
"bundle": "npm run clean && npm run build && rollup -c rollup.config.mjs && node -e \"require('fs').rmSync('build/node_modules', {recursive: true, force: true})\" && node scripts/append-lighthouse-notices.ts",
|
||||
"build": "tsc && node scripts/post-build.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"format": "eslint --cache --fix . && prettier --write --cache .",
|
||||
"check-format": "eslint --cache . && prettier --check --cache .;",
|
||||
"gen": "npm run build && npm run docs:generate && npm run cli:generate && npm run update-metrics && npm run format",
|
||||
"docs:generate": "node scripts/generate-docs.ts",
|
||||
"start": "npm run build && node build/src/bin/chrome-devtools-mcp.js",
|
||||
"start-debug": "DEBUG=mcp:* DEBUG_COLORS=false npm run build && node build/src/bin/chrome-devtools-mcp.js",
|
||||
"test": "npm run build && node scripts/test.mjs",
|
||||
"test:no-build": "node scripts/test.mjs",
|
||||
"test:only": "npm run build && node scripts/test.mjs --test-only",
|
||||
"test:update-snapshots": "npm run build && node scripts/test.mjs --test-update-snapshots",
|
||||
"prepare": "node scripts/prepare.ts",
|
||||
"verify-server-json-version": "node scripts/verify-server-json-version.ts",
|
||||
"update-lighthouse": "node scripts/update-lighthouse.ts",
|
||||
"update-metrics": "node scripts/update_metrics.ts",
|
||||
"verify-npm-package": "node scripts/verify-npm-package.mjs",
|
||||
"eval": "npm run build && node scripts/eval_gemini.ts",
|
||||
"count-tokens": "node scripts/count_tokens.ts"
|
||||
},
|
||||
"files": [
|
||||
"build/src",
|
||||
"LICENSE",
|
||||
"skills",
|
||||
"!*.tsbuildinfo",
|
||||
"!*.js.map"
|
||||
],
|
||||
"repository": "ChromeDevTools/chrome-devtools-mcp",
|
||||
"author": "Google LLC",
|
||||
"license": "Apache-2.0",
|
||||
"bugs": {
|
||||
"url": "https://github.com/ChromeDevTools/chrome-devtools-mcp/issues"
|
||||
},
|
||||
"homepage": "https://github.com/ChromeDevTools/chrome-devtools-mcp#readme",
|
||||
"mcpName": "io.github.ChromeDevTools/chrome-devtools-mcp",
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.35.0",
|
||||
"@google/genai": "^2.0.1",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@rollup/plugin-commonjs": "^29.0.0",
|
||||
"@rollup/plugin-json": "^6.1.0",
|
||||
"@rollup/plugin-node-resolve": "^16.0.3",
|
||||
"@stylistic/eslint-plugin": "^5.4.0",
|
||||
"@blackwell-systems/gcf": "^2.2.2",
|
||||
"@toon-format/toon": "^2.2.0",
|
||||
"@types/debug": "^4.1.12",
|
||||
"@types/filesystem": "^0.0.36",
|
||||
"@types/node": "^26.0.0",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@types/sinon": "^22.0.0",
|
||||
"@types/yargs": "^17.0.33",
|
||||
"@typescript-eslint/eslint-plugin": "^8.43.0",
|
||||
"@typescript-eslint/parser": "^8.43.0",
|
||||
"chrome-devtools-frontend": "1.0.1652307",
|
||||
"core-js": "3.49.0",
|
||||
"debug": "4.4.3",
|
||||
"eslint": "^9.35.0",
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"globals": "^17.0.0",
|
||||
"lighthouse": "13.4.0",
|
||||
"prettier": "^3.6.2",
|
||||
"puppeteer": "25.3.0",
|
||||
"rollup": "4.62.2",
|
||||
"rollup-plugin-cleanup": "^3.2.1",
|
||||
"rollup-plugin-license": "^3.6.0",
|
||||
"semver": "^7.7.4",
|
||||
"sinon": "^22.0.0",
|
||||
"typescript": "^6.0.2",
|
||||
"typescript-eslint": "^8.43.0",
|
||||
"yargs": "18.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@toon-format/toon": "^2.2.0",
|
||||
"@blackwell-systems/gcf": "^2.2.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@toon-format/toon": {
|
||||
"optional": true
|
||||
},
|
||||
"@blackwell-systems/gcf": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=23"
|
||||
},
|
||||
"overrides": {
|
||||
"puppeteer-core": "$puppeteer"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google Inc.
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* @type {import("puppeteer").Configuration}
|
||||
*/
|
||||
module.exports = {
|
||||
chrome: {
|
||||
skipDownload: false,
|
||||
},
|
||||
['chrome-headless-shell']: {
|
||||
skipDownload: true,
|
||||
},
|
||||
firefox: {
|
||||
skipDownload: true,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"changelog-sections": [
|
||||
{"type": "feat", "section": "🎉 Features", "hidden": false},
|
||||
{"type": "fix", "section": "🛠️ Fixes", "hidden": false},
|
||||
{"type": "docs", "section": "📄 Documentation", "hidden": false},
|
||||
|
||||
{"type": "perf", "section": "⚡ Performance", "hidden": false},
|
||||
{"type": "refactor", "section": "🏗️ Refactor", "hidden": false},
|
||||
{"type": "chore", "section": "♻️ Chores", "hidden": true},
|
||||
{"type": "test", "section": "♻️ Chores", "hidden": true},
|
||||
|
||||
{"type": "build", "section": "⚙️ Automation", "hidden": true},
|
||||
{"type": "ci", "section": "⚙️ Automation", "hidden": true}
|
||||
],
|
||||
|
||||
"packages": {
|
||||
".": {
|
||||
"extra-files": [
|
||||
{
|
||||
"type": "generic",
|
||||
"path": "src/version.ts"
|
||||
},
|
||||
{
|
||||
"type": "json",
|
||||
"path": "server.json",
|
||||
"jsonpath": "version"
|
||||
},
|
||||
{
|
||||
"type": "json",
|
||||
"path": "server.json",
|
||||
"jsonpath": "packages[0].version"
|
||||
},
|
||||
{
|
||||
"type": "json",
|
||||
"path": ".claude-plugin/marketplace.json",
|
||||
"jsonpath": "version"
|
||||
},
|
||||
{
|
||||
"type": "json",
|
||||
"path": ".claude-plugin/plugin.json",
|
||||
"jsonpath": "version"
|
||||
},
|
||||
{
|
||||
"type": "json",
|
||||
"path": ".claude-plugin/plugin.json",
|
||||
"jsonpath": "mcpServers['chrome-devtools'].args[0]"
|
||||
},
|
||||
{
|
||||
"type": "json",
|
||||
"path": ".cursor-plugin/plugin.json",
|
||||
"jsonpath": "version"
|
||||
},
|
||||
{
|
||||
"type": "json",
|
||||
"path": ".cursor-plugin/plugin.json",
|
||||
"jsonpath": "mcpServers['chrome-devtools'].args[0]"
|
||||
},
|
||||
{
|
||||
"type": "json",
|
||||
"path": "gemini-extension.json",
|
||||
"jsonpath": "version"
|
||||
},
|
||||
{
|
||||
"type": "json",
|
||||
"path": "gemini-extension.json",
|
||||
"jsonpath": "mcpServers['chrome-devtools'].args[0]"
|
||||
},
|
||||
{
|
||||
"type": "json",
|
||||
"path": ".github/plugin/plugin.json",
|
||||
"jsonpath": "version"
|
||||
},
|
||||
{
|
||||
"type": "json",
|
||||
"path": ".github/plugin/plugin.json",
|
||||
"jsonpath": "mcpServers['chrome-devtools'].args[0]"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* Copyright 2021 Google LLC.
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview taken from {@link https://github.com/GoogleChromeLabs/chromium-bidi/blob/main/rollup.config.mjs | chromium-bidi}
|
||||
* and modified to specific requirement.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import commonjs from '@rollup/plugin-commonjs';
|
||||
import json from '@rollup/plugin-json';
|
||||
import {nodeResolve} from '@rollup/plugin-node-resolve';
|
||||
import cleanup from 'rollup-plugin-cleanup';
|
||||
import license from 'rollup-plugin-license';
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
const allowedLicenses = [
|
||||
'MIT',
|
||||
'Apache 2.0',
|
||||
'Apache-2.0',
|
||||
'BSD-3-Clause',
|
||||
'BSD-2-Clause',
|
||||
'ISC',
|
||||
'0BSD',
|
||||
];
|
||||
|
||||
const thirdPartyDir = './build/src/third_party';
|
||||
|
||||
const {devDependencies = {}} = JSON.parse(
|
||||
fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf-8'),
|
||||
);
|
||||
|
||||
// special case for puppeteer, from which we only bundle puppeteer-core
|
||||
devDependencies['puppeteer-core'] = devDependencies['puppeteer'];
|
||||
|
||||
const packageLock = JSON.parse(
|
||||
fs.readFileSync(path.join(process.cwd(), 'package-lock.json'), 'utf-8'),
|
||||
);
|
||||
|
||||
function getResolvedVersion(packageName) {
|
||||
const packageEntry = packageLock.packages?.[`node_modules/${packageName}`];
|
||||
return packageEntry?.version || null;
|
||||
}
|
||||
|
||||
const aggregatedStats = {
|
||||
bundlesProcessed: 0,
|
||||
totalBundles: 0,
|
||||
bundledPackages: new Set(),
|
||||
};
|
||||
|
||||
const projectNodeModulesPath =
|
||||
path.join(process.cwd(), 'node_modules') + path.sep;
|
||||
|
||||
function getPackageName(modulePath) {
|
||||
// Handle rollup's virtual module paths (paths starting with 0x00)
|
||||
const absolutePathStart = modulePath.indexOf(projectNodeModulesPath);
|
||||
if (absolutePathStart < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const relativePath = modulePath.slice(
|
||||
projectNodeModulesPath.length + absolutePathStart,
|
||||
);
|
||||
const segments = relativePath.split(path.sep);
|
||||
|
||||
// handle scoped packages
|
||||
if (segments[0].startsWith('@') && segments[1]) {
|
||||
return `${segments[0]}/${segments[1]}`;
|
||||
}
|
||||
return segments[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {import('rollup').Plugin}
|
||||
*/
|
||||
function listBundledDeps() {
|
||||
aggregatedStats.totalBundles++;
|
||||
return {
|
||||
name: 'gather-bundled-dependencies',
|
||||
generateBundle(options, bundle) {
|
||||
for (const chunk of Object.values(bundle)) {
|
||||
if (chunk.type === 'chunk' && chunk.modules) {
|
||||
// chunk.modules is an object where keys are the absolute file paths
|
||||
Object.keys(chunk.modules).forEach(modulePath => {
|
||||
const packageName = getPackageName(modulePath);
|
||||
if (packageName) {
|
||||
aggregatedStats.bundledPackages.add(packageName);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
aggregatedStats.bundlesProcessed++;
|
||||
|
||||
// Only write the file when the last bundle is finished
|
||||
if (aggregatedStats.bundlesProcessed === aggregatedStats.totalBundles) {
|
||||
const outputPath = path.join(thirdPartyDir, 'bundled-packages.json');
|
||||
|
||||
const bundledDevDeps = {};
|
||||
for (const [name, versionRange] of Object.entries(devDependencies)) {
|
||||
if (
|
||||
aggregatedStats.bundledPackages.has(name) ||
|
||||
name === 'chrome-devtools-frontend' ||
|
||||
name === 'lighthouse'
|
||||
) {
|
||||
const resolvedVersion = getResolvedVersion(name);
|
||||
bundledDevDeps[name] = resolvedVersion || versionRange;
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(outputPath, JSON.stringify(bundledDevDeps, null, 2));
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const seenDependencies = new Map();
|
||||
|
||||
/**
|
||||
* @param {string} wrapperIndexName
|
||||
* @param {import('rollup').OutputOptions} [extraOutputOptions={}]
|
||||
* @param {import('rollup').ExternalOption} [external=[]]
|
||||
* @returns {import('rollup').RollupOptions}
|
||||
*/
|
||||
const bundleDependency = (
|
||||
wrapperIndexName,
|
||||
extraOutputOptions = {},
|
||||
external = [],
|
||||
) => ({
|
||||
input: path.join(thirdPartyDir, wrapperIndexName),
|
||||
output: {
|
||||
...extraOutputOptions,
|
||||
file: path.join(thirdPartyDir, wrapperIndexName),
|
||||
sourcemap: !isProduction,
|
||||
format: 'esm',
|
||||
},
|
||||
plugins: [
|
||||
cleanup({
|
||||
// Keep license comments. Other comments are removed due to
|
||||
// http://b/390559299 and
|
||||
// https://github.com/microsoft/TypeScript/issues/60811.
|
||||
comments: [/Copyright/i],
|
||||
}),
|
||||
license({
|
||||
thirdParty: {
|
||||
allow: {
|
||||
test: dependency => {
|
||||
return allowedLicenses.includes(dependency.license);
|
||||
},
|
||||
failOnUnlicensed: true,
|
||||
failOnViolation: true,
|
||||
},
|
||||
output: {
|
||||
file: path.join(thirdPartyDir, 'THIRD_PARTY_NOTICES'),
|
||||
template(dependencies) {
|
||||
for (const dependency of dependencies) {
|
||||
const key = `${dependency.name}:${dependency.version}`;
|
||||
seenDependencies.set(key, dependency);
|
||||
}
|
||||
|
||||
const stringifiedDependencies = Array.from(
|
||||
seenDependencies.values(),
|
||||
).map(dependency => {
|
||||
let arr = [];
|
||||
arr.push(`Name: ${dependency.name ?? 'N/A'}`);
|
||||
let url = dependency.homepage ?? dependency.repository;
|
||||
if (url !== null && typeof url !== 'string') {
|
||||
url = url.url;
|
||||
}
|
||||
arr.push(`URL: ${url ?? 'N/A'}`);
|
||||
arr.push(`Version: ${dependency.version ?? 'N/A'}`);
|
||||
arr.push(`License: ${dependency.license ?? 'N/A'}`);
|
||||
if (dependency.licenseText !== null) {
|
||||
arr.push('');
|
||||
arr.push(dependency.licenseText.replaceAll('\r', ''));
|
||||
}
|
||||
return arr.join('\n');
|
||||
});
|
||||
|
||||
// Manual license handling for chrome-devtools-frontend third_party
|
||||
const tsConfig = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(process.cwd(), 'tsconfig.json'),
|
||||
'utf-8',
|
||||
),
|
||||
);
|
||||
const thirdPartyDirectories = tsConfig.include.filter(location =>
|
||||
location.includes(
|
||||
'node_modules/chrome-devtools-frontend/front_end/third_party',
|
||||
),
|
||||
);
|
||||
|
||||
const manualLicenses = [];
|
||||
// Add chrome-devtools-frontend main license
|
||||
const cdtfLicensePath = path.join(
|
||||
process.cwd(),
|
||||
'node_modules/chrome-devtools-frontend/LICENSE',
|
||||
);
|
||||
if (fs.existsSync(cdtfLicensePath)) {
|
||||
manualLicenses.push(
|
||||
[
|
||||
'Name: chrome-devtools-frontend',
|
||||
'License: Apache-2.0',
|
||||
'',
|
||||
fs.readFileSync(cdtfLicensePath, 'utf-8'),
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
// Add chrome-devtools-frontend main license
|
||||
const lighthouseLicensePath = path.join(
|
||||
process.cwd(),
|
||||
'node_modules/lighthouse/LICENSE',
|
||||
);
|
||||
if (fs.existsSync(lighthouseLicensePath)) {
|
||||
manualLicenses.push(
|
||||
[
|
||||
'Name: lighthouse',
|
||||
'License: Apache-2.0',
|
||||
'',
|
||||
fs.readFileSync(lighthouseLicensePath, 'utf-8'),
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
for (const thirdPartyDir of thirdPartyDirectories) {
|
||||
const fullPath = path.join(process.cwd(), thirdPartyDir);
|
||||
const licenseFile = path.join(fullPath, 'LICENSE');
|
||||
if (fs.existsSync(licenseFile)) {
|
||||
const name = path.basename(thirdPartyDir);
|
||||
manualLicenses.push(
|
||||
[
|
||||
`Name: ${name}`,
|
||||
`License:`,
|
||||
'',
|
||||
fs.readFileSync(licenseFile, 'utf-8').replaceAll('\r', ''),
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (manualLicenses.length > 0) {
|
||||
stringifiedDependencies.push(...manualLicenses);
|
||||
}
|
||||
|
||||
const divider =
|
||||
'\n\n-------------------- DEPENDENCY DIVIDER --------------------\n\n';
|
||||
return stringifiedDependencies.join(divider);
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
listBundledDeps(),
|
||||
commonjs(),
|
||||
json(),
|
||||
nodeResolve(),
|
||||
],
|
||||
external,
|
||||
});
|
||||
|
||||
export default [
|
||||
bundleDependency(
|
||||
'index.js',
|
||||
{
|
||||
inlineDynamicImports: true,
|
||||
},
|
||||
(source, importer, _isResolved) => {
|
||||
if (
|
||||
source === 'yargs' &&
|
||||
importer &&
|
||||
importer.includes('puppeteer-core')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
source === '@toon-format/toon' ||
|
||||
source.startsWith('@toon-format/toon/')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
source === '@blackwell-systems/gcf' ||
|
||||
source.startsWith('@blackwell-systems/gcf/')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const existingExternals = [
|
||||
'./bidi.js',
|
||||
'../bidi/bidi.js',
|
||||
'./lighthouse-devtools-mcp-bundle.js',
|
||||
];
|
||||
|
||||
if (existingExternals.includes(source)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
),
|
||||
bundleDependency(
|
||||
'devtools-formatter-worker.js',
|
||||
{
|
||||
inlineDynamicImports: true,
|
||||
},
|
||||
(_source, _importer, _isResolved) => false,
|
||||
),
|
||||
bundleDependency(
|
||||
'devtools-heap-snapshot-worker.js',
|
||||
{
|
||||
inlineDynamicImports: true,
|
||||
},
|
||||
(_source, _importer, _isResolved) => false,
|
||||
),
|
||||
];
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const ROOT_DIR = process.cwd();
|
||||
const TARGET_DIR = path.join(ROOT_DIR, 'build/src/third_party');
|
||||
const SOURCE_DIR = path.join(ROOT_DIR, 'src/third_party');
|
||||
|
||||
function main() {
|
||||
const lighthouseNotices = fs.readFileSync(
|
||||
path.join(SOURCE_DIR, 'LIGHTHOUSE_MCP_BUNDLE_THIRD_PARTY_NOTICES'),
|
||||
'utf8',
|
||||
);
|
||||
const bundledNotices = fs.readFileSync(
|
||||
path.join(TARGET_DIR, 'THIRD_PARTY_NOTICES'),
|
||||
'utf8',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(TARGET_DIR, 'THIRD_PARTY_NOTICES'),
|
||||
bundledNotices +
|
||||
'\n\n-------------------- DEPENDENCY DIVIDER --------------------\n\n' +
|
||||
lighthouseNotices,
|
||||
);
|
||||
console.log('Done.');
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {parseArgs} from 'node:util';
|
||||
|
||||
import {GoogleGenAI} from '@google/genai';
|
||||
|
||||
const ai = new GoogleGenAI({apiKey: process.env.GEMINI_API_KEY});
|
||||
|
||||
const {values, positionals} = parseArgs({
|
||||
options: {
|
||||
model: {
|
||||
type: 'string',
|
||||
default: 'gemini-2.5-flash',
|
||||
},
|
||||
file: {
|
||||
type: 'string',
|
||||
short: 'f',
|
||||
},
|
||||
},
|
||||
allowPositionals: true,
|
||||
});
|
||||
|
||||
let contents = positionals[0];
|
||||
|
||||
if (values.file) {
|
||||
contents = readFileSync(values.file, 'utf8');
|
||||
}
|
||||
|
||||
if (!contents) {
|
||||
console.error('Usage: npm run count-tokens -- [-f <file>] [<text>]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const response = await ai.models.countTokens({
|
||||
model: values.model,
|
||||
contents,
|
||||
});
|
||||
console.log(`Input: ${values.file || positionals[0]}`);
|
||||
console.log(`Tokens: ${response.totalTokens}`);
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
const licenseHeader = `
|
||||
/**
|
||||
* @license
|
||||
* Copyright ${currentYear} Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
`;
|
||||
|
||||
export default {
|
||||
name: 'check-license',
|
||||
meta: {
|
||||
type: 'layout',
|
||||
docs: {
|
||||
description: 'Validate existence of license header',
|
||||
},
|
||||
fixable: 'code',
|
||||
schema: [],
|
||||
messages: {
|
||||
licenseRule: 'Add license header.',
|
||||
emptyLine: 'Add empty line after license header.',
|
||||
},
|
||||
},
|
||||
defaultOptions: [],
|
||||
create(context) {
|
||||
const sourceCode = context.getSourceCode();
|
||||
const comments = sourceCode.getAllComments();
|
||||
let insertAfter = [0, 0];
|
||||
let header = null;
|
||||
// Check only the first 2 comments
|
||||
for (let index = 0; index < 2; index++) {
|
||||
const comment = comments[index];
|
||||
if (!comment) {
|
||||
break;
|
||||
}
|
||||
// Shebang comments should be at the top
|
||||
if (
|
||||
comment.type === 'Shebang' ||
|
||||
(comment.type === 'Line' && comment.value.startsWith('#!'))
|
||||
) {
|
||||
insertAfter = comment.range;
|
||||
continue;
|
||||
}
|
||||
if (comment.type === 'Block') {
|
||||
header = comment;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
Program(node) {
|
||||
if (context.getFilename().endsWith('.json')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
header &&
|
||||
(header.value.includes('@license') ||
|
||||
header.value.includes('License') ||
|
||||
header.value.includes('Copyright'))
|
||||
) {
|
||||
const nextToken = sourceCode.getTokenAfter(header, {
|
||||
includeComments: true,
|
||||
});
|
||||
if (
|
||||
nextToken &&
|
||||
nextToken.loc.start.line === header.loc.end.line + 1
|
||||
) {
|
||||
context.report({
|
||||
node: node,
|
||||
loc: header.loc,
|
||||
messageId: 'emptyLine',
|
||||
fix(fixer) {
|
||||
return fixer.insertTextAfter(header, '\n');
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Add header license
|
||||
if (!header || !header.value.includes('@license')) {
|
||||
context.report({
|
||||
node: node,
|
||||
messageId: 'licenseRule',
|
||||
fix(fixer) {
|
||||
return fixer.insertTextAfterRange(insertAfter, licenseHeader);
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export default {
|
||||
name: 'enforce-zod-schema',
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description:
|
||||
'Disallow .nullable() and .object() in tool schemas. Use optional strings to represent complex objects.',
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
noNullable:
|
||||
'Do not use .nullable() in tool schemas. Use .optional() instead.',
|
||||
noObject:
|
||||
'Do not use .object() in tool schemas. Represent complex objects as a short formatted string.',
|
||||
},
|
||||
},
|
||||
defaultOptions: [],
|
||||
create(context) {
|
||||
return {
|
||||
CallExpression(node) {
|
||||
if (
|
||||
node.callee.type !== 'MemberExpression' ||
|
||||
node.callee.property.type !== 'Identifier'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const methodName = node.callee.property.name;
|
||||
|
||||
// We don't validate that .nullable() is called on a ZodObject
|
||||
// specifically - this intentionally catches all .nullable() calls
|
||||
// in tool schema files.
|
||||
if (methodName === 'nullable') {
|
||||
context.report({
|
||||
node: node.callee.property,
|
||||
messageId: 'noNullable',
|
||||
});
|
||||
}
|
||||
|
||||
if (methodName === 'object') {
|
||||
// Only flag zod.object() calls, not arbitrary .object() calls.
|
||||
const obj = node.callee.object;
|
||||
if (
|
||||
obj.type === 'Identifier' &&
|
||||
(obj.name === 'zod' || obj.name === 'z')
|
||||
) {
|
||||
context.report({
|
||||
node: node.callee.property,
|
||||
messageId: 'noObject',
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import checkLicenseRule from './check-license-rule.js';
|
||||
import enforceZodSchemaRule from './enforce-zod-schema-rule.js';
|
||||
import noDirectThirdPartyImportsRule from './no-direct-third-party-imports-rule.js';
|
||||
|
||||
export default {
|
||||
rules: {
|
||||
'check-license': checkLicenseRule,
|
||||
'no-direct-third-party-imports': noDirectThirdPartyImportsRule,
|
||||
'enforce-zod-schema': enforceZodSchemaRule,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* ESLint rule that prevents value (non-type) imports of third-party packages
|
||||
* that should go through the `src/third_party/index.ts` barrel file.
|
||||
*
|
||||
* Type-only imports are allowed because they are erased at compile time and
|
||||
* do not affect the bundle.
|
||||
*
|
||||
* This catches a class of bugs where a direct import works in development
|
||||
* (because devDependencies are installed) but fails once the package is
|
||||
* bundled and published via `npm pack`.
|
||||
*
|
||||
* The list of bundled packages is derived dynamically by scanning
|
||||
* `src/third_party/*.ts` for import/export statements at ESLint load time.
|
||||
*
|
||||
* See https://github.com/ChromeDevTools/chrome-devtools-mcp/issues/1123
|
||||
*/
|
||||
|
||||
import {readdirSync, readFileSync} from 'node:fs';
|
||||
import {join} from 'node:path';
|
||||
|
||||
const THIRD_PARTY_DIR = join(
|
||||
import.meta.dirname,
|
||||
'..',
|
||||
'..',
|
||||
'src',
|
||||
'third_party',
|
||||
);
|
||||
|
||||
/**
|
||||
* Parse all .ts files in src/third_party/ and extract the bare package names
|
||||
* from import/export statements. Relative imports and node_modules paths
|
||||
* (used for chrome-devtools-frontend) are skipped.
|
||||
*/
|
||||
function discoverBundledPackages() {
|
||||
const packages = new Set();
|
||||
// Match `from 'pkg'` (may appear on a different line than `import`)
|
||||
// and side-effect imports like `import 'pkg'`.
|
||||
const fromRe = /from\s+['"]([^'"]+)['"]/g;
|
||||
const sideEffectRe = /^import\s+['"]([^'"]+)['"]/gm;
|
||||
|
||||
let files;
|
||||
try {
|
||||
files = readdirSync(THIRD_PARTY_DIR).filter(f => f.endsWith('.ts'));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const content = readFileSync(join(THIRD_PARTY_DIR, file), 'utf8');
|
||||
for (const re of [fromRe, sideEffectRe]) {
|
||||
re.lastIndex = 0;
|
||||
let match;
|
||||
while ((match = re.exec(content)) !== null) {
|
||||
const source = match[1];
|
||||
// Skip relative imports and node_modules paths.
|
||||
if (source.startsWith('.') || source.startsWith('/')) {
|
||||
continue;
|
||||
}
|
||||
// Extract the bare package name (handle scoped packages like @foo/bar).
|
||||
const parts = source.split('/');
|
||||
const pkg = source.startsWith('@')
|
||||
? parts.slice(0, 2).join('/')
|
||||
: parts[0];
|
||||
packages.add(pkg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...packages];
|
||||
}
|
||||
|
||||
const THIRD_PARTY_PACKAGES = discoverBundledPackages();
|
||||
|
||||
/** Matches any import source that starts with one of the restricted packages. */
|
||||
function isRestrictedSource(source) {
|
||||
return THIRD_PARTY_PACKAGES.some(
|
||||
pkg => source === pkg || source.startsWith(pkg + '/'),
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns true when the file is inside src/third_party/. */
|
||||
function isThirdPartyBarrel(filename) {
|
||||
const normalized = filename.replace(/\\/g, '/');
|
||||
return normalized.includes('/src/third_party/');
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'no-direct-third-party-imports',
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description:
|
||||
'Disallow value imports of bundled third-party packages outside of src/third_party/',
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
noDirectImport:
|
||||
'Do not import "{{source}}" directly. Use the re-export from "src/third_party/index.js" instead so the import survives bundling. (Type-only imports are fine.)',
|
||||
},
|
||||
},
|
||||
defaultOptions: [],
|
||||
create(context) {
|
||||
const filename = context.filename;
|
||||
if (isThirdPartyBarrel(filename)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
ImportDeclaration(node) {
|
||||
// `import type { Foo } from '...'` is always safe.
|
||||
if (node.importKind === 'type') {
|
||||
return;
|
||||
}
|
||||
|
||||
const source = node.source.value;
|
||||
if (!isRestrictedSource(source)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If every specifier is `type`, the import is still safe.
|
||||
// e.g. `import { type Foo, type Bar } from '...'`
|
||||
const hasValueSpecifier = node.specifiers.some(
|
||||
s => s.type !== 'ImportSpecifier' || s.importKind !== 'type',
|
||||
);
|
||||
|
||||
if (!hasValueSpecifier) {
|
||||
return;
|
||||
}
|
||||
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'noDirectImport',
|
||||
data: {source},
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {pathToFileURL} from 'node:url';
|
||||
import {parseArgs} from 'node:util';
|
||||
|
||||
import {GoogleGenAI, mcpToTool} from '@google/genai';
|
||||
import {Client} from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import {StdioClientTransport} from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
|
||||
import {TestServer} from '../build/tests/server.js';
|
||||
|
||||
const ROOT_DIR = path.resolve(import.meta.dirname, '..');
|
||||
const SCENARIOS_DIR = path.join(import.meta.dirname, 'eval_scenarios');
|
||||
const SKILL_PATH = path.join(ROOT_DIR, 'skills', 'chrome-devtools', 'SKILL.md');
|
||||
|
||||
import type {CapturedFunctionCall, TestScenario} from './eval_result.ts';
|
||||
import {Result} from './eval_result.ts';
|
||||
export type {CapturedFunctionCall, TestScenario};
|
||||
export {Result};
|
||||
|
||||
async function loadScenario(scenarioPath: string): Promise<TestScenario> {
|
||||
const module = await import(pathToFileURL(scenarioPath).href);
|
||||
if (!module.scenario) {
|
||||
throw new Error(
|
||||
`Scenario file ${scenarioPath} does not export a 'scenario' object.`,
|
||||
);
|
||||
}
|
||||
return module.scenario;
|
||||
}
|
||||
|
||||
async function runSingleScenario(
|
||||
scenarioPath: string,
|
||||
apiKey: string,
|
||||
server: TestServer,
|
||||
modelId: string,
|
||||
debug: boolean,
|
||||
includeSkill: boolean,
|
||||
extraServerArgs: string[] = [],
|
||||
): Promise<void> {
|
||||
const debugLog = (...args: unknown[]) => {
|
||||
if (debug) {
|
||||
console.log(...args);
|
||||
}
|
||||
};
|
||||
const absolutePath = path.resolve(scenarioPath);
|
||||
debugLog(
|
||||
`\n### Running Scenario: ${path.relative(ROOT_DIR, absolutePath)} ###`,
|
||||
);
|
||||
|
||||
let client: Client | undefined;
|
||||
let transport: StdioClientTransport | undefined;
|
||||
|
||||
try {
|
||||
const loadedScenario = await loadScenario(absolutePath);
|
||||
const scenario = {...loadedScenario};
|
||||
|
||||
// Prepend skill content if requested
|
||||
if (includeSkill) {
|
||||
if (!fs.existsSync(SKILL_PATH)) {
|
||||
throw new Error(
|
||||
`Skill file not found at ${SKILL_PATH}. Please ensure the skill file exists.`,
|
||||
);
|
||||
}
|
||||
const skillContent = fs.readFileSync(SKILL_PATH, 'utf-8');
|
||||
scenario.prompt = `${skillContent}\n\n---\n\n${scenario.prompt}`;
|
||||
}
|
||||
|
||||
// Append random queryid to avoid caching issues and test distinct runs
|
||||
const randomId = Math.floor(Math.random() * 1000000);
|
||||
scenario.prompt = `${scenario.prompt}\nqueryid=${randomId}`;
|
||||
|
||||
if (scenario.htmlRoute) {
|
||||
server.addHtmlRoute(
|
||||
scenario.htmlRoute.path,
|
||||
scenario.htmlRoute.htmlContent,
|
||||
);
|
||||
scenario.prompt = scenario.prompt.replace(
|
||||
'<TEST_URL>',
|
||||
server.getRoute(scenario.htmlRoute.path),
|
||||
);
|
||||
}
|
||||
|
||||
// Path to the compiled MCP server
|
||||
const serverPath = path.join(
|
||||
ROOT_DIR,
|
||||
'build/src/bin/chrome-devtools-mcp.js',
|
||||
);
|
||||
if (!fs.existsSync(serverPath)) {
|
||||
throw new Error(
|
||||
`MCP server not found at ${serverPath}. Please run 'npm run build' first.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Environment variables
|
||||
const env: Record<string, string> = {};
|
||||
Object.entries(process.env).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
env[key] = value;
|
||||
}
|
||||
});
|
||||
env['CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS'] = 'true';
|
||||
|
||||
const args = [serverPath];
|
||||
if (!debug) {
|
||||
args.push('--headless');
|
||||
}
|
||||
if (scenario.serverArgs) {
|
||||
args.push(...scenario.serverArgs);
|
||||
}
|
||||
if (extraServerArgs.length > 0) {
|
||||
args.push(...extraServerArgs);
|
||||
}
|
||||
|
||||
transport = new StdioClientTransport({
|
||||
command: 'node',
|
||||
args,
|
||||
env,
|
||||
stderr: debug ? 'inherit' : 'ignore',
|
||||
});
|
||||
|
||||
client = new Client(
|
||||
{name: 'gemini-eval-client', version: '1.0.0'},
|
||||
{capabilities: {}},
|
||||
);
|
||||
|
||||
await client.connect(transport);
|
||||
|
||||
const allCalls: CapturedFunctionCall[] = [];
|
||||
const originalCallTool = client.callTool.bind(client);
|
||||
client.callTool = async (request, schema) => {
|
||||
// NOTE: request.name is the original name as the MCP client sees it.
|
||||
// mcpToTool handles the conversion from Gemini sanitized name to original name.
|
||||
debugLog(
|
||||
`Executing tool: ${request.name} with args: ${JSON.stringify(request.arguments)}`,
|
||||
);
|
||||
allCalls.push({
|
||||
name: request.name,
|
||||
args: (request.arguments as Record<string, unknown>) || {},
|
||||
});
|
||||
const response = await originalCallTool(request, schema);
|
||||
debugLog(`Tool response: ${JSON.stringify(response)}`);
|
||||
return response;
|
||||
};
|
||||
|
||||
const ai = new GoogleGenAI({apiKey});
|
||||
|
||||
debugLog(`\n--- Prompt ---\n${scenario.prompt}`);
|
||||
|
||||
const result = await ai.models.generateContent({
|
||||
model: modelId,
|
||||
contents: scenario.prompt,
|
||||
config: {
|
||||
tools: [mcpToTool(client)],
|
||||
automaticFunctionCalling: {
|
||||
maximumRemoteCalls: scenario.maxTurns,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
debugLog(`\n--- Response ---\n${result.text}`);
|
||||
|
||||
debugLog('\nVerifying expectations...');
|
||||
scenario.expectations(new Result(allCalls, args));
|
||||
} finally {
|
||||
try {
|
||||
await client?.close();
|
||||
} catch (e) {
|
||||
console.error('Error closing client:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const apiKey = process.env.GEMINI_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error('GEMINI_API_KEY environment variable is required.');
|
||||
}
|
||||
|
||||
const {values, positionals} = parseArgs({
|
||||
options: {
|
||||
model: {
|
||||
type: 'string',
|
||||
default: 'gemini-3-flash-preview',
|
||||
},
|
||||
debug: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
},
|
||||
repeat: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
},
|
||||
'include-skill': {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
},
|
||||
'server-args': {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
allowPositionals: true,
|
||||
});
|
||||
|
||||
const modelId = values.model;
|
||||
const debug = values.debug;
|
||||
const repeat = values.repeat;
|
||||
const includeSkill = values['include-skill'];
|
||||
const extraServerArgs = values['server-args']
|
||||
? values['server-args'].split(/\s+/)
|
||||
: [];
|
||||
|
||||
const scenarioFiles =
|
||||
positionals.length > 0
|
||||
? positionals.map(p => path.resolve(p))
|
||||
: fs
|
||||
.readdirSync(SCENARIOS_DIR)
|
||||
.filter(file => file.endsWith('.ts') || file.endsWith('.js'))
|
||||
.map(file => path.join(SCENARIOS_DIR, file));
|
||||
|
||||
const server = new TestServer(TestServer.randomPort());
|
||||
await server.start();
|
||||
|
||||
let successCount = 0;
|
||||
let failureCount = 0;
|
||||
|
||||
try {
|
||||
for (const scenarioPath of scenarioFiles) {
|
||||
for (let i = 1; i <= (repeat ? 3 : 1); i++) {
|
||||
try {
|
||||
if (debug) {
|
||||
console.log(
|
||||
`Running scenario: ${path.relative(ROOT_DIR, scenarioPath)} (Run ${i}/3)`,
|
||||
);
|
||||
}
|
||||
await runSingleScenario(
|
||||
scenarioPath,
|
||||
apiKey,
|
||||
server,
|
||||
modelId,
|
||||
debug,
|
||||
includeSkill,
|
||||
extraServerArgs,
|
||||
);
|
||||
console.log(`✔ ${path.relative(ROOT_DIR, scenarioPath)} (Run ${i})`);
|
||||
successCount++;
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`✖ ${path.relative(ROOT_DIR, scenarioPath)} (Run ${i})`,
|
||||
);
|
||||
console.error(e);
|
||||
failureCount++;
|
||||
} finally {
|
||||
server.restore();
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
|
||||
console.log(`\nSummary: ${successCount} passed, ${failureCount} failed`);
|
||||
|
||||
if (failureCount > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error('Fatal error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import assert from 'node:assert';
|
||||
|
||||
export interface CapturedFunctionCall {
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class Result {
|
||||
private nextCallIndex = 0;
|
||||
public readonly calls: CapturedFunctionCall[];
|
||||
public readonly serverArgs: string[];
|
||||
|
||||
constructor(calls: CapturedFunctionCall[], serverArgs: string[]) {
|
||||
this.calls = calls;
|
||||
this.serverArgs = serverArgs;
|
||||
}
|
||||
|
||||
get hasPageIdRouting(): boolean {
|
||||
return this.serverArgs.includes('--experimental-page-id-routing');
|
||||
}
|
||||
|
||||
get remainingCalls(): CapturedFunctionCall[] {
|
||||
return this.calls.slice(this.nextCallIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Consumes initial page navigation/setup boilerplate.
|
||||
* - Ignores/skips leading list_pages calls.
|
||||
* - Asserts that new_page or navigate_page was called.
|
||||
* - Determines the expected pageId.
|
||||
* - Returns the active pageId.
|
||||
*/
|
||||
consumePageNavigation(): number | undefined {
|
||||
if (this.calls[this.nextCallIndex]?.name === 'list_pages') {
|
||||
this.nextCallIndex++;
|
||||
}
|
||||
|
||||
const navCall = this.calls[this.nextCallIndex];
|
||||
assert.ok(
|
||||
navCall &&
|
||||
(navCall.name === 'new_page' || navCall.name === 'navigate_page'),
|
||||
`Expected navigation call (new_page or navigate_page), but got: ${navCall?.name || 'none'}`,
|
||||
);
|
||||
this.nextCallIndex++;
|
||||
|
||||
const isNewPage = navCall.name === 'new_page';
|
||||
let pageId: number | undefined;
|
||||
if (this.hasPageIdRouting) {
|
||||
pageId = isNewPage ? 2 : 1;
|
||||
}
|
||||
|
||||
return pageId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the next call in sequence has the correct name and matches expected arguments.
|
||||
* Increments the internal call index.
|
||||
*/
|
||||
assertNextCall(
|
||||
name: string,
|
||||
expectedArgs?: Record<string, unknown>,
|
||||
): CapturedFunctionCall {
|
||||
const call = this.calls[this.nextCallIndex];
|
||||
assert.ok(
|
||||
call,
|
||||
`Expected call at index ${this.nextCallIndex} (name: '${name}') to exist`,
|
||||
);
|
||||
assert.strictEqual(
|
||||
call.name,
|
||||
name,
|
||||
`Expected call at index ${this.nextCallIndex} to be '${name}', but got '${call.name}'`,
|
||||
);
|
||||
|
||||
if (expectedArgs) {
|
||||
for (const entry of Object.entries(expectedArgs)) {
|
||||
const key = entry[0];
|
||||
const value = entry[1];
|
||||
assert.deepStrictEqual(
|
||||
call.args[key],
|
||||
value,
|
||||
`Expected argument '${key}' on call '${name}' to be ${JSON.stringify(value)}, got ${JSON.stringify(call.args[key])}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.nextCallIndex++;
|
||||
return call;
|
||||
}
|
||||
}
|
||||
|
||||
export interface TestScenario {
|
||||
prompt: string;
|
||||
maxTurns: number;
|
||||
expectations: (result: Result) => void;
|
||||
htmlRoute?: {
|
||||
path: string;
|
||||
htmlContent: string;
|
||||
};
|
||||
/** Extra CLI flags passed to the MCP server (e.g. '--experimental-page-id-routing'). */
|
||||
serverArgs?: string[];
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import assert from 'node:assert';
|
||||
|
||||
import type {TestScenario} from '../eval_gemini.ts';
|
||||
|
||||
export const scenario: TestScenario = {
|
||||
prompt: 'Navigate to <TEST_URL> and check the console messages.',
|
||||
maxTurns: 3,
|
||||
htmlRoute: {
|
||||
path: '/console_test.html',
|
||||
htmlContent: `
|
||||
<script>
|
||||
console.log('Test log message');
|
||||
console.error('Test error message');
|
||||
</script>
|
||||
`,
|
||||
},
|
||||
expectations: result => {
|
||||
const pageId = result.consumePageNavigation();
|
||||
assert.strictEqual(result.remainingCalls.length, 1);
|
||||
result.assertNextCall(
|
||||
'list_console_messages',
|
||||
result.hasPageIdRouting ? {pageId} : undefined,
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import assert from 'node:assert';
|
||||
|
||||
import type {TestScenario} from '../eval_gemini.ts';
|
||||
|
||||
export const scenario: TestScenario = {
|
||||
prompt: 'Emulate offline network conditions.',
|
||||
maxTurns: 2,
|
||||
expectations: result => {
|
||||
assert.ok(result.remainingCalls.length >= 1);
|
||||
result.assertNextCall('list_pages');
|
||||
result.assertNextCall('emulate', {
|
||||
networkConditions: 'Offline',
|
||||
pageId: result.hasPageIdRouting ? 1 : undefined,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import assert from 'node:assert';
|
||||
|
||||
import type {TestScenario} from '../eval_gemini.ts';
|
||||
|
||||
export const scenario: TestScenario = {
|
||||
prompt: 'Emulate current page with iPhone 14 user agent',
|
||||
maxTurns: 2,
|
||||
expectations: result => {
|
||||
assert.ok(result.remainingCalls.length >= 1);
|
||||
if (
|
||||
result.hasPageIdRouting ||
|
||||
result.remainingCalls[0]?.name === 'list_pages'
|
||||
) {
|
||||
result.assertNextCall('list_pages');
|
||||
}
|
||||
result.assertNextCall('emulate', {
|
||||
userAgent:
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1',
|
||||
pageId: result.hasPageIdRouting ? 1 : undefined,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import assert from 'node:assert';
|
||||
|
||||
import type {TestScenario} from '../eval_gemini.ts';
|
||||
|
||||
export const scenario: TestScenario = {
|
||||
prompt: 'Emulate current page with iPhone 14 viewport',
|
||||
maxTurns: 2,
|
||||
expectations: result => {
|
||||
assert.ok(result.remainingCalls.length >= 1);
|
||||
if (
|
||||
result.hasPageIdRouting ||
|
||||
result.remainingCalls[0]?.name === 'list_pages'
|
||||
) {
|
||||
result.assertNextCall('list_pages');
|
||||
}
|
||||
result.assertNextCall('emulate', {
|
||||
viewport: '390x844x3,mobile,touch',
|
||||
pageId: result.hasPageIdRouting ? 1 : undefined,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import assert from 'node:assert';
|
||||
|
||||
import type {TestScenario} from '../eval_gemini.ts';
|
||||
|
||||
export const scenario: TestScenario = {
|
||||
prompt:
|
||||
'Go to <TEST_URL>, fill the form with size = 2 CPUs and components = [docker, nginx].',
|
||||
maxTurns: 5, // allow for at least one extra turn to verify there are no extra clicks after fill_form
|
||||
htmlRoute: {
|
||||
path: '/input_test.html',
|
||||
htmlContent: `
|
||||
<form action="/post" method="POST">
|
||||
<div>
|
||||
<label for="size">CPU/Memory size:</label>
|
||||
<select id="size" name="size" required>
|
||||
<option value="small">1 vCPU, 2GB RAM</option>
|
||||
<option value="medium">2 vCPU, 4GB RAM</option>
|
||||
<option value="large">4 vCPU, 8GB RAM</option>
|
||||
</select>
|
||||
</div>
|
||||
<br>
|
||||
<div>
|
||||
<p>Pre-installed components:</p>
|
||||
<input type="checkbox" id="docker" name="components" value="docker">
|
||||
<label for="docker">Docker</label><br>
|
||||
<input type="checkbox" id="nodejs" name="components" value="nodejs">
|
||||
<label for="nodejs">Node.js</label><br>
|
||||
<input type="checkbox" id="python" name="components" value="python">
|
||||
<label for="python">Python</label><br>
|
||||
<input type="checkbox" id="nginx" name="components" value="nginx">
|
||||
<label for="nginx">Nginx</label>
|
||||
</div>
|
||||
<button type="submit">Spawn Server</button>
|
||||
</form>
|
||||
`,
|
||||
},
|
||||
expectations: result => {
|
||||
const pageId = result.consumePageNavigation();
|
||||
assert.ok(result.remainingCalls.length >= 2, 'Not enough calls made');
|
||||
result.assertNextCall(
|
||||
'take_snapshot',
|
||||
result.hasPageIdRouting ? {pageId} : undefined,
|
||||
);
|
||||
const fillFormCall = result.assertNextCall(
|
||||
'fill_form',
|
||||
result.hasPageIdRouting ? {pageId} : undefined,
|
||||
);
|
||||
|
||||
const elements = fillFormCall.args.elements;
|
||||
assert.ok(Array.isArray(elements), 'elements should be an array');
|
||||
assert.strictEqual(
|
||||
elements.length,
|
||||
3,
|
||||
'fill_form should be used with all form elements at once',
|
||||
);
|
||||
|
||||
const typedElements = elements.map(e => {
|
||||
assert.ok(e && typeof e === 'object' && 'uid' in e && 'value' in e);
|
||||
return {
|
||||
uid: String(e.uid),
|
||||
value: String(e.value),
|
||||
};
|
||||
});
|
||||
|
||||
const uids = new Set(typedElements.map(e => e.uid));
|
||||
assert.strictEqual(
|
||||
uids.size,
|
||||
3,
|
||||
'fill_form should target three distinct elements',
|
||||
);
|
||||
|
||||
const values = typedElements.map(e => e.value).sort();
|
||||
assert.deepStrictEqual(
|
||||
values,
|
||||
['2 vCPU, 4GB RAM', 'true', 'true'],
|
||||
'fill_form should be used with correct values',
|
||||
);
|
||||
|
||||
const submitUid = '1_15';
|
||||
|
||||
const extraFormInteractions = result.remainingCalls.filter(
|
||||
c => ['fill', 'click'].includes(c.name) && c.args.uid !== submitUid,
|
||||
);
|
||||
assert.deepEqual(
|
||||
extraFormInteractions.length,
|
||||
0,
|
||||
'No extra clicks and fills after fill_form',
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* Eval scenario: user asks to fix issues with their webpage (no URL given).
|
||||
* When no URL is provided, the model should pick the current frontend and run
|
||||
* and inspect it. Verifies the MCP server is invoked and the model opens the
|
||||
* frontend and inspects it (snapshot, console, or network).
|
||||
*
|
||||
* Note: Tools like performance_start_trace, take_snapshot, list_console_messages,
|
||||
* and list_network_requests do not require a URL in the prompt—they operate on
|
||||
* the currently selected page. Only navigate_page/new_page need a URL to open
|
||||
* a page; the eval runner injects the test URL when htmlRoute is set.
|
||||
*/
|
||||
|
||||
import assert from 'node:assert';
|
||||
|
||||
import type {TestScenario} from '../eval_gemini.ts';
|
||||
|
||||
const INSPECTION_TOOLS = [
|
||||
'take_snapshot',
|
||||
'list_console_messages',
|
||||
'list_network_requests',
|
||||
];
|
||||
|
||||
export const scenario: TestScenario = {
|
||||
prompt: 'Can you fix issues with my webpage at <TEST_URL>?',
|
||||
maxTurns: 4,
|
||||
htmlRoute: {
|
||||
path: '/fix_issues_test.html',
|
||||
htmlContent: `
|
||||
<h1>Test Page</h1>
|
||||
<p>Some content</p>
|
||||
<script>
|
||||
console.error('Intentional error for testing');
|
||||
</script>
|
||||
`,
|
||||
},
|
||||
expectations: result => {
|
||||
const pageId = result.consumePageNavigation();
|
||||
const inspectionCalls = result.remainingCalls.filter(c =>
|
||||
INSPECTION_TOOLS.includes(c.name),
|
||||
);
|
||||
assert.ok(
|
||||
inspectionCalls.length >= 1,
|
||||
`Expected at least one inspection tool (${INSPECTION_TOOLS.join(', ')}) after navigation, got: ${result.remainingCalls.map(c => c.name).join(', ')}`,
|
||||
);
|
||||
if (result.hasPageIdRouting) {
|
||||
for (const inspectionCall of inspectionCalls) {
|
||||
assert.strictEqual(
|
||||
inspectionCall.args.pageId,
|
||||
pageId,
|
||||
`Inspection call ${inspectionCall.name} should target pageId: ${pageId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* Eval scenario using "website"/"webpage" wording to verify the model invokes
|
||||
* the right tools when users ask to open a site and read its content.
|
||||
*/
|
||||
|
||||
import assert from 'node:assert';
|
||||
|
||||
import type {TestScenario} from '../eval_gemini.ts';
|
||||
|
||||
export const scenario: TestScenario = {
|
||||
prompt:
|
||||
'Open the website at <TEST_URL> and tell me what content is on the page.',
|
||||
maxTurns: 3,
|
||||
htmlRoute: {
|
||||
path: '/frontend_snapshot.html',
|
||||
htmlContent: '<h1>Frontend Test</h1><p>This is a test webpage.</p>',
|
||||
},
|
||||
expectations: result => {
|
||||
const pageId = result.consumePageNavigation();
|
||||
assert.ok(result.remainingCalls.length >= 1);
|
||||
result.assertNextCall(
|
||||
'take_snapshot',
|
||||
result.hasPageIdRouting ? {pageId} : undefined,
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import assert from 'node:assert';
|
||||
|
||||
import type {TestScenario} from '../eval_gemini.ts';
|
||||
|
||||
export const scenario: TestScenario = {
|
||||
prompt:
|
||||
'Go to <TEST_URL>, fill the input with "hello world" and click the button five times in parallel without using a script.',
|
||||
maxTurns: 10,
|
||||
htmlRoute: {
|
||||
path: '/input_test.html',
|
||||
htmlContent: `
|
||||
<input type="text" id="test-input" />
|
||||
<button id="test-button">Submit</button>
|
||||
`,
|
||||
},
|
||||
expectations: result => {
|
||||
const pageId = result.consumePageNavigation();
|
||||
assert.ok(result.remainingCalls.length >= 7);
|
||||
result.assertNextCall(
|
||||
'take_snapshot',
|
||||
result.hasPageIdRouting ? {pageId} : undefined,
|
||||
);
|
||||
result.assertNextCall(
|
||||
'fill',
|
||||
result.hasPageIdRouting ? {pageId} : undefined,
|
||||
);
|
||||
for (let i = 2; i < 7; i++) {
|
||||
result.assertNextCall('click', {
|
||||
includeSnapshot: undefined,
|
||||
pageId: result.hasPageIdRouting ? pageId : undefined,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import assert from 'node:assert';
|
||||
|
||||
import type {TestScenario} from '../eval_gemini.ts';
|
||||
|
||||
export const scenario: TestScenario = {
|
||||
prompt:
|
||||
'Go to <TEST_URL>, fill the input with "hello world" and click the button.',
|
||||
maxTurns: 5,
|
||||
htmlRoute: {
|
||||
path: '/input_test.html',
|
||||
htmlContent: `
|
||||
<input type="text" id="test-input" />
|
||||
<button id="test-button">Submit</button>
|
||||
`,
|
||||
},
|
||||
expectations: result => {
|
||||
const pageId = result.consumePageNavigation();
|
||||
assert.ok(result.remainingCalls.length >= 3);
|
||||
result.assertNextCall(
|
||||
'take_snapshot',
|
||||
result.hasPageIdRouting ? {pageId} : undefined,
|
||||
);
|
||||
result.assertNextCall(
|
||||
'fill',
|
||||
result.hasPageIdRouting ? {pageId} : undefined,
|
||||
);
|
||||
result.assertNextCall(
|
||||
'click',
|
||||
result.hasPageIdRouting ? {pageId} : undefined,
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {TestScenario} from '../eval_gemini.ts';
|
||||
|
||||
export const scenario: TestScenario = {
|
||||
prompt:
|
||||
'Create a new page <TEST_URL> in an isolated context called contextB. Take a screenshot there.',
|
||||
maxTurns: 3,
|
||||
htmlRoute: {
|
||||
path: '/isolated_context.html',
|
||||
htmlContent: '<h1>Isolated Context</h1>',
|
||||
},
|
||||
expectations: result => {
|
||||
result.assertNextCall('new_page', {isolatedContext: 'contextB'});
|
||||
result.assertNextCall(
|
||||
'take_screenshot',
|
||||
result.hasPageIdRouting ? {pageId: 2} : undefined,
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import assert from 'node:assert';
|
||||
|
||||
import type {TestScenario} from '../eval_gemini.ts';
|
||||
|
||||
export const scenario: TestScenario = {
|
||||
prompt: 'Check a11y issues on the page at <TEST_URL>',
|
||||
maxTurns: 3,
|
||||
htmlRoute: {
|
||||
path: '/lighthouse_test.html',
|
||||
htmlContent: `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Lighthouse Audit Test</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Lighthouse Audit Test</h1>
|
||||
<p>This is a valid test page for running audits.</p>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
},
|
||||
expectations: result => {
|
||||
const pageId = result.consumePageNavigation();
|
||||
assert.ok(result.remainingCalls.length >= 1);
|
||||
result.assertNextCall(
|
||||
'lighthouse_audit',
|
||||
result.hasPageIdRouting ? {pageId} : undefined,
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import assert from 'node:assert';
|
||||
|
||||
import type {TestScenario} from '../eval_gemini.ts';
|
||||
|
||||
export const scenario: TestScenario = {
|
||||
prompt: 'Check for best practices on the page at <TEST_URL>',
|
||||
maxTurns: 3,
|
||||
htmlRoute: {
|
||||
path: '/lighthouse_test.html',
|
||||
htmlContent: `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Lighthouse Audit Test</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Lighthouse Audit Test</h1>
|
||||
<p>This is a valid test page for running audits.</p>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
},
|
||||
expectations: result => {
|
||||
const pageId = result.consumePageNavigation();
|
||||
assert.ok(result.remainingCalls.length >= 1);
|
||||
result.assertNextCall(
|
||||
'lighthouse_audit',
|
||||
result.hasPageIdRouting ? {pageId} : undefined,
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import assert from 'node:assert';
|
||||
|
||||
import type {TestScenario} from '../eval_gemini.ts';
|
||||
|
||||
export const scenario: TestScenario = {
|
||||
prompt:
|
||||
'Navigate in current page to https://developers.chrome.com and tell me if it worked.',
|
||||
maxTurns: 2,
|
||||
expectations: result => {
|
||||
if (result.hasPageIdRouting) {
|
||||
result.assertNextCall('list_pages');
|
||||
}
|
||||
assert.ok(result.remainingCalls.length >= 1);
|
||||
result.assertNextCall('navigate_page', {
|
||||
url: 'https://developers.chrome.com',
|
||||
pageId: result.hasPageIdRouting ? 1 : undefined,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import assert from 'node:assert';
|
||||
|
||||
import type {TestScenario} from '../eval_gemini.ts';
|
||||
|
||||
export const scenario: TestScenario = {
|
||||
prompt: 'Navigate to <TEST_URL> and list all network requests.',
|
||||
maxTurns: 3,
|
||||
htmlRoute: {
|
||||
path: '/network_test.html',
|
||||
htmlContent: `
|
||||
<h1>Network Test</h1>
|
||||
<script>
|
||||
fetch('/network_test.html'); // Self fetch to ensure at least one request
|
||||
</script>
|
||||
`,
|
||||
},
|
||||
expectations: result => {
|
||||
const pageId = result.consumePageNavigation();
|
||||
assert.ok(result.remainingCalls.length >= 1);
|
||||
result.assertNextCall(
|
||||
'list_network_requests',
|
||||
result.hasPageIdRouting ? {pageId} : undefined,
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import assert from 'node:assert';
|
||||
|
||||
import type {TestScenario} from '../eval_gemini.ts';
|
||||
|
||||
export const scenario: TestScenario = {
|
||||
serverArgs: ['--experimental-page-id-routing'],
|
||||
prompt: `Open two pages in the same isolated context "session":
|
||||
- Page 1 at data:text/html,<textarea id="ta"></textarea>
|
||||
- Page 2 at data:text/html,<h1>Other</h1>
|
||||
|
||||
Now use the press_key tool to type "a" on Page 1 without selecting it first. You must use press_key, not fill or type_text. If you encounter any errors, recover from them.`,
|
||||
maxTurns: 10,
|
||||
expectations: result => {
|
||||
// Should open 2 pages in the same context.
|
||||
const newPages = result.calls.filter(c => c.name === 'new_page');
|
||||
assert.strictEqual(newPages.length, 2, 'Should open 2 pages');
|
||||
assert.strictEqual(newPages[0].args.isolatedContext, 'session');
|
||||
assert.strictEqual(newPages[1].args.isolatedContext, 'session');
|
||||
|
||||
// Should attempt press_key at least once.
|
||||
const pressKeys = result.calls.filter(c => c.name === 'press_key');
|
||||
assert.ok(pressKeys.length >= 1, 'Should attempt press_key at least once');
|
||||
for (const pk of pressKeys) {
|
||||
assert.strictEqual(
|
||||
pk.args.pageId,
|
||||
2,
|
||||
'press_key should target Page 1 (pageId: 2)',
|
||||
);
|
||||
}
|
||||
|
||||
const selectPages = result.calls.filter(c => c.name === 'select_page');
|
||||
|
||||
if (selectPages.length > 0) {
|
||||
const firstPressKeyIndex = result.calls.indexOf(pressKeys[0]);
|
||||
const firstSelectPageIndex = result.calls.indexOf(selectPages[0]);
|
||||
|
||||
if (firstPressKeyIndex < firstSelectPageIndex) {
|
||||
// Error path: press_key was attempted first and failed.
|
||||
// Verify recovery: must have a second press_key after select_page.
|
||||
assert.ok(
|
||||
pressKeys.length >= 2,
|
||||
'Should retry press_key after error recovery',
|
||||
);
|
||||
const lastPressKeyIndex = result.calls.lastIndexOf(pressKeys.at(-1)!);
|
||||
assert.ok(
|
||||
firstSelectPageIndex < lastPressKeyIndex,
|
||||
'select_page should precede the successful press_key',
|
||||
);
|
||||
} else {
|
||||
// Proactive path: model selected page first.
|
||||
assert.ok(
|
||||
firstSelectPageIndex < firstPressKeyIndex,
|
||||
'select_page should precede press_key',
|
||||
);
|
||||
}
|
||||
}
|
||||
// If no select_page was called, the model found another recovery path.
|
||||
// This is acceptable as long as press_key was attempted.
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import assert from 'node:assert';
|
||||
|
||||
import type {TestScenario} from '../eval_gemini.ts';
|
||||
|
||||
export const scenario: TestScenario = {
|
||||
serverArgs: ['--experimental-page-id-routing'],
|
||||
prompt: `Open two new pages in isolated contexts:
|
||||
- Page A (isolatedContext "contextA") at data:text/html,<button>Click A</button>
|
||||
- Page B (isolatedContext "contextB") at data:text/html,<button>Click B</button>
|
||||
Then take a snapshot of Page A, take a snapshot of Page B, and then click the button on Page A.`,
|
||||
maxTurns: 12,
|
||||
expectations: result => {
|
||||
// Should have 2 new_page calls with isolatedContext.
|
||||
const newPages = result.calls.filter(c => c.name === 'new_page');
|
||||
assert.strictEqual(newPages.length, 2, 'Should open 2 pages');
|
||||
for (const np of newPages) {
|
||||
assert.strictEqual(
|
||||
typeof np.args.isolatedContext,
|
||||
'string',
|
||||
'new_page should use isolatedContext',
|
||||
);
|
||||
}
|
||||
|
||||
// Should have at least 2 take_snapshot calls (one per page).
|
||||
// The model may use pageId directly or select_page before each snapshot.
|
||||
const snapshots = result.calls.filter(c => c.name === 'take_snapshot');
|
||||
assert.ok(snapshots.length >= 2, 'Should take at least 2 snapshots');
|
||||
const snapshotPageIds = snapshots.map(s => s.args.pageId);
|
||||
assert.ok(
|
||||
snapshotPageIds.includes(2),
|
||||
'Should snapshot Page A (pageId: 2)',
|
||||
);
|
||||
assert.ok(
|
||||
snapshotPageIds.includes(3),
|
||||
'Should snapshot Page B (pageId: 3)',
|
||||
);
|
||||
|
||||
// Should have a click call (resolving uid from Page A's snapshot
|
||||
// even though Page B was snapshotted after).
|
||||
const clicks = result.calls.filter(c => c.name === 'click');
|
||||
assert.ok(clicks.length >= 1, 'Should click the button on Page A');
|
||||
assert.strictEqual(
|
||||
clicks[0].args.pageId,
|
||||
2,
|
||||
'Click should target Page A (pageId: 2)',
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import assert from 'node:assert';
|
||||
|
||||
import type {TestScenario} from '../eval_gemini.ts';
|
||||
|
||||
export const scenario: TestScenario = {
|
||||
prompt: 'Check the performance of https://developers.chrome.com',
|
||||
maxTurns: 3,
|
||||
expectations: result => {
|
||||
const pageId = result.consumePageNavigation();
|
||||
assert.ok(result.remainingCalls.length >= 1);
|
||||
result.assertNextCall(
|
||||
'performance_start_trace',
|
||||
result.hasPageIdRouting ? {pageId} : undefined,
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import assert from 'node:assert';
|
||||
|
||||
import type {TestScenario} from '../eval_gemini.ts';
|
||||
|
||||
export const scenario: TestScenario = {
|
||||
prompt:
|
||||
'Open new page <TEST_URL> and then open new page https://developers.chrome.com. Select the <TEST_URL> page.',
|
||||
maxTurns: 3,
|
||||
htmlRoute: {
|
||||
path: '/test.html',
|
||||
htmlContent: `
|
||||
<h1>test</h1>
|
||||
`,
|
||||
},
|
||||
expectations: result => {
|
||||
result.consumePageNavigation();
|
||||
assert.strictEqual(result.remainingCalls.length, 2);
|
||||
result.assertNextCall('new_page');
|
||||
result.assertNextCall('select_page', {pageId: 2, bringToFront: undefined});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import assert from 'node:assert';
|
||||
|
||||
import type {TestScenario} from '../eval_gemini.ts';
|
||||
|
||||
export const scenario: TestScenario = {
|
||||
prompt: 'Read the content of <TEST_URL>',
|
||||
maxTurns: 4,
|
||||
htmlRoute: {
|
||||
path: '/test.html',
|
||||
htmlContent: '<h1>Hello World</h1><p>This is a test.</p>',
|
||||
},
|
||||
expectations: result => {
|
||||
const pageId = result.consumePageNavigation();
|
||||
assert.strictEqual(result.remainingCalls.length, 1);
|
||||
result.assertNextCall(
|
||||
'take_snapshot',
|
||||
result.hasPageIdRouting ? {pageId} : undefined,
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import {Client} from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import {StdioClientTransport} from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
|
||||
import {parseArguments} from '../build/src/bin/chrome-devtools-mcp-cli-options.js';
|
||||
import {buildFlag} from '../build/src/index.js';
|
||||
import {
|
||||
labels,
|
||||
ToolCategory,
|
||||
OFF_BY_DEFAULT_CATEGORIES,
|
||||
} from '../build/src/tools/categories.js';
|
||||
import {createTools} from '../build/src/tools/tools.js';
|
||||
|
||||
const OUTPUT_PATH = path.join(
|
||||
import.meta.dirname,
|
||||
'../src/bin/chrome-devtools-cli-options.ts',
|
||||
);
|
||||
|
||||
async function fetchTools() {
|
||||
console.log('Connecting to chrome-devtools-mcp to fetch tools...');
|
||||
// Use the local build of the server
|
||||
const serverPath = path.join(
|
||||
import.meta.dirname,
|
||||
'../build/src/bin/chrome-devtools-mcp.js',
|
||||
);
|
||||
|
||||
const transport = new StdioClientTransport({
|
||||
command: 'node',
|
||||
args: [serverPath, '--viaCli'],
|
||||
env: {...process.env, CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true'},
|
||||
});
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
name: 'chrome-devtools-cli-generator',
|
||||
version: '0.1.0',
|
||||
},
|
||||
{
|
||||
capabilities: {},
|
||||
},
|
||||
);
|
||||
|
||||
await client.connect(transport);
|
||||
try {
|
||||
const toolsResponse = await client.listTools();
|
||||
if (!toolsResponse.tools?.length) {
|
||||
throw new Error(`No tools were fetched`);
|
||||
}
|
||||
const tools = toolsResponse.tools || [];
|
||||
console.log(`Fetched ${tools.length} tools`);
|
||||
return tools;
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
|
||||
interface CliOption {
|
||||
name: string;
|
||||
type: string;
|
||||
description: string;
|
||||
required: boolean;
|
||||
default?: unknown;
|
||||
enum?: unknown[];
|
||||
}
|
||||
|
||||
interface JsonSchema {
|
||||
type?: string | string[];
|
||||
description?: string;
|
||||
properties?: Record<string, JsonSchema>;
|
||||
required?: string[];
|
||||
default?: unknown;
|
||||
enum?: unknown[];
|
||||
}
|
||||
|
||||
function schemaToCLIOptions(schema: JsonSchema): CliOption[] {
|
||||
if (!schema || !schema.properties) {
|
||||
return [];
|
||||
}
|
||||
const required = schema.required || [];
|
||||
const properties = schema.properties;
|
||||
return Object.entries(properties).map(([name, prop]) => {
|
||||
const isRequired = required.includes(name);
|
||||
const description = prop.description || '';
|
||||
if (typeof prop.type !== 'string') {
|
||||
throw new Error(
|
||||
`Property ${name} has a complex type not supported by CLI.`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
name,
|
||||
type: prop.type,
|
||||
description,
|
||||
required: isRequired,
|
||||
default: prop.default,
|
||||
enum: prop.enum,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function generateCli() {
|
||||
const tools = await fetchTools();
|
||||
|
||||
const staticTools = createTools(parseArguments());
|
||||
const toolNameToCategoryEnum = new Map<string, string>();
|
||||
const toolNameToConditions = new Map<string, string[]>();
|
||||
|
||||
for (const tool of staticTools) {
|
||||
toolNameToCategoryEnum.set(tool.name, tool.annotations.category);
|
||||
toolNameToConditions.set(tool.name, tool.annotations.conditions || []);
|
||||
}
|
||||
|
||||
// Sort tools by name
|
||||
const sortedTools = tools
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.filter(tool => {
|
||||
// Skipping fill_form because it is not relevant in shell scripts
|
||||
// and CLI does not handle array/JSON args well.
|
||||
if (tool.name === 'fill_form') {
|
||||
return false;
|
||||
}
|
||||
// Skipping wait_for because CLI does not handle array/JSON args well
|
||||
// and shell scripts have many mechanisms for waiting.
|
||||
if (tool.name === 'wait_for') {
|
||||
return false;
|
||||
}
|
||||
// Skipping get_tab_id as it is for internal integrations
|
||||
if (tool.name === 'get_tab_id') {
|
||||
return false;
|
||||
}
|
||||
// Skipping in_page tools as they are not launched yet
|
||||
if (toolNameToCategoryEnum.get(tool.name) === ToolCategory.IN_PAGE) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const commands: Record<
|
||||
string,
|
||||
{description: string; category: string; args: Record<string, CliOption>}
|
||||
> = {};
|
||||
|
||||
for (const tool of sortedTools) {
|
||||
const options = schemaToCLIOptions(tool.inputSchema);
|
||||
const args: Record<string, CliOption> = {};
|
||||
for (const opt of options) {
|
||||
args[opt.name] = opt;
|
||||
}
|
||||
|
||||
const categoryEnum = toolNameToCategoryEnum.get(tool.name);
|
||||
if (!categoryEnum) {
|
||||
throw new Error(`Tool ${tool.name} has no category.`);
|
||||
}
|
||||
const category = labels[categoryEnum as unknown as keyof typeof labels];
|
||||
if (!tool.description) {
|
||||
throw new Error(`Tool ${tool.name} is missing description`);
|
||||
}
|
||||
|
||||
let description = tool.description;
|
||||
const requiredFlags: string[] = [];
|
||||
|
||||
const isOffByDefault = OFF_BY_DEFAULT_CATEGORIES.includes(categoryEnum);
|
||||
if (isOffByDefault) {
|
||||
const categoryFlag = buildFlag(categoryEnum);
|
||||
requiredFlags.push(`--${categoryFlag}=true`);
|
||||
}
|
||||
|
||||
const conditions = toolNameToConditions.get(tool.name) || [];
|
||||
for (const condition of conditions) {
|
||||
requiredFlags.push(`--${condition}=true`);
|
||||
}
|
||||
|
||||
if (requiredFlags.length > 0) {
|
||||
description += ` (requires flag: ${requiredFlags.join(', ')})`;
|
||||
}
|
||||
|
||||
commands[tool.name] = {
|
||||
description,
|
||||
category,
|
||||
args,
|
||||
};
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(`/**
|
||||
* @license
|
||||
* Copyright ${new Date().getFullYear()} Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
// NOTE: do not edit manually. Auto-generated by 'npm run cli:generate'.
|
||||
|
||||
export interface ArgDef {
|
||||
name: string;
|
||||
type: string;
|
||||
description: string;
|
||||
required: boolean;
|
||||
default?: string | number | boolean;
|
||||
enum?: ReadonlyArray<string | number>;
|
||||
}
|
||||
export type Commands = Record<
|
||||
string,
|
||||
{
|
||||
description: string;
|
||||
category: string;
|
||||
args: Record<string, ArgDef>
|
||||
}
|
||||
>;
|
||||
export const commands: Commands = ${JSON.stringify(commands, null, 2)} as const;
|
||||
`);
|
||||
|
||||
fs.mkdirSync(path.dirname(OUTPUT_PATH), {recursive: true});
|
||||
fs.writeFileSync(OUTPUT_PATH, lines.join(''));
|
||||
console.log(`Generated CLI at ${OUTPUT_PATH}`);
|
||||
}
|
||||
|
||||
generateCli().catch(err => {
|
||||
console.error('Error during generation:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,558 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
|
||||
import type {Tool} from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
import {cliOptions} from '../build/src/bin/chrome-devtools-mcp-cli-options.js';
|
||||
import type {ParsedArguments} from '../build/src/bin/chrome-devtools-mcp-cli-options.js';
|
||||
import {buildFlag} from '../build/src/index.js';
|
||||
import {
|
||||
ToolCategory,
|
||||
OFF_BY_DEFAULT_CATEGORIES,
|
||||
labels,
|
||||
} from '../build/src/tools/categories.js';
|
||||
import {createTools} from '../build/src/tools/tools.js';
|
||||
|
||||
const OUTPUT_PATH = './docs/tool-reference.md';
|
||||
const SLIM_OUTPUT_PATH = './docs/slim-tool-reference.md';
|
||||
const README_PATH = './README.md';
|
||||
|
||||
// Extend the MCP Tool type to include our annotations
|
||||
interface ToolWithAnnotations extends Tool {
|
||||
annotations?: {
|
||||
title?: string;
|
||||
category?: typeof ToolCategory;
|
||||
conditions?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
interface ZodCheck {
|
||||
kind: string;
|
||||
}
|
||||
|
||||
interface ZodDef {
|
||||
typeName: string;
|
||||
checks?: ZodCheck[];
|
||||
values?: string[];
|
||||
type?: ZodSchema;
|
||||
innerType?: ZodSchema;
|
||||
schema?: ZodSchema;
|
||||
defaultValue?: () => unknown;
|
||||
}
|
||||
|
||||
interface ZodSchema {
|
||||
_def: ZodDef;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface TypeInfo {
|
||||
type: string;
|
||||
enum?: string[];
|
||||
items?: TypeInfo;
|
||||
description?: string;
|
||||
default?: unknown;
|
||||
}
|
||||
|
||||
function escapeHtmlTags(text: string): string {
|
||||
return text
|
||||
.replace(/&(?![a-zA-Z]+;)/g, '&')
|
||||
.replace(/<([a-zA-Z][^>]*)>/g, '<$1>');
|
||||
}
|
||||
|
||||
function addCrossLinks(text: string, tools: ToolWithAnnotations[]): string {
|
||||
let result = text;
|
||||
|
||||
// Create a set of all tool names for efficient lookup
|
||||
const toolNames = new Set(tools.map(tool => tool.name));
|
||||
|
||||
// Sort tool names by length (descending) to match longer names first
|
||||
const sortedToolNames = Array.from(toolNames).sort(
|
||||
(a, b) => b.length - a.length,
|
||||
);
|
||||
|
||||
for (const toolName of sortedToolNames) {
|
||||
// Create regex to match tool name (case insensitive, word boundaries)
|
||||
const regex = new RegExp(`\\b${toolName}\\b`, 'gi');
|
||||
|
||||
result = result.replace(regex, match => {
|
||||
// Only create link if the match isn't already inside a link
|
||||
if (result.indexOf(`[${match}]`) !== -1) {
|
||||
return match; // Already linked
|
||||
}
|
||||
const anchorLink = toolName.toLowerCase();
|
||||
return `[\`${match}\`](#${anchorLink})`;
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function sortTools(a: ToolWithAnnotations, b: ToolWithAnnotations): number {
|
||||
const aHasConditions = Boolean(a.annotations?.conditions?.length > 0);
|
||||
const bHasConditions = Boolean(b.annotations?.conditions?.length > 0);
|
||||
|
||||
if (aHasConditions && !bHasConditions) {
|
||||
return 1;
|
||||
}
|
||||
if (!aHasConditions && bHasConditions) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
|
||||
function generateToolsTOC(
|
||||
categories: Record<string, ToolWithAnnotations[]>,
|
||||
sortedCategories: string[],
|
||||
): string {
|
||||
let toc = '';
|
||||
|
||||
for (const category of sortedCategories) {
|
||||
const categoryTools = categories[category];
|
||||
const categoryName = labels[category];
|
||||
toc += `- **${categoryName}** (${categoryTools.length} tools)\n`;
|
||||
|
||||
// Sort tools within category for TOC
|
||||
categoryTools.sort(sortTools);
|
||||
for (const tool of categoryTools) {
|
||||
const anchorLink = tool.name.toLowerCase();
|
||||
toc += ` - [\`${tool.name}\`](docs/tool-reference.md#${anchorLink})\n`;
|
||||
}
|
||||
}
|
||||
|
||||
return toc;
|
||||
}
|
||||
|
||||
function updateReadmeWithToolsTOC(toolsTOC: string): void {
|
||||
const readmeContent = fs.readFileSync(README_PATH, 'utf8');
|
||||
|
||||
const beginMarker = '<!-- BEGIN AUTO GENERATED TOOLS -->';
|
||||
const endMarker = '<!-- END AUTO GENERATED TOOLS -->';
|
||||
|
||||
const beginIndex = readmeContent.indexOf(beginMarker);
|
||||
const endIndex = readmeContent.indexOf(endMarker);
|
||||
|
||||
if (beginIndex === -1 || endIndex === -1) {
|
||||
console.warn('Could not find auto-generated tools markers in README.md');
|
||||
return;
|
||||
}
|
||||
|
||||
const before = readmeContent.substring(0, beginIndex + beginMarker.length);
|
||||
const after = readmeContent.substring(endIndex);
|
||||
|
||||
const updatedContent = before + '\n\n' + toolsTOC + '\n' + after;
|
||||
|
||||
fs.writeFileSync(README_PATH, updatedContent);
|
||||
console.log('Updated README.md with tools table of contents');
|
||||
}
|
||||
|
||||
function generateConfigOptionsMarkdown(): string {
|
||||
let markdown = '';
|
||||
|
||||
for (const [optionName, optionConfig] of Object.entries(cliOptions)) {
|
||||
// Skip hidden options
|
||||
if (optionConfig.hidden) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const aliasText = optionConfig.alias ? `, \`-${optionConfig.alias}\`` : '';
|
||||
const description = optionConfig.description || optionConfig.describe || '';
|
||||
|
||||
// Convert camelCase to dash-case
|
||||
const dashCaseName = optionName
|
||||
.replace(/([a-z])([A-Z])/g, '$1-$2')
|
||||
.toLowerCase();
|
||||
const nameDisplay =
|
||||
dashCaseName !== optionName
|
||||
? `\`--${optionName}\`/ \`--${dashCaseName}\``
|
||||
: `\`--${optionName}\``;
|
||||
|
||||
// Start with option name and description
|
||||
markdown += `- **${nameDisplay}${aliasText}**\n`;
|
||||
markdown += ` ${description}\n`;
|
||||
|
||||
// Add type information
|
||||
markdown += ` - **Type:** ${optionConfig.type}\n`;
|
||||
|
||||
// Add choices if available
|
||||
if (optionConfig.choices) {
|
||||
markdown += ` - **Choices:** ${optionConfig.choices.map(c => `\`${c}\``).join(', ')}\n`;
|
||||
}
|
||||
|
||||
// Add default if available
|
||||
markdown += ` - **Default:** \`${optionConfig.default ?? 'false'}\`\n`;
|
||||
|
||||
markdown += '\n';
|
||||
}
|
||||
|
||||
return markdown.trim();
|
||||
}
|
||||
|
||||
function updateReadmeWithOptionsMarkdown(optionsMarkdown: string): void {
|
||||
const readmeContent = fs.readFileSync(README_PATH, 'utf8');
|
||||
|
||||
const beginMarker = '<!-- BEGIN AUTO GENERATED OPTIONS -->';
|
||||
const endMarker = '<!-- END AUTO GENERATED OPTIONS -->';
|
||||
|
||||
const beginIndex = readmeContent.indexOf(beginMarker);
|
||||
const endIndex = readmeContent.indexOf(endMarker);
|
||||
|
||||
if (beginIndex === -1 || endIndex === -1) {
|
||||
console.warn('Could not find auto-generated options markers in README.md');
|
||||
return;
|
||||
}
|
||||
|
||||
const before = readmeContent.substring(0, beginIndex + beginMarker.length);
|
||||
const after = readmeContent.substring(endIndex);
|
||||
|
||||
const updatedContent = before + '\n\n' + optionsMarkdown + '\n\n' + after;
|
||||
|
||||
fs.writeFileSync(README_PATH, updatedContent);
|
||||
console.log('Updated README.md with options markdown');
|
||||
}
|
||||
|
||||
// Helper to convert Zod schema to JSON schema-like object for docs
|
||||
function getZodTypeInfo(schema: ZodSchema): TypeInfo {
|
||||
let description = schema.description;
|
||||
let def = schema._def;
|
||||
let defaultValue: unknown;
|
||||
|
||||
// Unwrap optional/default/effects
|
||||
while (
|
||||
def.typeName === 'ZodOptional' ||
|
||||
def.typeName === 'ZodDefault' ||
|
||||
def.typeName === 'ZodEffects'
|
||||
) {
|
||||
if (def.typeName === 'ZodDefault' && def.defaultValue) {
|
||||
defaultValue = def.defaultValue();
|
||||
}
|
||||
const next = def.innerType || def.schema;
|
||||
if (!next) {
|
||||
break;
|
||||
}
|
||||
schema = next;
|
||||
def = schema._def;
|
||||
if (!description && schema.description) {
|
||||
description = schema.description;
|
||||
}
|
||||
}
|
||||
|
||||
const result: TypeInfo = {type: 'unknown'};
|
||||
if (description) {
|
||||
result.description = description;
|
||||
}
|
||||
if (defaultValue !== undefined) {
|
||||
result.default = defaultValue;
|
||||
}
|
||||
|
||||
switch (def.typeName) {
|
||||
case 'ZodString':
|
||||
result.type = 'string';
|
||||
break;
|
||||
case 'ZodNumber':
|
||||
result.type = def.checks?.some((c: ZodCheck) => c.kind === 'int')
|
||||
? 'integer'
|
||||
: 'number';
|
||||
break;
|
||||
case 'ZodBoolean':
|
||||
result.type = 'boolean';
|
||||
break;
|
||||
case 'ZodEnum':
|
||||
result.type = 'string';
|
||||
result.enum = def.values;
|
||||
break;
|
||||
case 'ZodArray':
|
||||
result.type = 'array';
|
||||
if (def.type) {
|
||||
result.items = getZodTypeInfo(def.type);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
result.type = 'unknown';
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function isRequired(schema: ZodSchema): boolean {
|
||||
let def = schema._def;
|
||||
while (def.typeName === 'ZodEffects') {
|
||||
if (!def.schema) {
|
||||
break;
|
||||
}
|
||||
schema = def.schema;
|
||||
def = schema._def;
|
||||
}
|
||||
return def.typeName !== 'ZodOptional' && def.typeName !== 'ZodDefault';
|
||||
}
|
||||
|
||||
async function generateReference(
|
||||
title: string,
|
||||
outputPath: string,
|
||||
toolsWithAnnotations: ToolWithAnnotations[],
|
||||
categories: Record<string, ToolWithAnnotations[]>,
|
||||
sortedCategories: string[],
|
||||
) {
|
||||
console.log(`Found ${toolsWithAnnotations.length} tools`);
|
||||
|
||||
// Generate markdown documentation
|
||||
let markdown = `<!-- AUTO GENERATED DO NOT EDIT - run 'npm run gen' to update-->
|
||||
|
||||
# ${title}
|
||||
|
||||
`;
|
||||
// Generate table of contents
|
||||
for (const category of sortedCategories) {
|
||||
const categoryTools = categories[category];
|
||||
const categoryName = labels[category];
|
||||
const anchorName = categoryName.toLowerCase().replace(/\s+/g, '-');
|
||||
markdown += `- **[${categoryName}](#${anchorName})** (${categoryTools.length} tools)\n`;
|
||||
|
||||
// Sort tools within category for TOC
|
||||
categoryTools.sort(sortTools);
|
||||
for (const tool of categoryTools) {
|
||||
// Generate proper markdown anchor link: backticks are removed, keep underscores, lowercase
|
||||
const anchorLink = tool.name.toLowerCase();
|
||||
markdown += ` - [\`${tool.name}\`](#${anchorLink})\n`;
|
||||
}
|
||||
}
|
||||
markdown += '\n';
|
||||
|
||||
for (const category of sortedCategories) {
|
||||
const categoryTools = categories[category];
|
||||
const categoryName = labels[category];
|
||||
|
||||
markdown += `## ${categoryName}\n\n`;
|
||||
|
||||
if (OFF_BY_DEFAULT_CATEGORIES.includes(category)) {
|
||||
const flagName = `--${buildFlag(category)}`;
|
||||
|
||||
markdown += `> NOTE: The ${categoryName} category is not active by default. Use the '${flagName}' flag.\n\n`;
|
||||
}
|
||||
|
||||
// Sort tools within category
|
||||
categoryTools.sort(sortTools);
|
||||
|
||||
for (const tool of categoryTools) {
|
||||
markdown += `### \`${tool.name}\`\n\n`;
|
||||
|
||||
if (tool.description) {
|
||||
// Escape HTML tags but preserve JS function syntax
|
||||
let escapedDescription = escapeHtmlTags(tool.description);
|
||||
|
||||
const requiredFlags: string[] = [];
|
||||
|
||||
const isOffByDefault = OFF_BY_DEFAULT_CATEGORIES.includes(category);
|
||||
if (isOffByDefault) {
|
||||
const categoryFlag = buildFlag(category);
|
||||
requiredFlags.push(`--${categoryFlag}=true`);
|
||||
}
|
||||
|
||||
const conditions = tool.annotations?.conditions || [];
|
||||
for (const condition of conditions) {
|
||||
requiredFlags.push(`--${condition}=true`);
|
||||
}
|
||||
|
||||
if (requiredFlags.length > 0) {
|
||||
escapedDescription += ` (requires flag: ${requiredFlags.join(', ')})`;
|
||||
}
|
||||
|
||||
// Add cross-links to mentioned tools
|
||||
escapedDescription = addCrossLinks(
|
||||
escapedDescription,
|
||||
toolsWithAnnotations,
|
||||
);
|
||||
markdown += `**Description:** ${escapedDescription}\n\n`;
|
||||
}
|
||||
|
||||
// Handle input schema
|
||||
if (
|
||||
tool.inputSchema &&
|
||||
tool.inputSchema.properties &&
|
||||
Object.keys(tool.inputSchema.properties).length > 0
|
||||
) {
|
||||
const properties = tool.inputSchema.properties;
|
||||
const required = tool.inputSchema.required || [];
|
||||
|
||||
markdown += '**Parameters:**\n\n';
|
||||
|
||||
const propertyNames = Object.keys(properties).sort((a, b) => {
|
||||
const aRequired = required.includes(a);
|
||||
const bRequired = required.includes(b);
|
||||
if (aRequired && !bRequired) {
|
||||
return -1;
|
||||
}
|
||||
if (!aRequired && bRequired) {
|
||||
return 1;
|
||||
}
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
for (const propName of propertyNames) {
|
||||
const prop = properties[propName] as TypeInfo;
|
||||
const isRequired = required.includes(propName);
|
||||
const requiredText = isRequired ? ' **(required)**' : ' _(optional)_';
|
||||
|
||||
let typeInfo = prop.type || 'unknown';
|
||||
if (prop.enum) {
|
||||
typeInfo = `enum: ${prop.enum.map((v: string) => `"${v}"`).join(', ')}`;
|
||||
}
|
||||
|
||||
markdown += `- **${propName}** (${typeInfo})${requiredText}`;
|
||||
if (prop.description) {
|
||||
let escapedParamDesc = escapeHtmlTags(prop.description);
|
||||
|
||||
// Add cross-links to mentioned tools
|
||||
escapedParamDesc = addCrossLinks(
|
||||
escapedParamDesc,
|
||||
toolsWithAnnotations,
|
||||
);
|
||||
markdown += `: ${escapedParamDesc}`;
|
||||
}
|
||||
markdown += '\n';
|
||||
}
|
||||
markdown += '\n';
|
||||
} else {
|
||||
markdown += '**Parameters:** None\n\n';
|
||||
}
|
||||
|
||||
markdown += '---\n\n';
|
||||
}
|
||||
}
|
||||
|
||||
// Write the documentation to file
|
||||
fs.writeFileSync(outputPath, markdown.trim() + '\n');
|
||||
|
||||
console.log(
|
||||
`Generated documentation for ${toolsWithAnnotations.length} tools in ${outputPath}`,
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function getToolsAndCategories(tools: any) {
|
||||
// Convert ToolDefinitions to ToolWithAnnotations
|
||||
const toolsWithAnnotations: ToolWithAnnotations[] = tools
|
||||
.filter(tool => {
|
||||
// Skipping in_page tools as they are not launched yet
|
||||
if (tool.annotations.category === ToolCategory.IN_PAGE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skipping internal interop tools not meant for public documentation
|
||||
const skipTools = ['get_tab_id'];
|
||||
if (skipTools.includes(tool.name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.map(tool => {
|
||||
const properties: Record<string, TypeInfo> = {};
|
||||
const required: string[] = [];
|
||||
|
||||
for (const [key, schema] of Object.entries(
|
||||
tool.schema as unknown as Record<string, ZodSchema>,
|
||||
)) {
|
||||
const info = getZodTypeInfo(schema);
|
||||
properties[key] = info;
|
||||
if (isRequired(schema)) {
|
||||
required.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties,
|
||||
required,
|
||||
},
|
||||
annotations: tool.annotations,
|
||||
};
|
||||
});
|
||||
// Group tools by category (based on annotations)
|
||||
const categories: Record<string, ToolWithAnnotations[]> = {};
|
||||
toolsWithAnnotations.forEach((tool: ToolWithAnnotations) => {
|
||||
const category = tool.annotations?.category || 'Uncategorized';
|
||||
if (!categories[category]) {
|
||||
categories[category] = [];
|
||||
}
|
||||
categories[category].push(tool);
|
||||
});
|
||||
|
||||
// Sort categories using the enum order
|
||||
const categoryOrder = Object.values(ToolCategory);
|
||||
const sortedCategories = Object.keys(categories).sort((a, b) => {
|
||||
const aOff = OFF_BY_DEFAULT_CATEGORIES.includes(a as ToolCategory);
|
||||
const bOff = OFF_BY_DEFAULT_CATEGORIES.includes(b as ToolCategory);
|
||||
|
||||
if (aOff !== bOff) {
|
||||
return aOff ? 1 : -1;
|
||||
}
|
||||
|
||||
const aIndex = categoryOrder.indexOf(a as ToolCategory);
|
||||
const bIndex = categoryOrder.indexOf(b as ToolCategory);
|
||||
|
||||
// Put known categories first, unknown categories last
|
||||
if (aIndex === -1 && bIndex === -1) {
|
||||
return a.localeCompare(b);
|
||||
}
|
||||
if (aIndex === -1) {
|
||||
return 1;
|
||||
}
|
||||
if (bIndex === -1) {
|
||||
return -1;
|
||||
}
|
||||
return aIndex - bIndex;
|
||||
});
|
||||
return {toolsWithAnnotations, categories, sortedCategories};
|
||||
}
|
||||
|
||||
async function generateToolDocumentation(): Promise<void> {
|
||||
try {
|
||||
console.log('Generating tool documentation from definitions...');
|
||||
|
||||
{
|
||||
const {toolsWithAnnotations, categories, sortedCategories} =
|
||||
getToolsAndCategories(createTools({slim: false} as ParsedArguments));
|
||||
await generateReference(
|
||||
'Chrome DevTools MCP Tool Reference',
|
||||
OUTPUT_PATH,
|
||||
toolsWithAnnotations,
|
||||
categories,
|
||||
sortedCategories,
|
||||
);
|
||||
|
||||
// Generate tools TOC and update README
|
||||
const toolsTOC = generateToolsTOC(categories, sortedCategories);
|
||||
updateReadmeWithToolsTOC(toolsTOC);
|
||||
}
|
||||
|
||||
{
|
||||
const {toolsWithAnnotations, categories, sortedCategories} =
|
||||
getToolsAndCategories(createTools({slim: true} as ParsedArguments));
|
||||
await generateReference(
|
||||
'Chrome DevTools MCP Slim Tool Reference',
|
||||
SLIM_OUTPUT_PATH,
|
||||
toolsWithAnnotations,
|
||||
categories,
|
||||
sortedCategories,
|
||||
);
|
||||
}
|
||||
|
||||
// Generate and update configuration options
|
||||
const optionsMarkdown = generateConfigOptionsMarkdown();
|
||||
updateReadmeWithOptionsMarkdown(optionsMarkdown);
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Error generating documentation:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the documentation generator
|
||||
generateToolDocumentation().catch(console.error);
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
const BUILD_DIR = path.join(process.cwd(), 'build');
|
||||
|
||||
/**
|
||||
* Writes content to a file.
|
||||
* @param filePath The path to the file.
|
||||
* @param content The content to write.
|
||||
*/
|
||||
function writeFile(filePath: string, content: string): void {
|
||||
fs.writeFileSync(filePath, content, 'utf-8');
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const devtoolsThirdPartyPath =
|
||||
'node_modules/chrome-devtools-frontend/front_end/third_party';
|
||||
const devtoolsFrontEndCorePath =
|
||||
'node_modules/chrome-devtools-frontend/front_end/core';
|
||||
|
||||
// Create i18n mock
|
||||
const i18nDir = path.join(BUILD_DIR, devtoolsFrontEndCorePath, 'i18n');
|
||||
fs.mkdirSync(i18nDir, {recursive: true});
|
||||
const localesFile = path.join(i18nDir, 'locales.js');
|
||||
const localesContent = `
|
||||
export const LOCALES = [
|
||||
'en-US',
|
||||
];
|
||||
|
||||
export const BUNDLED_LOCALES = [
|
||||
'en-US',
|
||||
];
|
||||
|
||||
export const DEFAULT_LOCALE = 'en-US';
|
||||
|
||||
export const REMOTE_FETCH_PATTERN = '@HOST@/remote/serve_file/@VERSION@/core/i18n/locales/@LOCALE@.json';
|
||||
|
||||
export const LOCAL_FETCH_PATTERN = './locales/@LOCALE@.json';`;
|
||||
writeFile(localesFile, localesContent);
|
||||
|
||||
// Create codemirror.next mock.
|
||||
const codeMirrorDir = path.join(
|
||||
BUILD_DIR,
|
||||
devtoolsThirdPartyPath,
|
||||
'codemirror.next',
|
||||
);
|
||||
fs.mkdirSync(codeMirrorDir, {recursive: true});
|
||||
const codeMirrorFile = path.join(codeMirrorDir, 'codemirror.next.js');
|
||||
const codeMirrorContent = `
|
||||
export default {};
|
||||
export const cssStreamParser = () => Promise.resolve({ startState: () => ({}) });
|
||||
export class StringStream { constructor() {} }
|
||||
export const css = { cssLanguage: { parser: { parse: () => ({ topNode: { getChild: () => null } }) } } };
|
||||
`;
|
||||
writeFile(codeMirrorFile, codeMirrorContent);
|
||||
|
||||
// Create root mock
|
||||
const rootDir = path.join(BUILD_DIR, devtoolsFrontEndCorePath, 'root');
|
||||
fs.mkdirSync(rootDir, {recursive: true});
|
||||
const runtimeFile = path.join(rootDir, 'Runtime.js');
|
||||
const runtimeContent = `
|
||||
export function getChromeVersion() { return ''; };
|
||||
export function getRemoteBase() { return null; };
|
||||
export const hostConfig = {};
|
||||
export const GdpProfilesEnterprisePolicyValue = {
|
||||
ENABLED: 0,
|
||||
ENABLED_WITHOUT_BADGES: 1,
|
||||
DISABLED: 2,
|
||||
};
|
||||
export const Runtime = {
|
||||
isDescriptorEnabled: () => true,
|
||||
queryParam: () => null,
|
||||
}
|
||||
export const experiments = {
|
||||
isEnabled: () => false,
|
||||
}
|
||||
export const ExperimentName = {
|
||||
ALL: '*',
|
||||
CAPTURE_NODE_CREATION_STACKS: 'capture-node-creation-stacks',
|
||||
LIVE_HEAP_PROFILE: 'live-heap-profile',
|
||||
PROTOCOL_MONITOR: 'protocol-monitor',
|
||||
SAMPLING_HEAP_PROFILER_TIMELINE: 'sampling-heap-profiler-timeline',
|
||||
SHOW_OPTION_TO_EXPOSE_INTERNALS_IN_HEAP_SNAPSHOT: 'show-option-to-expose-internals-in-heap-snapshot',
|
||||
TIMELINE_INVALIDATION_TRACKING: 'timeline-invalidation-tracking',
|
||||
TIMELINE_SHOW_ALL_EVENTS: 'timeline-show-all-events',
|
||||
TIMELINE_V8_RUNTIME_CALL_STATS: 'timeline-v8-runtime-call-stats',
|
||||
APCA: 'apca',
|
||||
FONT_EDITOR: 'font-editor',
|
||||
FULL_ACCESSIBILITY_TREE: 'full-accessibility-tree',
|
||||
CONTRAST_ISSUES: 'contrast-issues',
|
||||
EXPERIMENTAL_COOKIE_FEATURES: 'experimental-cookie-features',
|
||||
INSTRUMENTATION_BREAKPOINTS: 'instrumentation-breakpoints',
|
||||
AUTHORED_DEPLOYED_GROUPING: 'authored-deployed-grouping',
|
||||
JUST_MY_CODE: 'just-my-code',
|
||||
USE_SOURCE_MAP_SCOPES: 'use-source-map-scopes',
|
||||
TIMELINE_SHOW_POST_MESSAGE_EVENTS: 'timeline-show-postmessage-events',
|
||||
TIMELINE_DEBUG_MODE: 'timeline-debug-mode',
|
||||
}
|
||||
`;
|
||||
writeFile(runtimeFile, runtimeContent);
|
||||
|
||||
// Copy missing CodeMirror .mjs files that tsc ignores due to .d.mts renames
|
||||
const codemirrorDir = path.join(
|
||||
BUILD_DIR,
|
||||
devtoolsThirdPartyPath,
|
||||
'codemirror',
|
||||
);
|
||||
const codemirrorSrcDir = path.join(
|
||||
process.cwd(),
|
||||
'node_modules',
|
||||
'chrome-devtools-frontend',
|
||||
'front_end',
|
||||
'third_party',
|
||||
'codemirror',
|
||||
);
|
||||
const filesToCopy = [
|
||||
'package/addon/runmode/runmode-standalone.mjs',
|
||||
'package/mode/css/css.mjs',
|
||||
'package/mode/javascript/javascript.mjs',
|
||||
'package/mode/xml/xml.mjs',
|
||||
];
|
||||
for (const file of filesToCopy) {
|
||||
const src = path.join(codemirrorSrcDir, file);
|
||||
const dest = path.join(codemirrorDir, file);
|
||||
fs.mkdirSync(path.dirname(dest), {recursive: true});
|
||||
fs.copyFileSync(src, dest);
|
||||
}
|
||||
|
||||
copyDevToolsDescriptionFiles();
|
||||
}
|
||||
|
||||
function copyDevToolsDescriptionFiles() {
|
||||
const devtoolsIssuesDescriptionPath =
|
||||
'node_modules/chrome-devtools-frontend/front_end/models/issues_manager/descriptions';
|
||||
const sourceDir = path.join(process.cwd(), devtoolsIssuesDescriptionPath);
|
||||
const destDir = path.join(
|
||||
BUILD_DIR,
|
||||
'src',
|
||||
'third_party',
|
||||
'issue-descriptions',
|
||||
);
|
||||
fs.cpSync(sourceDir, destDir, {recursive: true});
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {readFileSync, writeFileSync} from 'node:fs';
|
||||
import {rm} from 'node:fs/promises';
|
||||
import {resolve} from 'node:path';
|
||||
|
||||
const projectRoot = process.cwd();
|
||||
|
||||
const filesToRemove = [
|
||||
'node_modules/chrome-devtools-frontend/package.json',
|
||||
'node_modules/chrome-devtools-frontend/front_end/models/trace/lantern/testing',
|
||||
'node_modules/chrome-devtools-frontend/front_end/third_party/intl-messageformat/package/package.json',
|
||||
];
|
||||
|
||||
/**
|
||||
* Removes the conflicting global HTMLElementEventMap declaration from
|
||||
* @paulirish/trace_engine/models/trace/ModelImpl.d.ts to avoid TS2717 error
|
||||
* when both chrome-devtools-frontend and @paulirish/trace_engine declare
|
||||
* the same property.
|
||||
*/
|
||||
function removeConflictingGlobalDeclaration(): void {
|
||||
const filePath = resolve(
|
||||
projectRoot,
|
||||
'node_modules/@paulirish/trace_engine/models/trace/ModelImpl.d.ts',
|
||||
);
|
||||
console.log(
|
||||
'Removing conflicting global declaration from @paulirish/trace_engine...',
|
||||
);
|
||||
const content = readFileSync(filePath, 'utf-8');
|
||||
// Remove the declare global block using regex
|
||||
// Matches: declare global { ... interface HTMLElementEventMap { ... } ... }
|
||||
const newContent = content.replace(
|
||||
/declare global\s*\{\s*interface HTMLElementEventMap\s*\{[^}]*\[ModelUpdateEvent\.eventName\]:\s*ModelUpdateEvent;\s*\}\s*\}/s,
|
||||
'',
|
||||
);
|
||||
writeFileSync(filePath, newContent, 'utf-8');
|
||||
console.log('Successfully removed conflicting global declaration.');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Running prepare script to clean up chrome-devtools-frontend...');
|
||||
for (const file of filesToRemove) {
|
||||
const fullPath = resolve(projectRoot, file);
|
||||
console.log(`Removing: ${file}`);
|
||||
try {
|
||||
await rm(fullPath, {recursive: true, force: true});
|
||||
} catch (error) {
|
||||
console.error(`Failed to remove ${file}:`, error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
console.log('Clean up of chrome-devtools-frontend complete.');
|
||||
|
||||
removeConflictingGlobalDeclaration();
|
||||
}
|
||||
|
||||
void main();
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
// Note: can be converted to ts file once node 20 support is dropped.
|
||||
// Node 20 does not support --experimental-strip-types flag.
|
||||
|
||||
import {spawn, execSync} from 'node:child_process';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const userArgs = args.filter(arg => !arg.startsWith('-'));
|
||||
const flags = args.filter(arg => arg.startsWith('-'));
|
||||
|
||||
const files = [];
|
||||
|
||||
let shouldRetry = false;
|
||||
const retryIndex = flags.indexOf('--retry');
|
||||
if (retryIndex !== -1) {
|
||||
shouldRetry = true;
|
||||
flags.splice(retryIndex, 1);
|
||||
}
|
||||
|
||||
if (userArgs.length > 0) {
|
||||
for (const arg of userArgs) {
|
||||
// Map .ts files to build/ .js files
|
||||
let testPath = arg;
|
||||
if (testPath.endsWith('.ts')) {
|
||||
testPath = testPath.replace(/\.ts$/, '.js');
|
||||
if (!testPath.startsWith('build/')) {
|
||||
testPath = path.join('build', testPath);
|
||||
}
|
||||
}
|
||||
files.push(testPath);
|
||||
}
|
||||
} else {
|
||||
if (flags.includes('--test-only')) {
|
||||
const {glob} = await import('node:fs/promises');
|
||||
for await (const tsFile of glob('tests/**/*.test.ts')) {
|
||||
const content = await readFile(tsFile, 'utf8');
|
||||
if (content.includes('.only(')) {
|
||||
files.push(path.join('build', tsFile.replace(/\.ts$/, '.js')));
|
||||
}
|
||||
}
|
||||
if (files.length === 0) {
|
||||
console.warn('no files contain .only');
|
||||
process.exit(0);
|
||||
}
|
||||
} else if (files.length === 0) {
|
||||
files.push('build/tests/**/*.test.js');
|
||||
}
|
||||
}
|
||||
|
||||
const nodeArgs = [
|
||||
'--import',
|
||||
'./build/tests/setup.js',
|
||||
'--no-warnings=ExperimentalWarning',
|
||||
'--test-reporter',
|
||||
(process.env['NODE_TEST_REPORTER'] ?? process.env['CI']) ? 'spec' : 'dot',
|
||||
'--test-force-exit',
|
||||
'--test',
|
||||
'--test-timeout=120000',
|
||||
...flags,
|
||||
...files,
|
||||
];
|
||||
|
||||
function _installChrome(version) {
|
||||
try {
|
||||
return execSync(
|
||||
`npx puppeteer browsers install chrome@${version} --format "{{path}}"`,
|
||||
)
|
||||
.toString()
|
||||
.trim();
|
||||
} catch (e) {
|
||||
console.error(`Failed to install Chrome ${version}:`, e);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function runTests(attempt) {
|
||||
if (attempt > 1) {
|
||||
console.log(`\nRun attempt ${attempt}...\n`);
|
||||
}
|
||||
return new Promise(resolve => {
|
||||
const child = spawn('node', nodeArgs, {
|
||||
stdio: 'inherit',
|
||||
env: {
|
||||
...process.env,
|
||||
CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: true,
|
||||
CHROME_DEVTOOLS_MCP_CRASH_ON_UNCAUGHT: true,
|
||||
CHROME_DEVTOOLS_MCP_NO_UPDATE_CHECKS: true,
|
||||
...(process.env['RUNNER_DEBUG'] === '1' ? {DEBUG: 'puppeteer:*'} : {}),
|
||||
},
|
||||
});
|
||||
|
||||
child.on('close', code => {
|
||||
resolve(code);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const maxAttempts = shouldRetry ? 3 : 1;
|
||||
let exitCode = 1;
|
||||
|
||||
for (let i = 1; i <= maxAttempts; i++) {
|
||||
exitCode = await runTests(i);
|
||||
if (exitCode === 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(exitCode ?? 1);
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"outDir": "./ignored",
|
||||
"rootDir": ".",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noImplicitReturns": true,
|
||||
"noImplicitOverride": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"incremental": true,
|
||||
"allowJs": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
"useUnknownInCatchVariables": false
|
||||
},
|
||||
"include": ["./**/*.ts", "./**/*.js", "./**/*.mjs"]
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {execSync} from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const ROOT_DIR = process.cwd();
|
||||
const LIGHTHOUSE_DIR = path.resolve(ROOT_DIR, '../lighthouse');
|
||||
const DEST_DIR = path.join(ROOT_DIR, 'src/third_party');
|
||||
|
||||
function main() {
|
||||
if (!fs.existsSync(LIGHTHOUSE_DIR)) {
|
||||
console.error(`Lighthouse directory not found at ${LIGHTHOUSE_DIR}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('Running yarn in lighthouse directory...');
|
||||
execSync('yarn', {cwd: LIGHTHOUSE_DIR, stdio: 'inherit'});
|
||||
|
||||
console.log('Building lighthouse-devtools-mcp bundle...');
|
||||
execSync('yarn build-devtools-mcp', {cwd: LIGHTHOUSE_DIR, stdio: 'inherit'});
|
||||
|
||||
const bundlePath = path.join(
|
||||
LIGHTHOUSE_DIR,
|
||||
'dist',
|
||||
'lighthouse-devtools-mcp-bundle.js',
|
||||
);
|
||||
|
||||
console.log(`Copying bundle from ${bundlePath} to ${DEST_DIR}...`);
|
||||
fs.copyFileSync(
|
||||
bundlePath,
|
||||
path.join(DEST_DIR, 'lighthouse-devtools-mcp-bundle.js'),
|
||||
);
|
||||
|
||||
const noticesPath = path.join(
|
||||
LIGHTHOUSE_DIR,
|
||||
'dist',
|
||||
'LIGHTHOUSE_MCP_BUNDLE_THIRD_PARTY_NOTICES',
|
||||
);
|
||||
|
||||
console.log(`Copying notices from ${noticesPath} to ${DEST_DIR}...`);
|
||||
fs.copyFileSync(
|
||||
noticesPath,
|
||||
path.join(DEST_DIR, 'LIGHTHOUSE_MCP_BUNDLE_THIRD_PARTY_NOTICES'),
|
||||
);
|
||||
|
||||
console.log('Done.');
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import {
|
||||
cliOptions,
|
||||
parseArguments,
|
||||
} from '../build/src/bin/chrome-devtools-mcp-cli-options.js';
|
||||
import {ErrorCode} from '../build/src/telemetry/errors.js';
|
||||
import {
|
||||
getPossibleFlagMetrics,
|
||||
type FlagMetric,
|
||||
} from '../build/src/telemetry/flagUtils.js';
|
||||
import {
|
||||
applyToExisting,
|
||||
applyToExistingMetrics,
|
||||
generateToolMetrics,
|
||||
type ToolMetric,
|
||||
} from '../build/src/telemetry/metricsRegistry.js';
|
||||
import {createTools} from '../build/src/tools/tools.js';
|
||||
|
||||
export function HaveUniqueNames(tools: Array<{name: string}>): boolean {
|
||||
const toolNames = tools.map(tool => tool.name);
|
||||
const toolNamesSet = new Set(toolNames);
|
||||
return toolNamesSet.size === toolNames.length;
|
||||
}
|
||||
|
||||
function writeToolCallMetricsConfig() {
|
||||
const outputPath = path.resolve('src/telemetry/tool_call_metrics.json');
|
||||
|
||||
const dir = path.dirname(outputPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
throw new Error(`Error: Directory ${dir} does not exist.`);
|
||||
}
|
||||
|
||||
// Avoid 'as ParsedArguments' by using parseArguments
|
||||
const fullTools = createTools(parseArguments('0.0.0', ['', '']));
|
||||
const slimTools = createTools(parseArguments('0.0.0', ['', '', '--slim']));
|
||||
|
||||
const allTools = [...fullTools, ...slimTools];
|
||||
|
||||
if (!HaveUniqueNames(allTools)) {
|
||||
throw new Error('Error: Duplicate tool names found.');
|
||||
}
|
||||
|
||||
let existingMetrics: ToolMetric[] = [];
|
||||
if (fs.existsSync(outputPath)) {
|
||||
try {
|
||||
existingMetrics = JSON.parse(
|
||||
fs.readFileSync(outputPath, 'utf8'),
|
||||
) as ToolMetric[];
|
||||
} catch {
|
||||
console.warn(
|
||||
`Warning: Failed to parse existing metrics from ${outputPath}. Starting fresh.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const newMetrics = generateToolMetrics(allTools);
|
||||
const mergedMetrics = applyToExistingMetrics(existingMetrics, newMetrics);
|
||||
|
||||
fs.writeFileSync(outputPath, JSON.stringify(mergedMetrics, null, 2) + '\n');
|
||||
|
||||
console.log(
|
||||
`Successfully wrote ${mergedMetrics.length} total tool metrics (including deprecated ones) to ${outputPath}`,
|
||||
);
|
||||
}
|
||||
|
||||
function writeFlagUsageMetrics() {
|
||||
const outputPath = path.resolve('src/telemetry/flag_usage_metrics.json');
|
||||
|
||||
const dir = path.dirname(outputPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
throw new Error(`Error: Directory ${dir} does not exist.`);
|
||||
}
|
||||
|
||||
let existingMetrics: FlagMetric[] = [];
|
||||
if (fs.existsSync(outputPath)) {
|
||||
try {
|
||||
existingMetrics = JSON.parse(
|
||||
fs.readFileSync(outputPath, 'utf8'),
|
||||
) as FlagMetric[];
|
||||
} catch {
|
||||
console.warn(
|
||||
`Warning: Failed to parse existing metrics from ${outputPath}. Starting fresh.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const newMetrics = getPossibleFlagMetrics(cliOptions);
|
||||
const mergedMetrics = applyToExisting<FlagMetric>(
|
||||
existingMetrics,
|
||||
newMetrics,
|
||||
);
|
||||
|
||||
fs.writeFileSync(outputPath, JSON.stringify(mergedMetrics, null, 2) + '\n');
|
||||
|
||||
console.log(
|
||||
`Successfully wrote ${mergedMetrics.length} flag usage metrics to ${outputPath}`,
|
||||
);
|
||||
}
|
||||
|
||||
function validateErrorCodes(): void {
|
||||
// The compiled JavaScript object has both forward and backward mappings of the enum,
|
||||
// for example: { '0': 'ERROR_CODE_UNSPECIFIED', 'ERROR_CODE_UNSPECIFIED': 0 }.
|
||||
// This filters out the numeric keys.
|
||||
const stringKeysOnly = Object.entries(ErrorCode).filter(([key]) =>
|
||||
isNaN(Number(key)),
|
||||
);
|
||||
|
||||
let expectedIndex = 0;
|
||||
for (const [key, index] of stringKeysOnly) {
|
||||
if (index !== expectedIndex) {
|
||||
throw new Error(
|
||||
`Error: ErrorCode enums must be sequentially numbered from 0.`,
|
||||
);
|
||||
}
|
||||
if (/_\d/.test(key)) {
|
||||
throw new Error(
|
||||
`Error: ErrorCode enum ${key} is invalid. No numbers should be preceded with an underscore.`,
|
||||
);
|
||||
}
|
||||
expectedIndex++;
|
||||
}
|
||||
|
||||
console.log(`Successfully validated ${expectedIndex} ErrorCode enums.`);
|
||||
}
|
||||
|
||||
function main() {
|
||||
validateErrorCodes();
|
||||
writeToolCallMetricsConfig();
|
||||
writeFlagUsageMetrics();
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {execSync} from 'node:child_process';
|
||||
|
||||
// Checks that the select build files are present using `npm publish --dry-run`.
|
||||
function verifyPackageContents() {
|
||||
try {
|
||||
const output = execSync('npm publish --dry-run --json --silent', {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
// skip non-JSON output from prepare.
|
||||
const data = JSON.parse(output.substring(output.indexOf('{')));
|
||||
const files = data['chrome-devtools-mcp'].files.map(f => f.path);
|
||||
// Check some important files.
|
||||
const requiredPaths = [
|
||||
'build/src/index.js',
|
||||
'build/src/third_party/index.js',
|
||||
];
|
||||
for (const requiredPath of requiredPaths) {
|
||||
const hasBuildFolder = files.some(path => path.startsWith(requiredPath));
|
||||
if (!hasBuildFolder) {
|
||||
console.error(
|
||||
`Assertion Failed: "${requiredPath}" not found in tarball.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
`npm publish --dry-run contained ${JSON.stringify(requiredPaths)}`,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('failed to parse npm publish output', err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
verifyPackageContents();
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {execSync} from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const serverJsonFilePath = path.join(process.cwd(), 'server.json');
|
||||
const serverJson = JSON.parse(fs.readFileSync(serverJsonFilePath, 'utf-8'));
|
||||
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-verify-'));
|
||||
|
||||
try {
|
||||
const osName = os.platform();
|
||||
const arch = os.arch();
|
||||
let platform = '';
|
||||
if (osName === 'darwin') {
|
||||
platform = 'darwin';
|
||||
} else if (osName === 'linux') {
|
||||
platform = 'linux';
|
||||
}
|
||||
// mcp-publisher does not support windows
|
||||
else {
|
||||
throw new Error(`Unsupported platform: ${osName}`);
|
||||
}
|
||||
|
||||
let archName = '';
|
||||
if (arch === 'x64') {
|
||||
archName = 'amd64';
|
||||
} else if (arch === 'arm64') {
|
||||
archName = 'arm64';
|
||||
} else {
|
||||
throw new Error(`Unsupported architecture: ${arch}`);
|
||||
}
|
||||
|
||||
const osArch = `${platform}_${archName}`;
|
||||
const binName = 'mcp-publisher';
|
||||
const downloadUrl = `https://github.com/modelcontextprotocol/registry/releases/latest/download/${binName}_${osArch}.tar.gz`;
|
||||
|
||||
console.log(`Downloading ${binName} from ${downloadUrl}`);
|
||||
const downloadCmd = `curl -L "${downloadUrl}" | tar xz -C "${tmpDir}" ${binName}`;
|
||||
execSync(downloadCmd, {stdio: 'inherit'});
|
||||
|
||||
const publisherPath = path.join(tmpDir, binName);
|
||||
fs.chmodSync(publisherPath, 0o755);
|
||||
console.log(`Downloaded to ${publisherPath}`);
|
||||
|
||||
// Create the new server.json in the temporary directory
|
||||
execSync(`${publisherPath} init`, {cwd: tmpDir, stdio: 'inherit'});
|
||||
|
||||
const newServerJsonPath = path.join(tmpDir, 'server.json');
|
||||
const newServerJson = JSON.parse(fs.readFileSync(newServerJsonPath, 'utf-8'));
|
||||
|
||||
const propertyToVerify = ['$schema'];
|
||||
const diffProps = [];
|
||||
|
||||
for (const prop of propertyToVerify) {
|
||||
if (serverJson[prop] !== newServerJson[prop]) {
|
||||
diffProps.push(prop);
|
||||
}
|
||||
}
|
||||
|
||||
if (diffProps.length) {
|
||||
throw new Error(
|
||||
`The following props in ${serverJsonFilePath} did not match the latest init value:\n${diffProps.map(
|
||||
prop =>
|
||||
`- "${prop}": expected "${newServerJson[prop]}", got "${serverJson[prop]}"`,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, {recursive: true, force: true});
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
||||
"name": "io.github.ChromeDevTools/chrome-devtools-mcp",
|
||||
"title": "Chrome DevTools MCP",
|
||||
"description": "MCP server for Chrome DevTools",
|
||||
"repository": {
|
||||
"url": "https://github.com/ChromeDevTools/chrome-devtools-mcp",
|
||||
"source": "github"
|
||||
},
|
||||
"version": "1.5.0",
|
||||
"packages": [
|
||||
{
|
||||
"registryType": "npm",
|
||||
"registryBaseUrl": "https://registry.npmjs.org",
|
||||
"identifier": "chrome-devtools-mcp",
|
||||
"version": "1.5.0",
|
||||
"transport": {
|
||||
"type": "stdio"
|
||||
},
|
||||
"environmentVariables": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
name: a11y-debugging
|
||||
description: Uses Chrome DevTools MCP for accessibility (a11y) debugging and auditing based on web.dev guidelines. Use when testing semantic HTML, ARIA labels, focus states, keyboard navigation, tap targets, and color contrast.
|
||||
---
|
||||
|
||||
## Core Concepts
|
||||
|
||||
**Accessibility Tree vs DOM**: Visually hiding an element (e.g., `CSS opacity: 0`) behaves differently for screen readers than `display: none` or `aria-hidden="true"`. The `take_snapshot` tool returns the accessibility tree of the page, which represents what assistive technologies "see", making it the most reliable source of truth for semantic structure.
|
||||
|
||||
**Reading web.dev documentation**: If you need to research specific accessibility guidelines (like `https://web.dev/articles/accessible-tap-targets`), you can append `.md.txt` to the URL (e.g., `https://web.dev/articles/accessible-tap-targets.md.txt`) to fetch the clean, raw markdown version. This is much easier to read!
|
||||
|
||||
## Workflow Patterns
|
||||
|
||||
### 1. Automated Audit (Lighthouse)
|
||||
|
||||
Start by running a Lighthouse accessibility audit to get a comprehensive baseline. This tool provides a high-level score and lists specific failing elements with remediation advice.
|
||||
|
||||
1. Run the audit:
|
||||
- Set `mode` to `"navigation"` to refresh the page and capture load issues.
|
||||
- Set `outputDirPath` (e.g., `/tmp/lh-report`) to save the full JSON report.
|
||||
2. **Analyze the Summary**:
|
||||
- Check `scores` (0-1 scale). A score < 1 indicates violations.
|
||||
- Review `audits.failed` count.
|
||||
3. **Review the Report (CRITICAL)**:
|
||||
- **Parsing**: Do not read the entire file line-by-line. Use a CLI tool like `jq` or a Node.js one-liner to filter for failures:
|
||||
```bash
|
||||
# Extract failing audits with their details
|
||||
node -e "const r=require('./report.json'); Object.values(r.audits).filter(a=>a.score!==null && a.score<1).forEach(a=>console.log(JSON.stringify({id:a.id, title:a.title, items:a.details?.items})))"
|
||||
```
|
||||
- This efficiently extracts the `selector` and `snippet` of failing elements without loading the full report into context.
|
||||
|
||||
### 2. Browser Issues & Audits
|
||||
|
||||
Chrome automatically checks for common accessibility problems. Use `list_console_messages` to check for these native audits:
|
||||
|
||||
- `types`: `["issue"]`
|
||||
- `includePreservedMessages`: `true` (to catch issues that occurred during page load)
|
||||
|
||||
This often reveals missing labels, invalid ARIA attributes, and other critical errors without manual investigation.
|
||||
|
||||
### 3. Semantics & Structure
|
||||
|
||||
The accessibility tree exposes the heading hierarchy and semantic landmarks.
|
||||
|
||||
1. Navigate to the page.
|
||||
2. Use `take_snapshot` to capture the accessibility tree.
|
||||
3. **Check Heading Levels**: Ensure heading levels (`h1`, `h2`, `h3`, etc.) are logical and do not skip levels. The snapshot will include heading roles.
|
||||
4. **Content Reordering**: Verify that the DOM order (which drives the accessibility tree) matches the visual reading order. Use `take_screenshot` to inspect the visual layout and compare it against the snapshot structure to catch CSS floats or absolute positioning that jumbles the logical flow.
|
||||
|
||||
### 4. Labels, Forms & Text Alternatives
|
||||
|
||||
1. Locate buttons, inputs, and images in the `take_snapshot` output.
|
||||
2. Ensure interactive elements have an accessible name (e.g., a button should not just say `""` if it only contains an icon).
|
||||
3. **Orphaned Inputs**: Verify that all form inputs have associated labels. Use `evaluate_script` with the **"Find Orphaned Form Inputs" snippet** found in [references/a11y-snippets.md](references/a11y-snippets.md).
|
||||
4. Check images for `alt` text.
|
||||
|
||||
### 5. Focus & Keyboard Navigation
|
||||
|
||||
Testing "keyboard traps" and proper focus management without visual feedback relies on tracking the focused element.
|
||||
|
||||
1. Use the `press_key` tool with `"Tab"` or `"Shift+Tab"` to move focus.
|
||||
2. Use `take_snapshot` to capture the updated accessibility tree.
|
||||
3. Locate the element marked as focused in the snapshot to verify focus moved to the expected interactive element.
|
||||
4. If a modal opens, focus must move into the modal and "trap" within it until closed.
|
||||
|
||||
### 6. Tap Targets and Visuals
|
||||
|
||||
According to web.dev, tap targets should be at least 48x48 pixels with sufficient spacing. Since the accessibility tree doesn't show sizes, use `evaluate_script` with the **"Measure Tap Target Size" snippet** found in [references/a11y-snippets.md](references/a11y-snippets.md).
|
||||
|
||||
_Pass the element's `uid` from the snapshot as an argument to `evaluate_script`._
|
||||
|
||||
### 7. Color Contrast
|
||||
|
||||
To verify color contrast ratios, start by checking for native accessibility issues:
|
||||
|
||||
1. Call `list_console_messages` with `types: ["issue"]`.
|
||||
2. Look for "Low Contrast" issues in the output.
|
||||
|
||||
If native audits do not report issues (which may happen in some headless environments) or if you need to check a specific element manually, use `evaluate_script` with the **"Check Color Contrast" snippet** found in [references/a11y-snippets.md](references/a11y-snippets.md).
|
||||
|
||||
### 8. Global Page Checks
|
||||
|
||||
Verify document-level accessibility settings often missed in component testing using the **"Global Page Checks" snippet** found in [references/a11y-snippets.md](references/a11y-snippets.md).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If standard a11y queries fail or the `evaluate_script` snippets return unexpected results:
|
||||
|
||||
- **Visual Inspection**: If automated scripts cannot determine contrast (e.g., text over gradient images or complex backgrounds), use `take_screenshot` to capture the element. While models cannot measure exact contrast ratios from images, they can visually assess legibility and identify obvious issues.
|
||||
@@ -0,0 +1,92 @@
|
||||
# Accessibility Debugging Snippets
|
||||
|
||||
Use these JavaScript snippets with the `evaluate_script` tool.
|
||||
|
||||
## 1. Find Orphaned Form Inputs
|
||||
|
||||
Finds form inputs that lack an associated label (no `label[for]`, `aria-label`, `aria-labelledby`, or wrapping `<label>`).
|
||||
|
||||
```js
|
||||
() =>
|
||||
Array.from(document.querySelectorAll('input, select, textarea'))
|
||||
.filter(i => {
|
||||
const hasId = i.id && document.querySelector(`label[for="${i.id}"]`);
|
||||
const hasAria =
|
||||
i.getAttribute('aria-label') || i.getAttribute('aria-labelledby');
|
||||
return !hasId && !hasAria && !i.closest('label');
|
||||
})
|
||||
.map(i => ({
|
||||
tag: i.tagName,
|
||||
id: i.id,
|
||||
name: i.name,
|
||||
placeholder: i.placeholder,
|
||||
}));
|
||||
```
|
||||
|
||||
## 2. Measure Tap Target Size
|
||||
|
||||
Returns the bounding box dimensions of an element. Pass the element's `uid` from the snapshot as an argument to `evaluate_script`.
|
||||
|
||||
```js
|
||||
el => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
return {width: rect.width, height: rect.height};
|
||||
};
|
||||
```
|
||||
|
||||
## 3. Check Color Contrast
|
||||
|
||||
Approximates the contrast ratio between an element's text color and background color. Pass the element's `uid` to test against WCAG AA (4.5:1 for normal text, 3:1 for large text).
|
||||
|
||||
**Note**: This uses a simplified algorithm and may not account for transparency, gradients, or background images. For production-grade auditing, consider injecting `axe-core`.
|
||||
|
||||
```js
|
||||
el => {
|
||||
function getRGB(colorStr) {
|
||||
const match = colorStr.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
|
||||
return match
|
||||
? [parseInt(match[1]), parseInt(match[2]), parseInt(match[3])]
|
||||
: [255, 255, 255];
|
||||
}
|
||||
function luminance(r, g, b) {
|
||||
const a = [r, g, b].map(function (v) {
|
||||
v /= 255;
|
||||
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
|
||||
});
|
||||
return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722;
|
||||
}
|
||||
|
||||
const style = window.getComputedStyle(el);
|
||||
const fg = getRGB(style.color);
|
||||
let bg = getRGB(style.backgroundColor);
|
||||
|
||||
const l1 = luminance(fg[0], fg[1], fg[2]);
|
||||
const l2 = luminance(bg[0], bg[1], bg[2]);
|
||||
const ratio = (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
|
||||
|
||||
return {
|
||||
color: style.color,
|
||||
bg: style.backgroundColor,
|
||||
contrastRatio: ratio.toFixed(2),
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
## 4. Global Page Checks
|
||||
|
||||
Checks document-level accessibility settings often missed in component testing.
|
||||
|
||||
```js
|
||||
() => ({
|
||||
lang:
|
||||
document.documentElement.lang ||
|
||||
'MISSING - Screen readers need this for pronunciation',
|
||||
title: document.title || 'MISSING - Required for context',
|
||||
viewport:
|
||||
document.querySelector('meta[name="viewport"]')?.content ||
|
||||
'MISSING - Check for user-scalable=no (bad practice)',
|
||||
reducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
? 'Enabled'
|
||||
: 'Disabled',
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,153 @@
|
||||
---
|
||||
name: chrome-devtools-cli
|
||||
description: Use this skill to write shell scripts or run shell commands to automate tasks in the browser or otherwise use Chrome DevTools via CLI.
|
||||
---
|
||||
|
||||
The `chrome-devtools-mcp` CLI lets you interact with the browser from your terminal.
|
||||
|
||||
## Setup
|
||||
|
||||
_Note: If this is your very first time using the CLI, see [references/installation.md](references/installation.md) for setup. Installation is a one-time prerequisite and is **not** part of the regular AI workflow._
|
||||
|
||||
## AI Workflow
|
||||
|
||||
1. **Execute**: Run tools directly (e.g., `chrome-devtools list_pages`). The background server starts implicitly; **do not** run `start`/`status`/`stop` before each use.
|
||||
2. **Inspect**: Use `take_snapshot` to get an element `<uid>`.
|
||||
3. **Act**: Use `click`, `fill`, etc. State persists across commands.
|
||||
|
||||
Snapshot example:
|
||||
|
||||
```
|
||||
uid=1_0 RootWebArea "Example Domain" url="https://example.com/"
|
||||
uid=1_1 heading "Example Domain" level="1"
|
||||
```
|
||||
|
||||
## Command Usage
|
||||
|
||||
```sh
|
||||
chrome-devtools <tool> [arguments] [flags]
|
||||
```
|
||||
|
||||
Use `--help` on any command. Output defaults to Markdown, use `--output-format=json` for JSON.
|
||||
|
||||
## Input Automation (<uid> from snapshot)
|
||||
|
||||
```bash
|
||||
chrome-devtools take_snapshot --help # Help message for commands, works for any command.
|
||||
chrome-devtools take_snapshot # Take a text snapshot of the page to get UIDs for elements
|
||||
chrome-devtools click "id" # Clicks on the provided element
|
||||
chrome-devtools click "id" --dblClick true --includeSnapshot true # Double clicks and returns a snapshot
|
||||
chrome-devtools drag "src" "dst" # Drag an element onto another element
|
||||
chrome-devtools drag "src" "dst" --includeSnapshot true # Drag an element and return a snapshot
|
||||
chrome-devtools fill "id" "text" # Type text into an input or select an option
|
||||
chrome-devtools fill "id" "text" --includeSnapshot true # Fill an element and return a snapshot
|
||||
chrome-devtools handle_dialog accept # Handle a browser dialog
|
||||
chrome-devtools handle_dialog dismiss --promptText "hi" # Dismiss a dialog with prompt text
|
||||
chrome-devtools hover "id" # Hover over the provided element
|
||||
chrome-devtools hover "id" --includeSnapshot true # Hover over an element and return a snapshot
|
||||
chrome-devtools press_key "Enter" # Press a key or key combination
|
||||
chrome-devtools press_key "Control+A" --includeSnapshot true # Press a key and return a snapshot
|
||||
chrome-devtools type_text "hello" # Type text using keyboard into a focused input
|
||||
chrome-devtools type_text "hello" --submitKey "Enter" # Type text and press a submit key
|
||||
chrome-devtools upload_file "id" "file.txt" # Upload a file through a provided element
|
||||
chrome-devtools upload_file "id" "file.txt" --includeSnapshot true # Upload a file and return a snapshot
|
||||
```
|
||||
|
||||
## Navigation
|
||||
|
||||
```bash
|
||||
chrome-devtools close_page 1 # Closes the page by its index
|
||||
chrome-devtools list_pages # Get a list of pages open in the browser
|
||||
chrome-devtools navigate_page --url "https://example.com" # Navigates the currently selected page to a URL
|
||||
chrome-devtools navigate_page --type "reload" --ignoreCache true # Reload page ignoring cache
|
||||
chrome-devtools navigate_page --url "https://example.com" --timeout 5000 # Navigate with a timeout
|
||||
chrome-devtools navigate_page --handleBeforeUnload "accept" # Handle before unload dialog
|
||||
chrome-devtools navigate_page --type "back" --initScript "foo()" # Navigate back and run an init script
|
||||
chrome-devtools new_page "https://example.com" # Creates a new page
|
||||
chrome-devtools new_page "https://example.com" --background true --timeout 5000 # Create new page in background
|
||||
chrome-devtools new_page "https://example.com" --isolatedContext "ctx" # Create new page with isolated context
|
||||
chrome-devtools select_page 1 # Select a page as a context for future tool calls
|
||||
chrome-devtools select_page 1 --bringToFront true # Select a page and bring it to front
|
||||
```
|
||||
|
||||
## Emulation
|
||||
|
||||
```bash
|
||||
chrome-devtools emulate --networkConditions "Offline" # Emulate network conditions
|
||||
chrome-devtools emulate --cpuThrottlingRate 4 --geolocation "0x0" # Emulate CPU throttling and geolocation
|
||||
chrome-devtools emulate --colorScheme "dark" --viewport "1920x1080" # Emulate color scheme and viewport
|
||||
chrome-devtools emulate --userAgent "Mozilla/5.0..." # Emulate user agent
|
||||
chrome-devtools resize_page 1920 1080 # Resizes the selected page's window
|
||||
```
|
||||
|
||||
## Performance
|
||||
|
||||
```bash
|
||||
chrome-devtools performance_analyze_insight "1" "LCPBreakdown" # Get more details on a specific Performance Insight
|
||||
chrome-devtools performance_start_trace true false # Starts a performance trace recording
|
||||
chrome-devtools performance_start_trace true true --filePath t.gz # Start trace and save to a file
|
||||
chrome-devtools performance_stop_trace # Stops the active performance trace
|
||||
chrome-devtools performance_stop_trace --filePath "t.json" # Stop trace and save to a file
|
||||
chrome-devtools take_memory_snapshot "./snap.heapsnapshot" # Capture a memory heapsnapshot
|
||||
```
|
||||
|
||||
## Network
|
||||
|
||||
```bash
|
||||
chrome-devtools get_network_request # Get the currently selected network request
|
||||
chrome-devtools get_network_request --reqid 1 --requestFilePath req.md # Get request by id and save to file
|
||||
chrome-devtools get_network_request --responseFilePath res.md # Save response body to file
|
||||
chrome-devtools list_network_requests # List all network requests
|
||||
chrome-devtools list_network_requests --pageSize 50 --pageIdx 0 # List network requests with pagination
|
||||
chrome-devtools list_network_requests --resourceTypes Fetch # Filter requests by resource type
|
||||
chrome-devtools list_network_requests --includePreservedRequests true # Include preserved requests
|
||||
```
|
||||
|
||||
## Debugging & Inspection
|
||||
|
||||
```bash
|
||||
chrome-devtools evaluate_script "() => document.title" # Evaluate a JavaScript function on the page
|
||||
chrome-devtools evaluate_script "(a) => a.innerText" --args 1_4 # Evaluate JS with UID arguments
|
||||
chrome-devtools get_console_message 1 # Gets a console message by its ID
|
||||
chrome-devtools lighthouse_audit --mode "navigation" # Run Lighthouse audit for navigation
|
||||
chrome-devtools lighthouse_audit --mode "snapshot" --device "mobile" # Run Lighthouse audit for a snapshot on mobile
|
||||
chrome-devtools lighthouse_audit --outputDirPath ./out # Run Lighthouse audit and save reports
|
||||
chrome-devtools list_console_messages # List all console messages
|
||||
chrome-devtools list_console_messages --pageSize 20 --pageIdx 1 # List console messages with pagination
|
||||
chrome-devtools list_console_messages --types error --types info # Filter console messages by type
|
||||
chrome-devtools list_console_messages --includePreservedMessages true # Include preserved messages
|
||||
chrome-devtools take_screenshot # Take a screenshot of the page viewport
|
||||
chrome-devtools take_screenshot --fullPage true --format "jpeg" --quality 80 # Take a full page screenshot as JPEG with quality
|
||||
chrome-devtools take_screenshot --uid "id" --filePath "s.png" # Take a screenshot of an element
|
||||
chrome-devtools take_snapshot # Take a text snapshot of the page from the a11y tree
|
||||
chrome-devtools take_snapshot --verbose true --filePath "s.txt" # Take a verbose snapshot and save to file
|
||||
```
|
||||
|
||||
## Extensions
|
||||
|
||||
```bash
|
||||
chrome-devtools list_extensions # Lists all the Chrome extensions installed in the browser
|
||||
chrome-devtools install_extension "/path/to/extension" # Installs a Chrome extension from the given path
|
||||
chrome-devtools uninstall_extension "extension_id" # Uninstalls a Chrome extension by its ID
|
||||
chrome-devtools reload_extension "extension_id" # Reloads an unpacked Chrome extension by its ID
|
||||
chrome-devtools trigger_extension_action "extension_id" # Triggers the default action of an extension by its ID
|
||||
```
|
||||
|
||||
## Experimental Features
|
||||
|
||||
Experimental tools are disabled by default. Enable them with the corresponding flag during `start`.
|
||||
|
||||
```bash
|
||||
chrome-devtools click_at 100 200 # Clicks at the provided coordinates (requires --experimentalVision=true)
|
||||
chrome-devtools screencast_start # Starts a screencast recording (requires --experimentalScreencast=true and ffmpeg)
|
||||
chrome-devtools screencast_stop # Stops the active screencast
|
||||
chrome-devtools list_webmcp_tools # List all WebMCP tools (requires --categoryExperimentalWebmcp=true)
|
||||
```
|
||||
|
||||
## Service Management
|
||||
|
||||
```bash
|
||||
chrome-devtools start # Start or restart chrome-devtools-mcp
|
||||
chrome-devtools status # Checks if chrome-devtools-mcp is running
|
||||
chrome-devtools stop # Stop chrome-devtools-mcp if any
|
||||
```
|
||||
@@ -0,0 +1,14 @@
|
||||
# Installation
|
||||
|
||||
Install the package globally to make the `chrome-devtools` command available. You only need to do this the first time you use it.
|
||||
|
||||
```sh
|
||||
npm i chrome-devtools-mcp@latest -g
|
||||
chrome-devtools status # check if install worked.
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Command not found:** If `chrome-devtools` is not recognized, ensure your global npm `bin` directory is in your system's `PATH`. Restart your terminal or source your shell configuration file (e.g., `.bashrc`, `.zshrc`).
|
||||
- **Permission errors:** If you encounter `EACCES` or permission errors during installation, avoid using `sudo`. Instead, use a node version manager like `nvm`, or configure npm to use a different global directory.
|
||||
- **Old version running:** Run `chrome-devtools stop && npm uninstall -g chrome-devtools-mcp` before reinstalling, or ensure the latest version is being picked up by your path.
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
name: chrome-devtools
|
||||
description: Uses Chrome DevTools via MCP for efficient debugging, troubleshooting and browser automation. Use when debugging web pages, automating browser interactions, analyzing performance, or inspecting network requests. This skill does not apply to `--slim` mode (MCP configuration).
|
||||
---
|
||||
|
||||
## Core Concepts
|
||||
|
||||
**Browser lifecycle**: Browser starts automatically on first tool call using a persistent Chrome profile. Configure via CLI args in the MCP server configuration: `npx chrome-devtools-mcp@latest --help`.
|
||||
Addional tooling can be enabled by providing the following flags:
|
||||
|
||||
- For extension tooling, use the `--categoryExtensions` flag.
|
||||
- For memory tooling, use the `--memoryDebugging` flag.
|
||||
|
||||
**Page selection**: Tools operate on the currently selected page. Use `list_pages` to see available pages, then `select_page` to switch context.
|
||||
**Element interaction**: Use `take_snapshot` to get page structure with element `uid`s. Each element has a unique `uid` for interaction. If an element isn't found, take a fresh snapshot - the element may have been removed or the page changed.
|
||||
|
||||
## Workflow Patterns
|
||||
|
||||
### Before interacting with a page
|
||||
|
||||
1. Navigate: `navigate_page` or `new_page`
|
||||
2. Wait: `wait_for` to ensure content is loaded if you know what you look for.
|
||||
3. Snapshot: `take_snapshot` to understand page structure
|
||||
4. Interact: Use element `uid`s from snapshot for `click`, `fill`, etc.
|
||||
|
||||
### Efficient data retrieval
|
||||
|
||||
- Use `filePath` parameter for large outputs (screenshots, snapshots, traces)
|
||||
- Use pagination (`pageIdx`, `pageSize`) and filtering (`types`) to minimize data
|
||||
- Set `includeSnapshot: false` on input actions unless you need updated page state
|
||||
|
||||
### Tool selection
|
||||
|
||||
- **Automation/interaction**: `take_snapshot` (text-based, faster, better for automation)
|
||||
- **Visual inspection**: `take_screenshot` (when user needs to see visual state)
|
||||
- **Additional details**: `evaluate_script` for data not in accessibility tree
|
||||
|
||||
### Parallel execution
|
||||
|
||||
You can send multiple tool calls in parallel, but maintain correct order: navigate → wait → snapshot → interact.
|
||||
|
||||
### Testing an extension
|
||||
|
||||
> **Before proceeding**: Extension tools (`install_extension`, `list_extensions`, etc.) are only available when the MCP server is started with the `--categoryExtensions` flag. If these tools are not in your tool list, stop and ask the user to update their MCP server configuration:
|
||||
>
|
||||
> ```json
|
||||
> {
|
||||
> "mcpServers": {
|
||||
> "chrome-devtools": {
|
||||
> "command": "npx",
|
||||
> "args": ["chrome-devtools-mcp@latest", "--categoryExtensions"]
|
||||
> }
|
||||
> }
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> After updating, the user must restart the MCP server (or their AI client) for the change to take effect.
|
||||
|
||||
1. **Install**: Use `install_extension` with the path to the unpacked extension.
|
||||
2. **Identify**: Get the extension ID from the response or by calling `list_extensions`.
|
||||
3. **Trigger Action**: Use `trigger_extension_action` to open the popup or side panel if applicable.
|
||||
4. **Verify Service Worker**: Use `evaluate_script` with `serviceWorkerId` to check extension state or trigger background actions.
|
||||
5. **Verify Page Behavior**: Navigate to a page where the extension operates and use `take_snapshot` to check if content scripts injected elements or modified the page correctly.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If `chrome-devtools-mcp` is insufficient, guide users to use Chrome DevTools UI:
|
||||
|
||||
- https://developer.chrome.com/docs/devtools
|
||||
- https://developer.chrome.com/docs/devtools/ai-assistance
|
||||
|
||||
If there are errors launching `chrome-devtools-mcp` or Chrome, refer to https://github.com/ChromeDevTools/chrome-devtools-mcp/blob/main/docs/troubleshooting.md.
|
||||
@@ -0,0 +1,121 @@
|
||||
---
|
||||
name: debug-optimize-lcp
|
||||
description: Guides debugging and optimizing Largest Contentful Paint (LCP) using Chrome DevTools MCP tools. Use this skill whenever the user asks about LCP performance, slow page loads, Core Web Vitals optimization, or wants to understand why their page's main content takes too long to appear. Also use when the user mentions "largest contentful paint", "page load speed", "CWV", or wants to improve how fast their hero image or main content renders.
|
||||
---
|
||||
|
||||
## What is LCP and why it matters
|
||||
|
||||
Largest Contentful Paint (LCP) measures how quickly a page's main content becomes visible. It's the time from navigation start until the largest image or text block renders in the viewport.
|
||||
|
||||
- **Good**: 2.5 seconds or less
|
||||
- **Needs improvement**: 2.5–4.0 seconds
|
||||
- **Poor**: greater than 4.0 seconds
|
||||
|
||||
LCP is a Core Web Vital that directly affects user experience and search ranking. On 73% of mobile pages, the LCP element is an image.
|
||||
|
||||
## LCP Subparts Breakdown
|
||||
|
||||
Every page's LCP breaks down into four sequential subparts with no gaps or overlaps. Understanding which subpart is the bottleneck is the key to effective optimization.
|
||||
|
||||
| Subpart | Ideal % of LCP | What it measures |
|
||||
| ----------------------------- | -------------- | ---------------------------------------------- |
|
||||
| **Time to First Byte (TTFB)** | ~40% | Navigation start → first byte of HTML received |
|
||||
| **Resource load delay** | <10% | TTFB → browser starts loading the LCP resource |
|
||||
| **Resource load duration** | ~40% | Time to download the LCP resource |
|
||||
| **Element render delay** | <10% | LCP resource downloaded → LCP element rendered |
|
||||
|
||||
The "delay" subparts should be as close to zero as possible. If either delay subpart is large relative to the total LCP, that's the first place to optimize.
|
||||
|
||||
**Common Pitfall**: Optimizing one subpart (like compressing an image to reduce load duration) without checking others. If render delay is the real bottleneck, a smaller image won't help — the saved time just shifts to render delay.
|
||||
|
||||
## Debugging Workflow
|
||||
|
||||
Follow these steps in order. Each step builds on the previous one.
|
||||
|
||||
### Step 1: Record a Performance Trace
|
||||
|
||||
Navigate to the page, then record a trace with reload to capture the full page load including LCP:
|
||||
|
||||
1. `navigate_page` to the target URL.
|
||||
2. `performance_start_trace` with `reload: true` and `autoStop: true`.
|
||||
|
||||
The trace results will include LCP timing and available insight sets. Note the insight set IDs from the output — you'll need them in the next step.
|
||||
|
||||
### Step 2: Analyze LCP Insights
|
||||
|
||||
Use `performance_analyze_insight` to drill into LCP-specific insights. Look for these insight names in the trace results:
|
||||
|
||||
- **LCPBreakdown** — Shows the four LCP subparts with timing for each.
|
||||
- **DocumentLatency** — Server response time issues affecting TTFB.
|
||||
- **RenderBlocking** — Resources blocking the LCP element from rendering.
|
||||
- **LCPDiscovery** — Whether the LCP resource was discoverable early.
|
||||
|
||||
Call `performance_analyze_insight` with the insight set ID and the insight name from the trace results.
|
||||
|
||||
### Step 3: Identify the LCP Element
|
||||
|
||||
Use `evaluate_script` with the **"Identify LCP Element" snippet** found in [references/lcp-snippets.md](references/lcp-snippets.md) to reveal the LCP element's tag, resource URL, and raw timing data.
|
||||
|
||||
The `url` field tells you what resource to look for in the network waterfall. If `url` is empty, the LCP element is text-based (no resource to load).
|
||||
|
||||
### Step 4: Check the Network Waterfall
|
||||
|
||||
Use `list_network_requests` to see when the LCP resource loaded relative to other resources:
|
||||
|
||||
- Call `list_network_requests` filtered by `resourceTypes: ["Image", "Font"]` (adjust based on Step 3).
|
||||
- Then use `get_network_request` with the LCP resource's request ID for full details.
|
||||
|
||||
**Key Checks:**
|
||||
|
||||
- **Start Time**: Compare against the HTML document and the first resource. If the LCP resource starts much later than the first resource, there's resource load delay to eliminate.
|
||||
- **Duration**: A large resource load duration suggests the file is too big or the server is slow.
|
||||
|
||||
### Step 5: Inspect HTML for Common Issues
|
||||
|
||||
Use `evaluate_script` with the **"Audit Common Issues" snippet** found in [references/lcp-snippets.md](references/lcp-snippets.md) to check for lazy-loaded images in the viewport, missing fetchpriority, and render-blocking scripts.
|
||||
|
||||
## Optimization Strategies
|
||||
|
||||
After identifying the bottleneck subpart, apply these prioritized fixes.
|
||||
|
||||
### 1. Eliminate Resource Load Delay (target: <10%)
|
||||
|
||||
The most common bottleneck. The LCP resource should start loading immediately.
|
||||
|
||||
- **Root Cause**: LCP image loaded via JS/CSS, `data-src` usage, or `loading="lazy"`.
|
||||
- **Fix**: Use standard `<img>` with `src`. **Never** lazy-load the LCP image.
|
||||
- **Fix**: Add `<link rel="preload" fetchpriority="high">` if the image isn't discoverable in HTML.
|
||||
- **Fix**: Add `fetchpriority="high"` to the LCP `<img>` tag.
|
||||
|
||||
### 2. Eliminate Element Render Delay (target: <10%)
|
||||
|
||||
The element should render immediately after loading.
|
||||
|
||||
- **Root Cause**: Large stylesheets, synchronous scripts in `<head>`, or main thread blocking.
|
||||
- **Fix**: Inline critical CSS, defer non-critical CSS/JS.
|
||||
- **Fix**: Break up long tasks blocking the main thread.
|
||||
- **Fix**: Use Server-Side Rendering (SSR) so the element exists in initial HTML.
|
||||
|
||||
### 3. Reduce Resource Load Duration (target: ~40%)
|
||||
|
||||
Make the resource smaller or faster to deliver.
|
||||
|
||||
- **Fix**: Use modern formats (WebP, AVIF) and responsive images (`srcset`).
|
||||
- **Fix**: Serve from a CDN.
|
||||
- **Fix**: Set `Cache-Control` headers.
|
||||
- **Fix**: Use `font-display: swap` if LCP is text blocked by a web font.
|
||||
|
||||
### 4. Reduce TTFB (target: ~40%)
|
||||
|
||||
The HTML document itself takes too long to arrive.
|
||||
|
||||
- **Fix**: Minimize redirects and optimize server response time.
|
||||
- **Fix**: Cache HTML at the edge (CDN).
|
||||
- **Fix**: Ensure pages are eligible for back/forward cache (bfcache).
|
||||
|
||||
## Verifying Fixes & Emulation
|
||||
|
||||
- **Verification**: Re-run the trace (`performance_start_trace` with `reload: true`) and compare the new subpart breakdown. The bottleneck should shrink.
|
||||
- **Emulation**: Lab measurements differ from real-world experience. Use `emulate` to test under constraints:
|
||||
- `emulate` with `networkConditions: "Fast 3G"` and `cpuThrottlingRate: 4`.
|
||||
- This surfaces issues visible only on slower connections/devices.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Elements and Size for LCP
|
||||
|
||||
## What Elements are Considered?
|
||||
|
||||
The types of elements considered for Largest Contentful Paint (LCP) are:
|
||||
|
||||
- **`<img>` elements**: The first frame presentation time is used for animated content like GIFs.
|
||||
- **`<image>` elements** inside an `<svg>` element.
|
||||
- **`<video>` elements**: The poster image load time or first frame presentation time, whichever is earlier.
|
||||
- **Background images**: Elements with a background image loaded using `url()`.
|
||||
- **Block-level elements**: Containing text nodes or other inline-level text element children.
|
||||
|
||||
## Heuristics to Exclude Non-Contentful Elements
|
||||
|
||||
Chromium-based browsers use heuristics to exclude:
|
||||
|
||||
- Elements with **opacity of 0**.
|
||||
- Elements that **cover the full viewport** (likely background).
|
||||
- **Placeholder images** or low-entropy images.
|
||||
|
||||
## How is an Element's Size Determined?
|
||||
|
||||
- **Visible Area**: Typically the size visible within the viewport. Extending outside, clipped, or overflow portions don't count.
|
||||
- **Image Elements**: Either the visible size or the intrinsic size, whichever is smaller.
|
||||
- **Text Elements**: The smallest rectangle containing all text nodes.
|
||||
- **Exclusions**: Margin, padding, and borders are not considered toward the size.
|
||||
- **Containment**: Every text node belongs to its closest block-level ancestor element.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Largest Contentful Paint (LCP) Breakdown
|
||||
|
||||
LCP measures the time from when the user initiates loading the page until the largest image or text block is rendered within the viewport. To provide a good user experience, sites should strive to have an LCP of 2.5 seconds or less for at least 75% of page visits.
|
||||
|
||||
## The Four Subparts of LCP
|
||||
|
||||
Every page's LCP consists of these four subcategories. There's no gap or overlap between them, and they add up to the full LCP time.
|
||||
|
||||
| LCP subpart | % of LCP (Optimal) | Description |
|
||||
| ----------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **Time to First Byte (TTFB)** | ~40% | The time from when the user initiates loading the page until the browser receives the first byte of the HTML document response. |
|
||||
| **Resource load delay** | <10% | The time between TTFB and when the browser starts loading the LCP resource. If the LCP element doesn't require a resource load (e.g., system font text), this time is 0. |
|
||||
| **Resource load duration** | ~40% | The duration of time it takes to load the LCP resource itself. If the LCP element doesn't require a resource load, this time is 0. |
|
||||
| **Element render delay** | <10% | The time between when the LCP resource finishes loading and the LCP element rendering fully. |
|
||||
|
||||
## Why the Breakdown Matters
|
||||
|
||||
Optimizing for LCP requires identifying which of these subparts is the bottleneck:
|
||||
|
||||
- **Large delta between TTFB and FCP**: Indicates the browser needs to download a lot of render-blocking assets or complete a lot of work (e.g., client-side rendering).
|
||||
- **Large delta between FCP and LCP**: Indicates the LCP resource is not immediately available for the browser to prioritize or the browser is completing other work before it can display the LCP content.
|
||||
- **Large resource load delay**: Indicates the resource is not discoverable early or is deprioritized.
|
||||
- **Large element render delay**: Indicates rendering is blocked by stylesheets, scripts, or long tasks.
|
||||
@@ -0,0 +1,79 @@
|
||||
# LCP Debugging Snippets
|
||||
|
||||
Use these JavaScript snippets with the `evaluate_script` tool to extract deep insights from the page.
|
||||
|
||||
## 1. Identify LCP Element
|
||||
|
||||
Use this snippet to identify the LCP element and get raw timing data from the Performance API.
|
||||
|
||||
```javascript
|
||||
async () => {
|
||||
return await new Promise(resolve => {
|
||||
new PerformanceObserver(list => {
|
||||
const entries = list.getEntries();
|
||||
const last = entries[entries.length - 1];
|
||||
resolve({
|
||||
element: last.element?.tagName,
|
||||
id: last.element?.id,
|
||||
className: last.element?.className,
|
||||
url: last.url,
|
||||
startTime: last.startTime,
|
||||
renderTime: last.renderTime,
|
||||
loadTime: last.loadTime,
|
||||
size: last.size,
|
||||
});
|
||||
}).observe({type: 'largest-contentful-paint', buffered: true});
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
## 2. Audit Common Issues
|
||||
|
||||
Use this snippet to check for common DOM-based LCP issues (lazy loading, priority).
|
||||
|
||||
```javascript
|
||||
() => {
|
||||
const issues = [];
|
||||
|
||||
// Check for lazy-loaded images in viewport
|
||||
document.querySelectorAll('img[loading="lazy"]').forEach(img => {
|
||||
const rect = img.getBoundingClientRect();
|
||||
if (rect.top < window.innerHeight) {
|
||||
issues.push({
|
||||
issue: 'lazy-loaded image in viewport',
|
||||
element: img.outerHTML.substring(0, 200),
|
||||
fix: 'Remove loading="lazy" from this image — it is in the initial viewport and may be the LCP element',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Check for LCP-candidate images missing fetchpriority
|
||||
document.querySelectorAll('img:not([fetchpriority])').forEach(img => {
|
||||
const rect = img.getBoundingClientRect();
|
||||
if (rect.top < window.innerHeight && rect.width * rect.height > 50000) {
|
||||
issues.push({
|
||||
issue: 'large viewport image without fetchpriority',
|
||||
element: img.outerHTML.substring(0, 200),
|
||||
fix: 'Add fetchpriority="high" to this image — it is large and visible in the initial viewport',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Check for render-blocking scripts in head
|
||||
document
|
||||
.querySelectorAll(
|
||||
'head script:not([async]):not([defer]):not([type="module"])',
|
||||
)
|
||||
.forEach(script => {
|
||||
if (script.src) {
|
||||
issues.push({
|
||||
issue: 'render-blocking script in head',
|
||||
element: script.outerHTML.substring(0, 200),
|
||||
fix: 'Add async or defer attribute, or move to end of body',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return {issueCount: issues.length, issues};
|
||||
};
|
||||
```
|
||||
@@ -0,0 +1,38 @@
|
||||
# LCP Optimization Strategies
|
||||
|
||||
## 1. Eliminate Resource Load Delay
|
||||
|
||||
**Goal**: Ensure the LCP resource starts loading as early as possible.
|
||||
|
||||
- **Early Discovery**: Ensure the LCP resource is discoverable in the initial HTML document response (not dynamically added by JS or hidden in `data-src`).
|
||||
- **Preload**: Use `<link rel="preload">` with `fetchpriority="high"` for critical images or fonts.
|
||||
- **Avoid Lazy Loading**: Never set `loading="lazy"` on the LCP image.
|
||||
- **Fetch Priority**: Use `fetchpriority="high"` on the `<img>` tag.
|
||||
- **Same Origin**: Host critical resources on the same origin or use `<link rel="preconnect">`.
|
||||
|
||||
## 2. Eliminate Element Render Delay
|
||||
|
||||
**Goal**: Ensure the LCP element can render immediately after its resource has finished loading.
|
||||
|
||||
- **Minimize Render-Blocking CSS**: Inline critical CSS and defer non-critical CSS. Ensure the stylesheet is smaller than the LCP resource.
|
||||
- **Minimize Render-Blocking JS**: Avoid synchronous scripts in the `<head>`. Inline very small scripts.
|
||||
- **Server-Side Rendering (SSR)**: Deliver the full HTML markup from the server so image resources are discoverable immediately.
|
||||
- **Break Up Long Tasks**: Prevent large JavaScript tasks from blocking the main thread during rendering.
|
||||
|
||||
## 3. Reduce Resource Load Duration
|
||||
|
||||
**Goal**: Reduce the time spent transferring the bytes of the resource.
|
||||
|
||||
- **Optimize Resource Size**: Serve optimal image sizes, use modern formats (AVIF, WebP), and compress images/fonts.
|
||||
- **Geographic Proximity (CDN)**: Use a Content Delivery Network to get servers closer to users.
|
||||
- **Reduce Contention**: Use `fetchpriority="high"` to prevent lower-priority resources from competing for bandwidth.
|
||||
- **Caching**: Use efficient `Cache-Control` policies.
|
||||
|
||||
## 4. Reduce Time to First Byte (TTFB)
|
||||
|
||||
**Goal**: Deliver the initial HTML as quickly as possible.
|
||||
|
||||
- **Minimize Redirects**: Avoid multiple redirects from advertisements or shortened links.
|
||||
- **CDN Caching**: Cache static HTML documents at the edge.
|
||||
- **Edge Computing**: Move dynamic logic to the edge to avoid trips to the origin server.
|
||||
- **Back/Forward Cache**: Ensure pages are eligible for bfcache.
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
name: memory-leak-debugging
|
||||
description: Diagnoses and resolves memory leaks in JavaScript/Node.js applications. Use when a user reports high memory usage, OOM errors, or wants to analyze heapsnapshots or run memory leak detection tools like memlab.
|
||||
---
|
||||
|
||||
# Memory Leak Debugging
|
||||
|
||||
This skill provides expert guidance and workflows for finding, diagnosing, and fixing memory leaks in JavaScript and Node.js applications.
|
||||
|
||||
## Core Principles
|
||||
|
||||
- **Prefer `memlab`:** Do NOT attempt to read raw `.heapsnapshot` files directly, as they are extremely large and will consume too many tokens. Always recommend and use `memlab` to process snapshots and identify leak traces.
|
||||
- **Isolate the Leak:** Determine if the leak is in the browser (client-side) or Node.js (server-side).
|
||||
- **Common Culprits:** Look for detached DOM nodes, unhandled closures, global variables, event listeners not being removed, and caches growing unbounded. _Note: Detached DOM nodes are sometimes intentional caches; always ask the user before nulling them._
|
||||
|
||||
## Workflows
|
||||
|
||||
### 1. Capturing Snapshots
|
||||
|
||||
When investigating a frontend web application memory leak, utilize the `chrome-devtools-mcp` tools to interact with the application and take snapshots.
|
||||
|
||||
- Use tools like `click`, `navigate_page`, `fill`, etc., to manipulate the page into the desired state.
|
||||
- Revert the page back to the original state after interactions to see if memory is released.
|
||||
- Repeat the same user interactions 10 times to amplify the leak.
|
||||
- Use `take_heapsnapshot` to save `.heapsnapshot` files to disk at baseline, target (after actions), and final (after reverting actions) states.
|
||||
|
||||
### 2. Using Memlab to Find Leaks (Recommended)
|
||||
|
||||
Once you have generated `.heapsnapshot` files using `take_heapsnapshot`, use `memlab` to automatically find memory leaks.
|
||||
|
||||
- Read [references/memlab.md](references/memlab.md) for how to use `memlab` to analyze the generated heapsnapshots.
|
||||
- Do **not** read raw `.heapsnapshot` files using `read_file` or `cat`.
|
||||
|
||||
### 3. Identifying Common Leaks
|
||||
|
||||
When you have found a leak trace (e.g., via `memlab` output), you must identify the root cause in the code.
|
||||
|
||||
- Read [references/common-leaks.md](references/common-leaks.md) for examples of common memory leaks and how to fix them.
|
||||
|
||||
### 4. Fallback: Comparing Snapshots Manually
|
||||
|
||||
If `memlab` is not available, you MUST use the fallback script in the references directory to compare two `.heapsnapshot` files and identify the top growing objects and common leak types.
|
||||
|
||||
Run the script using Node.js:
|
||||
|
||||
```bash
|
||||
node skills/memory-leak-debugging/references/compare_snapshots.js <baseline.heapsnapshot> <target.heapsnapshot>
|
||||
```
|
||||
|
||||
The script will analyze and output the top growing objects by size and highlight the 3 most common types of memory leaks (e.g., Detached DOM nodes, closures, Contexts) if they are present.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Common Memory Leaks
|
||||
|
||||
When analyzing a retainer trace from `memlab`, look for these common patterns in the codebase:
|
||||
|
||||
## 1. Uncleared Event Listeners
|
||||
|
||||
Event listeners attached to global objects (like `window` or `document`) or long-living objects prevent garbage collection of the objects referenced in their callbacks.
|
||||
|
||||
**Fix:** Always call `removeEventListener` when a component unmounts or the listener is no longer needed.
|
||||
|
||||
## 2. Detached DOM Nodes
|
||||
|
||||
A DOM node is removed from the document tree but is still referenced by a JavaScript variable. While detachedness is a good signal for a memory leak, it's not always a bug. For example, websites sometimes intentionally cache detached navigation trees.
|
||||
|
||||
**Fix:** Signal the detached nodes to the user first. **Ask the user first** before nulling the references or changing the code, as the detached nodes might be part of an intentional cache. If confirmed as a leak, ensure variables holding DOM references are set to `null` when the node is removed, or limit their scope.
|
||||
|
||||
## 3. Unintentional Global Variables
|
||||
|
||||
Variables declared without `var`, `let`, or `const` (in non-strict mode) or explicitly attached to `window` remain in memory forever.
|
||||
|
||||
**Fix:** Use strict mode, properly declare variables, and avoid global state.
|
||||
|
||||
## 4. Closures
|
||||
|
||||
Closures can unintentionally keep references to large objects in their outer scope.
|
||||
|
||||
**Fix:** Nullify large objects when they are no longer needed, or refactor the closure to not capture unnecessary variables.
|
||||
|
||||
## 5. Unbounded Caches or Arrays
|
||||
|
||||
Data structures used for caching (like objects, Arrays, or Maps) that grow without limits.
|
||||
|
||||
**Fix:** Implement caching limits, use LRU caches, or use `WeakMap`/`WeakSet` for data associated with object lifecycles.
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
function parseSnapshot(filePath) {
|
||||
console.log(`Loading ${filePath}...`);
|
||||
const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
const strings = data.strings;
|
||||
const nodes = data.nodes;
|
||||
const nodeFields = data.snapshot.meta.node_fields;
|
||||
const nodeFieldCount = nodeFields.length;
|
||||
|
||||
const typeOffset = nodeFields.indexOf('type');
|
||||
const nameOffset = nodeFields.indexOf('name');
|
||||
const sizeOffset = nodeFields.indexOf('self_size');
|
||||
|
||||
const nodeTypes = data.snapshot.meta.node_types[typeOffset];
|
||||
|
||||
const counts = {};
|
||||
const sizes = {};
|
||||
|
||||
for (let i = 0; i < nodes.length; i += nodeFieldCount) {
|
||||
const typeIdx = nodes[i + typeOffset];
|
||||
const typeName = nodeTypes[typeIdx];
|
||||
const nameIdx = nodes[i + nameOffset];
|
||||
const name = typeof nameIdx === 'number' ? strings[nameIdx] : nameIdx;
|
||||
const size = nodes[i + sizeOffset];
|
||||
|
||||
// Ignore native primitives/arrays that clutter the output unless specifically looking for them
|
||||
if (
|
||||
typeName === 'string' ||
|
||||
typeName === 'number' ||
|
||||
typeName === 'array'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = `${typeName}::${name}`;
|
||||
counts[key] = (counts[key] || 0) + 1;
|
||||
sizes[key] = (sizes[key] || 0) + size;
|
||||
}
|
||||
return {counts, sizes};
|
||||
}
|
||||
|
||||
const [, , file1, file2] = process.argv;
|
||||
if (!file1 || !file2) {
|
||||
console.error(
|
||||
'Usage: node compare_snapshots.js <baseline.heapsnapshot> <target.heapsnapshot>',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const snap1 = parseSnapshot(file1);
|
||||
const snap2 = parseSnapshot(file2);
|
||||
|
||||
const diffs = [];
|
||||
for (const key in snap2.counts) {
|
||||
const count1 = snap1.counts[key] || 0;
|
||||
const count2 = snap2.counts[key];
|
||||
const size1 = snap1.sizes[key] || 0;
|
||||
const size2 = snap2.sizes[key];
|
||||
|
||||
if (count2 > count1) {
|
||||
diffs.push({
|
||||
key,
|
||||
countDiff: count2 - count1,
|
||||
sizeDiff: size2 - size1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
diffs.sort((a, b) => b.sizeDiff - a.sizeDiff);
|
||||
|
||||
console.log('\n--- Top 10 growing objects by size ---');
|
||||
diffs.slice(0, 10).forEach(d => {
|
||||
console.log(`${d.key}: +${d.countDiff} objects, +${d.sizeDiff} bytes`);
|
||||
});
|
||||
|
||||
// Look for common leak indicators
|
||||
const commonLeaks = diffs.filter(
|
||||
d =>
|
||||
d.key.toLowerCase().includes('detached') ||
|
||||
d.key.toLowerCase().includes('html') ||
|
||||
d.key.toLowerCase().includes('eventlistener') ||
|
||||
d.key.toLowerCase().includes('context') ||
|
||||
d.key.toLowerCase().includes('closure'),
|
||||
);
|
||||
|
||||
commonLeaks.sort((a, b) => b.countDiff - a.countDiff);
|
||||
|
||||
console.log('\n--- Top 3 most common types of memory leaks found ---');
|
||||
if (commonLeaks.length === 0) {
|
||||
console.log('No common DOM or Closure leaks detected.');
|
||||
} else {
|
||||
commonLeaks.slice(0, 3).forEach(d => {
|
||||
console.log(`${d.key}: +${d.countDiff} objects, +${d.sizeDiff} bytes`);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Error parsing snapshots. They might be too large for JSON.parse or invalid.',
|
||||
);
|
||||
console.error(error.message);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
# Using Memlab
|
||||
|
||||
[Memlab](https://facebook.github.io/memlab/) is an E2E testing and analysis framework for finding JavaScript memory leaks.
|
||||
|
||||
## Important Rule
|
||||
|
||||
**NEVER read raw `.heapsnapshot` files directly.** They are too large and will exceed context limits. Always use `memlab` commands to analyze them.
|
||||
|
||||
## Analyzing Snapshots
|
||||
|
||||
You can use the `take_heapsnapshot` tool provided by the `chrome-devtools-mcp` extension to generate heap snapshots during an investigation. To find leaks, you generally need 3 snapshots:
|
||||
|
||||
1. **Baseline:** Before the suspect action.
|
||||
2. **Target:** After the suspect action.
|
||||
3. **Final:** After reverting the suspect action (e.g., closing a modal, navigating away).
|
||||
|
||||
Once you have these 3 snapshots saved to disk, you can use `memlab` to find leaks:
|
||||
|
||||
```bash
|
||||
npx memlab find-leaks --baseline <path-to-baseline> --target <path-to-target> --final <path-to-final>
|
||||
```
|
||||
|
||||
You can also parse a single snapshot to find the largest objects or explore it individually:
|
||||
|
||||
```bash
|
||||
npx memlab analyze snapshot --snapshot <path-to-snapshot>
|
||||
```
|
||||
|
||||
Memlab will output the retainer traces for identified leaks. Use these traces to guide your search in the codebase.
|
||||
@@ -0,0 +1,98 @@
|
||||
---
|
||||
name: troubleshooting
|
||||
description: Uses Chrome DevTools MCP and documentation to troubleshoot connection and target issues. Trigger this skill when list_pages, new_page, or navigate_page fail, or when the server initialization fails.
|
||||
---
|
||||
|
||||
## Troubleshooting Wizard
|
||||
|
||||
You are acting as a troubleshooting wizard to help the user configure and fix their Chrome DevTools MCP server setup. When this skill is triggered (e.g., because `list_pages`, `new_page`, or `navigate_page` failed, or the server wouldn't start), follow this step-by-step diagnostic process:
|
||||
|
||||
### Step 1: Find and Read Configuration
|
||||
|
||||
Your first action should be to locate and read the MCP configuration file. Search for the following files in the user's workspace: `.mcp.json`, `gemini-extension.json`, `.claude/settings.json`, `.vscode/launch.json`, or `.gemini/settings.json`.
|
||||
|
||||
If you find a configuration file, read and interpret it to identify potential issues such as:
|
||||
|
||||
- Incorrect arguments or flags.
|
||||
- Missing environment variables.
|
||||
- Usage of `--autoConnect` in incompatible environments.
|
||||
|
||||
If you cannot find any of these files, only then should you ask the user to provide their configuration file content.
|
||||
|
||||
### Step 2: Triage Common Connection Errors
|
||||
|
||||
Before reading documentation or suggesting configuration changes, check if the error message matches one of the following common patterns.
|
||||
|
||||
#### Error: `Could not find DevToolsActivePort`
|
||||
|
||||
This error is highly specific to the `--autoConnect` feature. It means the MCP server cannot find the file created by a running, debuggable Chrome instance. This is not a generic connection failure.
|
||||
|
||||
Your primary goal is to guide the user to ensure Chrome is running and properly configured. Do not immediately suggest switching to `--browserUrl`. Follow this exact sequence:
|
||||
|
||||
1. **Ask the user to confirm that the correct Chrome version** (e.g., "Chrome Canary" if the error mentions it) is currently running.
|
||||
2. **If the user confirms it is running, instruct them to enable remote debugging.** Be very specific about the URL and the action: "Please open a new tab in Chrome, navigate to `chrome://inspect/#remote-debugging`, and make sure the 'Enable remote debugging' checkbox is checked."
|
||||
3. **Once the user confirms both steps, your only next action should be to call the `list_pages` tool.** This is the simplest and safest way to verify if the connection is now successful. Do not retry the original, more complex command yet.
|
||||
4. **If `list_pages` succeeds, the problem is resolved.** If it still fails with the same error, then you can proceed to the more advanced steps like suggesting `--browserUrl` or checking for sandboxing issues.
|
||||
|
||||
#### Symptom: Server starts but creates a new empty profile
|
||||
|
||||
If the server starts successfully but `list_pages` returns an empty list or creates a new profile instead of connecting to the existing Chrome instance, check for typos in the arguments.
|
||||
|
||||
- **Check for flag typos:** For example, `--autoBronnect` instead of `--autoConnect`.
|
||||
- **Verify the configuration:** Ensure the arguments match the expected flags exactly.
|
||||
|
||||
#### Symptom: Missing Tools / Only 9 tools available
|
||||
|
||||
If the server starts successfully but only a limited subset of tools (like `list_pages`, `get_console_message`, `lighthouse_audit`, `take_heapsnapshot`) are available, this is likely because the MCP client is enforcing a **read-only mode**.
|
||||
|
||||
All tools in `chrome-devtools-mcp` are annotated with `readOnlyHint: true` (for safe, non-modifying tools) or `readOnlyHint: false` (for tools that modify browser state, like `emulate`, `click`, `navigate_page`). To access the full suite of tools, the user must disable read-only mode in their MCP client (e.g., by exiting "Plan Mode" in Gemini CLI or adjusting their client's tool safety settings).
|
||||
|
||||
#### Symptom: Extension tools are missing or extensions fail to load
|
||||
|
||||
If the tools related to extensions (like `install_extension`) are not available, or if the extensions you load are not functioning:
|
||||
|
||||
1. **Check for the `--categoryExtensions` flag**: Ensure this flag is passed in the MCP server configuration to enable the extension category tools.
|
||||
2. **Make sure the MCP server in configured to launch Chrome instead of connecting to an instance**: Chrome before 149 is not able to load extensions when connecting to an existing instance (`--auto-connect`, `--browserUrl`).
|
||||
|
||||
#### Other Common Errors
|
||||
|
||||
Identify other error messages from the failed tool call or the MCP initialization logs:
|
||||
|
||||
- `Target closed`
|
||||
- "Tool not found" (check if they are using `--slim` which only enables navigation and screenshot tools).
|
||||
- `ProtocolError: Network.enable timed out` or `The socket connection was closed unexpectedly`
|
||||
- `Error [ERR_MODULE_NOT_FOUND]: Cannot find module`
|
||||
- Any sandboxing or host validation errors.
|
||||
|
||||
### Step 3: Read Known Issues
|
||||
|
||||
Read the contents of https://github.com/ChromeDevTools/chrome-devtools-mcp/blob/main/docs/troubleshooting.md to map the error to a known issue. Pay close attention to:
|
||||
|
||||
- Sandboxing restrictions (macOS Seatbelt, Linux containers).
|
||||
- WSL requirements.
|
||||
- `--autoConnect` handshakes, timeouts, and requirements (requires **running** Chrome 144+).
|
||||
|
||||
### Step 4: Formulate a Configuration
|
||||
|
||||
Based on the exact error and the user's environment (OS, MCP client), formulate the correct MCP configuration snippet. Check if they need to:
|
||||
|
||||
- Pass `--browser-url=http://127.0.0.1:9222` instead of `--autoConnect` (e.g. if they are in a sandboxed environment like Claude Desktop).
|
||||
- Enable remote debugging in Chrome (`chrome://inspect/#remote-debugging`) and accept the connection prompt. **Ask the user to verify this is enabled if using `--autoConnect`.**
|
||||
- Add `--logFile <absolute_path_to_log_file>` to capture debug logs for analysis.
|
||||
- Increase `startup_timeout_ms` (e.g. to 20000) if using Codex on Windows.
|
||||
|
||||
_If you are unsure of the user's configuration, ask the user to provide their current MCP server JSON configuration._
|
||||
|
||||
### Step 5: Run Diagnostic Commands
|
||||
|
||||
If the issue is still unclear, run diagnostic commands to test the server directly:
|
||||
|
||||
- Run `npx chrome-devtools-mcp@latest --help` to verify the installation and Node.js environment.
|
||||
- If you need more information, run `DEBUG=* npx chrome-devtools-mcp@latest --logFile=/tmp/cdm-test.log` to capture verbose logs. Analyze the output for errors.
|
||||
|
||||
### Step 6: Check GitHub for Existing Issues
|
||||
|
||||
If https://github.com/ChromeDevTools/chrome-devtools-mcp/blob/main/docs/troubleshooting.md does not cover the specific error, check if the `gh` (GitHub CLI) tool is available in the environment. If so, search the GitHub repository for similar issues:
|
||||
`gh issue list --repo ChromeDevTools/chrome-devtools-mcp --search "<error snippet>" --state all`
|
||||
|
||||
Alternatively, you can recommend that the user checks https://github.com/ChromeDevTools/chrome-devtools-mcp/issues and https://github.com/ChromeDevTools/chrome-devtools-mcp/discussions for help.
|
||||
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fsSync from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import {DevTools} from './third_party/index.js';
|
||||
import {
|
||||
createIdGenerator,
|
||||
stableIdSymbol,
|
||||
type WithSymbolId,
|
||||
} from './utils/id.js';
|
||||
|
||||
export type AggregatedInfoWithId =
|
||||
WithSymbolId<DevTools.HeapSnapshotModel.HeapSnapshotModel.AggregatedInfo>;
|
||||
|
||||
export interface HeapSnapshotAggregateData {
|
||||
aggregates: Record<string, AggregatedInfoWithId>;
|
||||
objectCount: number;
|
||||
totalSelfSize: number;
|
||||
}
|
||||
|
||||
export interface HeapSnapshotClassDiff {
|
||||
className: string;
|
||||
addedCount: number;
|
||||
removedCount: number;
|
||||
countDelta: number;
|
||||
addedSize: number;
|
||||
removedSize: number;
|
||||
sizeDelta: number;
|
||||
}
|
||||
|
||||
export interface HeapSnapshotDetailedClassDiff extends HeapSnapshotClassDiff {
|
||||
addedIds: number[];
|
||||
addedSelfSizes: number[];
|
||||
deletedIds: number[];
|
||||
deletedSelfSizes: number[];
|
||||
}
|
||||
|
||||
export type DuplicateStringGroup =
|
||||
DevTools.HeapSnapshotModel.HeapSnapshotModel.DuplicateStringGroup;
|
||||
|
||||
export class HeapSnapshotManager {
|
||||
#snapshotIdGenerator = createIdGenerator();
|
||||
#snapshots = new Map<
|
||||
string,
|
||||
{
|
||||
snapshot: DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotProxy;
|
||||
worker: DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotWorkerProxy;
|
||||
// 1-indexed array where index is the class ID.
|
||||
idToClassKey: string[];
|
||||
classKeyToId: Map<string, number>;
|
||||
}
|
||||
>();
|
||||
|
||||
async getSnapshot(
|
||||
filePath: string,
|
||||
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotProxy> {
|
||||
const absolutePath = path.resolve(filePath);
|
||||
const cached = this.#snapshots.get(absolutePath);
|
||||
if (cached) {
|
||||
return cached.snapshot;
|
||||
}
|
||||
|
||||
const uid = this.#snapshotIdGenerator();
|
||||
const {snapshot, worker} = await this.#loadSnapshot(absolutePath, uid);
|
||||
this.#snapshots.set(absolutePath, {
|
||||
snapshot,
|
||||
worker,
|
||||
idToClassKey: [''],
|
||||
classKeyToId: new Map<string, number>(),
|
||||
});
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async getAggregates(
|
||||
filePath: string,
|
||||
filterName?: string,
|
||||
): Promise<HeapSnapshotAggregateData> {
|
||||
const snapshot = await this.getSnapshot(filePath);
|
||||
const filter =
|
||||
new DevTools.HeapSnapshotModel.HeapSnapshotModel.NodeFilter();
|
||||
if (filterName) {
|
||||
filter.filterName = filterName;
|
||||
}
|
||||
const aggregates: Record<string, AggregatedInfoWithId> =
|
||||
await snapshot.aggregatesWithFilter(filter);
|
||||
let objectCount = 0;
|
||||
let totalSelfSize = 0;
|
||||
|
||||
for (const [key, aggregate] of Object.entries(aggregates)) {
|
||||
const id = await this.getOrCreateIdForClassKey(filePath, key);
|
||||
aggregate[stableIdSymbol] = id;
|
||||
objectCount += aggregate.count;
|
||||
totalSelfSize += aggregate.self;
|
||||
}
|
||||
|
||||
return {
|
||||
aggregates,
|
||||
objectCount,
|
||||
totalSelfSize,
|
||||
};
|
||||
}
|
||||
|
||||
async getStats(
|
||||
filePath: string,
|
||||
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.Statistics> {
|
||||
const snapshot = await this.getSnapshot(filePath);
|
||||
return await snapshot.getStatistics();
|
||||
}
|
||||
|
||||
async getStaticData(
|
||||
filePath: string,
|
||||
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.StaticData | null> {
|
||||
const snapshot = await this.getSnapshot(filePath);
|
||||
return snapshot.staticData;
|
||||
}
|
||||
|
||||
async getOrCreateIdForClassKey(
|
||||
filePath: string,
|
||||
classKey: string,
|
||||
): Promise<number> {
|
||||
const cached = this.#getCachedSnapshot(filePath);
|
||||
let id = cached.classKeyToId.get(classKey);
|
||||
if (!id) {
|
||||
id = cached.idToClassKey.length;
|
||||
cached.classKeyToId.set(classKey, id);
|
||||
cached.idToClassKey.push(classKey);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
async getNodesById(
|
||||
filePath: string,
|
||||
id: number,
|
||||
filterName?: string,
|
||||
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange> {
|
||||
const snapshot = await this.getSnapshot(filePath);
|
||||
const filter =
|
||||
new DevTools.HeapSnapshotModel.HeapSnapshotModel.NodeFilter();
|
||||
if (filterName) {
|
||||
filter.filterName = filterName;
|
||||
}
|
||||
const className = await this.resolveClassKeyFromId(filePath, id);
|
||||
if (!className) {
|
||||
throw new Error(`Class with ID ${id} not found in heap snapshot`);
|
||||
}
|
||||
const provider = snapshot.createNodesProviderForClass(className, filter);
|
||||
|
||||
return await provider.serializeItemsRange(0, Infinity);
|
||||
}
|
||||
|
||||
async getRetainers(
|
||||
filePath: string,
|
||||
nodeId: number,
|
||||
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange> {
|
||||
const snapshot = await this.getSnapshot(filePath);
|
||||
const nodeIndex = await snapshot.nodeIndexForId(nodeId);
|
||||
if (nodeIndex === undefined) {
|
||||
throw new Error(`Node with ID ${nodeId} not found`);
|
||||
}
|
||||
const provider = snapshot.createRetainingEdgesProvider(nodeIndex);
|
||||
return await provider.serializeItemsRange(0, Infinity);
|
||||
}
|
||||
|
||||
async getRetainingPaths(
|
||||
filePath: string,
|
||||
nodeId: number,
|
||||
maxDepth?: number,
|
||||
maxNodes?: number,
|
||||
maxSiblings?: number,
|
||||
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.RetainingPaths> {
|
||||
const snapshot = await this.getSnapshot(filePath);
|
||||
const nodeIndex = await snapshot.nodeIndexForId(nodeId);
|
||||
if (nodeIndex === undefined) {
|
||||
throw new Error(`Node with ID ${nodeId} not found`);
|
||||
}
|
||||
return await snapshot.getRetainingPaths(
|
||||
nodeIndex,
|
||||
maxDepth,
|
||||
maxNodes,
|
||||
maxSiblings,
|
||||
);
|
||||
}
|
||||
|
||||
async getDominatorsOf(
|
||||
filePath: string,
|
||||
nodeId: number,
|
||||
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.DominatorChain> {
|
||||
const snapshot = await this.getSnapshot(filePath);
|
||||
const nodeIndex = await snapshot.nodeIndexForId(nodeId);
|
||||
if (nodeIndex === undefined) {
|
||||
throw new Error(`Node with ID ${nodeId} not found`);
|
||||
}
|
||||
return await snapshot.getDominatorsOf(nodeIndex);
|
||||
}
|
||||
|
||||
async getEdges(
|
||||
filePath: string,
|
||||
nodeId: number,
|
||||
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange> {
|
||||
const snapshot = await this.getSnapshot(filePath);
|
||||
const nodeIndex = await snapshot.nodeIndexForId(nodeId);
|
||||
if (nodeIndex === undefined) {
|
||||
throw new Error(`Node with ID ${nodeId} not found`);
|
||||
}
|
||||
const provider = snapshot.createEdgesProvider(nodeIndex);
|
||||
return await provider.serializeItemsRange(0, Infinity);
|
||||
}
|
||||
|
||||
async getClassDiffs(
|
||||
baseFilePath: string,
|
||||
currentFilePath: string,
|
||||
): Promise<HeapSnapshotClassDiff[]> {
|
||||
const rawDiffs = await this.#getSortedRawClassDiffs(
|
||||
baseFilePath,
|
||||
currentFilePath,
|
||||
);
|
||||
return rawDiffs.map(rawDiff => ({
|
||||
className: rawDiff.name,
|
||||
addedCount: rawDiff.addedCount,
|
||||
removedCount: rawDiff.removedCount,
|
||||
countDelta: rawDiff.countDelta,
|
||||
addedSize: rawDiff.addedSize,
|
||||
removedSize: rawDiff.removedSize,
|
||||
sizeDelta: rawDiff.sizeDelta,
|
||||
}));
|
||||
}
|
||||
|
||||
async getDetailedClassDiff(
|
||||
baseFilePath: string,
|
||||
currentFilePath: string,
|
||||
classIndex: number,
|
||||
): Promise<HeapSnapshotDetailedClassDiff> {
|
||||
const classDiffs = await this.#getSortedRawClassDiffs(
|
||||
baseFilePath,
|
||||
currentFilePath,
|
||||
);
|
||||
const rawDiff = classDiffs[classIndex];
|
||||
if (!rawDiff) {
|
||||
throw new Error(
|
||||
`Invalid classIndex: ${classIndex}. Total classes with changes: ${classDiffs.length}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
className: rawDiff.name,
|
||||
addedCount: rawDiff.addedCount,
|
||||
removedCount: rawDiff.removedCount,
|
||||
countDelta: rawDiff.countDelta,
|
||||
addedSize: rawDiff.addedSize,
|
||||
removedSize: rawDiff.removedSize,
|
||||
sizeDelta: rawDiff.sizeDelta,
|
||||
addedIds: rawDiff.addedIds ?? [],
|
||||
addedSelfSizes: rawDiff.addedSelfSizes ?? [],
|
||||
deletedIds: rawDiff.deletedIds ?? [],
|
||||
deletedSelfSizes: rawDiff.deletedSelfSizes ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
#getCachedSnapshot(filePath: string) {
|
||||
const absolutePath = path.resolve(filePath);
|
||||
const cached = this.#snapshots.get(absolutePath);
|
||||
if (!cached) {
|
||||
throw new Error(`Snapshot not loaded for ${filePath}`);
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
async #getSortedRawClassDiffs(
|
||||
baseFilePath: string,
|
||||
currentFilePath: string,
|
||||
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.Diff[]> {
|
||||
const baseSnapshot = await this.getSnapshot(baseFilePath);
|
||||
const currentSnapshot = await this.getSnapshot(currentFilePath);
|
||||
|
||||
const interfaceDefinitions = await currentSnapshot.interfaceDefinitions();
|
||||
const aggregatesForDiff =
|
||||
await baseSnapshot.aggregatesForDiff(interfaceDefinitions);
|
||||
const baseSnapshotId = baseSnapshot.uid;
|
||||
if (baseSnapshotId === undefined) {
|
||||
throw new Error('Base snapshot UID is undefined');
|
||||
}
|
||||
// DevTools calculateSnapshotDiff uses the first parameter (baseSnapshotId)
|
||||
// as a cache key. We pass the unique UID of the base snapshot.
|
||||
const rawDiffs = await currentSnapshot.calculateSnapshotDiff(
|
||||
baseSnapshotId,
|
||||
aggregatesForDiff,
|
||||
);
|
||||
|
||||
// Return a filtered and sorted array here to ensure that
|
||||
// compare_heapsnapshot_summary and compare_heapsnapshot_details agree
|
||||
// on indices.
|
||||
return Object.values(rawDiffs)
|
||||
.filter(diff => diff.addedCount > 0 || diff.removedCount > 0)
|
||||
.sort((a, b) => b.sizeDelta - a.sizeDelta);
|
||||
}
|
||||
|
||||
async resolveClassKeyFromId(
|
||||
filePath: string,
|
||||
id: number,
|
||||
): Promise<string | undefined> {
|
||||
const cached = this.#getCachedSnapshot(filePath);
|
||||
return cached.idToClassKey[id];
|
||||
}
|
||||
|
||||
async #loadSnapshot(
|
||||
absolutePath: string,
|
||||
uid: number,
|
||||
): Promise<{
|
||||
snapshot: DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotProxy;
|
||||
worker: DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotWorkerProxy;
|
||||
}> {
|
||||
const workerProxy =
|
||||
new DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotWorkerProxy(
|
||||
() => {
|
||||
/* noop */
|
||||
},
|
||||
import.meta.resolve('./third_party/devtools-heap-snapshot-worker.js'),
|
||||
);
|
||||
|
||||
const {promise: snapshotPromise, resolve: resolveSnapshot} =
|
||||
Promise.withResolvers<DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotProxy>();
|
||||
|
||||
const loaderProxy = workerProxy.createLoader(uid, snapshotProxy => {
|
||||
resolveSnapshot(snapshotProxy);
|
||||
});
|
||||
|
||||
const fileStream = fsSync.createReadStream(absolutePath, {
|
||||
encoding: 'utf-8',
|
||||
highWaterMark: 1024 * 1024,
|
||||
});
|
||||
|
||||
for await (const chunk of fileStream) {
|
||||
await loaderProxy.write(chunk);
|
||||
}
|
||||
|
||||
await loaderProxy.close();
|
||||
|
||||
const snapshot = await snapshotPromise;
|
||||
return {snapshot, worker: workerProxy};
|
||||
}
|
||||
|
||||
async getDuplicateStrings(filePath: string): Promise<DuplicateStringGroup[]> {
|
||||
const snapshot = await this.getSnapshot(filePath);
|
||||
return await snapshot.getDuplicateStrings();
|
||||
}
|
||||
|
||||
hasSnapshots(): boolean {
|
||||
return this.#snapshots.size > 0;
|
||||
}
|
||||
|
||||
dispose(filePath: string): boolean {
|
||||
const absolutePath = path.resolve(filePath);
|
||||
const cached = this.#snapshots.get(absolutePath);
|
||||
if (cached) {
|
||||
cached.worker.dispose();
|
||||
this.#snapshots.delete(absolutePath);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,826 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import fsPromises from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath, pathToFileURL} from 'node:url';
|
||||
|
||||
import type {TargetUniverse} from './devtools/DevtoolsUtils.js';
|
||||
import {overrideDevToolsGlobals} from './devtools/DevtoolsUtils.js';
|
||||
import {HeapSnapshotManager} from './HeapSnapshotManager.js';
|
||||
import type {
|
||||
HeapSnapshotAggregateData,
|
||||
HeapSnapshotClassDiff,
|
||||
HeapSnapshotDetailedClassDiff,
|
||||
DuplicateStringGroup,
|
||||
} from './HeapSnapshotManager.js';
|
||||
import {McpPage} from './McpPage.js';
|
||||
import {type UncaughtError} from './PageCollector.js';
|
||||
import {ServiceWorkerConsoleCollector} from './ServiceWorkerCollector.js';
|
||||
import {
|
||||
Locator,
|
||||
type Browser,
|
||||
type BrowserContext,
|
||||
type ConsoleMessage,
|
||||
type Page,
|
||||
type ScreenRecorder,
|
||||
type Target,
|
||||
type Extension,
|
||||
type Root,
|
||||
type DevTools,
|
||||
} from './third_party/index.js';
|
||||
import {listPages} from './tools/pages.js';
|
||||
import {CLOSE_PAGE_ERROR} from './tools/ToolDefinition.js';
|
||||
import type {Context, SupportedExtensions} from './tools/ToolDefinition.js';
|
||||
import type {TraceResult} from './trace-processing/parse.js';
|
||||
import type {Logger} from './types.js';
|
||||
import type {ExtensionServiceWorker} from './types.js';
|
||||
import {getTempFilePath, resolveCanonicalPath} from './utils/files.js';
|
||||
interface McpContextOptions {
|
||||
// Whether the DevTools windows are exposed as pages for debugging of DevTools.
|
||||
experimentalDevToolsDebugging: boolean;
|
||||
// Whether all page-like targets are exposed as pages.
|
||||
experimentalIncludeAllPages?: boolean;
|
||||
// Whether CrUX data should be fetched.
|
||||
performanceCrux: boolean;
|
||||
// The allow list of URL patterns to allow loading resources.
|
||||
allowList?: string[];
|
||||
// The block list of URL patterns to block loading resources.
|
||||
blocklist?: string[];
|
||||
// Whether to skip path validation when the client did not negotiate the roots
|
||||
// capability. When false (default), file-writing tools are restricted to the
|
||||
// OS temp directory. When true, the previous permissive behavior is restored.
|
||||
allowUnrestrictedPaths?: boolean;
|
||||
}
|
||||
|
||||
export class McpContext implements Context {
|
||||
browser: Browser;
|
||||
logger: Logger;
|
||||
|
||||
// Maps LLM-provided isolatedContext name → Puppeteer BrowserContext.
|
||||
#isolatedContexts = new Map<string, BrowserContext>();
|
||||
// Auto-generated name counter for when no name is provided.
|
||||
#nextIsolatedContextId = 1;
|
||||
|
||||
#extensionServiceWorkers: ExtensionServiceWorker[] = [];
|
||||
|
||||
#mcpPages = new Map<Page, McpPage>();
|
||||
#selectedPage?: McpPage;
|
||||
#selectedPageFallback?: {wasClosed: boolean};
|
||||
|
||||
#serviceWorkerConsoleCollector: ServiceWorkerConsoleCollector;
|
||||
|
||||
#isRunningTrace = false;
|
||||
#screenRecorderData: {recorder: ScreenRecorder; filePath: string} | null =
|
||||
null;
|
||||
|
||||
#nextPageId = 1;
|
||||
#extensionPages = new WeakMap<Target, Page>();
|
||||
|
||||
#extensionServiceWorkerMap = new WeakMap<Target, string>();
|
||||
#nextExtensionServiceWorkerId = 1;
|
||||
|
||||
#traceResults: TraceResult[] = [];
|
||||
|
||||
#locatorClass: typeof Locator;
|
||||
#options: McpContextOptions;
|
||||
#heapSnapshotManager = new HeapSnapshotManager();
|
||||
#roots: Root[] | undefined = undefined;
|
||||
#allowUnrestrictedPaths: boolean;
|
||||
|
||||
private constructor(
|
||||
browser: Browser,
|
||||
logger: Logger,
|
||||
options: McpContextOptions,
|
||||
locatorClass: typeof Locator,
|
||||
) {
|
||||
overrideDevToolsGlobals({
|
||||
loadResource: (url: string) => {
|
||||
return this.loadResource(url);
|
||||
},
|
||||
});
|
||||
|
||||
this.browser = browser;
|
||||
this.logger = logger;
|
||||
this.#locatorClass = locatorClass;
|
||||
this.#options = options;
|
||||
this.#allowUnrestrictedPaths = options.allowUnrestrictedPaths ?? false;
|
||||
|
||||
this.#serviceWorkerConsoleCollector = new ServiceWorkerConsoleCollector(
|
||||
this.browser,
|
||||
);
|
||||
}
|
||||
|
||||
async #init() {
|
||||
await this.createPagesSnapshot();
|
||||
const workers = await this.createExtensionServiceWorkersSnapshot();
|
||||
|
||||
await this.#serviceWorkerConsoleCollector.init(workers);
|
||||
this.browser.on('targetcreated', this.#onTargetCreated);
|
||||
this.browser.on('targetdestroyed', this.#onTargetDestroyed);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.browser.off('targetcreated', this.#onTargetCreated);
|
||||
this.browser.off('targetdestroyed', this.#onTargetDestroyed);
|
||||
|
||||
this.#serviceWorkerConsoleCollector.dispose();
|
||||
for (const mcpPage of this.#mcpPages.values()) {
|
||||
mcpPage.dispose();
|
||||
}
|
||||
this.#mcpPages.clear();
|
||||
// Isolated contexts are intentionally not closed here.
|
||||
// Either the entire browser will be closed or we disconnect
|
||||
// without destroying browser state.
|
||||
this.#isolatedContexts.clear();
|
||||
}
|
||||
|
||||
#onTargetCreated = async (target: Target) => {
|
||||
try {
|
||||
const page = await target.page();
|
||||
if (!page) {
|
||||
return;
|
||||
}
|
||||
this.#createMcpPage(page);
|
||||
} catch (err) {
|
||||
this.logger?.('Error handling targetcreated', err);
|
||||
}
|
||||
};
|
||||
|
||||
#onTargetDestroyed = (target: Target) => {
|
||||
try {
|
||||
let foundPage: Page | undefined;
|
||||
for (const page of this.#mcpPages.keys()) {
|
||||
if (page.target() === target) {
|
||||
foundPage = page;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!foundPage) {
|
||||
return;
|
||||
}
|
||||
const mcpPage = this.#mcpPages.get(foundPage);
|
||||
if (mcpPage) {
|
||||
mcpPage.dispose();
|
||||
this.#mcpPages.delete(foundPage);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger?.('Error handling targetdestroyed', err);
|
||||
}
|
||||
};
|
||||
|
||||
static async from(
|
||||
browser: Browser,
|
||||
logger: Logger,
|
||||
opts: McpContextOptions,
|
||||
/* Let tests use unbundled Locator class to avoid overly strict checks within puppeteer that fail when mixing bundled and unbundled class instances */
|
||||
locatorClass: typeof Locator = Locator,
|
||||
) {
|
||||
const context = new McpContext(browser, logger, opts, locatorClass);
|
||||
await context.#init();
|
||||
return context;
|
||||
}
|
||||
|
||||
roots(): Root[] {
|
||||
return [
|
||||
...(this.#roots ?? []),
|
||||
{
|
||||
uri: pathToFileURL(os.tmpdir()).href,
|
||||
name: 'temp',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
setRoots(roots: Root[] | undefined): void {
|
||||
this.#roots = roots;
|
||||
}
|
||||
|
||||
async validatePath(filePath?: string): Promise<void> {
|
||||
if (filePath === undefined) {
|
||||
return;
|
||||
}
|
||||
// If the client never negotiated roots and the operator has explicitly
|
||||
// opted into unrestricted access via --allow-unrestricted-paths, restore
|
||||
// the previous permissive behavior and skip validation.
|
||||
if (this.#roots === undefined && this.#allowUnrestrictedPaths) {
|
||||
return;
|
||||
}
|
||||
// roots() always returns at least the temp directory, even if the
|
||||
// connecting client never negotiated the optional `roots` capability.
|
||||
// Path validation must not be skipped just because no workspace roots
|
||||
// were configured.
|
||||
const roots = this.roots();
|
||||
|
||||
let canonicalPath: string;
|
||||
|
||||
try {
|
||||
canonicalPath = await resolveCanonicalPath(filePath);
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
console.error(
|
||||
`[MCP Context] Error resolving real path for ${filePath}: ${errMsg}`,
|
||||
);
|
||||
throw new Error(
|
||||
`Access denied: Cannot resolve base path for ${filePath}.`,
|
||||
);
|
||||
}
|
||||
|
||||
let allowed = false;
|
||||
const resolvedRoots = await Promise.allSettled(
|
||||
roots.map(async root => {
|
||||
const rootPathUri = root.uri;
|
||||
const rootPath = path.resolve(fileURLToPath(rootPathUri));
|
||||
return await fsPromises.realpath(rootPath);
|
||||
}),
|
||||
);
|
||||
|
||||
for (let i = 0; i < roots.length; i++) {
|
||||
const root = roots[i];
|
||||
const result = resolvedRoots[i];
|
||||
|
||||
if (result.status === 'fulfilled') {
|
||||
const canonicalRoot = result.value;
|
||||
if (
|
||||
canonicalPath === canonicalRoot ||
|
||||
canonicalPath.startsWith(canonicalRoot + path.sep)
|
||||
) {
|
||||
allowed = true;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
const rootErr = result.reason;
|
||||
const errMsg =
|
||||
rootErr instanceof Error ? rootErr.message : String(rootErr);
|
||||
console.warn(
|
||||
`[MCP Context] Could not resolve configured root ${root.uri}: ${errMsg}`,
|
||||
);
|
||||
// Skip this root if it cannot be resolved.
|
||||
}
|
||||
}
|
||||
|
||||
if (!allowed) {
|
||||
throw new Error(
|
||||
`Access denied: path ${filePath} (canonical: ${canonicalPath}) is not within any of the configured workspace roots.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async ensureExtension<Extension extends `.${string}`>(
|
||||
filePath: string,
|
||||
extension: Extension,
|
||||
): Promise<`${string}${Extension}`> {
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
const currentExtension = path.extname(resolvedPath);
|
||||
const outputPath: `${string}${Extension}` = `${resolvedPath.slice(
|
||||
0,
|
||||
resolvedPath.length - currentExtension.length,
|
||||
)}${extension}`;
|
||||
await this.validatePath(outputPath);
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
getDevToolsUniverse(page: McpPage): TargetUniverse | null {
|
||||
return page.devtoolsUniverse ?? null;
|
||||
}
|
||||
|
||||
async newPage(
|
||||
background?: boolean,
|
||||
isolatedContextName?: string,
|
||||
): Promise<McpPage> {
|
||||
let page: Page;
|
||||
if (isolatedContextName !== undefined) {
|
||||
let ctx = this.#isolatedContexts.get(isolatedContextName);
|
||||
if (!ctx) {
|
||||
ctx = await this.browser.createBrowserContext();
|
||||
this.#isolatedContexts.set(isolatedContextName, ctx);
|
||||
}
|
||||
page = await ctx.newPage();
|
||||
} else {
|
||||
page = await this.browser.newPage({background});
|
||||
}
|
||||
const mcpPage = this.#createMcpPage(page);
|
||||
await this.createPagesSnapshot();
|
||||
this.selectPage(mcpPage);
|
||||
return mcpPage;
|
||||
}
|
||||
async closePage(pageId: number): Promise<void> {
|
||||
if (this.getPages().length === 1) {
|
||||
throw new Error(CLOSE_PAGE_ERROR);
|
||||
}
|
||||
const page = this.getPageById(pageId);
|
||||
if (page) {
|
||||
page.dispose();
|
||||
this.#mcpPages.delete(page.pptrPage);
|
||||
}
|
||||
await page.pptrPage.close({runBeforeUnload: false});
|
||||
}
|
||||
|
||||
get #hasNetworkBlockOrAllowlist(): boolean {
|
||||
return !!(this.#options.allowList || this.#options.blocklist);
|
||||
}
|
||||
|
||||
setIsRunningPerformanceTrace(x: boolean): void {
|
||||
this.#isRunningTrace = x;
|
||||
}
|
||||
|
||||
isRunningPerformanceTrace(): boolean {
|
||||
return this.#isRunningTrace;
|
||||
}
|
||||
|
||||
getScreenRecorder(): {recorder: ScreenRecorder; filePath: string} | null {
|
||||
return this.#screenRecorderData;
|
||||
}
|
||||
|
||||
setScreenRecorder(
|
||||
data: {recorder: ScreenRecorder; filePath: string} | null,
|
||||
): void {
|
||||
this.#screenRecorderData = data;
|
||||
}
|
||||
|
||||
isCruxEnabled(): boolean {
|
||||
return this.#options.performanceCrux;
|
||||
}
|
||||
|
||||
getSelectedMcpPage(): McpPage {
|
||||
const page = this.#selectedPage;
|
||||
if (!page) {
|
||||
throw new Error('No page selected');
|
||||
}
|
||||
if (page.pptrPage.isClosed()) {
|
||||
throw new Error(
|
||||
`The selected page has been closed. Call ${listPages().name} to see open pages.`,
|
||||
);
|
||||
}
|
||||
return page;
|
||||
}
|
||||
|
||||
getPageById(pageId: number): McpPage {
|
||||
// Resolve only among listed pages (`getPages()`), so an id that
|
||||
// `list_pages` never showed cannot be targeted (e.g. a `devtools://`
|
||||
// frontend, which the listing excludes unless `experimentalDevToolsDebugging`
|
||||
// is set).
|
||||
const page = this.getPages().find(mcpPage => mcpPage.id === pageId);
|
||||
if (!page) {
|
||||
throw new Error('No page found');
|
||||
}
|
||||
return page;
|
||||
}
|
||||
|
||||
isPageSelected(page: McpPage): boolean {
|
||||
return this.#selectedPage === page;
|
||||
}
|
||||
|
||||
selectPage(newPage: McpPage): void {
|
||||
this.#selectedPage = newPage;
|
||||
newPage.updateTimeouts();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns details about the last page snapshot automatically replacing the
|
||||
* selection because the selected page disappeared from the page list, or
|
||||
* `undefined` if the snapshot left the selection intact. Recomputed on every
|
||||
* createPagesSnapshot() call.
|
||||
*/
|
||||
getSelectedPageFallback(): {wasClosed: boolean} | undefined {
|
||||
return this.#selectedPageFallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a snapshot of the extension service workers.
|
||||
*/
|
||||
async createExtensionServiceWorkersSnapshot(): Promise<
|
||||
ExtensionServiceWorker[]
|
||||
> {
|
||||
const allTargets = this.browser.targets();
|
||||
|
||||
const serviceWorkers = allTargets.filter(target => {
|
||||
return (
|
||||
target.type() === 'service_worker' &&
|
||||
target.url().includes('chrome-extension://')
|
||||
);
|
||||
});
|
||||
|
||||
for (const serviceWorker of serviceWorkers) {
|
||||
if (!this.#extensionServiceWorkerMap.has(serviceWorker)) {
|
||||
this.#extensionServiceWorkerMap.set(
|
||||
serviceWorker,
|
||||
'sw-' + this.#nextExtensionServiceWorkerId++,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.#extensionServiceWorkers = serviceWorkers.map(serviceWorker => {
|
||||
return {
|
||||
target: serviceWorker,
|
||||
id: this.#extensionServiceWorkerMap.get(serviceWorker)!,
|
||||
url: serviceWorker.url(),
|
||||
};
|
||||
});
|
||||
|
||||
return this.#extensionServiceWorkers;
|
||||
}
|
||||
|
||||
getServiceWorkerConsoleData(
|
||||
extensionId: string,
|
||||
): Array<ConsoleMessage | UncaughtError> {
|
||||
return this.#serviceWorkerConsoleCollector.getData(extensionId);
|
||||
}
|
||||
|
||||
#createMcpPage(page: Page): McpPage {
|
||||
let mcpPage = this.#mcpPages.get(page);
|
||||
if (!mcpPage) {
|
||||
mcpPage = new McpPage(page, this.#nextPageId++, {
|
||||
locatorClass: this.#locatorClass,
|
||||
hasNetworkBlockOrAllowlist: this.#hasNetworkBlockOrAllowlist,
|
||||
});
|
||||
this.#mcpPages.set(page, mcpPage);
|
||||
void mcpPage.init();
|
||||
}
|
||||
return mcpPage;
|
||||
}
|
||||
|
||||
async createPagesSnapshot(): Promise<Page[]> {
|
||||
const {pages: allPages, isolatedContextNames} =
|
||||
await this.#fetchBrowserPages();
|
||||
|
||||
for (const page of allPages) {
|
||||
const mcpPage = this.#createMcpPage(page);
|
||||
mcpPage.isolatedContextName = isolatedContextNames.get(page);
|
||||
}
|
||||
|
||||
// Prune orphaned #mcpPages entries (pages that no longer exist).
|
||||
const currentPages = new Set(allPages);
|
||||
for (const [page, mcpPage] of this.#mcpPages) {
|
||||
if (!currentPages.has(page)) {
|
||||
mcpPage.dispose();
|
||||
this.#mcpPages.delete(page);
|
||||
}
|
||||
}
|
||||
|
||||
const pages = this.getPages();
|
||||
|
||||
// Only fall back when the selected page is actually gone. Gating on
|
||||
// `isClosed()` instead of `pages` membership avoids silently swapping a
|
||||
// live page that is momentarily missing from the snapshot.
|
||||
this.#selectedPageFallback = undefined;
|
||||
if (
|
||||
(!this.#selectedPage || this.#selectedPage.pptrPage.isClosed()) &&
|
||||
pages[0]
|
||||
) {
|
||||
// Record the automatic change so the response can surface it. Skipped on
|
||||
// first connect, when there was no prior selection to replace.
|
||||
if (this.#selectedPage) {
|
||||
this.#selectedPageFallback = {
|
||||
wasClosed: this.#selectedPage.pptrPage.isClosed(),
|
||||
};
|
||||
}
|
||||
this.selectPage(pages[0]);
|
||||
}
|
||||
|
||||
await this.detectOpenDevToolsWindows();
|
||||
|
||||
return pages.map(p => p.pptrPage);
|
||||
}
|
||||
|
||||
async #fetchBrowserPages(): Promise<{
|
||||
pages: Page[];
|
||||
isolatedContextNames: Map<Page, string>;
|
||||
}> {
|
||||
const defaultCtx = this.browser.defaultBrowserContext();
|
||||
const allPages = await this.browser.pages(
|
||||
this.#options.experimentalIncludeAllPages,
|
||||
);
|
||||
|
||||
const allTargets = this.browser.targets();
|
||||
const extensionTargets = allTargets.filter(target => {
|
||||
return (
|
||||
target.url().startsWith('chrome-extension://') &&
|
||||
target.type() === 'page'
|
||||
);
|
||||
});
|
||||
|
||||
for (const target of extensionTargets) {
|
||||
// Right now target.page() returns null for popup and side panel pages.
|
||||
let page = await target.page();
|
||||
if (!page) {
|
||||
// We need to cache pages instances for targets because target.asPage()
|
||||
// returns a new page instance every time.
|
||||
page = this.#extensionPages.get(target) ?? null;
|
||||
if (!page) {
|
||||
try {
|
||||
page = await target.asPage();
|
||||
this.#extensionPages.set(target, page);
|
||||
} catch (e) {
|
||||
this.logger?.('Failed to get page for extension target', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (page && !allPages.includes(page)) {
|
||||
allPages.push(page);
|
||||
}
|
||||
}
|
||||
|
||||
// Build a reverse lookup from BrowserContext instance → name.
|
||||
const contextToName = new Map<BrowserContext, string>();
|
||||
for (const [name, ctx] of this.#isolatedContexts) {
|
||||
contextToName.set(ctx, name);
|
||||
}
|
||||
|
||||
// Auto-discover BrowserContexts not in our mapping (e.g., externally
|
||||
// created incognito contexts) and assign generated names.
|
||||
const knownContexts = new Set(this.#isolatedContexts.values());
|
||||
for (const ctx of this.browser.browserContexts()) {
|
||||
if (ctx !== defaultCtx && !ctx.closed && !knownContexts.has(ctx)) {
|
||||
const name = `isolated-context-${this.#nextIsolatedContextId++}`;
|
||||
this.#isolatedContexts.set(name, ctx);
|
||||
contextToName.set(ctx, name);
|
||||
}
|
||||
}
|
||||
|
||||
// Map each page to its isolated context name (if any).
|
||||
const isolatedContextNames = new Map<Page, string>();
|
||||
for (const page of allPages) {
|
||||
const ctx = page.browserContext();
|
||||
const name = contextToName.get(ctx);
|
||||
if (name) {
|
||||
isolatedContextNames.set(page, name);
|
||||
}
|
||||
}
|
||||
|
||||
return {pages: allPages, isolatedContextNames};
|
||||
}
|
||||
|
||||
async detectOpenDevToolsWindows() {
|
||||
this.logger?.('Detecting open DevTools windows');
|
||||
|
||||
await Promise.all(
|
||||
Array.from(this.#mcpPages.values()).map(async mcpPage => {
|
||||
const page = mcpPage.pptrPage;
|
||||
// Prior to Chrome 144.0.7559.59, the command fails,
|
||||
// Some Electron apps still use older version
|
||||
// Fall back to not exposing DevTools at all.
|
||||
try {
|
||||
if (await page.hasDevTools()) {
|
||||
mcpPage.devToolsPage = await page.openDevTools();
|
||||
} else {
|
||||
mcpPage.devToolsPage = undefined;
|
||||
}
|
||||
} catch {
|
||||
mcpPage.devToolsPage = undefined;
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
getExtensionServiceWorkers(): ExtensionServiceWorker[] {
|
||||
return this.#extensionServiceWorkers;
|
||||
}
|
||||
|
||||
getExtensionServiceWorkerId(
|
||||
extensionServiceWorker: ExtensionServiceWorker,
|
||||
): string | undefined {
|
||||
return this.#extensionServiceWorkerMap.get(extensionServiceWorker.target);
|
||||
}
|
||||
|
||||
getPages(): McpPage[] {
|
||||
return Array.from(this.#mcpPages.values()).filter(mcpPage => {
|
||||
return (
|
||||
this.#options.experimentalDevToolsDebugging ||
|
||||
!mcpPage.pptrPage.url().startsWith('devtools://')
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async saveTemporaryFile(
|
||||
data: Uint8Array<ArrayBufferLike>,
|
||||
filename: string,
|
||||
): Promise<{filepath: string}> {
|
||||
const filepath = await getTempFilePath(filename);
|
||||
await this.validatePath(filepath);
|
||||
try {
|
||||
await fs.writeFile(filepath, data);
|
||||
} catch (err) {
|
||||
throw new Error('Could not save a file', {cause: err});
|
||||
}
|
||||
return {filepath};
|
||||
}
|
||||
|
||||
async saveFile(
|
||||
data: Uint8Array<ArrayBufferLike>,
|
||||
clientProvidedFilePath: string,
|
||||
extension: SupportedExtensions,
|
||||
): Promise<{filename: string}> {
|
||||
const filePath = await this.ensureExtension(
|
||||
clientProvidedFilePath,
|
||||
extension,
|
||||
);
|
||||
try {
|
||||
await fs.mkdir(path.dirname(filePath), {recursive: true});
|
||||
await fs.writeFile(filePath, data);
|
||||
return {filename: filePath};
|
||||
} catch (err) {
|
||||
this.logger?.(err);
|
||||
throw new Error('Could not save a file', {cause: err});
|
||||
}
|
||||
}
|
||||
|
||||
storeTraceRecording(result: TraceResult): void {
|
||||
// Clear the trace results because we only consume the latest trace currently.
|
||||
this.#traceResults = [];
|
||||
this.#traceResults.push(result);
|
||||
}
|
||||
|
||||
recordedTraces(): TraceResult[] {
|
||||
return this.#traceResults;
|
||||
}
|
||||
|
||||
async installExtension(extensionPath: string): Promise<string> {
|
||||
const id = await this.browser.installExtension(extensionPath);
|
||||
return id;
|
||||
}
|
||||
|
||||
async uninstallExtension(id: string): Promise<void> {
|
||||
await this.browser.uninstallExtension(id);
|
||||
}
|
||||
|
||||
async triggerExtensionAction(id: string): Promise<void> {
|
||||
const extensions = await this.browser.extensions();
|
||||
const extension = extensions.get(id);
|
||||
if (!extension) {
|
||||
throw new Error(`Extension with ID ${id} not found.`);
|
||||
}
|
||||
const page = this.getSelectedMcpPage().pptrPage;
|
||||
await extension.triggerAction(page);
|
||||
}
|
||||
|
||||
listExtensions(): Promise<Map<string, Extension>> {
|
||||
return this.browser.extensions();
|
||||
}
|
||||
|
||||
async getExtension(id: string): Promise<Extension | undefined> {
|
||||
const pptrExtensions = await this.browser.extensions();
|
||||
return pptrExtensions.get(id);
|
||||
}
|
||||
|
||||
async getHeapSnapshotAggregates(
|
||||
filePath: string,
|
||||
filterName?: string,
|
||||
): Promise<HeapSnapshotAggregateData> {
|
||||
return await this.#heapSnapshotManager.getAggregates(filePath, filterName);
|
||||
}
|
||||
|
||||
async getHeapSnapshotDuplicateStrings(
|
||||
filePath: string,
|
||||
): Promise<DuplicateStringGroup[]> {
|
||||
return await this.#heapSnapshotManager.getDuplicateStrings(filePath);
|
||||
}
|
||||
|
||||
async getHeapSnapshotStats(
|
||||
filePath: string,
|
||||
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.Statistics> {
|
||||
return await this.#heapSnapshotManager.getStats(filePath);
|
||||
}
|
||||
|
||||
async getHeapSnapshotStaticData(
|
||||
filePath: string,
|
||||
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.StaticData | null> {
|
||||
return await this.#heapSnapshotManager.getStaticData(filePath);
|
||||
}
|
||||
|
||||
async getHeapSnapshotNodesById(
|
||||
filePath: string,
|
||||
id: number,
|
||||
filterName?: string,
|
||||
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange> {
|
||||
return await this.#heapSnapshotManager.getNodesById(
|
||||
filePath,
|
||||
id,
|
||||
filterName,
|
||||
);
|
||||
}
|
||||
|
||||
async getHeapSnapshotRetainers(
|
||||
filePath: string,
|
||||
nodeId: number,
|
||||
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange> {
|
||||
return await this.#heapSnapshotManager.getRetainers(filePath, nodeId);
|
||||
}
|
||||
|
||||
async closeHeapSnapshot(filePath: string): Promise<boolean> {
|
||||
return this.#heapSnapshotManager.dispose(filePath);
|
||||
}
|
||||
|
||||
hasHeapSnapshots(): boolean {
|
||||
return this.#heapSnapshotManager.hasSnapshots();
|
||||
}
|
||||
|
||||
async getHeapSnapshotRetainingPaths(
|
||||
filePath: string,
|
||||
nodeId: number,
|
||||
maxDepth?: number,
|
||||
maxNodes?: number,
|
||||
maxSiblings?: number,
|
||||
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.RetainingPaths> {
|
||||
return await this.#heapSnapshotManager.getRetainingPaths(
|
||||
filePath,
|
||||
nodeId,
|
||||
maxDepth,
|
||||
maxNodes,
|
||||
maxSiblings,
|
||||
);
|
||||
}
|
||||
|
||||
async getHeapSnapshotDominators(
|
||||
filePath: string,
|
||||
nodeId: number,
|
||||
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.DominatorChain> {
|
||||
return await this.#heapSnapshotManager.getDominatorsOf(filePath, nodeId);
|
||||
}
|
||||
|
||||
#validateUrlNotBlocked(url: URL): void {
|
||||
if (!this.#options.blocklist) {
|
||||
return;
|
||||
}
|
||||
for (const block of this.#options.blocklist) {
|
||||
const pattern = new URLPattern(block);
|
||||
if (pattern.test(url)) {
|
||||
throw new Error(`Blocked by blocklist: ${url}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#validateUrlAllowed(url: URL): void {
|
||||
if (!this.#options.allowList) {
|
||||
return;
|
||||
}
|
||||
for (const allow of this.#options.allowList) {
|
||||
const pattern = new URLPattern(allow);
|
||||
if (pattern.test(url)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new Error(`Not allowed by allowlist: ${url}`);
|
||||
}
|
||||
|
||||
async loadResource(path: string): Promise<string> {
|
||||
const url = new URL(path);
|
||||
|
||||
this.#validateUrlNotBlocked(url);
|
||||
|
||||
switch (url.protocol) {
|
||||
case 'https:':
|
||||
case 'http:': {
|
||||
this.#validateUrlAllowed(url);
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load resource: ${url}`);
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
|
||||
case 'file:': {
|
||||
await this.validatePath(fileURLToPath(url));
|
||||
return await fsPromises.readFile(url, 'utf-8');
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported protocol for: ${url}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getHeapSnapshotEdges(
|
||||
filePath: string,
|
||||
nodeId: number,
|
||||
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange> {
|
||||
return await this.#heapSnapshotManager.getEdges(filePath, nodeId);
|
||||
}
|
||||
|
||||
async getHeapSnapshotClassDiffs(
|
||||
baseFilePath: string,
|
||||
currentFilePath: string,
|
||||
): Promise<HeapSnapshotClassDiff[]> {
|
||||
return await this.#heapSnapshotManager.getClassDiffs(
|
||||
baseFilePath,
|
||||
currentFilePath,
|
||||
);
|
||||
}
|
||||
|
||||
async getHeapSnapshotDetailedClassDiff(
|
||||
baseFilePath: string,
|
||||
currentFilePath: string,
|
||||
classIndex: number,
|
||||
): Promise<HeapSnapshotDetailedClassDiff> {
|
||||
return await this.#heapSnapshotManager.getDetailedClassDiff(
|
||||
baseFilePath,
|
||||
currentFilePath,
|
||||
classIndex,
|
||||
);
|
||||
}
|
||||
}
|
||||
+696
@@ -0,0 +1,696 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
createTargetUniverse,
|
||||
type TargetUniverse,
|
||||
} from './devtools/DevtoolsUtils.js';
|
||||
import {logger} from './logger.js';
|
||||
import {
|
||||
ConsoleCollector,
|
||||
NetworkCollector,
|
||||
type ListenerMap,
|
||||
type UncaughtError,
|
||||
} from './PageCollector.js';
|
||||
import {TextSnapshot} from './TextSnapshot.js';
|
||||
import type {Locator} from './third_party/index.js';
|
||||
import {
|
||||
PredefinedNetworkConditions,
|
||||
type Dialog,
|
||||
type ElementHandle,
|
||||
type Viewport,
|
||||
type WebMCPTool,
|
||||
type Protocol,
|
||||
type Page,
|
||||
type ConsoleMessage,
|
||||
type HTTPRequest,
|
||||
type DevTools,
|
||||
} from './third_party/index.js';
|
||||
import {takeSnapshot} from './tools/snapshot.js';
|
||||
import type {ToolGroups} from './tools/thirdPartyDeveloper.js';
|
||||
const DEFAULT_TIMEOUT = 5_000;
|
||||
const NAVIGATION_TIMEOUT = 10_000;
|
||||
import type {
|
||||
ContextPage,
|
||||
DevToolsData,
|
||||
Response,
|
||||
} from './tools/ToolDefinition.js';
|
||||
import type {
|
||||
EmulationSettings,
|
||||
GeolocationOptions,
|
||||
TextSnapshotNode,
|
||||
} from './types.js';
|
||||
import {type WithSymbolId, stableIdSymbol} from './utils/id.js';
|
||||
import {
|
||||
getNetworkMultiplierFromString,
|
||||
WaitForHelper,
|
||||
type WaitForEventsResult,
|
||||
type DialogAction,
|
||||
} from './WaitForHelper.js';
|
||||
|
||||
/**
|
||||
* Per-page state wrapper. Consolidates dialog, snapshot, emulation,
|
||||
* and metadata that were previously scattered across Maps in McpContext.
|
||||
*
|
||||
* Internal class consumed only by McpContext. Fields are public for direct
|
||||
* read/write access. The dialog field is private because it requires an
|
||||
* event listener lifecycle managed by the constructor/dispose pair.
|
||||
*/
|
||||
export class McpPage implements ContextPage {
|
||||
readonly pptrPage: Page;
|
||||
readonly id: number;
|
||||
|
||||
// Snapshot
|
||||
textSnapshot: TextSnapshot | null = null;
|
||||
uniqueBackendNodeIdToMcpId = new Map<string, string>();
|
||||
extraHandles: ElementHandle[] = [];
|
||||
|
||||
// Emulation
|
||||
emulationSettings: EmulationSettings = {};
|
||||
|
||||
// Metadata
|
||||
isolatedContextName?: string;
|
||||
devToolsPage?: Page;
|
||||
#devtoolsUniverse?: TargetUniverse;
|
||||
|
||||
// Dialog
|
||||
#dialog?: Dialog;
|
||||
#dialogHandler: (dialog: Dialog) => void;
|
||||
|
||||
thirdPartyDeveloperTools: ToolGroups = [];
|
||||
|
||||
networkCollector: NetworkCollector;
|
||||
consoleCollector: ConsoleCollector;
|
||||
|
||||
#hasNetworkBlockOrAllowlist: boolean;
|
||||
#locatorClass: typeof Locator;
|
||||
|
||||
constructor(
|
||||
page: Page,
|
||||
id: number,
|
||||
options: {
|
||||
hasNetworkBlockOrAllowlist: boolean;
|
||||
locatorClass: typeof Locator;
|
||||
},
|
||||
) {
|
||||
this.#hasNetworkBlockOrAllowlist = options.hasNetworkBlockOrAllowlist;
|
||||
this.#locatorClass = options.locatorClass;
|
||||
this.pptrPage = page;
|
||||
this.id = id;
|
||||
this.#dialogHandler = (dialog: Dialog): void => {
|
||||
this.#dialog = dialog;
|
||||
};
|
||||
page.on('dialog', this.#dialogHandler);
|
||||
|
||||
this.networkCollector = new NetworkCollector(page);
|
||||
this.consoleCollector = new ConsoleCollector(page, collect => {
|
||||
return {
|
||||
console: event => {
|
||||
collect(event);
|
||||
},
|
||||
uncaughtError: event => {
|
||||
collect(event);
|
||||
},
|
||||
devtoolsAggregatedIssue: event => {
|
||||
collect(event);
|
||||
},
|
||||
} as ListenerMap;
|
||||
});
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
if (this.#devtoolsUniverse) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.#devtoolsUniverse = await createTargetUniverse(this.pptrPage);
|
||||
} catch (e) {
|
||||
logger?.('Failed to initialize DevTools universe', e);
|
||||
}
|
||||
|
||||
// We emulate a focused page for all pages to support multi-agent workflows.
|
||||
void this.pptrPage.emulateFocusedPage(true).catch(error => {
|
||||
logger?.('Error turning on focused page emulation', error);
|
||||
});
|
||||
}
|
||||
|
||||
get devtoolsUniverse(): TargetUniverse | undefined {
|
||||
return this.#devtoolsUniverse;
|
||||
}
|
||||
|
||||
getDialog(): Dialog | undefined {
|
||||
return this.#dialog;
|
||||
}
|
||||
|
||||
clearDialog(): void {
|
||||
this.#dialog = undefined;
|
||||
}
|
||||
|
||||
throwIfDialogOpen(): void {
|
||||
if (this.#dialog) {
|
||||
throw new Error(
|
||||
`A dialog is open (${this.#dialog.type()}: ${this.#dialog.message()}).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getThirdPartyDeveloperTools(): ToolGroups {
|
||||
return this.thirdPartyDeveloperTools;
|
||||
}
|
||||
|
||||
getWebMcpTools(): WebMCPTool[] {
|
||||
return this.pptrPage.webmcp.tools();
|
||||
}
|
||||
|
||||
resolveCdpRequestId(cdpRequestId: string): number | undefined {
|
||||
if (!cdpRequestId) {
|
||||
logger?.('no network request');
|
||||
return;
|
||||
}
|
||||
const request = this.networkCollector.find(request => {
|
||||
// @ts-expect-error id is internal.
|
||||
return request.id === cdpRequestId;
|
||||
});
|
||||
if (!request) {
|
||||
logger?.('no network request for ' + cdpRequestId);
|
||||
return;
|
||||
}
|
||||
return this.networkCollector.getIdForResource(request);
|
||||
}
|
||||
|
||||
getNetworkRequests(includePreservedRequests?: boolean): HTTPRequest[] {
|
||||
return this.networkCollector.getData(includePreservedRequests);
|
||||
}
|
||||
|
||||
getConsoleData(
|
||||
includePreservedMessages?: boolean,
|
||||
): Array<ConsoleMessage | Error | DevTools.AggregatedIssue | UncaughtError> {
|
||||
return this.consoleCollector.getData(includePreservedMessages);
|
||||
}
|
||||
|
||||
getConsoleMessageById(
|
||||
id: number,
|
||||
): ConsoleMessage | Error | DevTools.AggregatedIssue | UncaughtError {
|
||||
return this.consoleCollector.getById(id);
|
||||
}
|
||||
|
||||
getNetworkRequestById(reqid: number): HTTPRequest {
|
||||
return this.networkCollector.getById(reqid);
|
||||
}
|
||||
|
||||
get networkConditions(): string | null {
|
||||
return this.emulationSettings.networkConditions ?? null;
|
||||
}
|
||||
|
||||
get cpuThrottlingRate(): number {
|
||||
return this.emulationSettings.cpuThrottlingRate ?? 1;
|
||||
}
|
||||
|
||||
get geolocation(): GeolocationOptions | null {
|
||||
return this.emulationSettings.geolocation ?? null;
|
||||
}
|
||||
|
||||
get viewport(): Viewport | null {
|
||||
return this.emulationSettings.viewport ?? null;
|
||||
}
|
||||
|
||||
get userAgent(): string | null {
|
||||
return this.emulationSettings.userAgent ?? null;
|
||||
}
|
||||
|
||||
get colorScheme(): 'dark' | 'light' | null {
|
||||
return this.emulationSettings.colorScheme ?? null;
|
||||
}
|
||||
|
||||
// Public for testability: tests spy on this method to verify throttle multipliers.
|
||||
createWaitForHelper(
|
||||
cpuMultiplier: number,
|
||||
networkMultiplier: number,
|
||||
): WaitForHelper {
|
||||
return new WaitForHelper(this.pptrPage, cpuMultiplier, networkMultiplier);
|
||||
}
|
||||
|
||||
waitForEventsAfterAction(
|
||||
action: () => Promise<unknown>,
|
||||
options?: {
|
||||
timeout?: number;
|
||||
handleDialog?:
|
||||
DialogAction | Partial<Record<Protocol.Page.DialogType, DialogAction>>;
|
||||
},
|
||||
): Promise<WaitForEventsResult> {
|
||||
const helper = this.createWaitForHelper(
|
||||
this.cpuThrottlingRate,
|
||||
getNetworkMultiplierFromString(this.networkConditions),
|
||||
);
|
||||
return helper.waitForEventsAfterAction(action, options);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.pptrPage.off('dialog', this.#dialogHandler);
|
||||
this.networkCollector.dispose();
|
||||
this.consoleCollector.dispose();
|
||||
}
|
||||
|
||||
async executeThirdPartyDeveloperTool(
|
||||
toolName: string,
|
||||
params: Record<string, unknown>,
|
||||
response: Response,
|
||||
): Promise<void> {
|
||||
// Creates array of ElementHandles from the UIDs in the params.
|
||||
// We do not replace the uids with the ElementsHandles yet, because
|
||||
// the `evaluate` function only turns them into DOM elements if they
|
||||
// are passed as non-nested arguments.
|
||||
const handles: ElementHandle[] = [];
|
||||
for (const value of Object.values(params)) {
|
||||
if (
|
||||
value instanceof Object &&
|
||||
'uid' in value &&
|
||||
typeof value.uid === 'string' &&
|
||||
Object.keys(value).length === 1
|
||||
) {
|
||||
handles.push(await this.getElementByUid(value.uid));
|
||||
}
|
||||
}
|
||||
|
||||
const result = await this.pptrPage.evaluate(
|
||||
async (name, args, ...elements) => {
|
||||
// Replace the UIDs with DOM elements.
|
||||
for (const [key, value] of Object.entries(args)) {
|
||||
if (
|
||||
value instanceof Object &&
|
||||
'uid' in value &&
|
||||
typeof value.uid === 'string' &&
|
||||
Object.keys(value).length === 1
|
||||
) {
|
||||
args[key] = elements.shift();
|
||||
}
|
||||
}
|
||||
|
||||
if (!window.__dtmcp?.executeTool) {
|
||||
throw new Error('No tools found on the page');
|
||||
}
|
||||
const toolResult = await window.__dtmcp.executeTool(name, args);
|
||||
|
||||
const stashDOMElement = (el: Element) => {
|
||||
if (!window.__dtmcp) {
|
||||
window.__dtmcp = {};
|
||||
}
|
||||
if (window.__dtmcp.stashedElements === undefined) {
|
||||
window.__dtmcp.stashedElements = [];
|
||||
}
|
||||
window.__dtmcp.stashedElements.push(el);
|
||||
return {
|
||||
stashedId: `stashed-${window.__dtmcp.stashedElements.length - 1}`,
|
||||
};
|
||||
};
|
||||
|
||||
const ancestors: unknown[] = [];
|
||||
// Recursively walks the tool result:
|
||||
// - Replaces DOM elements with an ID and stashes the DOM element on the window object
|
||||
// - Replaces non-plain objects with a string representation of the object
|
||||
// - Replaces circular references with the string '<Circular reference>'
|
||||
// - Replaces functions with the string '<Function object>'
|
||||
const processToolResult = (
|
||||
data: unknown,
|
||||
parentEl?: unknown,
|
||||
): unknown => {
|
||||
// 1. Handle DOM Elements
|
||||
if (data instanceof Element) {
|
||||
return stashDOMElement(data);
|
||||
}
|
||||
|
||||
// 2. Handle Arrays
|
||||
if (Array.isArray(data)) {
|
||||
return data.map((item: unknown) =>
|
||||
processToolResult(item, parentEl),
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Handle Objects
|
||||
if (data !== null && typeof data === 'object') {
|
||||
while (ancestors.length > 0 && ancestors.at(-1) !== parentEl) {
|
||||
ancestors.pop();
|
||||
}
|
||||
if (ancestors.includes(data)) {
|
||||
return '<Circular reference>';
|
||||
}
|
||||
ancestors.push(data);
|
||||
|
||||
// If not a plain object, return a string representation of the object
|
||||
if (Object.getPrototypeOf(data) !== Object.prototype) {
|
||||
return `<${data.constructor.name} instance>`;
|
||||
}
|
||||
|
||||
const processedObj: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
processedObj[key] = processToolResult(value, data);
|
||||
}
|
||||
return processedObj;
|
||||
}
|
||||
|
||||
// 4. Handle Functions
|
||||
if (typeof data === 'function') {
|
||||
return '<Function object>';
|
||||
}
|
||||
|
||||
// 5. Return primitives (strings, numbers, booleans) as-is
|
||||
return data;
|
||||
};
|
||||
|
||||
return {
|
||||
result: processToolResult(toolResult),
|
||||
stashed: window.__dtmcp?.stashedElements?.length ?? 0,
|
||||
};
|
||||
},
|
||||
toolName,
|
||||
params,
|
||||
...handles,
|
||||
);
|
||||
|
||||
const elementHandles: ElementHandle[] = [];
|
||||
for (let i = 0; i < (result.stashed ?? 0); i++) {
|
||||
const elementHandle = await this.pptrPage.evaluateHandle(index => {
|
||||
const el = window.__dtmcp?.stashedElements?.[index];
|
||||
if (!el) {
|
||||
throw new Error(`Stashed element at index ${index} not found`);
|
||||
}
|
||||
return el;
|
||||
}, i);
|
||||
elementHandles.push(elementHandle);
|
||||
}
|
||||
|
||||
if (elementHandles.length) {
|
||||
const oldHandles = [...this.extraHandles];
|
||||
this.textSnapshot = await TextSnapshot.create(this, {
|
||||
extraHandles: elementHandles,
|
||||
});
|
||||
response.includeSnapshot();
|
||||
|
||||
for (const handle of oldHandles) {
|
||||
await handle
|
||||
.dispose()
|
||||
.catch(e => logger?.('Failed to dispose old handle', e));
|
||||
}
|
||||
}
|
||||
|
||||
const cdpElementIds = await Promise.all(
|
||||
elementHandles.map(async (elementHandle, index) => {
|
||||
const backendNodeId = await elementHandle.backendNodeId();
|
||||
if (!backendNodeId) {
|
||||
logger?.(
|
||||
`No backendNodeId for stashed DOM element with index ${index}`,
|
||||
);
|
||||
return `stashed-${index}`;
|
||||
}
|
||||
const cdpElementId =
|
||||
this.textSnapshot?.resolveCdpElementId(backendNodeId);
|
||||
if (!cdpElementId) {
|
||||
logger?.(
|
||||
`Could not get cdpElementId for backend node ${backendNodeId}`,
|
||||
);
|
||||
return `stashed-${index}`;
|
||||
}
|
||||
return cdpElementId;
|
||||
}),
|
||||
);
|
||||
|
||||
const recursivelyReplaceStashedElements = (node: unknown): unknown => {
|
||||
if (Array.isArray(node)) {
|
||||
return node.map(x => recursivelyReplaceStashedElements(x));
|
||||
}
|
||||
if (node !== null && typeof node === 'object') {
|
||||
if (
|
||||
'stashedId' in node &&
|
||||
typeof node.stashedId === 'string' &&
|
||||
node.stashedId.startsWith('stashed-') &&
|
||||
Object.keys(node).length === 1
|
||||
) {
|
||||
const index = parseInt(node.stashedId.split('-')[1]);
|
||||
return {uid: cdpElementIds[index]};
|
||||
}
|
||||
const resultObj: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(node)) {
|
||||
resultObj[key] = recursivelyReplaceStashedElements(value);
|
||||
}
|
||||
return resultObj;
|
||||
}
|
||||
return node;
|
||||
};
|
||||
|
||||
const resultWithUids = recursivelyReplaceStashedElements(result.result);
|
||||
response.appendResponseLine(JSON.stringify(resultWithUids, null, 2));
|
||||
}
|
||||
|
||||
async getElementByUid(uid: string): Promise<ElementHandle<Element>> {
|
||||
if (!this.textSnapshot) {
|
||||
throw new Error(
|
||||
`No snapshot found for page ${this.id ?? '?'}. Use ${takeSnapshot.name} to capture one.`,
|
||||
);
|
||||
}
|
||||
const node = this.textSnapshot.idToNode.get(uid);
|
||||
if (!node) {
|
||||
throw new Error(`Element uid "${uid}" not found on page ${this.id}.`);
|
||||
}
|
||||
return this.#resolveElementHandle(node, uid);
|
||||
}
|
||||
|
||||
async #resolveElementHandle(
|
||||
node: TextSnapshotNode,
|
||||
uid: string,
|
||||
): Promise<ElementHandle<Element>> {
|
||||
const message = `Element with uid ${uid} no longer exists on the page.`;
|
||||
try {
|
||||
const handle = await node.elementHandle();
|
||||
if (!handle) {
|
||||
throw new Error(message);
|
||||
}
|
||||
return handle;
|
||||
} catch (error) {
|
||||
throw new Error(message, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getAXNodeByUid(uid: string) {
|
||||
return this.textSnapshot?.idToNode.get(uid);
|
||||
}
|
||||
|
||||
async getDevToolsData(): Promise<DevToolsData> {
|
||||
try {
|
||||
logger?.('Getting DevTools UI data');
|
||||
const devtoolsPage = this.devToolsPage;
|
||||
if (!devtoolsPage) {
|
||||
logger?.('No DevTools page detected');
|
||||
return {};
|
||||
}
|
||||
const {cdpRequestId, cdpBackendNodeId} = await devtoolsPage.evaluate(
|
||||
async () => {
|
||||
// @ts-expect-error no types
|
||||
const UI = await import('/bundled/ui/legacy/legacy.js');
|
||||
// @ts-expect-error no types
|
||||
const SDK = await import('/bundled/core/sdk/sdk.js');
|
||||
const request = UI.Context.Context.instance().flavor(
|
||||
SDK.NetworkRequest.NetworkRequest,
|
||||
);
|
||||
const node = UI.Context.Context.instance().flavor(
|
||||
SDK.DOMModel.DOMNode,
|
||||
);
|
||||
return {
|
||||
cdpRequestId: request?.requestId(),
|
||||
cdpBackendNodeId: node?.backendNodeId(),
|
||||
};
|
||||
},
|
||||
);
|
||||
return {cdpBackendNodeId, cdpRequestId};
|
||||
} catch (err) {
|
||||
logger?.('error getting devtools data', err);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
getConsoleMessageStableId(
|
||||
message: ConsoleMessage | Error | DevTools.AggregatedIssue | UncaughtError,
|
||||
): number {
|
||||
return (message as WithSymbolId<typeof message>)[stableIdSymbol] ?? -1;
|
||||
}
|
||||
|
||||
getNetworkRequestStableId(request: HTTPRequest): number {
|
||||
return (request as WithSymbolId<typeof request>)[stableIdSymbol] ?? -1;
|
||||
}
|
||||
|
||||
async restoreEmulation() {
|
||||
const currentSetting = this.emulationSettings;
|
||||
await this.emulate(currentSetting);
|
||||
}
|
||||
|
||||
async emulate(options: {
|
||||
networkConditions?: string;
|
||||
cpuThrottlingRate?: number;
|
||||
geolocation?: GeolocationOptions;
|
||||
userAgent?: string;
|
||||
colorScheme?: 'dark' | 'light' | 'auto';
|
||||
viewport?: Viewport;
|
||||
extraHttpHeaders?: Record<string, string> | undefined;
|
||||
}): Promise<void> {
|
||||
const page = this.pptrPage;
|
||||
const newSettings: EmulationSettings = {...this.emulationSettings};
|
||||
|
||||
// Skip network emulation if blocklist/allowlist is configured, as it conflicts with blocking rules in Puppeteer.
|
||||
if (this.#hasNetworkBlockOrAllowlist) {
|
||||
if (options.networkConditions !== undefined) {
|
||||
throw new Error(
|
||||
'Network throttling is not supported when network blocking (allowlist/blocklist) is configured.',
|
||||
);
|
||||
}
|
||||
} else if (!options.networkConditions) {
|
||||
await page.emulateNetworkConditions(null);
|
||||
delete newSettings.networkConditions;
|
||||
} else if (options.networkConditions === 'Offline') {
|
||||
await page.emulateNetworkConditions({
|
||||
offline: true,
|
||||
download: 0,
|
||||
upload: 0,
|
||||
latency: 0,
|
||||
});
|
||||
newSettings.networkConditions = 'Offline';
|
||||
} else if (options.networkConditions in PredefinedNetworkConditions) {
|
||||
const networkCondition =
|
||||
PredefinedNetworkConditions[
|
||||
options.networkConditions as keyof typeof PredefinedNetworkConditions
|
||||
];
|
||||
await page.emulateNetworkConditions(networkCondition);
|
||||
newSettings.networkConditions = options.networkConditions;
|
||||
}
|
||||
|
||||
const secondarySession = this.devtoolsUniverse?.session;
|
||||
if (!options.cpuThrottlingRate) {
|
||||
await page.emulateCPUThrottling(1);
|
||||
if (secondarySession) {
|
||||
await secondarySession.send('Emulation.setCPUThrottlingRate', {
|
||||
rate: 1,
|
||||
});
|
||||
}
|
||||
delete newSettings.cpuThrottlingRate;
|
||||
} else {
|
||||
await page.emulateCPUThrottling(options.cpuThrottlingRate);
|
||||
if (secondarySession) {
|
||||
await secondarySession.send('Emulation.setCPUThrottlingRate', {
|
||||
rate: options.cpuThrottlingRate,
|
||||
});
|
||||
}
|
||||
newSettings.cpuThrottlingRate = options.cpuThrottlingRate;
|
||||
}
|
||||
|
||||
if (!options.geolocation) {
|
||||
await page.setGeolocation({latitude: 0, longitude: 0});
|
||||
delete newSettings.geolocation;
|
||||
} else {
|
||||
await page.setGeolocation(options.geolocation);
|
||||
newSettings.geolocation = options.geolocation;
|
||||
}
|
||||
|
||||
if (!options.userAgent) {
|
||||
await page.setUserAgent({userAgent: undefined});
|
||||
delete newSettings.userAgent;
|
||||
} else {
|
||||
await page.setUserAgent({userAgent: options.userAgent});
|
||||
newSettings.userAgent = options.userAgent;
|
||||
}
|
||||
|
||||
if (!options.colorScheme || options.colorScheme === 'auto') {
|
||||
await page.emulateMediaFeatures([
|
||||
{name: 'prefers-color-scheme', value: ''},
|
||||
]);
|
||||
delete newSettings.colorScheme;
|
||||
} else {
|
||||
await page.emulateMediaFeatures([
|
||||
{name: 'prefers-color-scheme', value: options.colorScheme},
|
||||
]);
|
||||
newSettings.colorScheme = options.colorScheme;
|
||||
}
|
||||
|
||||
if (!options.viewport) {
|
||||
delete newSettings.viewport;
|
||||
} else {
|
||||
const defaults = {
|
||||
deviceScaleFactor: 1,
|
||||
isMobile: false,
|
||||
hasTouch: false,
|
||||
isLandscape: false,
|
||||
};
|
||||
newSettings.viewport = {...defaults, ...options.viewport};
|
||||
}
|
||||
|
||||
if (options.extraHttpHeaders !== undefined) {
|
||||
await page.setExtraHTTPHeaders(options.extraHttpHeaders);
|
||||
newSettings.extraHttpHeaders = options.extraHttpHeaders;
|
||||
if (Object.keys(options.extraHttpHeaders).length === 0) {
|
||||
delete newSettings.extraHttpHeaders;
|
||||
}
|
||||
}
|
||||
|
||||
this.emulationSettings = Object.keys(newSettings).length ? newSettings : {};
|
||||
|
||||
this.updateTimeouts();
|
||||
|
||||
// This should happen after updating the page timeouts.
|
||||
// Setting the viewport can trigger a reload which we don't want to timeout.
|
||||
await page.setViewport(newSettings.viewport ?? null);
|
||||
}
|
||||
|
||||
updateTimeouts() {
|
||||
// For waiters 5sec timeout should be sufficient.
|
||||
// Increased in case we throttle the CPU
|
||||
const cpuMultiplier = this.cpuThrottlingRate;
|
||||
this.pptrPage.setDefaultTimeout(DEFAULT_TIMEOUT * cpuMultiplier);
|
||||
// 10sec should be enough for the load event to be emitted during
|
||||
// navigations.
|
||||
// Increased in case we throttle the network requests or the CPU
|
||||
const networkMultiplier = getNetworkMultiplierFromString(
|
||||
this.networkConditions,
|
||||
);
|
||||
this.pptrPage.setDefaultNavigationTimeout(
|
||||
NAVIGATION_TIMEOUT * networkMultiplier * cpuMultiplier,
|
||||
);
|
||||
}
|
||||
|
||||
waitForTextOnPage(text: string[], timeout?: number): Promise<Element> {
|
||||
const frames = this.pptrPage.frames();
|
||||
|
||||
let locator = this.#locatorClass.race(
|
||||
frames.flatMap(frame =>
|
||||
text.flatMap(value => [
|
||||
frame.locator(`aria/${value}`),
|
||||
frame.locator(`text/${value}`),
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
if (timeout) {
|
||||
locator = locator.setTimeout(timeout);
|
||||
}
|
||||
|
||||
return locator.wait();
|
||||
}
|
||||
|
||||
/**
|
||||
* We need to ignore favicon request as they make our test flaky
|
||||
*/
|
||||
async setUpNetworkCollectorForTesting() {
|
||||
this.networkCollector.dispose();
|
||||
this.networkCollector = new NetworkCollector(this.pptrPage, collect => {
|
||||
return {
|
||||
request: req => {
|
||||
if (req.url().includes('favicon.ico')) {
|
||||
return;
|
||||
}
|
||||
collect(req);
|
||||
},
|
||||
} as ListenerMap;
|
||||
});
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user