diff --git a/.github/workflows/publish-to-npm-on-tag.yml b/.github/workflows/publish-to-npm-on-tag.yml
index 015e9b04b..c83debc67 100644
--- a/.github/workflows/publish-to-npm-on-tag.yml
+++ b/.github/workflows/publish-to-npm-on-tag.yml
@@ -36,10 +36,6 @@ jobs:
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
diff --git a/.nvmrc b/.nvmrc
index 92f279e3e..18c92ea98 100644
--- a/.nvmrc
+++ b/.nvmrc
@@ -1 +1 @@
-v22
\ No newline at end of file
+v24
\ No newline at end of file
diff --git a/README.md b/README.md
index 6f83fe56e..cdc842d46 100644
--- a/README.md
+++ b/README.md
@@ -1,11 +1,12 @@
-# Chrome DevTools MCP
+# Chrome DevTools for Agents
[](https://npmjs.org/package/chrome-devtools-mcp)
-`chrome-devtools-mcp` lets your coding agent (such as Gemini, Claude, Cursor or Copilot)
+Chrome DevTools for Agents (`chrome-devtools-mcp`) lets your coding agent (such as Gemini, 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)
@@ -373,6 +374,21 @@ Once connected, the Chrome DevTools MCP tools will be available in StudioAssist.
+
+ Mistral Vibe
+
+Add in ~/.vibe/config.toml:
+
+```toml
+[[mcp_servers]]
+name = "chrome-devtools"
+transport = "stdio"
+command = "npx"
+args = ["chrome-devtools-mcp@latest"]
+```
+
+
+
OpenCode
@@ -568,6 +584,10 @@ The Chrome DevTools MCP server supports the following configuration option:
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
+- **`--experimentalWebmcp`/ `--experimental-webmcp`**
+ Set to true to enable debugging WebMCP tools. Requires Chrome 149+ with the following flags: `--enable-features=WebMCPTesting,DevToolsWebMCPSupport`
+ - **Type:** boolean
+
- **`--chromeArg`/ `--chrome-arg`**
Additional arguments for Chrome. Only applies when Chrome is launched by chrome-devtools-mcp.
- **Type:** array
@@ -605,6 +625,11 @@ The Chrome DevTools MCP server supports the following configuration option:
Exposes a "slim" set of 3 tools covering navigation, script execution and screenshots only. Useful for basic browser tasks.
- **Type:** boolean
+- **`--redactNetworkHeaders`/ `--redact-network-headers`**
+ If true, redacts some of the network headers considered senstive before returning to the client.
+ - **Type:** boolean
+ - **Default:** `false`
+
Pass them via the `args` property in the JSON configuration. For example:
diff --git a/SECURITY.md b/SECURITY.md
index c5bfca281..b8fac9564 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -1,3 +1,9 @@
## 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 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.
diff --git a/package-lock.json b/package-lock.json
index e35394a6e..f6892ec12 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -27,7 +27,7 @@
"@types/yargs": "^17.0.33",
"@typescript-eslint/eslint-plugin": "^8.43.0",
"@typescript-eslint/parser": "^8.43.0",
- "chrome-devtools-frontend": "1.0.1611390",
+ "chrome-devtools-frontend": "1.0.1613625",
"core-js": "3.49.0",
"debug": "4.4.3",
"eslint": "^9.35.0",
@@ -36,7 +36,7 @@
"globals": "^17.0.0",
"lighthouse": "13.1.0",
"prettier": "^3.6.2",
- "puppeteer": "24.40.0",
+ "puppeteer": "24.41.0",
"rollup": "4.60.1",
"rollup-plugin-cleanup": "^3.2.1",
"rollup-plugin-license": "^3.6.0",
@@ -381,9 +381,9 @@
}
},
"node_modules/@google/genai": {
- "version": "1.48.0",
- "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.48.0.tgz",
- "integrity": "sha512-plonYK4ML2PrxsRD9SeqmFt76eREWkQdPCglOA6aYDzL1AAbE+7PUnT54SvpWGfws13L0AZEqGSpL7+1IPnTxQ==",
+ "version": "1.50.1",
+ "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.50.1.tgz",
+ "integrity": "sha512-YbkX7H9+1Pt8wOt7DDREy8XSoiL6fRDzZQRyaVBarFf8MR3zHGqVdvM4cLbDXqPhxqvegZShgfxb8kw9C7YhAQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -2004,9 +2004,9 @@
}
},
"node_modules/@sinonjs/fake-timers": {
- "version": "15.1.1",
- "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.1.1.tgz",
- "integrity": "sha512-cO5W33JgAPbOh07tvZjUOJ7oWhtaqGHiZw+11DPbyqh2kHTBc3eF/CjJDeQ4205RLQsX6rxCuYOroFQwl7JDRw==",
+ "version": "15.3.2",
+ "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.3.2.tgz",
+ "integrity": "sha512-mrn35Jl2pCpns+mE3HaZa1yPN5EYCRgiMI+135COjr2hr8Cls9DXqIZ57vZe2cz7y2XVSq92tcs6kGQcT1J8Rw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
@@ -2014,9 +2014,9 @@
}
},
"node_modules/@sinonjs/samsam": {
- "version": "9.0.3",
- "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-9.0.3.tgz",
- "integrity": "sha512-ZgYY7Dc2RW+OUdnZ1DEHg00lhRt+9BjymPKHog4PRFzr1U3MbK57+djmscWyKxzO1qfunHqs4N45WWyKIFKpiQ==",
+ "version": "10.0.2",
+ "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-10.0.2.tgz",
+ "integrity": "sha512-8lVwD1Df1BmzoaOLhMcGGcz/Jyr5QY2KSB75/YK1QgKzoabTeLdIVyhXNZK9ojfSKSdirbXqdbsXXqP9/Ve8+A==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
@@ -2149,13 +2149,13 @@
}
},
"node_modules/@types/node": {
- "version": "25.5.0",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
- "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
+ "version": "25.6.0",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
+ "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "undici-types": "~7.18.0"
+ "undici-types": "~7.19.0"
}
},
"node_modules/@types/pg": {
@@ -2202,9 +2202,9 @@
"license": "MIT"
},
"node_modules/@types/sinon": {
- "version": "21.0.0",
- "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-21.0.0.tgz",
- "integrity": "sha512-+oHKZ0lTI+WVLxx1IbJDNmReQaIsQJjN2e7UUrJHEeByG7bFeKJYsv1E75JxTQ9QKJDp21bAa/0W2Xo4srsDnw==",
+ "version": "21.0.1",
+ "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-21.0.1.tgz",
+ "integrity": "sha512-5yoJSqLbjH8T9V2bksgRayuhpZy+723/z6wBOR+Soe4ZlXC0eW8Na71TeaZPUWDQvM7LYKa9UGFc6LRqxiR5fQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2257,17 +2257,17 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
- "version": "8.58.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz",
- "integrity": "sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==",
+ "version": "8.58.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.2.tgz",
+ "integrity": "sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
- "@typescript-eslint/scope-manager": "8.58.0",
- "@typescript-eslint/type-utils": "8.58.0",
- "@typescript-eslint/utils": "8.58.0",
- "@typescript-eslint/visitor-keys": "8.58.0",
+ "@typescript-eslint/scope-manager": "8.58.2",
+ "@typescript-eslint/type-utils": "8.58.2",
+ "@typescript-eslint/utils": "8.58.2",
+ "@typescript-eslint/visitor-keys": "8.58.2",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
@@ -2286,16 +2286,16 @@
}
},
"node_modules/@typescript-eslint/parser": {
- "version": "8.58.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.0.tgz",
- "integrity": "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==",
+ "version": "8.58.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.2.tgz",
+ "integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/scope-manager": "8.58.0",
- "@typescript-eslint/types": "8.58.0",
- "@typescript-eslint/typescript-estree": "8.58.0",
- "@typescript-eslint/visitor-keys": "8.58.0",
+ "@typescript-eslint/scope-manager": "8.58.2",
+ "@typescript-eslint/types": "8.58.2",
+ "@typescript-eslint/typescript-estree": "8.58.2",
+ "@typescript-eslint/visitor-keys": "8.58.2",
"debug": "^4.4.3"
},
"engines": {
@@ -2311,14 +2311,14 @@
}
},
"node_modules/@typescript-eslint/project-service": {
- "version": "8.58.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.0.tgz",
- "integrity": "sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==",
+ "version": "8.58.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.2.tgz",
+ "integrity": "sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/tsconfig-utils": "^8.58.0",
- "@typescript-eslint/types": "^8.58.0",
+ "@typescript-eslint/tsconfig-utils": "^8.58.2",
+ "@typescript-eslint/types": "^8.58.2",
"debug": "^4.4.3"
},
"engines": {
@@ -2333,14 +2333,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
- "version": "8.58.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.0.tgz",
- "integrity": "sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==",
+ "version": "8.58.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.2.tgz",
+ "integrity": "sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.58.0",
- "@typescript-eslint/visitor-keys": "8.58.0"
+ "@typescript-eslint/types": "8.58.2",
+ "@typescript-eslint/visitor-keys": "8.58.2"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2351,9 +2351,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
- "version": "8.58.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.0.tgz",
- "integrity": "sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==",
+ "version": "8.58.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.2.tgz",
+ "integrity": "sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2368,15 +2368,15 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
- "version": "8.58.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.0.tgz",
- "integrity": "sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg==",
+ "version": "8.58.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.2.tgz",
+ "integrity": "sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.58.0",
- "@typescript-eslint/typescript-estree": "8.58.0",
- "@typescript-eslint/utils": "8.58.0",
+ "@typescript-eslint/types": "8.58.2",
+ "@typescript-eslint/typescript-estree": "8.58.2",
+ "@typescript-eslint/utils": "8.58.2",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
@@ -2393,9 +2393,9 @@
}
},
"node_modules/@typescript-eslint/types": {
- "version": "8.58.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz",
- "integrity": "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==",
+ "version": "8.58.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.2.tgz",
+ "integrity": "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2407,16 +2407,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
- "version": "8.58.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.0.tgz",
- "integrity": "sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==",
+ "version": "8.58.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.2.tgz",
+ "integrity": "sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/project-service": "8.58.0",
- "@typescript-eslint/tsconfig-utils": "8.58.0",
- "@typescript-eslint/types": "8.58.0",
- "@typescript-eslint/visitor-keys": "8.58.0",
+ "@typescript-eslint/project-service": "8.58.2",
+ "@typescript-eslint/tsconfig-utils": "8.58.2",
+ "@typescript-eslint/types": "8.58.2",
+ "@typescript-eslint/visitor-keys": "8.58.2",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
@@ -2474,16 +2474,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
- "version": "8.58.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.0.tgz",
- "integrity": "sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==",
+ "version": "8.58.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.2.tgz",
+ "integrity": "sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
- "@typescript-eslint/scope-manager": "8.58.0",
- "@typescript-eslint/types": "8.58.0",
- "@typescript-eslint/typescript-estree": "8.58.0"
+ "@typescript-eslint/scope-manager": "8.58.2",
+ "@typescript-eslint/types": "8.58.2",
+ "@typescript-eslint/typescript-estree": "8.58.2"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2498,13 +2498,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
- "version": "8.58.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.0.tgz",
- "integrity": "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==",
+ "version": "8.58.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.2.tgz",
+ "integrity": "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.58.0",
+ "@typescript-eslint/types": "8.58.2",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
@@ -3293,9 +3293,9 @@
"license": "MIT"
},
"node_modules/basic-ftp": {
- "version": "5.2.1",
- "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.1.tgz",
- "integrity": "sha512-0yaL8JdxTknKDILitVpfYfV2Ob6yb3udX/hK97M7I3jOeznBNxQPtVvTUtnhUkyHlxFWyr5Lvknmgzoc7jf+1Q==",
+ "version": "5.2.2",
+ "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.2.tgz",
+ "integrity": "sha512-1tDrzKsdCg70WGvbFss/ulVAxupNauGnOlgpyjKzeQxzyllBLS0CGLV7tjIXTK3ZQA9/FBEm9qyFFN1bciA6pw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3452,9 +3452,9 @@
}
},
"node_modules/chrome-devtools-frontend": {
- "version": "1.0.1611390",
- "resolved": "https://registry.npmjs.org/chrome-devtools-frontend/-/chrome-devtools-frontend-1.0.1611390.tgz",
- "integrity": "sha512-hoDgtFvV0Gbg12upe+rOq4Q1dgMjVQ66a/ckOiD5bPfFiBerDFJT9mfh5JfJjqEPl3NttC/dqiY6MKT1NlE9lA==",
+ "version": "1.0.1613625",
+ "resolved": "https://registry.npmjs.org/chrome-devtools-frontend/-/chrome-devtools-frontend-1.0.1613625.tgz",
+ "integrity": "sha512-Ao1y2Nq6A2XWJPEX00encO/uzt7dssmAAz5EViySpC/8bqoKM6CDNQdM1U++Gh7+qOXGM9DIMU31BQK47Krd5A==",
"dev": true,
"license": "BSD-3-Clause"
},
@@ -3870,16 +3870,16 @@
}
},
"node_modules/devtools-protocol": {
- "version": "0.0.1581282",
- "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1581282.tgz",
- "integrity": "sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ==",
+ "version": "0.0.1595872",
+ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1595872.tgz",
+ "integrity": "sha512-kRfgp8vWVjBu/fbYCiVFiOqsCk3CrMKEo3WbgGT2NXK2dG7vawWPBljixajVgGK9II8rDO9G0oD0zLt3I1daRg==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/diff": {
- "version": "8.0.3",
- "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz",
- "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==",
+ "version": "8.0.4",
+ "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz",
+ "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
@@ -5269,9 +5269,9 @@
}
},
"node_modules/globals": {
- "version": "17.4.0",
- "resolved": "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz",
- "integrity": "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==",
+ "version": "17.5.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-17.5.0.tgz",
+ "integrity": "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g==",
"dev": true,
"license": "MIT",
"engines": {
@@ -5456,9 +5456,9 @@
}
},
"node_modules/hono": {
- "version": "4.12.12",
- "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz",
- "integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==",
+ "version": "4.12.14",
+ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz",
+ "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==",
"dev": true,
"license": "MIT",
"engines": {
@@ -7364,9 +7364,9 @@
}
},
"node_modules/prettier": {
- "version": "3.8.1",
- "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
- "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
+ "version": "3.8.3",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz",
+ "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
"dev": true,
"license": "MIT",
"bin": {
@@ -7477,9 +7477,9 @@
}
},
"node_modules/puppeteer": {
- "version": "24.40.0",
- "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.40.0.tgz",
- "integrity": "sha512-IxQbDq93XHVVLWHrAkFP7F7iHvb9o0mgfsSIMlhHb+JM+JjM1V4v4MNSQfcRWJopx9dsNOr9adYv0U5fm9BJBQ==",
+ "version": "24.41.0",
+ "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.41.0.tgz",
+ "integrity": "sha512-W6Fk0J3TPjjtwjXOyR/qf+YaL0H/Uq8HIgHcXG4mNM/IgbKMCH/HPyK0Fi2qbTU/QpSl9bCte2yBpGHKejTpIw==",
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
@@ -7487,8 +7487,8 @@
"@puppeteer/browsers": "2.13.0",
"chromium-bidi": "14.0.0",
"cosmiconfig": "^9.0.0",
- "devtools-protocol": "0.0.1581282",
- "puppeteer-core": "24.40.0",
+ "devtools-protocol": "0.0.1595872",
+ "puppeteer-core": "24.41.0",
"typed-query-selector": "^2.12.1"
},
"bin": {
@@ -7499,16 +7499,16 @@
}
},
"node_modules/puppeteer-core": {
- "version": "24.40.0",
- "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.40.0.tgz",
- "integrity": "sha512-MWL3XbUCfVgGR0gRsidzT6oKJT2QydPLhMITU6HoVWiiv4gkb6gJi3pcdAa8q4HwjBTbqISOWVP4aJiiyUJvag==",
+ "version": "24.41.0",
+ "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.41.0.tgz",
+ "integrity": "sha512-rLIUri7E/NQ3APSEYCCozaSJx0u8Tu9wxO6BJwnvXmIgILSK3L0TombaVh3izp1njAGrO6H2ru0hcIrLF+gWLw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@puppeteer/browsers": "2.13.0",
"chromium-bidi": "14.0.0",
"debug": "^4.4.3",
- "devtools-protocol": "0.0.1581282",
+ "devtools-protocol": "0.0.1595872",
"typed-query-selector": "^2.12.1",
"webdriver-bidi-protocol": "0.4.1",
"ws": "^8.19.0"
@@ -7778,9 +7778,9 @@
}
},
"node_modules/rollup-plugin-license": {
- "version": "3.7.0",
- "resolved": "https://registry.npmjs.org/rollup-plugin-license/-/rollup-plugin-license-3.7.0.tgz",
- "integrity": "sha512-RvvOIF+GH3fBR3wffgc/vmjQn6qOn72WjppWVDp/v+CLpT0BbcRBdSkPeeIOL6U5XccdYgSIMjUyXgxlKEEFcw==",
+ "version": "3.7.1",
+ "resolved": "https://registry.npmjs.org/rollup-plugin-license/-/rollup-plugin-license-3.7.1.tgz",
+ "integrity": "sha512-FcGXUbAmPvRSLxjVdjp/r/MUtKBlttVQd+ApUyvKfREnsoAfAZA6Ic2fE1Tz4RL0f9XqEQU9UIRNUMdtQtliDw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -8153,17 +8153,16 @@
}
},
"node_modules/sinon": {
- "version": "21.0.3",
- "resolved": "https://registry.npmjs.org/sinon/-/sinon-21.0.3.tgz",
- "integrity": "sha512-0x8TQFr8EjADhSME01u1ZK31yv2+bd6Z5NrBCHVM+n4qL1wFqbxftmeyi3bwlr49FbbzRfrqSFOpyHCOh/YmYA==",
+ "version": "21.1.2",
+ "resolved": "https://registry.npmjs.org/sinon/-/sinon-21.1.2.tgz",
+ "integrity": "sha512-FS6mN+/bx7e2ajpXkEmOcWB6xBzWiuNoAQT18/+a20SS4U7FSYl8Ms7N6VTUxN/1JAjkx7aXp+THMC8xdpp0gA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"@sinonjs/commons": "^3.0.1",
- "@sinonjs/fake-timers": "^15.1.1",
- "@sinonjs/samsam": "^9.0.3",
- "diff": "^8.0.3",
- "supports-color": "^7.2.0"
+ "@sinonjs/fake-timers": "^15.3.2",
+ "@sinonjs/samsam": "^10.0.2",
+ "diff": "^8.0.4"
},
"funding": {
"type": "opencollective",
@@ -8886,16 +8885,16 @@
}
},
"node_modules/typescript-eslint": {
- "version": "8.58.0",
- "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.0.tgz",
- "integrity": "sha512-e2TQzKfaI85fO+F3QywtX+tCTsu/D3WW5LVU6nz8hTFKFZ8yBJ6mSYRpXqdR3mFjPWmO0eWsTa5f+UpAOe/FMA==",
+ "version": "8.58.2",
+ "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.2.tgz",
+ "integrity": "sha512-V8iSng9mRbdZjl54VJ9NKr6ZB+dW0J3TzRXRGcSbLIej9jV86ZRtlYeTKDR/QLxXykocJ5icNzbsl2+5TzIvcQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/eslint-plugin": "8.58.0",
- "@typescript-eslint/parser": "8.58.0",
- "@typescript-eslint/typescript-estree": "8.58.0",
- "@typescript-eslint/utils": "8.58.0"
+ "@typescript-eslint/eslint-plugin": "8.58.2",
+ "@typescript-eslint/parser": "8.58.2",
+ "@typescript-eslint/typescript-estree": "8.58.2",
+ "@typescript-eslint/utils": "8.58.2"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -8929,9 +8928,9 @@
}
},
"node_modules/undici-types": {
- "version": "7.18.2",
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
- "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
+ "version": "7.19.2",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
+ "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
"dev": true,
"license": "MIT"
},
diff --git a/package.json b/package.json
index a5d253ad2..3ea6bfc5d 100644
--- a/package.json
+++ b/package.json
@@ -60,7 +60,7 @@
"@types/yargs": "^17.0.33",
"@typescript-eslint/eslint-plugin": "^8.43.0",
"@typescript-eslint/parser": "^8.43.0",
- "chrome-devtools-frontend": "1.0.1611390",
+ "chrome-devtools-frontend": "1.0.1613625",
"core-js": "3.49.0",
"debug": "4.4.3",
"eslint": "^9.35.0",
@@ -69,7 +69,7 @@
"globals": "^17.0.0",
"lighthouse": "13.1.0",
"prettier": "^3.6.2",
- "puppeteer": "24.40.0",
+ "puppeteer": "24.41.0",
"rollup": "4.60.1",
"rollup-plugin-cleanup": "^3.2.1",
"rollup-plugin-license": "^3.6.0",
diff --git a/scripts/post-build.ts b/scripts/post-build.ts
index edf822599..7cf9da6ae 100644
--- a/scripts/post-build.ts
+++ b/scripts/post-build.ts
@@ -26,6 +26,7 @@ function main(): void {
// Create i18n mock
const i18nDir = path.join(BUILD_DIR, devtoolsFrontEndCorePath, 'i18n');
+ fs.mkdirSync(i18nDir, {recursive: true});
const localesFile = path.join(i18nDir, 'locales.js');
const localesContent = `
export const LOCALES = [
diff --git a/scripts/test.mjs b/scripts/test.mjs
index 4b07d6021..997b4759a 100644
--- a/scripts/test.mjs
+++ b/scripts/test.mjs
@@ -8,6 +8,7 @@
// 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';
@@ -38,10 +39,27 @@ if (userArgs.length > 0) {
}
} else {
const isNode20 = process.version.startsWith('v20.');
- if (isNode20) {
- files.push('build/tests');
- } else {
- files.push('build/tests/**/*.test.js');
+ if (flags.includes('--test-only')) {
+ if (isNode20) {
+ throw new Error(`--test-only is not supported for Node 20`);
+ }
+ 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) {
+ if (isNode20) {
+ files.push('build/tests');
+ } else {
+ files.push('build/tests/**/*.test.js');
+ }
}
}
@@ -83,6 +101,7 @@ async function runTests(attempt) {
...process.env,
CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: true,
CHROME_DEVTOOLS_MCP_CRASH_ON_UNCAUGHT: true,
+ CHROME_DEVTOOLS_MCP_NO_UPDATE_CHECKS: true,
},
});
diff --git a/src/McpContext.ts b/src/McpContext.ts
index 32b7413e6..d98376bd0 100644
--- a/src/McpContext.ts
+++ b/src/McpContext.ts
@@ -16,7 +16,7 @@ import {
type ListenerMap,
type UncaughtError,
} from './PageCollector.js';
-import type {DevTools} from './third_party/index.js';
+import type {DevTools, Protocol} from './third_party/index.js';
import type {
Browser,
BrowserContext,
@@ -29,11 +29,17 @@ import type {
Viewport,
Target,
} from './third_party/index.js';
-import {Locator} from './third_party/index.js';
+import {Locator, type ElementHandle} from './third_party/index.js';
import {PredefinedNetworkConditions} from './third_party/index.js';
import {listPages} from './tools/pages.js';
import {CLOSE_PAGE_ERROR} from './tools/ToolDefinition.js';
-import type {Context, DevToolsData} from './tools/ToolDefinition.js';
+import type {
+ Context,
+ DevToolsData,
+ ContextPage,
+ SupportedExtensions,
+ ContextPage,
+} from './tools/ToolDefinition.js';
import type {TraceResult} from './trace-processing/parse.js';
import type {
EmulationSettings,
@@ -46,7 +52,7 @@ import {
ExtensionRegistry,
type InstalledExtension,
} from './utils/ExtensionRegistry.js';
-import {saveTemporaryFile} from './utils/files.js';
+import {ensureExtension, saveTemporaryFile} from './utils/files.js';
import {getNetworkMultiplierFromString} from './WaitForHelper.js';
interface McpContextOptions {
@@ -74,7 +80,7 @@ export class McpContext implements Context {
#extensionServiceWorkers: ExtensionServiceWorker[] = [];
#mcpPages = new Map();
- #selectedPage?: McpPage;
+ #selectedPage?: ContextPage;
#networkCollector: NetworkCollector;
#consoleCollector: ConsoleCollector;
#devtoolsUniverseManager: UniverseManager;
@@ -117,7 +123,7 @@ export class McpContext implements Context {
uncaughtError: event => {
collect(event);
},
- issue: event => {
+ devtoolsAggregatedIssue: event => {
collect(event);
},
} as ListenerMap;
@@ -159,7 +165,10 @@ export class McpContext implements Context {
return context;
}
- resolveCdpRequestId(page: McpPage, cdpRequestId: string): number | undefined {
+ resolveCdpRequestId(
+ page: ContextPage,
+ cdpRequestId: string,
+ ): number | undefined {
if (!cdpRequestId) {
this.logger('no network request');
return;
@@ -176,14 +185,14 @@ export class McpContext implements Context {
}
resolveCdpElementId(
- page: McpPage,
+ page: ContextPage,
cdpBackendNodeId: number,
): string | undefined {
if (!cdpBackendNodeId) {
this.logger('no cdpBackendNodeId');
return;
}
- const snapshot = page.textSnapshot;
+ const snapshot = page.getSnapshot();
if (!snapshot) {
this.logger('no text snapshot');
return;
@@ -276,7 +285,7 @@ export class McpContext implements Context {
return this.#networkCollector.getById(page.pptrPage, reqid);
}
- async restoreEmulation(page: McpPage) {
+ async restoreEmulation(page: ContextPage) {
const currentSetting = page.emulationSettings;
await this.emulate(currentSetting, page.pptrPage);
}
@@ -442,7 +451,7 @@ export class McpContext implements Context {
return this.#selectedPage?.pptrPage === page;
}
- selectPage(newPage: McpPage): void {
+ selectPage(newPage: ContextPage): void {
this.#selectedPage = newPage;
this.#updateSelectedPageTimeouts();
}
@@ -675,7 +684,7 @@ export class McpContext implements Context {
return this.#mcpPages.get(page)?.devToolsPage;
}
- async getDevToolsData(page: McpPage): Promise {
+ async getDevToolsData(page: ContextPage): Promise {
try {
this.logger('Getting DevTools UI data');
const devtoolsPage = this.getDevToolsPage(page.pptrPage);
@@ -712,9 +721,10 @@ export class McpContext implements Context {
* Creates a text snapshot of a page.
*/
async createTextSnapshot(
- page: McpPage,
+ page: ContextPage,
verbose = false,
devtoolsData: DevToolsData | undefined = undefined,
+ extraHandles?: ElementHandle[],
): Promise {
const rootNode = await page.pptrPage.accessibility.snapshot({
includeIframes: true,
@@ -768,6 +778,151 @@ export class McpContext implements Context {
};
const rootNodeWithId = assignIds(rootNode);
+
+ const createExtraNode = async (
+ handle: ElementHandle,
+ ): Promise => {
+ const backendNodeId = await handle.backendNodeId();
+ if (!backendNodeId) {
+ return null;
+ }
+ const uniqueBackendId = `custom_${backendNodeId}`;
+ if (seenUniqueIds.has(uniqueBackendId)) {
+ return null;
+ }
+
+ let id = '';
+ if (uniqueBackendNodeIdToMcpId.has(uniqueBackendId)) {
+ id = uniqueBackendNodeIdToMcpId.get(uniqueBackendId)!;
+ } else {
+ id = `${snapshotId}_${idCounter++}`;
+ uniqueBackendNodeIdToMcpId.set(uniqueBackendId, id);
+ }
+ seenUniqueIds.add(uniqueBackendId);
+
+ const tagHandle = await handle.getProperty('localName');
+ const tagValue = await tagHandle.jsonValue();
+ const extraNode: TextSnapshotNode = {
+ role: tagValue,
+ id,
+ backendNodeId,
+ children: [],
+ elementHandle: async () => handle,
+ };
+ return extraNode;
+ };
+
+ const findAncestorNode = async (
+ handle: ElementHandle,
+ ): Promise => {
+ let ancestorHandle = await handle.evaluateHandle(el => el.parentElement);
+
+ while (ancestorHandle) {
+ const ancestorElement = ancestorHandle.asElement();
+ if (!ancestorElement) {
+ await ancestorHandle.dispose();
+ return null;
+ }
+
+ const ancestorBackendId = await ancestorElement.backendNodeId();
+ if (ancestorBackendId) {
+ const ancestorNode = idToNode
+ .values()
+ .find(node => node.backendNodeId === ancestorBackendId);
+ if (ancestorNode) {
+ await ancestorHandle.dispose();
+ return ancestorNode;
+ }
+ }
+
+ const nextHandle = await ancestorElement.evaluateHandle(
+ el => el.parentElement,
+ );
+ await ancestorHandle.dispose();
+ ancestorHandle = nextHandle;
+ }
+ return null;
+ };
+
+ const findDescendantNodes = async (
+ backendNodeId: number,
+ ): Promise> => {
+ const descendantIds = new Set();
+ try {
+ // @ts-expect-error internal API
+ const client = page.pptrPage._client();
+ if (client) {
+ const {node}: {node: Protocol.DOM.Node} = await client.send(
+ 'DOM.describeNode',
+ {
+ backendNodeId,
+ depth: -1,
+ pierce: true,
+ },
+ );
+ const collect = (node: Protocol.DOM.Node) => {
+ if (node.backendNodeId && node.backendNodeId !== backendNodeId) {
+ descendantIds.add(node.backendNodeId);
+ }
+ if (node.children) {
+ for (const child of node.children) {
+ collect(child);
+ }
+ }
+ };
+ collect(node);
+ }
+ } catch (e) {
+ this.logger(
+ `Failed to collect descendants for backend node ${backendNodeId}`,
+ e,
+ );
+ }
+ return descendantIds;
+ };
+
+ const moveChildNodes = (
+ attachTarget: TextSnapshotNode,
+ extraNode: TextSnapshotNode,
+ descendantIds: Set,
+ ): number => {
+ let firstMovedIndex = -1;
+ if (descendantIds.size > 0 && attachTarget.children) {
+ const remainingChildren: TextSnapshotNode[] = [];
+ for (const child of attachTarget.children) {
+ if (child.backendNodeId && descendantIds.has(child.backendNodeId)) {
+ if (firstMovedIndex === -1) {
+ firstMovedIndex = remainingChildren.length;
+ }
+ extraNode.children.push(child);
+ } else {
+ remainingChildren.push(child);
+ }
+ }
+ attachTarget.children = remainingChildren;
+ }
+ return firstMovedIndex !== -1
+ ? firstMovedIndex
+ : attachTarget.children
+ ? attachTarget.children.length
+ : 0;
+ };
+
+ if (extraHandles) {
+ page.setExtraHandles(extraHandles);
+ }
+ for (const handle of page.getExtraHandles() ?? []) {
+ const extraNode = await createExtraNode(handle);
+ if (!extraNode) {
+ continue;
+ }
+ idToNode.set(extraNode.id, extraNode);
+ const attachTarget = (await findAncestorNode(handle)) || rootNodeWithId;
+ const descendantIds = await findDescendantNodes(extraNode.backendNodeId!);
+ const index = moveChildNodes(attachTarget, extraNode, descendantIds);
+ attachTarget.children.splice(index, 0, extraNode);
+ }
+
const snapshot: TextSnapshot = {
root: rootNodeWithId,
snapshotId: String(snapshotId),
@@ -775,7 +930,7 @@ export class McpContext implements Context {
hasSelectedElement: false,
verbose,
};
- page.textSnapshot = snapshot;
+ page.setSnapshot(snapshot);
const data = devtoolsData ?? (await this.getDevToolsData(page));
if (data?.cdpBackendNodeId) {
snapshot.hasSelectedElement = true;
@@ -801,10 +956,14 @@ export class McpContext implements Context {
}
async saveFile(
data: Uint8Array,
- filename: string,
+ clientProvidedFilePath: string,
+ extension: SupportedExtensions,
): Promise<{filename: string}> {
try {
- const filePath = path.resolve(filename);
+ const filePath = ensureExtension(
+ path.resolve(clientProvidedFilePath),
+ extension,
+ );
await fs.mkdir(path.dirname(filePath), {recursive: true});
await fs.writeFile(filePath, data);
return {filename: filePath};
diff --git a/src/McpPage.ts b/src/McpPage.ts
index 1e311bc62..419356a97 100644
--- a/src/McpPage.ts
+++ b/src/McpPage.ts
@@ -9,6 +9,7 @@ import type {
ElementHandle,
Page,
Viewport,
+ WebMCPTool,
} from './third_party/index.js';
import type {ToolGroup, ToolDefinition} from './tools/inPage.js';
import {takeSnapshot} from './tools/snapshot.js';
@@ -39,6 +40,7 @@ export class McpPage implements ContextPage {
// Snapshot
textSnapshot: TextSnapshot | null = null;
uniqueBackendNodeIdToMcpId = new Map();
+ extraHandles?: ElementHandle[];
// Emulation
emulationSettings: EmulationSettings = {};
@@ -78,6 +80,10 @@ export class McpPage implements ContextPage {
return this.inPageTools;
}
+ getWebMcpTools(): WebMCPTool[] {
+ return this.pptrPage.webmcp.tools();
+ }
+
get networkConditions(): string | null {
return this.emulationSettings.networkConditions ?? null;
}
@@ -159,4 +165,20 @@ export class McpPage implements ContextPage {
getAXNodeByUid(uid: string) {
return this.textSnapshot?.idToNode.get(uid);
}
+
+ getSnapshot(): TextSnapshot | null {
+ return this.textSnapshot;
+ }
+
+ setSnapshot(snapshot: TextSnapshot): void {
+ this.textSnapshot = snapshot;
+ }
+
+ getExtraHandles(): ElementHandle[] | undefined {
+ return this.extraHandles;
+ }
+
+ setExtraHandles(extraHandles: ElementHandle[]): void {
+ this.extraHandles = extraHandles;
+ }
}
diff --git a/src/McpResponse.ts b/src/McpResponse.ts
index 77898b5df..21c13c3f5 100644
--- a/src/McpResponse.ts
+++ b/src/McpResponse.ts
@@ -4,6 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
+import type {WebMCPTool} from 'puppeteer-core';
+
import type {ParsedArguments} from './bin/chrome-devtools-mcp-cli-options.js';
import {ConsoleFormatter} from './formatters/ConsoleFormatter.js';
import {IssueFormatter} from './formatters/IssueFormatter.js';
@@ -181,10 +183,12 @@ export class McpResponse implements Response {
};
#listExtensions?: boolean;
#listInPageTools?: boolean;
+ #listWebMcpTools?: boolean;
#devToolsData?: DevToolsData;
#tabId?: string;
#args: ParsedArguments;
#page?: McpPage;
+ #redactNetworkHeaders = true;
constructor(args: ParsedArguments) {
this.#args = args;
@@ -194,6 +198,10 @@ export class McpResponse implements Response {
this.#page = page;
}
+ setRedactNetworkHeaders(value: boolean): void {
+ this.#redactNetworkHeaders = value;
+ }
+
attachDevToolsData(data: DevToolsData): void {
this.#devToolsData = data;
}
@@ -227,6 +235,10 @@ export class McpResponse implements Response {
}
}
+ setListWebMcpTools(): void {
+ this.#listWebMcpTools = true;
+ }
+
setIncludeNetworkRequests(
value: boolean,
options?: PaginationOptions & {
@@ -369,6 +381,10 @@ export class McpResponse implements Response {
return this.#snapshotParams;
}
+ get listWebMcpTools(): boolean | undefined {
+ return this.#listWebMcpTools;
+ }
+
async handle(
toolName: string,
context: McpContext,
@@ -398,11 +414,12 @@ export class McpResponse implements Response {
if (textSnapshot) {
const formatter = new SnapshotFormatter(textSnapshot);
if (this.#snapshotParams.filePath) {
- await context.saveFile(
+ const result = await context.saveFile(
new TextEncoder().encode(formatter.toString()),
this.#snapshotParams.filePath,
+ '.txt',
);
- snapshot = this.#snapshotParams.filePath;
+ snapshot = result.filename;
} else {
snapshot = formatter;
}
@@ -424,7 +441,9 @@ export class McpResponse implements Response {
fetchData: true,
requestFilePath: this.#attachedNetworkRequestOptions?.requestFilePath,
responseFilePath: this.#attachedNetworkRequestOptions?.responseFilePath,
- saveFile: (data, filename) => context.saveFile(data, filename),
+ saveFile: (data, filename, extension) =>
+ context.saveFile(data, filename, extension),
+ redactNetworkHeaders: this.#redactNetworkHeaders,
});
detailedNetworkRequest = formatter;
}
@@ -482,6 +501,12 @@ export class McpResponse implements Response {
page.inPageTools = inPageTools;
}
+ let webmcpTools: WebMCPTool[] | undefined;
+ if (this.#listWebMcpTools && this.#args.experimentalWebmcp) {
+ const page = this.#page ?? context.getSelectedMcpPage();
+ webmcpTools = page.getWebMcpTools();
+ }
+
let consoleMessages: Array | undefined;
if (this.#consoleDataOptions?.include) {
if (!this.#page) {
@@ -567,7 +592,9 @@ export class McpResponse implements Response {
context.getNetworkRequestStableId(request) ===
this.#networkRequestsOptions?.networkRequestIdInDevToolsUI,
fetchData: false,
- saveFile: (data, filename) => context.saveFile(data, filename),
+ saveFile: (data, filename, extension) =>
+ context.saveFile(data, filename, extension),
+ redactNetworkHeaders: this.#redactNetworkHeaders,
}),
),
);
@@ -585,6 +612,7 @@ export class McpResponse implements Response {
extensions,
lighthouseResult: this.#attachedLighthouseResult,
inPageTools,
+ webmcpTools,
});
}
@@ -602,6 +630,7 @@ export class McpResponse implements Response {
extensions?: InstalledExtension[];
lighthouseResult?: LighthouseData;
inPageTools?: ToolGroup;
+ webmcpTools?: WebMCPTool[];
},
): {content: Array; structuredContent: object} {
const structuredContent: {
@@ -617,6 +646,7 @@ export class McpResponse implements Response {
lighthouseResult?: object;
extensions?: object[];
inPageTools?: object;
+ webmcpTools?: object[];
message?: string;
networkConditions?: string;
navigationTimeout?: number;
@@ -874,6 +904,30 @@ Call ${handleDialog.name} to handle it before continuing.`);
}
}
+ if (this.#listWebMcpTools && data.webmcpTools) {
+ structuredContent.webmcpTools = data.webmcpTools.map(
+ ({name, description, inputSchema, annotations}) => ({
+ name,
+ description,
+ inputSchema,
+ annotations,
+ }),
+ );
+ response.push('## WebMCP tools');
+ if (data.webmcpTools.length === 0) {
+ response.push('No WebMCP tools available.');
+ } else {
+ const webmcpToolsMessage = data.webmcpTools
+ .map(tool => {
+ return `name="${tool.name}", description="${tool.description}", inputSchema=${JSON.stringify(
+ tool.inputSchema,
+ )}, annotations=${JSON.stringify(tool.annotations)}`;
+ })
+ .join('\n');
+ response.push(webmcpToolsMessage);
+ }
+ }
+
if (this.#networkRequestsOptions?.include && data.networkRequests) {
const requests = data.networkRequests;
diff --git a/src/PageCollector.ts b/src/PageCollector.ts
index d943baa54..ce6a792e6 100644
--- a/src/PageCollector.ts
+++ b/src/PageCollector.ts
@@ -11,6 +11,7 @@ import type {
CDPSession,
ConsoleMessage,
Protocol,
+ Issue,
} from './third_party/index.js';
import {DevTools} from './third_party/index.js';
import {
@@ -33,7 +34,7 @@ export class UncaughtError {
}
interface PageEvents extends PuppeteerPageEvents {
- issue: DevTools.AggregatedIssue;
+ devtoolsAggregatedIssue: DevTools.AggregatedIssue;
uncaughtError: UncaughtError;
}
@@ -285,7 +286,7 @@ class PageEventSubscriber {
async subscribe() {
this.#resetIssueAggregator();
this.#page.on('framenavigated', this.#onFrameNavigated);
- this.#session.on('Audits.issueAdded', this.#onIssueAdded);
+ this.#page.on('issue', this.#onIssueAdded);
this.#session.on('Runtime.exceptionThrown', this.#onExceptionThrown);
try {
await this.#session.send('Audits.enable');
@@ -298,7 +299,7 @@ class PageEventSubscriber {
this.#seenKeys.clear();
this.#seenIssues.clear();
this.#page.off('framenavigated', this.#onFrameNavigated);
- this.#session.off('Audits.issueAdded', this.#onIssueAdded);
+ this.#page.off('issue', this.#onIssueAdded);
this.#session.off('Runtime.exceptionThrown', this.#onExceptionThrown);
if (this.#issueAggregator) {
this.#issueAggregator.removeEventListener(
@@ -318,7 +319,7 @@ class PageEventSubscriber {
return;
}
this.#seenIssues.add(event.data);
- this.#page.emit('issue', event.data);
+ this.#page.emit('devtoolsAggregatedIssue', event.data);
};
#onExceptionThrown = (event: Protocol.Runtime.ExceptionThrownEvent) => {
@@ -339,9 +340,8 @@ class PageEventSubscriber {
this.#resetIssueAggregator();
};
- #onIssueAdded = (data: Protocol.Audits.IssueAddedEvent) => {
+ #onIssueAdded = (inspectorIssue: Issue) => {
try {
- const inspectorIssue = data.issue;
const issue = DevTools.createIssuesFromProtocolIssue(
null,
// @ts-expect-error Protocol types diverge.
diff --git a/src/bin/chrome-devtools-mcp-cli-options.ts b/src/bin/chrome-devtools-mcp-cli-options.ts
index 3cfbacac0..c08038d12 100644
--- a/src/bin/chrome-devtools-mcp-cli-options.ts
+++ b/src/bin/chrome-devtools-mcp-cli-options.ts
@@ -185,6 +185,11 @@ export const cliOptions = {
describe:
'Exposes experimental screencast tools (requires ffmpeg). Install ffmpeg https://www.ffmpeg.org/download.html and ensure it is available in the MCP server PATH.',
},
+ experimentalWebmcp: {
+ type: 'boolean',
+ describe:
+ 'Set to true to enable debugging WebMCP tools. Requires Chrome 149+ with the following flags: `--enable-features=WebMCPTesting,DevToolsWebMCPSupport`',
+ },
chromeArg: {
type: 'array',
describe:
@@ -261,6 +266,12 @@ export const cliOptions = {
'Set by Chrome DevTools CLI if the MCP server is started via the CLI client (this arg exists for usage stats)',
hidden: true,
},
+ redactNetworkHeaders: {
+ type: 'boolean',
+ describe:
+ 'If true, redacts some of the network headers considered senstive before returning to the client.',
+ default: false,
+ },
} satisfies Record;
export type ParsedArguments = ReturnType;
diff --git a/src/bin/chrome-devtools.ts b/src/bin/chrome-devtools.ts
index e3aab5f45..979533e84 100644
--- a/src/bin/chrome-devtools.ts
+++ b/src/bin/chrome-devtools.ts
@@ -51,6 +51,7 @@ delete startCliOptions.viewport;
// tools, they need to be enabled during CLI generation.
delete startCliOptions.experimentalPageIdRouting;
delete startCliOptions.experimentalVision;
+delete startCliOptions.experimentalWebmcp;
delete startCliOptions.experimentalInteropTools;
delete startCliOptions.experimentalScreencast;
delete startCliOptions.categoryEmulation;
diff --git a/src/formatters/NetworkFormatter.ts b/src/formatters/NetworkFormatter.ts
index abfb0693e..9e21d2c60 100644
--- a/src/formatters/NetworkFormatter.ts
+++ b/src/formatters/NetworkFormatter.ts
@@ -6,7 +6,11 @@
import {isUtf8} from 'node:buffer';
-import type {HTTPRequest, HTTPResponse} from '../third_party/index.js';
+import {
+ DevTools,
+ type HTTPRequest,
+ type HTTPResponse,
+} from '../third_party/index.js';
const BODY_CONTEXT_SIZE_LIMIT = 10000;
@@ -20,7 +24,9 @@ export interface NetworkFormatterOptions {
saveFile?: (
data: Uint8Array,
filename: string,
+ extension: '.network-request' | '.network-response',
) => Promise<{filename: string}>;
+ redactNetworkHeaders: boolean;
}
interface NetworkRequestConcise {
@@ -83,11 +89,12 @@ export class NetworkFormatter {
throw new Error('saveFile is not provided');
}
if (data) {
- await this.#options.saveFile(
+ const result = await this.#options.saveFile(
Buffer.from(data),
this.#options.requestFilePath,
+ '.network-request',
);
- this.#requestBodyFilePath = this.#options.requestFilePath;
+ this.#requestBodyFilePath = result.filename;
} else {
this.#requestBody = requestBodyNotAvailableMessage;
}
@@ -114,8 +121,12 @@ export class NetworkFormatter {
if (!this.#options.saveFile) {
throw new Error('saveFile is not provided');
}
- await this.#options.saveFile(buffer, this.#options.responseFilePath);
- this.#responseBodyFilePath = this.#options.responseFilePath;
+ const result = await this.#options.saveFile(
+ buffer,
+ this.#options.responseFilePath,
+ '.network-response',
+ );
+ this.#responseBodyFilePath = result.filename;
} catch {
// Flatten error handling for buffer() failure and save failure
}
@@ -150,6 +161,20 @@ export class NetworkFormatter {
};
}
+ #redactNetworkHeaders(
+ headers: Record,
+ ): Record {
+ const headersList = Object.entries(headers).map(item => {
+ return {name: item[0], value: item[1]};
+ });
+ const redacted =
+ DevTools.NetworkRequestFormatter.sanitizeHeaders(headersList);
+ return redacted.reduce>((acc, item) => {
+ acc[item.name] = item.value;
+ return acc;
+ }, {});
+ }
+
toJSONDetailed(): NetworkRequestDetailed {
const redirectChain = this.#request.redirectChain();
const formattedRedirectChain = redirectChain.reverse().map(request => {
@@ -159,16 +184,24 @@ export class NetworkFormatter {
const formatter = new NetworkFormatter(request, {
requestId: id,
saveFile: this.#options.saveFile,
+ redactNetworkHeaders: this.#options.redactNetworkHeaders,
});
return formatter.toJSON();
});
+ const responseHeaders = this.#request.response()?.headers();
+
return {
...this.toJSON(),
- requestHeaders: this.#request.headers(),
+ requestHeaders: this.#options.redactNetworkHeaders
+ ? this.#redactNetworkHeaders(this.#request.headers())
+ : this.#request.headers(),
requestBody: this.#requestBody,
requestBodyFilePath: this.#requestBodyFilePath,
- responseHeaders: this.#request.response()?.headers(),
+ responseHeaders:
+ this.#options.redactNetworkHeaders && responseHeaders
+ ? this.#redactNetworkHeaders(responseHeaders)
+ : this.#request.response()?.headers(),
responseBody: this.#responseBody,
responseBodyFilePath: this.#responseBodyFilePath,
failure: this.#request.failure()?.errorText,
diff --git a/src/index.ts b/src/index.ts
index 362f2348a..415c6a031 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -164,6 +164,12 @@ export async function createMcpServer(
) {
return;
}
+ if (
+ tool.annotations.conditions?.includes('experimentalWebmcp') &&
+ !serverArgs.experimentalWebmcp
+ ) {
+ return;
+ }
const schema =
'pageScoped' in tool &&
tool.pageScoped &&
@@ -191,6 +197,8 @@ export async function createMcpServer(
const response = serverArgs.slim
? new SlimMcpResponse(serverArgs)
: new McpResponse(serverArgs);
+
+ response.setRedactNetworkHeaders(serverArgs.redactNetworkHeaders);
if ('pageScoped' in tool && tool.pageScoped) {
const page =
serverArgs.experimentalPageIdRouting &&
@@ -252,6 +260,8 @@ export async function createMcpServer(
} finally {
void clearcutLogger?.logToolInvocation({
toolName: tool.name,
+ params,
+ schema,
success,
latencyMs: bucketizeLatency(Date.now() - startTime),
});
diff --git a/src/telemetry/ClearcutLogger.ts b/src/telemetry/ClearcutLogger.ts
index 82f766cd4..27a499d91 100644
--- a/src/telemetry/ClearcutLogger.ts
+++ b/src/telemetry/ClearcutLogger.ts
@@ -17,6 +17,7 @@ import {
type FlagUsage,
WatchdogMessageType,
OsType,
+ type ToolInvocation,
} from './types.js';
import {WatchdogClient} from './WatchdogClient.js';
@@ -60,12 +61,16 @@ export function getZodType(zodType: zod.ZodTypeAny): ZodType {
type LoggedToolCallArgValue = string | number | boolean;
export function transformArgName(zodType: ZodType, name: string): string {
+ const snakeCaseName = name.replace(
+ /[A-Z]/g,
+ letter => `_${letter.toLowerCase()}`,
+ );
if (zodType === 'ZodString') {
- return `${name}_length`;
+ return `${snakeCaseName}_length`;
} else if (zodType === 'ZodArray') {
- return `${name}_count`;
+ return `${snakeCaseName}_count`;
} else {
- return name;
+ return snakeCaseName;
}
}
@@ -203,18 +208,27 @@ export class ClearcutLogger {
async logToolInvocation(args: {
toolName: string;
+ params: ShapeOutput;
+ schema: zod.ZodRawShape;
success: boolean;
latencyMs: number;
}): Promise {
+ const tool_invocation: ToolInvocation = {
+ tool_name: args.toolName,
+ success: args.success,
+ latency_ms: args.latencyMs,
+ };
+ if (Object.keys(args.params).length > 0) {
+ tool_invocation.tool_params = {
+ [`${args.toolName}_params`]: sanitizeParams(args.params, args.schema),
+ };
+ }
+
this.#watchdog.send({
type: WatchdogMessageType.LOG_EVENT,
payload: {
mcp_client: this.#mcpClient,
- tool_invocation: {
- tool_name: args.toolName,
- success: args.success,
- latency_ms: args.latencyMs,
- },
+ tool_invocation: tool_invocation,
},
});
}
diff --git a/src/telemetry/tool_call_metrics.json b/src/telemetry/tool_call_metrics.json
index 496045ff6..cdaff57d5 100644
--- a/src/telemetry/tool_call_metrics.json
+++ b/src/telemetry/tool_call_metrics.json
@@ -3,11 +3,11 @@
"name": "click",
"args": [
{
- "name": "dblClick",
+ "name": "dbl_click",
"argType": "boolean"
},
{
- "name": "includeSnapshot",
+ "name": "include_snapshot",
"argType": "boolean"
}
]
@@ -24,11 +24,11 @@
"argType": "number"
},
{
- "name": "dblClick",
+ "name": "dbl_click",
"argType": "boolean"
},
{
- "name": "includeSnapshot",
+ "name": "include_snapshot",
"argType": "boolean"
}
]
@@ -37,7 +37,7 @@
"name": "close_page",
"args": [
{
- "name": "pageId",
+ "name": "page_id",
"argType": "number"
}
]
@@ -54,7 +54,7 @@
"argType": "number"
},
{
- "name": "includeSnapshot",
+ "name": "include_snapshot",
"argType": "boolean"
}
]
@@ -63,11 +63,11 @@
"name": "emulate",
"args": [
{
- "name": "networkConditions",
+ "name": "network_conditions",
"argType": "string"
},
{
- "name": "cpuThrottlingRate",
+ "name": "cpu_throttling_rate",
"argType": "number"
},
{
@@ -75,11 +75,11 @@
"argType": "number"
},
{
- "name": "userAgent_length",
+ "name": "user_agent_length",
"argType": "number"
},
{
- "name": "colorScheme",
+ "name": "color_scheme",
"argType": "string"
},
{
@@ -114,7 +114,7 @@
"name": "execute_in_page_tool",
"args": [
{
- "name": "toolName_length",
+ "name": "tool_name_length",
"argType": "number"
},
{
@@ -131,7 +131,7 @@
"argType": "number"
},
{
- "name": "includeSnapshot",
+ "name": "include_snapshot",
"argType": "boolean"
}
]
@@ -144,7 +144,7 @@
"argType": "number"
},
{
- "name": "includeSnapshot",
+ "name": "include_snapshot",
"argType": "boolean"
}
]
@@ -157,11 +157,11 @@
"name": "get_network_request",
"args": [
{
- "name": "requestFilePath_length",
+ "name": "request_file_path_length",
"argType": "number"
},
{
- "name": "responseFilePath_length",
+ "name": "response_file_path_length",
"argType": "number"
}
]
@@ -170,7 +170,7 @@
"name": "get_tab_id",
"args": [
{
- "name": "pageId",
+ "name": "page_id",
"argType": "number"
}
]
@@ -183,7 +183,7 @@
"argType": "string"
},
{
- "name": "promptText_length",
+ "name": "prompt_text_length",
"argType": "number"
}
]
@@ -192,7 +192,7 @@
"name": "hover",
"args": [
{
- "name": "includeSnapshot",
+ "name": "include_snapshot",
"argType": "boolean"
}
]
@@ -218,7 +218,7 @@
"argType": "string"
},
{
- "name": "outputDirPath_length",
+ "name": "output_dir_path_length",
"argType": "number"
}
]
@@ -227,11 +227,11 @@
"name": "list_console_messages",
"args": [
{
- "name": "pageSize",
+ "name": "page_size",
"argType": "number"
},
{
- "name": "pageIdx",
+ "name": "page_idx",
"argType": "number"
},
{
@@ -239,7 +239,7 @@
"argType": "number"
},
{
- "name": "includePreservedMessages",
+ "name": "include_preserved_messages",
"argType": "boolean"
}
]
@@ -256,19 +256,19 @@
"name": "list_network_requests",
"args": [
{
- "name": "pageSize",
+ "name": "page_size",
"argType": "number"
},
{
- "name": "pageIdx",
+ "name": "page_idx",
"argType": "number"
},
{
- "name": "resourceTypes_count",
+ "name": "resource_types_count",
"argType": "number"
},
{
- "name": "includePreservedRequests",
+ "name": "include_preserved_requests",
"argType": "boolean"
}
]
@@ -298,15 +298,15 @@
"argType": "number"
},
{
- "name": "ignoreCache",
+ "name": "ignore_cache",
"argType": "boolean"
},
{
- "name": "handleBeforeUnload",
+ "name": "handle_before_unload",
"argType": "string"
},
{
- "name": "initScript_length",
+ "name": "init_script_length",
"argType": "number"
},
{
@@ -327,7 +327,7 @@
"argType": "boolean"
},
{
- "name": "isolatedContext_length",
+ "name": "isolated_context_length",
"argType": "number"
},
{
@@ -340,11 +340,11 @@
"name": "performance_analyze_insight",
"args": [
{
- "name": "insightSetId_length",
+ "name": "insight_set_id_length",
"argType": "number"
},
{
- "name": "insightName_length",
+ "name": "insight_name_length",
"argType": "number"
}
]
@@ -357,11 +357,11 @@
"argType": "boolean"
},
{
- "name": "autoStop",
+ "name": "auto_stop",
"argType": "boolean"
},
{
- "name": "filePath_length",
+ "name": "file_path_length",
"argType": "number"
}
]
@@ -370,7 +370,7 @@
"name": "performance_stop_trace",
"args": [
{
- "name": "filePath_length",
+ "name": "file_path_length",
"argType": "number"
}
]
@@ -383,7 +383,7 @@
"argType": "number"
},
{
- "name": "includeSnapshot",
+ "name": "include_snapshot",
"argType": "boolean"
}
]
@@ -431,11 +431,11 @@
"name": "select_page",
"args": [
{
- "name": "pageId",
+ "name": "page_id",
"argType": "number"
},
{
- "name": "bringToFront",
+ "name": "bring_to_front",
"argType": "boolean"
}
]
@@ -444,7 +444,7 @@
"name": "take_memory_snapshot",
"args": [
{
- "name": "filePath_length",
+ "name": "file_path_length",
"argType": "number"
}
]
@@ -461,11 +461,11 @@
"argType": "number"
},
{
- "name": "fullPage",
+ "name": "full_page",
"argType": "boolean"
},
{
- "name": "filePath_length",
+ "name": "file_path_length",
"argType": "number"
}
]
@@ -478,7 +478,7 @@
"argType": "boolean"
},
{
- "name": "filePath_length",
+ "name": "file_path_length",
"argType": "number"
}
]
@@ -500,7 +500,7 @@
"argType": "number"
},
{
- "name": "submitKey_length",
+ "name": "submit_key_length",
"argType": "number"
}
]
@@ -518,11 +518,11 @@
"name": "upload_file",
"args": [
{
- "name": "filePath_length",
+ "name": "file_path_length",
"argType": "number"
},
{
- "name": "includeSnapshot",
+ "name": "include_snapshot",
"argType": "boolean"
}
]
@@ -539,5 +539,22 @@
"argType": "number"
}
]
+ },
+ {
+ "name": "list_webmcp_tools",
+ "args": []
+ },
+ {
+ "name": "execute_webmcp_tool",
+ "args": [
+ {
+ "name": "tool_name_length",
+ "argType": "number"
+ },
+ {
+ "name": "input_length",
+ "argType": "number"
+ }
+ ]
}
]
diff --git a/src/telemetry/types.ts b/src/telemetry/types.ts
index 8acb32922..d3caf3934 100644
--- a/src/telemetry/types.ts
+++ b/src/telemetry/types.ts
@@ -22,6 +22,7 @@ export interface ToolInvocation {
tool_name: string;
success: boolean;
latency_ms: number;
+ tool_params?: object;
}
export interface ServerStart {
diff --git a/src/telemetry/watchdog/ClearcutSender.ts b/src/telemetry/watchdog/ClearcutSender.ts
index ba55c80fa..0a3678e02 100644
--- a/src/telemetry/watchdog/ClearcutSender.ts
+++ b/src/telemetry/watchdog/ClearcutSender.ts
@@ -70,14 +70,14 @@ export class ClearcutSender {
this.#sessionCreated = Date.now();
}
- logger('Enqueing telemetry event', JSON.stringify(event, null, 2));
-
- this.#addToBuffer({
+ const eventToSend = {
...event,
session_id: this.#sessionId,
app_version: this.#appVersion,
os_type: this.#osType,
- });
+ };
+ logger('Enqueing telemetry event', JSON.stringify(eventToSend, null, 2));
+ this.#addToBuffer(eventToSend);
if (!this.#timerStarted) {
this.#timerStarted = true;
diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts
index 8001e18c7..d7ee9ff82 100644
--- a/src/tools/ToolDefinition.ts
+++ b/src/tools/ToolDefinition.ts
@@ -19,6 +19,8 @@ import type {
TextSnapshotNode,
GeolocationOptions,
ExtensionServiceWorker,
+ TextSnapshot,
+ EmulationSettings,
} from '../types.js';
import type {InstalledExtension} from '../utils/ExtensionRegistry.js';
import type {PaginationOptions} from '../utils/types.js';
@@ -134,8 +136,21 @@ export interface Response {
setListExtensions(): void;
attachLighthouseResult(result: LighthouseData): void;
setListInPageTools(): void;
+ setListWebMcpTools(): void;
}
+export type SupportedExtensions =
+ | '.png'
+ | '.jpeg'
+ | '.webp'
+ | '.json'
+ | '.network-response'
+ | '.network-request'
+ | '.html'
+ | '.txt'
+ | '.csv'
+ | '.json.gz';
+
/**
* Only add methods required by tools/*.
*/
@@ -170,7 +185,8 @@ export type Context = Readonly<{
): Promise<{filepath: string}>;
saveFile(
data: Uint8Array,
- filename: string,
+ clientProvidedFilePath: string,
+ extension: SupportedExtensions,
): Promise<{filename: string}>;
waitForTextOnPage(
text: string[],
@@ -194,6 +210,16 @@ export type Context = Readonly<{
triggerExtensionAction(id: string): Promise;
listExtensions(): InstalledExtension[];
getExtension(id: string): InstalledExtension | undefined;
+ resolveCdpElementId(
+ page: ContextPage,
+ cdpBackendNodeId: number,
+ ): string | undefined;
+ createTextSnapshot(
+ page: ContextPage,
+ verbose: boolean,
+ devtoolsData: DevToolsData | undefined,
+ extraHandles?: ElementHandle[],
+ ): Promise;
getSelectedMcpPage(): McpPage;
getExtensionServiceWorkers(): ExtensionServiceWorker[];
getExtensionServiceWorkerId(
@@ -213,6 +239,12 @@ export type ContextPage = Readonly<{
options?: {timeout?: number},
): Promise;
getInPageTools(): ToolGroup | undefined;
+ getSnapshot(): TextSnapshot | null;
+ setSnapshot(snapshot: TextSnapshot): void;
+ getExtraHandles(): ElementHandle[] | undefined;
+ setExtraHandles(extraHandles: ElementHandle[]): void;
+ readonly uniqueBackendNodeIdToMcpId: Map;
+ readonly emulationSettings: EmulationSettings;
}>;
export function defineTool(
diff --git a/src/tools/inPage.ts b/src/tools/inPage.ts
index 0cb1b6ba7..073c0067b 100644
--- a/src/tools/inPage.ts
+++ b/src/tools/inPage.ts
@@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
+import {logger} from '../logger.js';
import {
zod,
ajv,
@@ -32,6 +33,7 @@ declare global {
toolGroup?: ToolGroup<
ToolDefinition & {execute: (args: Record) => unknown}
>;
+ stashedElements?: Element[];
executeTool?: (
toolName: string,
args: Record,
@@ -75,7 +77,7 @@ export const executeInPageTool = definePageTool({
.optional()
.describe('The JSON-stringified parameters to pass to the tool'),
},
- handler: async (request, response) => {
+ handler: async (request, response, context) => {
const toolName = request.params.toolName;
let params: Record = {};
if (request.params.params) {
@@ -141,14 +143,155 @@ export const executeInPageTool = definePageTool({
}
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 ''
+ // - Replaces functions with the string ''
+ 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 '';
+ }
+ 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 = {};
+ for (const [key, value] of Object.entries(data)) {
+ processedObj[key] = processToolResult(value, data);
+ }
+ return processedObj;
+ }
+
+ // 4. Handle Functions
+ if (typeof data === 'function') {
+ return '';
+ }
+
+ // 5. Return primitives (strings, numbers, booleans) as-is
+ return data;
+ };
+
return {
- result: toolResult,
+ result: processToolResult(toolResult),
+ stashed: window.__dtmcp?.stashedElements?.length ?? 0,
};
},
toolName,
params,
...handles,
);
- response.appendResponseLine(JSON.stringify(result, null, 2));
+
+ const elementHandles: ElementHandle[] = [];
+ for (let i = 0; i < (result.stashed ?? 0); i++) {
+ const elementHandle = await request.page.pptrPage.evaluateHandle(
+ index => {
+ return window.__dtmcp?.stashedElements?.[index] ?? null;
+ },
+ i,
+ );
+ elementHandles.push(elementHandle as ElementHandle);
+ }
+ const resultWithStashedElements = result.result;
+
+ let isPageSnapshotUpdated = false;
+ const stashedToUid = async (index: number) => {
+ const backendNodeId = await elementHandles[index].backendNodeId();
+ if (!backendNodeId) {
+ logger(`No backendNodeId for stashed DOM element with index ${index}`);
+ return {uid: `stashed-${index}`};
+ }
+ let cdpElementId = context.resolveCdpElementId(
+ request.page,
+ backendNodeId,
+ );
+ if (!cdpElementId) {
+ await context.createTextSnapshot(
+ request.page,
+ false,
+ undefined,
+ elementHandles,
+ );
+ isPageSnapshotUpdated = true;
+ cdpElementId = context.resolveCdpElementId(request.page, backendNodeId);
+ }
+ if (!cdpElementId) {
+ logger(`Could not get cdpElementId for backend node ${backendNodeId}`);
+ return {uid: `stashed-${index}`};
+ }
+ return {uid: cdpElementId};
+ };
+
+ const recursivelyReplaceStashedElements = async (
+ node: unknown,
+ ): Promise => {
+ if (Array.isArray(node)) {
+ return await Promise.all(
+ node.map(async x => await 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 stashedToUid(index);
+ }
+ const resultObj: Record = {};
+ for (const [key, value] of Object.entries(node)) {
+ resultObj[key] = await recursivelyReplaceStashedElements(value);
+ }
+ return resultObj;
+ }
+ return node;
+ };
+
+ const resultWithUids = await recursivelyReplaceStashedElements(
+ resultWithStashedElements,
+ );
+ response.appendResponseLine(JSON.stringify(resultWithUids, null, 2));
+ if (isPageSnapshotUpdated) {
+ response.includeSnapshot();
+ }
},
});
diff --git a/src/tools/lighthouse.ts b/src/tools/lighthouse.ts
index 606abf1ad..4356f1e62 100644
--- a/src/tools/lighthouse.ts
+++ b/src/tools/lighthouse.ts
@@ -107,8 +107,12 @@ export const lighthouseAudit = definePageTool({
const report = generateReport(lhr, format);
const data = encoder.encode(report);
if (outputDirPath) {
- const reportPath = path.join(outputDirPath, `report.${format}`);
- const {filename} = await context.saveFile(data, reportPath);
+ const reportPath = path.join(outputDirPath, `report`);
+ const {filename} = await context.saveFile(
+ data,
+ reportPath,
+ `.${format}`,
+ );
reportPaths.push(filename);
} else {
const {filepath} = await context.saveTemporaryFile(
diff --git a/src/tools/memory.ts b/src/tools/memory.ts
index b1f302ae1..91fb73a17 100644
--- a/src/tools/memory.ts
+++ b/src/tools/memory.ts
@@ -5,6 +5,7 @@
*/
import {zod} from '../third_party/index.js';
+import {ensureExtension} from '../utils/files.js';
import {ToolCategory} from './categories.js';
import {definePageTool} from './ToolDefinition.js';
@@ -25,7 +26,7 @@ export const takeMemorySnapshot = definePageTool({
const page = request.page;
await page.pptrPage.captureHeapSnapshot({
- path: request.params.filePath,
+ path: ensureExtension(request.params.filePath, '.heapsnapshot'),
});
response.appendResponseLine(
diff --git a/src/tools/pages.ts b/src/tools/pages.ts
index fa5a40857..9cc776c81 100644
--- a/src/tools/pages.ts
+++ b/src/tools/pages.ts
@@ -28,6 +28,7 @@ export const listPages = defineTool(args => {
handler: async (_request, response) => {
response.setIncludePages(true);
response.setListInPageTools();
+ response.setListWebMcpTools();
},
};
});
@@ -55,6 +56,7 @@ export const selectPage = defineTool({
context.selectPage(page);
response.setIncludePages(true);
response.setListInPageTools();
+ response.setListWebMcpTools();
if (request.params.bringToFront) {
await page.pptrPage.bringToFront();
}
@@ -222,7 +224,7 @@ export const navigatePage = definePageTool({
);
} catch (error) {
response.appendResponseLine(
- `Unable to navigate in the selected page: ${error.message}.`,
+ `Unable to navigate in the selected page: ${error.message}.`,
);
}
break;
@@ -280,6 +282,7 @@ export const navigatePage = definePageTool({
response.setIncludePages(true);
response.setListInPageTools();
+ response.setListWebMcpTools();
},
});
diff --git a/src/tools/performance.ts b/src/tools/performance.ts
index c02b627b9..acc588655 100644
--- a/src/tools/performance.ts
+++ b/src/tools/performance.ts
@@ -197,7 +197,11 @@ async function stopTracingAndAppendOutput(
});
});
}
- const file = await context.saveFile(dataToWrite, filePath);
+ const file = await context.saveFile(
+ dataToWrite,
+ filePath,
+ filePath.endsWith('.gz') ? '.json.gz' : '.json',
+ );
response.appendResponseLine(
`The raw trace data was saved to ${file.filename}.`,
);
diff --git a/src/tools/screencast.ts b/src/tools/screencast.ts
index a5ab1c38e..9f6f6a5f8 100644
--- a/src/tools/screencast.ts
+++ b/src/tools/screencast.ts
@@ -10,6 +10,7 @@ import path from 'node:path';
import {zod} from '../third_party/index.js';
import type {ScreenRecorder} from '../third_party/index.js';
+import {ensureExtension} from '../utils/files.js';
import {ToolCategory} from './categories.js';
import {definePageTool} from './ToolDefinition.js';
@@ -46,7 +47,7 @@ export const startScreencast = definePageTool({
}
const filePath = request.params.path ?? (await generateTempFilePath());
- const resolvedPath = path.resolve(filePath);
+ const resolvedPath = ensureExtension(path.resolve(filePath), '.mp4');
const page = request.page;
diff --git a/src/tools/screenshot.ts b/src/tools/screenshot.ts
index 2e648531c..f740fda4e 100644
--- a/src/tools/screenshot.ts
+++ b/src/tools/screenshot.ts
@@ -87,8 +87,12 @@ export const screenshot = definePageTool({
}
if (request.params.filePath) {
- const file = await context.saveFile(screenshot, request.params.filePath);
- response.appendResponseLine(`Saved screenshot to ${file.filename}.`);
+ const result = await context.saveFile(
+ screenshot,
+ request.params.filePath,
+ `.${format}`,
+ );
+ response.appendResponseLine(`Saved screenshot to ${result.filename}.`);
} else if (screenshot.length >= 2_000_000) {
const {filepath} = await context.saveTemporaryFile(
screenshot,
diff --git a/src/tools/tools.ts b/src/tools/tools.ts
index 3c74115c3..b3477b906 100644
--- a/src/tools/tools.ts
+++ b/src/tools/tools.ts
@@ -22,6 +22,7 @@ import * as scriptTools from './script.js';
import * as slimTools from './slim/tools.js';
import * as snapshotTools from './snapshot.js';
import type {ToolDefinition} from './ToolDefinition.js';
+import * as webmcpTools from './webmcp.js';
export const createTools = (args: ParsedArguments) => {
const rawTools = args.slim
@@ -41,6 +42,7 @@ export const createTools = (args: ParsedArguments) => {
...Object.values(screenshotTools),
...Object.values(scriptTools),
...Object.values(snapshotTools),
+ ...Object.values(webmcpTools),
];
const tools = [];
diff --git a/src/tools/webmcp.ts b/src/tools/webmcp.ts
new file mode 100644
index 000000000..d7f06afec
--- /dev/null
+++ b/src/tools/webmcp.ts
@@ -0,0 +1,70 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {zod} from '../third_party/index.js';
+
+import {ToolCategory} from './categories.js';
+import {definePageTool} from './ToolDefinition.js';
+
+export const listWebMcpTools = definePageTool({
+ name: 'list_webmcp_tools',
+ description: `Lists all WebMCP tools the page exposes.`,
+ annotations: {
+ category: ToolCategory.DEBUGGING,
+ readOnlyHint: true,
+ conditions: ['experimentalWebmcp'],
+ },
+ schema: {},
+ handler: async (_request, response, _context) => {
+ response.setListWebMcpTools();
+ },
+});
+
+export const executeWebMcpTool = definePageTool({
+ name: 'execute_webmcp_tool',
+ description: `Executes a WebMCP tool exposed by the page.`,
+ annotations: {
+ category: ToolCategory.DEBUGGING,
+ readOnlyHint: false,
+ conditions: ['experimentalWebmcp'],
+ },
+ schema: {
+ toolName: zod.string().describe('The name of the WebMCP tool to execute'),
+ input: zod
+ .string()
+ .optional()
+ .describe('The JSON-stringified parameters to pass to the WebMCP tool'),
+ },
+ handler: async (request, response) => {
+ const toolName = request.params.toolName;
+
+ let input: Record = {};
+ if (request.params.input) {
+ try {
+ const parsed = JSON.parse(request.params.input);
+ if (typeof parsed === 'object' && parsed !== null) {
+ input = parsed;
+ } else {
+ throw new Error('Parsed input is not an object');
+ }
+ } catch (e) {
+ const errorMessage = e instanceof Error ? e.message : String(e);
+ throw new Error(`Failed to parse input as JSON: ${errorMessage}`);
+ }
+ }
+
+ const tools = request.page.pptrPage.webmcp.tools();
+ const tool = tools.find(t => t.name === toolName);
+ if (!tool) {
+ throw new Error(`Tool ${toolName} not found`);
+ }
+
+ const {status, output, errorText} = await tool.execute(input);
+ response.appendResponseLine(
+ JSON.stringify({status, output, errorText}, null, 2),
+ );
+ },
+});
diff --git a/src/utils/files.ts b/src/utils/files.ts
index 03ddfc45f..abdba3ed8 100644
--- a/src/utils/files.ts
+++ b/src/utils/files.ts
@@ -24,3 +24,11 @@ export async function saveTemporaryFile(
throw new Error('Could not save a file', {cause: err});
}
}
+
+export function ensureExtension(
+ filepath: string,
+ extension: `.${string}`,
+): string {
+ const ext = path.extname(filepath);
+ return filepath.slice(0, filepath.length - ext.length) + extension;
+}
diff --git a/tests/McpContext.test.js.snapshot b/tests/McpContext.test.js.snapshot
index cfdd6f902..3f69a75c9 100644
--- a/tests/McpContext.test.js.snapshot
+++ b/tests/McpContext.test.js.snapshot
@@ -6,7 +6,7 @@ exports[`McpContext > should include detailed network request in structured cont
"url": "http://example.com/detail",
"status": "pending",
"requestHeaders": {
- "content-size": "10"
+ "content-size": ""
}
}
}
diff --git a/tests/McpContext.test.ts b/tests/McpContext.test.ts
index 31a6c88b3..034ce508a 100644
--- a/tests/McpContext.test.ts
+++ b/tests/McpContext.test.ts
@@ -12,6 +12,7 @@ import sinon from 'sinon';
import {NetworkFormatter} from '../src/formatters/NetworkFormatter.js';
import type {HTTPResponse} from '../src/third_party/index.js';
import type {TraceResult} from '../src/trace-processing/parse.js';
+import type {TextSnapshotNode} from '../src/types.js';
import {getMockRequest, html, withMcpContext} from './utils.js';
@@ -208,4 +209,138 @@ describe('McpContext', () => {
fromStub.restore();
});
});
+
+ it('inserts extraHandles into the snapshot correctly', async () => {
+ await withMcpContext(async (_response, context) => {
+ const page = context.getSelectedMcpPage();
+ await page.pptrPage.setContent(html`
+
+ `);
+
+ const middleHandle = await page.pptrPage.$('#middle');
+ if (!middleHandle) {
+ throw new Error('middle element not found');
+ }
+
+ const backendNodeId = await middleHandle.backendNodeId();
+ if (!backendNodeId) {
+ throw new Error('Failed to get backendNodeId');
+ }
+
+ // Verify it is not in the snapshot by default (due to role="none")
+ await context.createTextSnapshot(page, false, undefined, []);
+ const snapshotBefore = page.getSnapshot();
+ if (!snapshotBefore) {
+ throw new Error('Snapshot not created');
+ }
+
+ let foundMiddleBefore = false;
+ for (const node of snapshotBefore.idToNode.values()) {
+ if (node.backendNodeId === backendNodeId) {
+ foundMiddleBefore = true;
+ break;
+ }
+ }
+ assert.ok(
+ !foundMiddleBefore,
+ 'Middle element should NOT be in the snapshot when not passed as extra handle',
+ );
+
+ // Now take snapshot with extra handle
+ await context.createTextSnapshot(page, false, undefined, [middleHandle]);
+
+ const snapshot = page.getSnapshot();
+ if (!snapshot) {
+ throw new Error('Snapshot not created');
+ }
+
+ // Find the extra node in idToNode
+ let extraNode: TextSnapshotNode | undefined;
+ for (const node of snapshot.idToNode.values()) {
+ if (node.backendNodeId === backendNodeId) {
+ extraNode = node;
+ break;
+ }
+ }
+
+ assert.ok(extraNode, 'Extra node should be in the snapshot');
+ assert.strictEqual(
+ extraNode.role,
+ 'div',
+ 'Extra node should have role "div"',
+ );
+
+ // Check if the child was moved to extraNode
+ const childHandle = await page.pptrPage.$('#child');
+ if (!childHandle) {
+ throw new Error('child element not found');
+ }
+ const childBackendNodeId = await childHandle.backendNodeId();
+
+ let foundChild = false;
+ for (const child of extraNode.children) {
+ if (child.backendNodeId === childBackendNodeId) {
+ foundChild = true;
+ break;
+ }
+ }
+ assert.ok(
+ foundChild,
+ 'Child node should be moved to extra node children',
+ );
+
+ // Find parent node in snapshot
+ const parentHandle = await page.pptrPage.$('#parent');
+ if (!parentHandle) {
+ throw new Error('parent element not found');
+ }
+ const parentBackendId = await parentHandle.backendNodeId();
+
+ let parentNode: TextSnapshotNode | undefined;
+ for (const node of snapshot.idToNode.values()) {
+ if (node.backendNodeId === parentBackendId) {
+ parentNode = node;
+ break;
+ }
+ }
+
+ assert.ok(parentNode, 'Parent node should be in snapshot');
+
+ // Check that child is NOT a child of parent anymore
+ let foundChildInParent = false;
+ for (const child of parentNode.children) {
+ if (child.backendNodeId === childBackendNodeId) {
+ foundChildInParent = true;
+ break;
+ }
+ }
+ assert.ok(
+ !foundChildInParent,
+ 'Child node should NOT be in parent children',
+ );
+
+ // Check that middle IS a child of parent
+ let foundMiddleInParent = false;
+ for (const child of parentNode.children) {
+ if (child.backendNodeId === backendNodeId) {
+ foundMiddleInParent = true;
+ break;
+ }
+ }
+ assert.ok(
+ foundMiddleInParent,
+ 'Middle node should be in parent children',
+ );
+ });
+ });
});
diff --git a/tests/McpResponse.test.js.snapshot b/tests/McpResponse.test.js.snapshot
index 1fb633b76..641187d2f 100644
--- a/tests/McpResponse.test.js.snapshot
+++ b/tests/McpResponse.test.js.snapshot
@@ -2,7 +2,7 @@ exports[`McpResponse > add network request when attached 1`] = `
## Request http://example.com
Status: pending
### Request Headers
-- content-size:10
+- content-size:
## Network requests
Showing 1-1 of 1 (Page 1 of 1).
reqid=1 GET http://example.com [pending]
@@ -16,7 +16,7 @@ exports[`McpResponse > add network request when attached 2`] = `
"url": "http://example.com",
"status": "pending",
"requestHeaders": {
- "content-size": "10"
+ "content-size": ""
}
},
"pagination": {
@@ -44,7 +44,7 @@ exports[`McpResponse > add network request when attached with POST data 1`] = `
## Request http://example.com
Status: 200
### Request Headers
-- content-size:10
+- content-size:
### Request Body
{"request":"body"}
### Response Headers
@@ -64,7 +64,7 @@ exports[`McpResponse > add network request when attached with POST data 2`] = `
"url": "http://example.com",
"status": "200",
"requestHeaders": {
- "content-size": "10"
+ "content-size": ""
},
"requestBody": "{\\"request\\":\\"body\\"}",
"responseHeaders": {
@@ -1281,3 +1281,113 @@ exports[`lighthouse > includes lighthouse report paths 2`] = `
}
}
`;
+
+exports[`webmcp > includes webmcp tools in list_pages response 1`] = `
+## Pages
+1: about:blank [selected]
+## WebMCP tools
+name="test_tool", description="A test tool", inputSchema={}, annotations=undefined
+`;
+
+exports[`webmcp > includes webmcp tools in list_pages response 2`] = `
+{
+ "pages": [
+ {
+ "id": 1,
+ "url": "about:blank",
+ "selected": true
+ }
+ ],
+ "webmcpTools": [
+ {
+ "name": "test_tool",
+ "description": "A test tool",
+ "inputSchema": {}
+ }
+ ]
+}
+`;
+
+exports[`webmcp > includes webmcp tools in navigate_page response 1`] = `
+Successfully navigated to about:blank.
+## Pages
+1: about:blank [selected]
+## WebMCP tools
+name="test_tool", description="A test tool", inputSchema={}, annotations=undefined
+`;
+
+exports[`webmcp > includes webmcp tools in navigate_page response 2`] = `
+{
+ "message": "Successfully navigated to about:blank.",
+ "pages": [
+ {
+ "id": 1,
+ "url": "about:blank",
+ "selected": true
+ }
+ ],
+ "webmcpTools": [
+ {
+ "name": "test_tool",
+ "description": "A test tool",
+ "inputSchema": {}
+ }
+ ]
+}
+`;
+
+exports[`webmcp > includes webmcp tools in select_page response 1`] = `
+## Pages
+1: about:blank [selected]
+## WebMCP tools
+name="test_tool", description="A test tool", inputSchema={}, annotations=undefined
+`;
+
+exports[`webmcp > includes webmcp tools in select_page response 2`] = `
+{
+ "pages": [
+ {
+ "id": 1,
+ "url": "about:blank",
+ "selected": true
+ }
+ ],
+ "webmcpTools": [
+ {
+ "name": "test_tool",
+ "description": "A test tool",
+ "inputSchema": {}
+ }
+ ]
+}
+`;
+
+exports[`webmcp > list no webmcp tools if experimentalWebmcp is false 1`] = `
+Successfully navigated to about:blank.
+## Pages
+1: about:blank [selected]
+`;
+
+exports[`webmcp > list no webmcp tools if experimentalWebmcp is false 2`] = `
+{
+ "message": "Successfully navigated to about:blank.",
+ "pages": [
+ {
+ "id": 1,
+ "url": "about:blank",
+ "selected": true
+ }
+ ]
+}
+`;
+
+exports[`webmcp > list no webmcp tools if there are none 1`] = `
+## WebMCP tools
+No WebMCP tools available.
+`;
+
+exports[`webmcp > list no webmcp tools if there are none 2`] = `
+{
+ "webmcpTools": []
+}
+`;
diff --git a/tests/McpResponse.test.ts b/tests/McpResponse.test.ts
index 915377197..30ba25d83 100644
--- a/tests/McpResponse.test.ts
+++ b/tests/McpResponse.test.ts
@@ -156,7 +156,7 @@ describe('McpResponse', () => {
});
it('saves snapshot to file and returns structured content', async t => {
- const filePath = join(tmpdir(), 'test-screenshot.png');
+ const filePath = join(tmpdir(), 'test-snapshot.txt');
try {
await withMcpContext(async (response, context) => {
const page = context.getSelectedPptrPage();
@@ -1455,3 +1455,130 @@ describe('replaceHtmlElementsWithUids', () => {
}
});
});
+
+describe('webmcp', () => {
+ async function testIncludesWebmcpTools(
+ t: it.TestContext,
+ parseArguments: ParsedArguments,
+ handlerAction: (
+ response: McpResponse,
+ context: McpContext,
+ ) => Promise,
+ toolName: string,
+ ) {
+ await withMcpContext(
+ async (response, context) => {
+ response.setListWebMcpTools();
+
+ await handlerAction(response, context);
+
+ const page = context.getSelectedMcpPage().pptrPage;
+ await page.setContent(
+ html``,
+ );
+
+ const {content, structuredContent} = await response.handle(
+ toolName,
+ context,
+ );
+ assert.ok(getTextContent(content[0]));
+ t.assert.snapshot?.(getTextContent(content[0]));
+ t.assert.snapshot?.(
+ JSON.stringify(
+ stabilizeStructuredContent(structuredContent),
+ null,
+ 2,
+ ),
+ );
+ },
+ {args: ['--enable-features=WebMCPTesting,DevToolsWebMCPSupport']},
+ parseArguments,
+ );
+ }
+
+ it('includes webmcp tools in list_pages response', async t => {
+ await testIncludesWebmcpTools(
+ t,
+ {experimentalWebmcp: true} as ParsedArguments,
+ async (response, context) => {
+ await listPages().handler({params: {}}, response, context);
+ },
+ 'list_pages',
+ );
+ });
+
+ it('includes webmcp tools in select_page response', async t => {
+ await testIncludesWebmcpTools(
+ t,
+ {experimentalWebmcp: true} as ParsedArguments,
+ async (response, context) => {
+ const pageId =
+ context.getPageId(context.getSelectedMcpPage().pptrPage) ?? 1;
+ await selectPage.handler({params: {pageId}}, response, context);
+ },
+ 'select_page',
+ );
+ });
+
+ it('includes webmcp tools in navigate_page response', async t => {
+ await testIncludesWebmcpTools(
+ t,
+ {experimentalWebmcp: true} as ParsedArguments,
+ async (response, context) => {
+ await navigatePage.handler(
+ {
+ params: {type: 'url', url: 'about:blank'},
+ page: context.getSelectedMcpPage(),
+ },
+ response,
+ context,
+ );
+ },
+ 'navigate_page',
+ );
+ });
+
+ it('list no webmcp tools if there are none', async t => {
+ await withMcpContext(
+ async (response, context) => {
+ response.setListWebMcpTools();
+ const {content, structuredContent} = await response.handle(
+ 'test',
+ context,
+ );
+ assert.ok(getTextContent(content[0]));
+ t.assert.snapshot?.(getTextContent(content[0]));
+ t.assert.snapshot?.(
+ JSON.stringify(
+ stabilizeStructuredContent(structuredContent),
+ null,
+ 2,
+ ),
+ );
+ },
+ {args: ['--enable-features=WebMCPTesting,DevToolsWebMCPSupport']},
+ {experimentalWebmcp: true} as ParsedArguments,
+ );
+ });
+
+ it('list no webmcp tools if experimentalWebmcp is false', async t => {
+ await testIncludesWebmcpTools(
+ t,
+ {experimentalWebmcp: false} as ParsedArguments,
+ async (response, context) => {
+ await navigatePage.handler(
+ {
+ params: {type: 'url', url: 'about:blank'},
+ page: context.getSelectedMcpPage(),
+ },
+ response,
+ context,
+ );
+ },
+ 'navigate_page',
+ );
+ });
+});
diff --git a/tests/PageCollector.test.ts b/tests/PageCollector.test.ts
index 6814002a5..be5b0b0ef 100644
--- a/tests/PageCollector.test.ts
+++ b/tests/PageCollector.test.ts
@@ -329,40 +329,13 @@ describe('ConsoleCollector', () => {
sinon.restore();
});
- it('emits issues on page', async () => {
- const browser = getMockBrowser();
- const page = (await browser.pages())[0];
- // @ts-expect-error internal API.
- const cdpSession = page._client();
- const onIssuesListener = sinon.spy();
-
- page.on('issue', onIssuesListener);
-
- const collector = new ConsoleCollector(browser, collect => {
- return {
- issue: issue => {
- collect(issue as DevTools.AggregatedIssue);
- },
- } as ListenerMap;
- });
- await collector.init([page]);
- cdpSession.emit('Audits.issueAdded', {issue});
- sinon.assert.calledOnce(onIssuesListener);
-
- const issueArgument = onIssuesListener.getCall(0).args[0];
- assert(issueArgument instanceof DevTools.AggregatedIssue);
- });
-
it('collects issues', async () => {
const browser = getMockBrowser();
const page = (await browser.pages())[0];
- // @ts-expect-error internal API.
- const cdpSession = page._client();
-
const collector = new ConsoleCollector(browser, collect => {
return {
- issue: issue => {
- collect(issue as DevTools.AggregatedIssue);
+ devtoolsAggregatedIssue: issue => {
+ collect(issue);
},
} as ListenerMap;
});
@@ -379,8 +352,8 @@ describe('ConsoleCollector', () => {
},
} satisfies Protocol.Audits.InspectorIssue;
- cdpSession.emit('Audits.issueAdded', {issue});
- cdpSession.emit('Audits.issueAdded', {issue: issue2});
+ page.emit('issue', issue);
+ page.emit('issue', issue2);
const data = collector.getData(page);
assert.equal(data.length, 2);
});
@@ -388,20 +361,18 @@ describe('ConsoleCollector', () => {
it('filters duplicated issues', async () => {
const browser = getMockBrowser();
const page = (await browser.pages())[0];
- // @ts-expect-error internal API.
- const cdpSession = page._client();
const collector = new ConsoleCollector(browser, collect => {
return {
- issue: issue => {
- collect(issue as DevTools.AggregatedIssue);
+ devtoolsAggregatedIssue: issue => {
+ collect(issue);
},
} as ListenerMap;
});
await collector.init([page]);
- cdpSession.emit('Audits.issueAdded', {issue});
- cdpSession.emit('Audits.issueAdded', {issue});
+ page.emit('issue', issue);
+ page.emit('issue', issue);
const data = collector.getData(page);
assert.equal(data.length, 1);
const collectedIssue = data[0];
diff --git a/tests/cli.test.ts b/tests/cli.test.ts
index b18a4532f..44347af2e 100644
--- a/tests/cli.test.ts
+++ b/tests/cli.test.ts
@@ -23,6 +23,8 @@ describe('cli args parsing', () => {
performanceCrux: true,
'usage-statistics': true,
usageStatistics: true,
+ 'redact-network-headers': false,
+ redactNetworkHeaders: false,
};
it('parses with default args', async () => {
diff --git a/tests/formatters/NetworkFormatter.test.ts b/tests/formatters/NetworkFormatter.test.ts
index d09e18184..a8fad2c26 100644
--- a/tests/formatters/NetworkFormatter.test.ts
+++ b/tests/formatters/NetworkFormatter.test.ts
@@ -31,6 +31,7 @@ describe('NetworkFormatter', () => {
const formatter = await NetworkFormatter.from(request, {
requestId: 1,
saveFile: async () => ({filename: ''}),
+ redactNetworkHeaders: false,
});
assert.equal(
@@ -43,6 +44,7 @@ describe('NetworkFormatter', () => {
const formatter = await NetworkFormatter.from(request, {
requestId: 1,
saveFile: async () => ({filename: ''}),
+ redactNetworkHeaders: false,
});
assert.equal(
@@ -56,6 +58,7 @@ describe('NetworkFormatter', () => {
const formatter = await NetworkFormatter.from(request, {
requestId: 1,
saveFile: async () => ({filename: ''}),
+ redactNetworkHeaders: false,
});
assert.equal(
@@ -71,6 +74,7 @@ describe('NetworkFormatter', () => {
const formatter = await NetworkFormatter.from(request, {
requestId: 1,
saveFile: async () => ({filename: ''}),
+ redactNetworkHeaders: false,
});
assert.equal(
@@ -86,6 +90,7 @@ describe('NetworkFormatter', () => {
const formatter = await NetworkFormatter.from(request, {
requestId: 1,
saveFile: async () => ({filename: ''}),
+ redactNetworkHeaders: false,
});
assert.equal(
@@ -104,6 +109,7 @@ describe('NetworkFormatter', () => {
const formatter = await NetworkFormatter.from(request, {
requestId: 1,
saveFile: async () => ({filename: ''}),
+ redactNetworkHeaders: false,
});
assert.equal(
@@ -118,6 +124,7 @@ describe('NetworkFormatter', () => {
requestId: 1,
selectedInDevToolsUI: true,
saveFile: async () => ({filename: ''}),
+ redactNetworkHeaders: false,
});
assert.equal(
@@ -138,6 +145,7 @@ describe('NetworkFormatter', () => {
requestId: 200,
fetchData: true,
saveFile: async () => ({filename: ''}),
+ redactNetworkHeaders: false,
});
const result = formatter.toStringDetailed();
assert.match(result, /test/);
@@ -154,6 +162,7 @@ describe('NetworkFormatter', () => {
requestId: 200,
fetchData: true,
saveFile: async () => ({filename: ''}),
+ redactNetworkHeaders: false,
});
const result = formatter.toStringDetailed();
@@ -176,6 +185,7 @@ describe('NetworkFormatter', () => {
requestId: 20,
fetchData: true,
saveFile: async () => ({filename: ''}),
+ redactNetworkHeaders: false,
});
const result = formatter.toStringDetailed();
assert.match(result, /some text/);
@@ -209,6 +219,7 @@ describe('NetworkFormatter', () => {
await writeFile(filename, data);
return {filename};
},
+ redactNetworkHeaders: false,
});
const json = formatter.toJSONDetailed() as {
@@ -252,6 +263,7 @@ describe('NetworkFormatter', () => {
await writeFile(filename, data);
return {filename};
},
+ redactNetworkHeaders: false,
});
const reqContent = await readFile(reqPath, 'utf8');
@@ -272,6 +284,7 @@ describe('NetworkFormatter', () => {
requestId: 200,
fetchData: true,
saveFile: async () => ({filename: ''}),
+ redactNetworkHeaders: false,
});
const result = formatter.toStringDetailed();
@@ -289,6 +302,7 @@ describe('NetworkFormatter', () => {
requestId: 1,
requestIdResolver: () => 2,
saveFile: async () => ({filename: ''}),
+ redactNetworkHeaders: false,
});
const result = formatter.toStringDetailed();
assert.match(result, /Redirect chain/);
@@ -322,6 +336,7 @@ describe('NetworkFormatter', () => {
await writeFile(filename, data);
return {filename};
},
+ redactNetworkHeaders: false,
});
const result = formatter.toStringDetailed();
@@ -361,6 +376,7 @@ describe('NetworkFormatter', () => {
await writeFile(filename, data);
return {filename};
},
+ redactNetworkHeaders: false,
});
const result = formatter.toStringDetailed();
@@ -379,6 +395,7 @@ describe('NetworkFormatter', () => {
requestId: 1,
selectedInDevToolsUI: true,
saveFile: async () => ({filename: ''}),
+ redactNetworkHeaders: false,
});
const result = formatter.toJSON();
assert.deepEqual(result, {
@@ -404,6 +421,7 @@ describe('NetworkFormatter', () => {
requestId: 1,
fetchData: true,
saveFile: async () => ({filename: ''}),
+ redactNetworkHeaders: false,
});
const result = formatter.toJSONDetailed();
assert.deepEqual(result, {
@@ -425,6 +443,38 @@ describe('NetworkFormatter', () => {
});
});
+ it('redacts headers', async () => {
+ const response = getMockResponse({
+ headers: {
+ 'set-cookie': 'secret=123',
+ 'content-type': 'text/plain',
+ },
+ });
+ response.buffer = () => Promise.resolve(Buffer.from('response'));
+ const request = getMockRequest({
+ response,
+ headers: {
+ cookie: 'secret=123',
+ 'user-agent': 'test',
+ },
+ });
+ const formatter = await NetworkFormatter.from(request, {
+ requestId: 1,
+ fetchData: true,
+ saveFile: async () => ({filename: ''}),
+ redactNetworkHeaders: true,
+ });
+ const result = formatter.toJSONDetailed();
+ assert.deepEqual(result.requestHeaders, {
+ cookie: '',
+ 'user-agent': 'test',
+ });
+ assert.deepEqual(result.responseHeaders, {
+ 'set-cookie': '',
+ 'content-type': 'text/plain',
+ });
+ });
+
it('returns file paths in structured detailed data', async () => {
const request = {
method: () => 'POST',
@@ -453,6 +503,7 @@ describe('NetworkFormatter', () => {
await writeFile(filename, data);
return {filename};
},
+ redactNetworkHeaders: false,
});
const result = formatter.toJSONDetailed() as {
diff --git a/tests/index.test.ts b/tests/index.test.ts
index f08350c08..6fc08dc66 100644
--- a/tests/index.test.ts
+++ b/tests/index.test.ts
@@ -162,4 +162,19 @@ describe('e2e', () => {
['--experimental-interop-tools'],
);
});
+
+ it('has experimental webmcp', async () => {
+ await withClient(
+ async client => {
+ const {tools} = await client.listTools();
+ const listWebMcpTools = tools.find(t => t.name === 'list_webmcp_tools');
+ const executeWebMcpTool = tools.find(
+ t => t.name === 'execute_webmcp_tool',
+ );
+ assert.ok(listWebMcpTools);
+ assert.ok(executeWebMcpTool);
+ },
+ ['--experimental-webmcp'],
+ );
+ });
});
diff --git a/tests/telemetry/ClearcutLogger.test.ts b/tests/telemetry/ClearcutLogger.test.ts
index 792202856..8ef061bdd 100644
--- a/tests/telemetry/ClearcutLogger.test.ts
+++ b/tests/telemetry/ClearcutLogger.test.ts
@@ -46,6 +46,8 @@ describe('ClearcutLogger', () => {
});
await logger.logToolInvocation({
toolName: 'test_tool',
+ params: {},
+ schema: {},
success: true,
latencyMs: 123,
});
@@ -57,6 +59,40 @@ describe('ClearcutLogger', () => {
assert.strictEqual(msg.payload.tool_invocation?.success, true);
assert.strictEqual(msg.payload.tool_invocation?.latency_ms, 123);
});
+ it('sends sanitized params', async () => {
+ const logger = new ClearcutLogger({
+ persistence: mockPersistence,
+ appVersion: '1.0.0',
+ watchdogClient: mockWatchdogClient,
+ });
+
+ const schema = {
+ uid: zod.string(),
+ myString: zod.string(),
+ };
+
+ const params = {
+ uid: 'sensitive',
+ myString: 'hello',
+ };
+
+ await logger.logToolInvocation({
+ toolName: 'test_tool',
+ params,
+ schema,
+ success: true,
+ latencyMs: 123,
+ });
+
+ assert(mockWatchdogClient.send.calledOnce);
+ const msg = mockWatchdogClient.send.firstCall.args[0];
+ assert.strictEqual(msg.type, WatchdogMessageType.LOG_EVENT);
+ assert.deepStrictEqual(msg.payload.tool_invocation?.tool_params, {
+ test_tool_params: {
+ my_string_length: 5,
+ },
+ });
+ });
});
describe('setClientName', () => {
@@ -191,11 +227,11 @@ describe('ClearcutLogger', () => {
const sanitized = sanitizeParams(params, schema);
assert.deepStrictEqual(sanitized, {
- myString_length: 5,
- myArray_count: 2,
- myNumber: 42,
- myBool: true,
- myEnum: 'a',
+ my_string_length: 5,
+ my_array_count: 2,
+ my_number: 42,
+ my_bool: true,
+ my_enum: 'a',
});
});
diff --git a/tests/telemetry/toolMetricsUtils.test.ts b/tests/telemetry/toolMetricsUtils.test.ts
index 0c369aaea..2a3fa53bf 100644
--- a/tests/telemetry/toolMetricsUtils.test.ts
+++ b/tests/telemetry/toolMetricsUtils.test.ts
@@ -55,7 +55,7 @@ describe('toolMetricsUtils', () => {
assert.strictEqual(metrics.length, 1);
assert.strictEqual(metrics[0].name, 'test_tool');
assert.strictEqual(metrics[0].args.length, 1); // uid is blocked
- assert.strictEqual(metrics[0].args[0].name, 'argStr_length');
+ assert.strictEqual(metrics[0].args[0].name, 'arg_str_length');
assert.strictEqual(metrics[0].args[0].argType, 'number');
});
@@ -77,7 +77,7 @@ describe('toolMetricsUtils', () => {
const metrics = generateToolMetrics([mockTool]);
assert.strictEqual(metrics.length, 1);
- assert.strictEqual(metrics[0].args[0].name, 'argEnum');
+ assert.strictEqual(metrics[0].args[0].name, 'arg_enum');
assert.strictEqual(metrics[0].args[0].argType, 'string');
});
});
diff --git a/tests/tools/inPage.test.ts b/tests/tools/inPage.test.ts
index d87bbea83..225fba934 100644
--- a/tests/tools/inPage.test.ts
+++ b/tests/tools/inPage.test.ts
@@ -7,6 +7,8 @@
import assert from 'node:assert';
import {describe, it} from 'node:test';
+import sinon from 'sinon';
+
import type {ParsedArguments} from '../../src/bin/chrome-devtools-mcp-cli-options.js';
import type {McpContext} from '../../src/McpContext.js';
import type {McpResponse} from '../../src/McpResponse.js';
@@ -208,7 +210,7 @@ describe('inPage', () => {
);
assert.strictEqual(
response.responseLines[0],
- JSON.stringify({result: 'result'}, null, 2),
+ JSON.stringify('result', null, 2),
);
},
undefined,
@@ -340,7 +342,7 @@ describe('inPage', () => {
);
assert.strictEqual(
response.responseLines[0],
- JSON.stringify({result: {foo: 'bar'}}, null, 2),
+ JSON.stringify({foo: 'bar'}, null, 2),
);
},
undefined,
@@ -428,11 +430,9 @@ describe('inPage', () => {
response.responseLines[0],
JSON.stringify(
{
- result: {
- isElement: true,
- tagName: 'DIV',
- id: 'test-id',
- },
+ isElement: true,
+ tagName: 'DIV',
+ id: 'test-id',
},
null,
2,
@@ -440,5 +440,280 @@ describe('inPage', () => {
);
});
});
+
+ it('processToolResult replaces functions with ""', async () => {
+ await withMcpContext(
+ async (response, context) => {
+ await setupInPageTools(response, context, () => {
+ window.__dtmcp = {
+ toolGroup: {
+ name: 'test-group',
+ description: 'test description',
+ tools: [
+ {
+ name: 'test-tool',
+ description: 'test tool description',
+ inputSchema: {},
+ execute: () => ({
+ foo: 'bar',
+ func: () => undefined,
+ }),
+ },
+ ],
+ },
+ };
+ window.addEventListener('devtoolstooldiscovery', (e: Event) => {
+ // @ts-expect-error Event has `respondWith`
+ e.respondWith(window.__dtmcp?.toolGroup);
+ });
+ });
+
+ await executeInPageTool.handler(
+ {
+ params: {
+ toolName: 'test-tool',
+ params: JSON.stringify({}),
+ },
+ page: context.getSelectedMcpPage(),
+ },
+ response,
+ context,
+ );
+ assert.strictEqual(
+ response.responseLines[0],
+ JSON.stringify({foo: 'bar', func: ''}, null, 2),
+ );
+ },
+ undefined,
+ {categoryInPageTools: true} as ParsedArguments,
+ );
+ });
+
+ it('processToolResult replaces circular references with ""', async () => {
+ await withMcpContext(
+ async (response, context) => {
+ await setupInPageTools(response, context, () => {
+ window.__dtmcp = {
+ toolGroup: {
+ name: 'test-group',
+ description: 'test description',
+ tools: [
+ {
+ name: 'test-tool',
+ description: 'test tool description',
+ inputSchema: {},
+ execute: () => {
+ const obj: Record = {foo: 'bar'};
+ obj.self = obj;
+ return obj;
+ },
+ },
+ ],
+ },
+ };
+ window.addEventListener('devtoolstooldiscovery', (e: Event) => {
+ // @ts-expect-error Event has `respondWith`
+ e.respondWith(window.__dtmcp?.toolGroup);
+ });
+ });
+
+ await executeInPageTool.handler(
+ {
+ params: {
+ toolName: 'test-tool',
+ params: JSON.stringify({}),
+ },
+ page: context.getSelectedMcpPage(),
+ },
+ response,
+ context,
+ );
+ assert.strictEqual(
+ response.responseLines[0],
+ JSON.stringify({foo: 'bar', self: ''}, null, 2),
+ );
+ },
+ undefined,
+ {categoryInPageTools: true} as ParsedArguments,
+ );
+ });
+
+ it('processToolResult replaces non-plain objects with ""', async () => {
+ await withMcpContext(
+ async (response, context) => {
+ await setupInPageTools(response, context, () => {
+ class CustomClass {
+ val = 'value';
+ }
+ window.__dtmcp = {
+ toolGroup: {
+ name: 'test-group',
+ description: 'test description',
+ tools: [
+ {
+ name: 'test-tool',
+ description: 'test tool description',
+ inputSchema: {},
+ execute: () => ({
+ foo: 'bar',
+ custom: new CustomClass(),
+ }),
+ },
+ ],
+ },
+ };
+ window.addEventListener('devtoolstooldiscovery', (e: Event) => {
+ // @ts-expect-error Event has `respondWith`
+ e.respondWith(window.__dtmcp?.toolGroup);
+ });
+ });
+
+ await executeInPageTool.handler(
+ {
+ params: {
+ toolName: 'test-tool',
+ params: JSON.stringify({}),
+ },
+ page: context.getSelectedMcpPage(),
+ },
+ response,
+ context,
+ );
+ assert.strictEqual(
+ response.responseLines[0],
+ JSON.stringify(
+ {foo: 'bar', custom: ''},
+ null,
+ 2,
+ ),
+ );
+ },
+ undefined,
+ {categoryInPageTools: true} as ParsedArguments,
+ );
+ });
+
+ it('stashDOMElement stashes elements and returns UID', async () => {
+ await withMcpContext(
+ async (response, context) => {
+ const page = await context.newPage();
+ response.setPage(page);
+
+ page.inPageTools = {
+ name: 'test-group',
+ description: 'test description',
+ tools: [
+ {
+ name: 'test-tool',
+ description: 'test tool description',
+ inputSchema: {},
+ },
+ ],
+ };
+
+ await page.pptrPage.evaluate(() => {
+ window.__dtmcp = {
+ executeTool: async () => {
+ const div = document.createElement('div');
+ div.id = 'test-element';
+ document.body.appendChild(div);
+ return div;
+ },
+ };
+ });
+
+ const stub = sinon
+ .stub(context, 'resolveCdpElementId')
+ .returns('mock-uid');
+
+ await executeInPageTool.handler(
+ {
+ params: {
+ toolName: 'test-tool',
+ params: JSON.stringify({}),
+ },
+ page: page,
+ },
+ response,
+ context,
+ );
+
+ assert.strictEqual(
+ response.responseLines[0],
+ JSON.stringify({uid: 'mock-uid'}, null, 2),
+ );
+
+ stub.restore();
+ },
+ undefined,
+ {categoryInPageTools: true} as ParsedArguments,
+ );
+ });
+
+ it('creates a new snapshot if the stashed ID cannot be mapped to a UID initially', async () => {
+ await withMcpContext(
+ async (response, context) => {
+ const page = await context.newPage();
+ response.setPage(page);
+
+ page.inPageTools = {
+ name: 'test-group',
+ description: 'test description',
+ tools: [
+ {
+ name: 'test-tool',
+ description: 'test tool description',
+ inputSchema: {},
+ },
+ ],
+ };
+
+ await page.pptrPage.evaluate(() => {
+ window.__dtmcp = {
+ executeTool: async () => {
+ const div = document.createElement('div');
+ div.id = 'test-element';
+ document.body.appendChild(div);
+ return div;
+ },
+ };
+ });
+
+ const stubResolve = sinon.stub(context, 'resolveCdpElementId');
+ stubResolve.onFirstCall().returns(undefined);
+ stubResolve.onSecondCall().returns('mock-uid');
+
+ const stubSnapshot = sinon
+ .stub(context, 'createTextSnapshot')
+ .resolves();
+
+ await executeInPageTool.handler(
+ {
+ params: {
+ toolName: 'test-tool',
+ params: JSON.stringify({}),
+ },
+ page: page,
+ },
+ response,
+ context,
+ );
+
+ assert.ok(
+ stubSnapshot.calledOnce,
+ 'Expected createTextSnapshot to be called',
+ );
+ assert.strictEqual(
+ response.responseLines[0],
+ JSON.stringify({uid: 'mock-uid'}, null, 2),
+ );
+
+ stubResolve.restore();
+ stubSnapshot.restore();
+ },
+ undefined,
+ {categoryInPageTools: true} as ParsedArguments,
+ );
+ });
});
});
diff --git a/tests/tools/network.test.js.snapshot b/tests/tools/network.test.js.snapshot
index b77255483..4d920f18c 100644
--- a/tests/tools/network.test.js.snapshot
+++ b/tests/tools/network.test.js.snapshot
@@ -5,23 +5,23 @@ Status: 200
- accept-language:
- upgrade-insecure-requests:1
- user-agent:
-- sec-ch-ua:"Not-A.Brand";v="24", "Chromium";v="146"
-- sec-ch-ua-mobile:?0
-- sec-ch-ua-platform:""
+- sec-ch-ua:
+- sec-ch-ua-mobile:
+- sec-ch-ua-platform:
- accept:text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
- accept-encoding:gzip, deflate, br, zstd
- connection:keep-alive
- host:localhost:
-- sec-fetch-dest:document
-- sec-fetch-mode:navigate
-- sec-fetch-site:none
-- sec-fetch-user:?1
+- sec-fetch-dest:
+- sec-fetch-mode:
+- sec-fetch-site:
+- sec-fetch-user:
### Response Headers
- connection:keep-alive
-- content-length:239
+- content-length:
- content-type:text/html; charset=utf-8
- date:
-- keep-alive:timeout=5
+- keep-alive:
### Response Body
`;
diff --git a/tests/tools/webmcp.test.ts b/tests/tools/webmcp.test.ts
new file mode 100644
index 000000000..fcbe5f3e1
--- /dev/null
+++ b/tests/tools/webmcp.test.ts
@@ -0,0 +1,132 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import assert from 'node:assert';
+import {describe, it} from 'node:test';
+
+import type {ParsedArguments} from '../../src/bin/chrome-devtools-mcp-cli-options.js';
+import type {McpPage} from '../../src/McpPage.js';
+import {listPages, navigatePage, selectPage} from '../../src/tools/pages.js';
+import {executeWebMcpTool} from '../../src/tools/webmcp.js';
+import {html, withMcpContext} from '../utils.js';
+
+describe('webmcp', () => {
+ describe('list_webmcp_tools', () => {
+ it('list webmcp tools in navigate_page response', async () => {
+ await withMcpContext(async (response, context) => {
+ await navigatePage.handler(
+ {params: {url: 'about:blank'}, page: context.getSelectedMcpPage()},
+ response,
+ context,
+ );
+ assert.ok(response.listWebMcpTools);
+ });
+ });
+
+ it('list webmcp tools in list_pages response', async () => {
+ await withMcpContext(async (response, context) => {
+ await listPages().handler({params: {}}, response, context);
+ assert.ok(response.listWebMcpTools);
+ });
+ });
+
+ it('list webmcp tools in select_page response', async () => {
+ await withMcpContext(async (response, context) => {
+ const pageId =
+ context.getPageId(context.getSelectedMcpPage().pptrPage) ?? 1;
+ await selectPage.handler({params: {pageId}}, response, context);
+ assert.ok(response.listWebMcpTools);
+ });
+ });
+ });
+
+ describe('execute_webmcp_tool', () => {
+ async function setupWebMcpTool(page: McpPage) {
+ await page.pptrPage.setContent(
+ html``,
+ );
+ }
+
+ // TODO: Remove `.skip` once Chrome 149 reaches stable channel.
+ it.skip('executes a tool successfully', async () => {
+ await withMcpContext(
+ async (response, context) => {
+ const page = context.getSelectedMcpPage();
+ await setupWebMcpTool(page);
+
+ await executeWebMcpTool.handler(
+ {params: {toolName: 'test_tool', input: JSON.stringify({})}, page},
+ response,
+ context,
+ );
+ assert.strictEqual(
+ response.responseLines[0],
+ JSON.stringify({status: 'Completed', output: 'hello'}, null, 2),
+ );
+ },
+ {args: ['--enable-features=WebMCPTesting,DevToolsWebMCPSupport']},
+ {experimentalWebmcp: true} as ParsedArguments,
+ );
+ });
+
+ it('throws if tool is not found', async () => {
+ await withMcpContext(
+ async (response, context) => {
+ await assert.rejects(
+ async () => {
+ await executeWebMcpTool.handler(
+ {
+ params: {toolName: 'missing-tool', input: JSON.stringify({})},
+ page: context.getSelectedMcpPage(),
+ },
+ response,
+ context,
+ );
+ },
+ {message: /Tool missing-tool not found/},
+ );
+ },
+ {args: ['--enable-features=WebMCPTesting,DevToolsWebMCPSupport']},
+ {experimentalWebmcp: true} as ParsedArguments,
+ );
+ });
+
+ it('throws if input is invalid', async () => {
+ await withMcpContext(
+ async (response, context) => {
+ await assert.rejects(
+ async () => {
+ const page = context.getSelectedMcpPage();
+ await setupWebMcpTool(page);
+
+ await executeWebMcpTool.handler(
+ {params: {toolName: 'test_tool', input: 'invalid'}, page},
+ response,
+ context,
+ );
+ },
+ {
+ message:
+ /Failed to parse input as JSON: Unexpected token 'i', "invalid" is not valid JSON/,
+ },
+ );
+ },
+ {args: ['--enable-features=WebMCPTesting,DevToolsWebMCPSupport']},
+ {experimentalWebmcp: true} as ParsedArguments,
+ );
+ });
+ });
+});
diff --git a/tests/utils.ts b/tests/utils.ts
index 23257616f..6d4668fbf 100644
--- a/tests/utils.ts
+++ b/tests/utils.ts
@@ -64,6 +64,7 @@ export async function withBrowser(
debug?: boolean;
autoOpenDevTools?: boolean;
executablePath?: string;
+ args?: string[];
} = {},
) {
const launchOptions: LaunchOptions = {
@@ -74,7 +75,7 @@ export async function withBrowser(
devtools: options.autoOpenDevTools ?? false,
pipe: true,
handleDevToolsAsPage: true,
- args: ['--screen-info={3840x2160}'],
+ args: [...(options.args || []), '--screen-info={3840x2160}'],
enableExtensions: true,
};
const key = JSON.stringify(launchOptions);
@@ -104,6 +105,7 @@ export async function withMcpContext(
autoOpenDevTools?: boolean;
performanceCrux?: boolean;
executablePath?: string;
+ args?: string[];
} = {},
args: ParsedArguments = {} as ParsedArguments,
) {
@@ -142,6 +144,7 @@ export function getMockRequest(
navigationRequest?: boolean;
frame?: Frame;
redirectChain?: HTTPRequest[];
+ headers?: Record;
} = {},
): HTTPRequest {
return {
@@ -170,9 +173,11 @@ export function getMockRequest(
return options.resourceType ?? 'document';
},
headers(): Record {
- return {
- 'content-size': '10',
- };
+ return (
+ options.headers ?? {
+ 'content-size': '10',
+ }
+ );
},
redirectChain(): HTTPRequest[] {
return options.redirectChain ?? [];
@@ -190,6 +195,7 @@ export function getMockRequest(
export function getMockResponse(
options: {
status?: number;
+ headers?: Record;
} = {},
): HTTPResponse {
return {
@@ -197,9 +203,9 @@ export function getMockResponse(
return options.status ?? 200;
},
headers(): Record {
- return {};
+ return options.headers ?? {};
},
- } as HTTPResponse;
+ } as unknown as HTTPResponse;
}
export function html(
diff --git a/tests/utils/files.test.ts b/tests/utils/files.test.ts
new file mode 100644
index 000000000..44052642e
--- /dev/null
+++ b/tests/utils/files.test.ts
@@ -0,0 +1,43 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import assert from 'node:assert';
+import {describe, it} from 'node:test';
+
+import {ensureExtension} from '../../src/utils/files.js';
+
+describe('ensureExtension', () => {
+ it('should add an extension to a filename without one', () => {
+ assert.strictEqual(ensureExtension('filename', '.txt'), 'filename.txt');
+ });
+
+ it('should replace an existing extension', () => {
+ assert.strictEqual(ensureExtension('filename.jpg', '.txt'), 'filename.txt');
+ });
+
+ it('should handle extension without a leading dot', () => {
+ assert.strictEqual(ensureExtension('filename', '.txt'), 'filename.txt');
+ });
+
+ it('should not add a second dot if already present', () => {
+ assert.strictEqual(ensureExtension('filename.txt', '.txt'), 'filename.txt');
+ });
+
+ it('should handle paths with directories', () => {
+ assert.strictEqual(
+ ensureExtension('/path/to/file.jpg', '.png'),
+ '/path/to/file.png',
+ );
+ });
+
+ it('should handle hidden files (starting with dot)', () => {
+ assert.strictEqual(ensureExtension('.bashrc', '.txt'), '.bashrc.txt');
+ });
+
+ it('should handle complex extensions (like .tar.gz) - path.extname only gets the last one', () => {
+ assert.strictEqual(ensureExtension('file.tar.gz', '.zip'), 'file.tar.zip');
+ });
+});
diff --git a/tsconfig.json b/tsconfig.json
index ab5d0f00b..eb629e3f8 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -5,8 +5,8 @@
"ES2023",
"DOM",
"ES2024.Promise",
- "ES2025.Iterator",
- "ES2025.Collection"
+ "ESNext.Iterator",
+ "ESNext.Collection"
],
"types": ["node", "filesystem"],
"moduleResolution": "bundler",