diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 210d2903..bcd05228 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.5.1" + ".": "0.6.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 2085a7ea..d708adcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## [0.6.0](https://github.com/ChromeDevTools/chrome-devtools-mcp/compare/chrome-devtools-mcp-v0.5.1...chrome-devtools-mcp-v0.6.0) (2025-10-01) + + +### Features + +* **screenshot:** add WebP format support with quality parameter ([#220](https://github.com/ChromeDevTools/chrome-devtools-mcp/issues/220)) ([03e02a2](https://github.com/ChromeDevTools/chrome-devtools-mcp/commit/03e02a2d769fbfc0c98599444dfed5413d15ae6e)) +* **screenshot:** adds ability to output screenshot to a specific pat… ([#172](https://github.com/ChromeDevTools/chrome-devtools-mcp/issues/172)) ([f030726](https://github.com/ChromeDevTools/chrome-devtools-mcp/commit/f03072698ddda8587ce23229d733405f88b7c89e)) +* support --accept-insecure-certs CLI ([#231](https://github.com/ChromeDevTools/chrome-devtools-mcp/issues/231)) ([efb106d](https://github.com/ChromeDevTools/chrome-devtools-mcp/commit/efb106dc94af0057f88c89f810beb65114eeaa4b)) +* support --proxy-server CLI ([#230](https://github.com/ChromeDevTools/chrome-devtools-mcp/issues/230)) ([dfacc75](https://github.com/ChromeDevTools/chrome-devtools-mcp/commit/dfacc75ee9f46137b5194e35fc604b89a00ff53f)) +* support initial viewport in the CLI ([#229](https://github.com/ChromeDevTools/chrome-devtools-mcp/issues/229)) ([ef61a08](https://github.com/ChromeDevTools/chrome-devtools-mcp/commit/ef61a08707056c5078d268a83a2c95d10e224f31)) +* support timeouts in wait_for and navigations ([#228](https://github.com/ChromeDevTools/chrome-devtools-mcp/issues/228)) ([36e64d5](https://github.com/ChromeDevTools/chrome-devtools-mcp/commit/36e64d5ae21e8bb244a18201a23a16932947e938)) + + +### Bug Fixes + +* **network:** show only selected request ([#236](https://github.com/ChromeDevTools/chrome-devtools-mcp/issues/236)) ([73f0aec](https://github.com/ChromeDevTools/chrome-devtools-mcp/commit/73f0aecd8a48b9d1ee354897fe14d785c80e863e)) +* PageCollector subscribing multiple times ([#241](https://github.com/ChromeDevTools/chrome-devtools-mcp/issues/241)) ([0412878](https://github.com/ChromeDevTools/chrome-devtools-mcp/commit/0412878bf51ae46e48a171183bb38cfbbee1038a)) +* snapshot does not capture Iframe content ([#217](https://github.com/ChromeDevTools/chrome-devtools-mcp/issues/217)) ([ce356f2](https://github.com/ChromeDevTools/chrome-devtools-mcp/commit/ce356f256545e805db74664797de5f42e7b92bed)), closes [#186](https://github.com/ChromeDevTools/chrome-devtools-mcp/issues/186) + ## [0.5.1](https://github.com/ChromeDevTools/chrome-devtools-mcp/compare/chrome-devtools-mcp-v0.5.0...chrome-devtools-mcp-v0.5.1) (2025-09-29) diff --git a/README.md b/README.md index eab1ab8f..469ef252 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,8 @@ 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. +## [Tool reference](./docs/tool-reference.md) | [Changelog](./CHANGELOG.md) | [Contributing](./CONTRIBUTING.md) | [Troubleshooting](./docs/troubleshooting.md) + ## Key features - **Get performance insights**: Uses [Chrome @@ -40,7 +42,7 @@ Add the following config to your MCP client: "mcpServers": { "chrome-devtools": { "command": "npx", - "args": ["chrome-devtools-mcp@latest"] + "args": ["-y", "chrome-devtools-mcp@latest"] } } } @@ -94,6 +96,30 @@ startup_timeout_ms = 20_000 +
+ Copilot CLI + +Start Copilot CLI: + +``` +copilot +``` + +Start the dialog to add a new MCP server by running: + +``` +/mcp add +``` + +Configure the following fields and press `CTR-S` to save the configuration: + +- **Server name:** `chrome-devtools` +- **Server Type:** `[1] Local` +- **Command:** `npx` +- **Arguments:** `-y, chrome-devtools-mcp@latest` + +
+
Copilot / VS Code Follow the MCP install guide, @@ -109,7 +135,7 @@ startup_timeout_ms = 20_000 **Click the button to install:** -[Install in Cursor](https://cursor.com/en/install-mcp?name=chrome-devtools&config=eyJjb21tYW5kIjoibnB4IGNocm9tZS1kZXZ0b29scy1tY3BAbGF0ZXN0In0%3D) +[Install in Cursor](https://cursor.com/en/install-mcp?name=chrome-devtools&config=eyJjb21tYW5kIjoibnB4IC15IGNocm9tZS1kZXZ0b29scy1tY3BAbGF0ZXN0In0%3D) **Or install manually:** @@ -151,6 +177,14 @@ The same way chrome-devtools-mcp can be configured for JetBrains Junie in `Setti
+
+ Visual Studio + + **Click the button to install:** + + [Install in Visual Studio](https://vs-open.link/mcp-install?%7B%22name%22%3A%22chrome-devtools%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22chrome-devtools-mcp%40latest%22%5D%7D) +
+ ### Your first prompt Enter the following prompt in your MCP Client to check if everything is working: @@ -166,6 +200,8 @@ Your MCP client should open the browser and record a performance trace. ## Tools +If you run into any issues, checkout our [troubleshooting guide](./docs/troubleshooting.md). + - **Input automation** (7 tools) @@ -236,6 +272,18 @@ The Chrome DevTools MCP server supports the following configuration option: Path to a file to write debug logs to. Set the env variable `DEBUG` to `*` to enable verbose logs. Useful for submitting bug reports. - **Type:** string +- **`--viewport`** + Initial viewport size for the Chromee instances started by the server. For example, `1280x720` + - **Type:** string + +- **`--proxyServer`** + Proxy server configuration for Chrome passed as --proxy-server when launching the browser. See https://www.chromium.org/developers/design-documents/network-settings/ for details. + - **Type:** string + +- **`--acceptInsecureCerts`** + If enabled, ignores errors relative to self-signed and expired certificates. Use with caution. + - **Type:** boolean + Pass them via the `args` property in the JSON configuration. For example: diff --git a/docs/tool-reference.md b/docs/tool-reference.md index 1b39e869..c4c4bc9e 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -138,6 +138,7 @@ **Parameters:** +- **timeout** (integer) _(optional)_: Maximum wait time in milliseconds. If set to 0, the default timeout will be used. - **url** (string) **(required)**: URL to navigate the page to --- @@ -149,6 +150,7 @@ **Parameters:** - **navigate** (enum: "back", "forward") **(required)**: Whether to navigate back or navigate forward in the selected pages history +- **timeout** (integer) _(optional)_: Maximum wait time in milliseconds. If set to 0, the default timeout will be used. --- @@ -158,6 +160,7 @@ **Parameters:** +- **timeout** (integer) _(optional)_: Maximum wait time in milliseconds. If set to 0, the default timeout will be used. - **url** (string) **(required)**: URL to load in a new page. --- @@ -179,6 +182,7 @@ **Parameters:** - **text** (string) **(required)**: Text to appear on the page +- **timeout** (integer) _(optional)_: Maximum wait time in milliseconds. If set to 0, the default timeout will be used. --- @@ -306,9 +310,10 @@ so returned values have to JSON-serializable. **Parameters:** -- **format** (enum: "png", "jpeg") _(optional)_: Type of format to save the screenshot as. Default is "png" +- **filePath** (string) _(optional)_: The absolute path, or a path relative to the current working directory, to save the screenshot to instead of attaching it to the response. +- **format** (enum: "png", "jpeg", "webp") _(optional)_: Type of format to save the screenshot as. Default is "png" - **fullPage** (boolean) _(optional)_: If set to true takes a screenshot of the full page instead of the currently visible viewport. Incompatible with uid. -- **quality** (number) _(optional)_: Compression quality for JPEG format (0-100). Higher values mean better quality but larger file sizes. Ignored for PNG format. +- **quality** (number) _(optional)_: Compression quality for JPEG and WebP formats (0-100). Higher values mean better quality but larger file sizes. Ignored for PNG format. - **uid** (string) _(optional)_: The uid of an element on the page from the page content snapshot. If omitted takes a pages screenshot. --- diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 00000000..302fe560 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,29 @@ +# Troubleshooting + +## General tips + +- Run `npx chrome-devtools-mcp@latest --help` to test if the MCP server runs on your machine. +- Make sure that your MCP client uses the same npm and node version as your terminal. +- When configuring your MCP client, try using the `--yes` argument to `npx` to + auto-accept installation prompt. +- Find a specific error in the output of the `chrome-devtools-mcp` server. + Usually, if you client is an IDE, logs would be in the Output pane. + +## Specific problems + +### `Error [ERR_MODULE_NOT_FOUND]: Cannot find module ...` + +This usually indicates either a non-supported Node version is in use or that the +`npm`/`npx` cache is corrupted. Try clearing the cache, uninstalling +`chrome-devtools-mcp` and installing it again. Clear the cache by running: + +```sh +rm -rf ~/.npm/_npx # NOTE: this might remove other installed npx executables. +npm cache clean --force +``` + +### `Target closed` error + +This indicates that the browser could not be started. Make sure that no Chrome +instances are running or close them. Make sure you have the latest stable Chrome +installed and that [your system is able to run Chrome](https://support.google.com/chrome/a/answer/7100626?hl=en). diff --git a/package-lock.json b/package-lock.json index 0cf872dd..f5c40be3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "chrome-devtools-mcp", - "version": "0.5.1", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "chrome-devtools-mcp", - "version": "0.5.1", + "version": "0.6.0", "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/sdk": "1.18.2", @@ -28,7 +28,7 @@ "@types/yargs": "^17.0.33", "@typescript-eslint/eslint-plugin": "^8.43.0", "@typescript-eslint/parser": "^8.43.0", - "chrome-devtools-frontend": "1.0.1520535", + "chrome-devtools-frontend": "1.0.1521880", "eslint": "^9.35.0", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "^2.32.0", @@ -40,7 +40,7 @@ "typescript-eslint": "^8.43.0" }, "engines": { - "node": ">=22.12.0" + "node": "^20.19.0 || ^22.12.0 || >=23" } }, "node_modules/@babel/code-frame": { @@ -265,9 +265,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.35.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.35.0.tgz", - "integrity": "sha512-30iXE9whjlILfWobBkNerJo+TXYsgVM5ERQwMcMKCHckHflCmf7wXDAHlARoWnh0s1U72WqlbeyE7iAcCzuCPw==", + "version": "9.36.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.36.0.tgz", + "integrity": "sha512-uhCbYtYynH30iZErszX78U+nR3pJU3RHGQ57NXy5QupD4SBVwDeU8TNBy+MjMngc1UyIW9noKqsRqfjQTBU2dw==", "dev": true, "license": "MIT", "engines": { @@ -589,20 +589,6 @@ "eslint": ">=9.0.0" } }, - "node_modules/@stylistic/eslint-plugin/node_modules/@typescript-eslint/types": { - "version": "8.44.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.44.1.tgz", - "integrity": "sha512-Lk7uj7y9uQUOEguiDIDLYLJOrYHQa7oBiURYVFqIpGxclAFQ78f6VUOM8lI2XEuNOKNB7XuvM2+2cMXAoq4ALQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, "node_modules/@stylistic/eslint-plugin/node_modules/eslint-visitor-keys": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", @@ -702,13 +688,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.3.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.3.tgz", - "integrity": "sha512-GKBNHjoNw3Kra1Qg5UXttsY5kiWMEfoHq2TmXb+b1rcm6N7B3wTrFYIf/oSZ1xNQ+hVVijgLkiDZh7jRRsh+Gw==", + "version": "24.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.6.0.tgz", + "integrity": "sha512-F1CBxgqwOMc4GKJ7eY22hWhBVQuMYTtqI8L0FcszYcpYX0fzfDGpez22Xau8Mgm7O9fI+zA/TYIdq3tGWfweBA==", "devOptional": true, "license": "MIT", "dependencies": { - "undici-types": "~7.10.0" + "undici-types": "~7.13.0" } }, "node_modules/@types/sinon": { @@ -756,17 +742,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.43.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.43.0.tgz", - "integrity": "sha512-8tg+gt7ENL7KewsKMKDHXR1vm8tt9eMxjJBYINf6swonlWgkYn5NwyIgXpbbDxTNU5DgpDFfj95prcTq2clIQQ==", + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.45.0.tgz", + "integrity": "sha512-HC3y9CVuevvWCl/oyZuI47dOeDF9ztdMEfMH8/DW/Mhwa9cCLnK1oD7JoTVGW/u7kFzNZUKUoyJEqkaJh5y3Wg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.43.0", - "@typescript-eslint/type-utils": "8.43.0", - "@typescript-eslint/utils": "8.43.0", - "@typescript-eslint/visitor-keys": "8.43.0", + "@typescript-eslint/scope-manager": "8.45.0", + "@typescript-eslint/type-utils": "8.45.0", + "@typescript-eslint/utils": "8.45.0", + "@typescript-eslint/visitor-keys": "8.45.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", @@ -786,16 +772,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.43.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.43.0.tgz", - "integrity": "sha512-B7RIQiTsCBBmY+yW4+ILd6mF5h1FUwJsVvpqkrgpszYifetQ2Ke+Z4u6aZh0CblkUGIdR59iYVyXqqZGkZ3aBw==", + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.45.0.tgz", + "integrity": "sha512-TGf22kon8KW+DeKaUmOibKWktRY8b2NSAZNdtWh798COm1NWx8+xJ6iFBtk3IvLdv6+LGLJLRlyhrhEDZWargQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.43.0", - "@typescript-eslint/types": "8.43.0", - "@typescript-eslint/typescript-estree": "8.43.0", - "@typescript-eslint/visitor-keys": "8.43.0", + "@typescript-eslint/scope-manager": "8.45.0", + "@typescript-eslint/types": "8.45.0", + "@typescript-eslint/typescript-estree": "8.45.0", + "@typescript-eslint/visitor-keys": "8.45.0", "debug": "^4.3.4" }, "engines": { @@ -811,14 +797,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.43.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.43.0.tgz", - "integrity": "sha512-htB/+D/BIGoNTQYffZw4uM4NzzuolCoaA/BusuSIcC8YjmBYQioew5VUZAYdAETPjeed0hqCaW7EHg+Robq8uw==", + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.45.0.tgz", + "integrity": "sha512-3pcVHwMG/iA8afdGLMuTibGR7pDsn9RjDev6CCB+naRsSYs2pns5QbinF4Xqw6YC/Sj3lMrm/Im0eMfaa61WUg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.43.0", - "@typescript-eslint/types": "^8.43.0", + "@typescript-eslint/tsconfig-utils": "^8.45.0", + "@typescript-eslint/types": "^8.45.0", "debug": "^4.3.4" }, "engines": { @@ -833,14 +819,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.43.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.43.0.tgz", - "integrity": "sha512-daSWlQ87ZhsjrbMLvpuuMAt3y4ba57AuvadcR7f3nl8eS3BjRc8L9VLxFLk92RL5xdXOg6IQ+qKjjqNEimGuAg==", + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.45.0.tgz", + "integrity": "sha512-clmm8XSNj/1dGvJeO6VGH7EUSeA0FMs+5au/u3lrA3KfG8iJ4u8ym9/j2tTEoacAffdW1TVUzXO30W1JTJS7dA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.43.0", - "@typescript-eslint/visitor-keys": "8.43.0" + "@typescript-eslint/types": "8.45.0", + "@typescript-eslint/visitor-keys": "8.45.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -851,9 +837,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.43.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.43.0.tgz", - "integrity": "sha512-ALC2prjZcj2YqqL5X/bwWQmHA2em6/94GcbB/KKu5SX3EBDOsqztmmX1kMkvAJHzxk7TazKzJfFiEIagNV3qEA==", + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.45.0.tgz", + "integrity": "sha512-aFdr+c37sc+jqNMGhH+ajxPXwjv9UtFZk79k8pLoJ6p4y0snmYpPA52GuWHgt2ZF4gRRW6odsEj41uZLojDt5w==", "dev": true, "license": "MIT", "engines": { @@ -868,15 +854,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.43.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.43.0.tgz", - "integrity": "sha512-qaH1uLBpBuBBuRf8c1mLJ6swOfzCXryhKND04Igr4pckzSEW9JX5Aw9AgW00kwfjWJF0kk0ps9ExKTfvXfw4Qg==", + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.45.0.tgz", + "integrity": "sha512-bpjepLlHceKgyMEPglAeULX1vixJDgaKocp0RVJ5u4wLJIMNuKtUXIczpJCPcn2waII0yuvks/5m5/h3ZQKs0A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.43.0", - "@typescript-eslint/typescript-estree": "8.43.0", - "@typescript-eslint/utils": "8.43.0", + "@typescript-eslint/types": "8.45.0", + "@typescript-eslint/typescript-estree": "8.45.0", + "@typescript-eslint/utils": "8.45.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, @@ -893,9 +879,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.43.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.43.0.tgz", - "integrity": "sha512-vQ2FZaxJpydjSZJKiSW/LJsabFFvV7KgLC5DiLhkBcykhQj8iK9BOaDmQt74nnKdLvceM5xmhaTF+pLekrxEkw==", + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.45.0.tgz", + "integrity": "sha512-WugXLuOIq67BMgQInIxxnsSyRLFxdkJEJu8r4ngLR56q/4Q5LrbfkFRH27vMTjxEK8Pyz7QfzuZe/G15qQnVRA==", "dev": true, "license": "MIT", "engines": { @@ -907,16 +893,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.43.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.43.0.tgz", - "integrity": "sha512-7Vv6zlAhPb+cvEpP06WXXy/ZByph9iL6BQRBDj4kmBsW98AqEeQHlj/13X+sZOrKSo9/rNKH4Ul4f6EICREFdw==", + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.45.0.tgz", + "integrity": "sha512-GfE1NfVbLam6XQ0LcERKwdTTPlLvHvXXhOeUGC1OXi4eQBoyy1iVsW+uzJ/J9jtCz6/7GCQ9MtrQ0fml/jWCnA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.43.0", - "@typescript-eslint/tsconfig-utils": "8.43.0", - "@typescript-eslint/types": "8.43.0", - "@typescript-eslint/visitor-keys": "8.43.0", + "@typescript-eslint/project-service": "8.45.0", + "@typescript-eslint/tsconfig-utils": "8.45.0", + "@typescript-eslint/types": "8.45.0", + "@typescript-eslint/visitor-keys": "8.45.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -936,16 +922,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.43.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.43.0.tgz", - "integrity": "sha512-S1/tEmkUeeswxd0GGcnwuVQPFWo8NzZTOMxCvw8BX7OMxnNae+i8Tm7REQen/SwUIPoPqfKn7EaZ+YLpiB3k9g==", + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.45.0.tgz", + "integrity": "sha512-bxi1ht+tLYg4+XV2knz/F7RVhU0k6VrSMc9sb8DQ6fyCTrGQLHfo7lDtN0QJjZjKkLA2ThrKuCdHEvLReqtIGg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.43.0", - "@typescript-eslint/types": "8.43.0", - "@typescript-eslint/typescript-estree": "8.43.0" + "@typescript-eslint/scope-manager": "8.45.0", + "@typescript-eslint/types": "8.45.0", + "@typescript-eslint/typescript-estree": "8.45.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -960,13 +946,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.43.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.43.0.tgz", - "integrity": "sha512-T+S1KqRD4sg/bHfLwrpF/K3gQLBM1n7Rp7OjjikjTEssI2YJzQpi5WXoynOaQ93ERIuq3O8RBTOUYDKszUCEHw==", + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.45.0.tgz", + "integrity": "sha512-qsaFBA3e09MIDAGFUrTk+dzqtfv1XPVz8t8d1f0ybTzrCY7BKiMC5cjrl1O/P7UmHsNyW90EYSkU/ZWpmXelag==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.43.0", + "@typescript-eslint/types": "8.45.0", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -1761,9 +1747,9 @@ } }, "node_modules/chrome-devtools-frontend": { - "version": "1.0.1520535", - "resolved": "https://registry.npmjs.org/chrome-devtools-frontend/-/chrome-devtools-frontend-1.0.1520535.tgz", - "integrity": "sha512-kCjiu7ZQ3vLNQ5hmmQZRwwpGpQykJECWuDUc3nKjXOOsMEWgJJh+3OhG6vOrtEG5BxL9jWZoBvf1GG8ddNpkeg==", + "version": "1.0.1521880", + "resolved": "https://registry.npmjs.org/chrome-devtools-frontend/-/chrome-devtools-frontend-1.0.1521880.tgz", + "integrity": "sha512-43m+y3kaBebVPu2Ja9u2GsvPgWvpn10jF4l3zr8ElhnxRRx1DOi58PXJ6EzxbjbrS5uzTZ0sa0cKNov/KtV6mQ==", "dev": true, "license": "BSD-3-Clause" }, @@ -2408,9 +2394,9 @@ } }, "node_modules/eslint": { - "version": "9.35.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.35.0.tgz", - "integrity": "sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg==", + "version": "9.36.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.36.0.tgz", + "integrity": "sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2420,7 +2406,7 @@ "@eslint/config-helpers": "^0.3.1", "@eslint/core": "^0.15.2", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.35.0", + "@eslint/js": "9.36.0", "@eslint/plugin-kit": "^0.3.5", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -5759,16 +5745,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.43.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.43.0.tgz", - "integrity": "sha512-FyRGJKUGvcFekRRcBKFBlAhnp4Ng8rhe8tuvvkR9OiU0gfd4vyvTRQHEckO6VDlH57jbeUQem2IpqPq9kLJH+w==", + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.45.0.tgz", + "integrity": "sha512-qzDmZw/Z5beNLUrXfd0HIW6MzIaAV5WNDxmMs9/3ojGOpYavofgNAAD/nC6tGV2PczIi0iw8vot2eAe/sBn7zg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.43.0", - "@typescript-eslint/parser": "8.43.0", - "@typescript-eslint/typescript-estree": "8.43.0", - "@typescript-eslint/utils": "8.43.0" + "@typescript-eslint/eslint-plugin": "8.45.0", + "@typescript-eslint/parser": "8.45.0", + "@typescript-eslint/typescript-estree": "8.45.0", + "@typescript-eslint/utils": "8.45.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5802,9 +5788,9 @@ } }, "node_modules/undici-types": { - "version": "7.10.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", - "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==", + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.13.0.tgz", + "integrity": "sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ==", "devOptional": true, "license": "MIT" }, diff --git a/package.json b/package.json index 97f5f192..86f486db 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "chrome-devtools-mcp", - "version": "0.5.1", + "version": "0.6.0", "description": "MCP server for Chrome DevTools", "type": "module", "bin": "./build/src/index.js", @@ -53,7 +53,7 @@ "@types/yargs": "^17.0.33", "@typescript-eslint/eslint-plugin": "^8.43.0", "@typescript-eslint/parser": "^8.43.0", - "chrome-devtools-frontend": "1.0.1520535", + "chrome-devtools-frontend": "1.0.1521880", "eslint": "^9.35.0", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "^2.32.0", diff --git a/src/McpContext.ts b/src/McpContext.ts index 71308676..ce980769 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -302,7 +302,9 @@ export class McpContext implements Context { */ async createTextSnapshot(): Promise { const page = this.getSelectedPage(); - const rootNode = await page.accessibility.snapshot(); + const rootNode = await page.accessibility.snapshot({ + includeIframes: true, + }); if (!rootNode) { return; } @@ -338,17 +340,20 @@ export class McpContext implements Context { async saveTemporaryFile( data: Uint8Array, - mimeType: 'image/png' | 'image/jpeg', + mimeType: 'image/png' | 'image/jpeg' | 'image/webp', ): Promise<{filename: string}> { try { const dir = await fs.mkdtemp( path.join(os.tmpdir(), 'chrome-devtools-mcp-'), ); - const filename = path.join( - dir, - mimeType == 'image/png' ? `screenshot.png` : 'screenshot.jpg', - ); - await fs.writeFile(path.join(dir, `screenshot.png`), data); + const ext = + mimeType === 'image/png' + ? 'png' + : mimeType === 'image/jpeg' + ? 'jpg' + : 'webp'; + const filename = path.join(dir, `screenshot.${ext}`); + await fs.writeFile(filename, data); return {filename}; } catch (err) { this.logger(err); diff --git a/src/McpResponse.ts b/src/McpResponse.ts index 1d3d3ed7..77ab5e5a 100644 --- a/src/McpResponse.ts +++ b/src/McpResponse.ts @@ -154,7 +154,7 @@ export class McpResponse implements Response { response.push(`## Network emulation`); response.push(`Emulating: ${networkConditions}`); response.push( - `Navigation timeout set to ${context.getNavigationTimeout()} ms`, + `Default navigation timeout set to ${context.getNavigationTimeout()} ms`, ); } diff --git a/src/PageCollector.ts b/src/PageCollector.ts index cb7618bd..9b078d55 100644 --- a/src/PageCollector.ts +++ b/src/PageCollector.ts @@ -9,6 +9,11 @@ import type {Browser, HTTPRequest, Page} from 'puppeteer-core'; export class PageCollector { #browser: Browser; #initializer: (page: Page, collector: (item: T) => void) => void; + /** + * The Array in this map should only be set once + * As we use the reference to it. + * Use methods that manipulate the array in place. + */ protected storage = new WeakMap(); constructor( @@ -30,7 +35,6 @@ export class PageCollector { if (!page) { return; } - this.#initializePage(page); }); } @@ -44,6 +48,9 @@ export class PageCollector { return; } + const stored: T[] = []; + this.storage.set(page, stored); + page.on('framenavigated', frame => { // Only reset the storage on main frame navigation if (frame !== page.mainFrame()) { @@ -52,16 +59,16 @@ export class PageCollector { this.cleanup(page); }); this.#initializer(page, value => { - const stored = this.storage.get(page) ?? []; stored.push(value); - this.storage.set(page, stored); }); } protected cleanup(page: Page) { - const collection = this.storage.get(page) ?? []; - // Keep the reference alive - collection.length = 0; + const collection = this.storage.get(page); + if (collection) { + // Keep the reference alive + collection.length = 0; + } } getData(page: Page): T[] { @@ -72,6 +79,9 @@ export class PageCollector { export class NetworkCollector extends PageCollector { override cleanup(page: Page) { const requests = this.storage.get(page) ?? []; + if (!requests) { + return; + } const lastRequestIdx = requests.findLastIndex(request => { return request.frame() === page.mainFrame() ? request.isNavigationRequest() @@ -79,6 +89,7 @@ export class NetworkCollector extends PageCollector { }); // Keep all requests since the last navigation request including that // navigation request itself. - this.storage.set(page, requests.slice(Math.max(lastRequestIdx, 0))); + // Keep the reference + requests.splice(0, Math.max(lastRequestIdx, 0)); } } diff --git a/src/browser.ts b/src/browser.ts index 2a171209..1cbaa6da 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -44,7 +44,7 @@ const connectOptions: ConnectOptions = { protocolTimeout: 10_000, }; -async function ensureBrowserConnected(browserURL: string) { +export async function ensureBrowserConnected(browserURL: string) { if (browser?.connected) { return browser; } @@ -57,6 +57,7 @@ async function ensureBrowserConnected(browserURL: string) { } interface McpLaunchOptions { + acceptInsecureCerts?: boolean; executablePath?: string; customDevTools?: string; channel?: Channel; @@ -64,6 +65,11 @@ interface McpLaunchOptions { headless: boolean; isolated: boolean; logFile?: fs.WriteStream; + viewport?: { + width: number; + height: number; + }; + args?: string[]; } export async function launch(options: McpLaunchOptions): Promise { @@ -86,7 +92,10 @@ export async function launch(options: McpLaunchOptions): Promise { }); } - const args: LaunchOptions['args'] = ['--hide-crash-restore-bubble']; + const args: LaunchOptions['args'] = [ + ...(options.args ?? []), + '--hide-crash-restore-bubble', + ]; if (customDevTools) { args.push(`--custom-devtools-frontend=file://${customDevTools}`); } @@ -108,6 +117,7 @@ export async function launch(options: McpLaunchOptions): Promise { pipe: true, headless, args, + acceptInsecureCerts: options.acceptInsecureCerts, }); if (options.logFile) { // FIXME: we are probably subscribing too late to catch startup logs. We @@ -115,6 +125,14 @@ export async function launch(options: McpLaunchOptions): Promise { browser.process()?.stderr?.pipe(options.logFile); browser.process()?.stdout?.pipe(options.logFile); } + if (options.viewport) { + const [page] = await browser.pages(); + // @ts-expect-error internal API for now. + await page?.resize({ + contentWidth: options.viewport.width, + contentHeight: options.viewport.height, + }); + } return browser; } catch (error) { if ( @@ -134,7 +152,7 @@ export async function launch(options: McpLaunchOptions): Promise { } } -async function ensureBrowserLaunched( +export async function ensureBrowserLaunched( options: McpLaunchOptions, ): Promise { if (browser?.connected) { @@ -144,20 +162,4 @@ async function ensureBrowserLaunched( return browser; } -export async function resolveBrowser(options: { - browserUrl?: string; - executablePath?: string; - customDevTools?: string; - channel?: Channel; - headless: boolean; - isolated: boolean; - logFile?: fs.WriteStream; -}) { - const browser = options.browserUrl - ? await ensureBrowserConnected(options.browserUrl) - : await ensureBrowserLaunched(options); - - return browser; -} - export type Channel = 'stable' | 'canary' | 'beta' | 'dev'; diff --git a/src/cli.ts b/src/cli.ts index df07bfad..02ebf3d8 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,12 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type {Options as YargsOptions} from 'yargs'; import yargs from 'yargs'; import {hideBin} from 'yargs/helpers'; export const cliOptions = { browserUrl: { - type: 'string' as const, + type: 'string', description: 'Connect to a running Chrome instance using port forwarding. For more details see: https://developer.chrome.com/docs/devtools/remote-debugging/local-server.', alias: 'u', @@ -19,42 +20,68 @@ export const cliOptions = { }, }, headless: { - type: 'boolean' as const, + type: 'boolean', description: 'Whether to run in headless (no UI) mode.', default: false, }, executablePath: { - type: 'string' as const, + type: 'string', description: 'Path to custom Chrome executable.', conflicts: 'browserUrl', alias: 'e', }, isolated: { - type: 'boolean' as const, + type: 'boolean', description: 'If specified, creates a temporary user-data-dir that is automatically cleaned up after the browser is closed.', default: false, }, customDevtools: { - type: 'string' as const, + type: 'string', description: 'Path to custom DevTools.', hidden: true, conflicts: 'browserUrl', alias: 'd', }, channel: { - type: 'string' as const, + type: 'string', description: 'Specify a different Chrome channel that should be used. The default is the stable channel version.', choices: ['stable', 'canary', 'beta', 'dev'] as const, conflicts: ['browserUrl', 'executablePath'], }, logFile: { - type: 'string' as const, + type: 'string', describe: 'Path to a file to write debug logs to. Set the env variable `DEBUG` to `*` to enable verbose logs. Useful for submitting bug reports.', }, -}; + viewport: { + type: 'string', + describe: + 'Initial viewport size for the Chromee instances started by the server. For example, `1280x720`', + coerce: (arg: string | undefined) => { + if (arg === undefined) { + return; + } + const [width, height] = arg.split('x').map(Number); + if (!width || !height || Number.isNaN(width) || Number.isNaN(height)) { + throw new Error('Invalid viewport. Expected format is `1280x720`.'); + } + return { + width, + height, + }; + }, + }, + proxyServer: { + type: 'string', + description: `Proxy server configuration for Chrome passed as --proxy-server when launching the browser. See https://www.chromium.org/developers/design-documents/network-settings/ for details.`, + }, + acceptInsecureCerts: { + type: 'boolean', + description: `If enabled, ignores errors relative to self-signed and expired certificates. Use with caution.`, + }, +} satisfies Record; export function parseArguments(version: string, argv = process.argv) { const yargsInstance = yargs(hideBin(argv)) @@ -79,6 +106,10 @@ export function parseArguments(version: string, argv = process.argv) { ['$0 --channel stable', 'Use stable Chrome installed on this system'], ['$0 --logFile /tmp/log.txt', 'Save logs to a file'], ['$0 --help', 'Print CLI options'], + [ + '$0 --viewport 1280x720', + 'Launch Chrome with the initial viewport size of 1280x720px', + ], ]); return yargsInstance diff --git a/src/logger.ts b/src/logger.ts index f04b9c4d..f939e4cd 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -18,12 +18,12 @@ export function saveLogsToFile(fileName: string): fs.WriteStream { // Enable overrides everything so we need to add them debug.enable(namespacesToEnable.join(',')); - const logFile = fs.createWriteStream(fileName, {flags: 'a'}); + const logFile = fs.createWriteStream(fileName, {flags: 'a+'}); debug.log = function (...chunks: any[]) { logFile.write(`${chunks.join(' ')}\n`); }; logFile.on('error', function (error) { - console.log(`Error when opening/writing to log file: ${error.message}`); + console.error(`Error when opening/writing to log file: ${error.message}`); logFile.end(); process.exit(1); }); diff --git a/src/main.ts b/src/main.ts index 6add9a90..2663d3c1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -16,7 +16,7 @@ import type {CallToolResult} from '@modelcontextprotocol/sdk/types.js'; import {SetLevelRequestSchema} from '@modelcontextprotocol/sdk/types.js'; import type {Channel} from './browser.js'; -import {resolveBrowser} from './browser.js'; +import {ensureBrowserConnected, ensureBrowserLaunched} from './browser.js'; import {parseArguments} from './cli.js'; import {logger, saveLogsToFile} from './logger.js'; import {McpContext} from './McpContext.js'; @@ -69,15 +69,24 @@ server.server.setRequestHandler(SetLevelRequestSchema, () => { let context: McpContext; async function getContext(): Promise { - const browser = await resolveBrowser({ - browserUrl: args.browserUrl, - headless: args.headless, - executablePath: args.executablePath, - customDevTools: args.customDevtools, - channel: args.channel as Channel, - isolated: args.isolated, - logFile, - }); + const extraArgs: string[] = []; + if (args.proxyServer) { + extraArgs.push(`--proxy-server=${args.proxyServer}`); + } + const browser = args.browserUrl + ? await ensureBrowserConnected(args.browserUrl) + : await ensureBrowserLaunched({ + headless: args.headless, + executablePath: args.executablePath, + customDevTools: args.customDevtools, + channel: args.channel as Channel, + isolated: args.isolated, + logFile, + viewport: args.viewport, + args: extraArgs, + acceptInsecureCerts: args.acceptInsecureCerts, + }); + if (context?.browser !== browser) { context = await McpContext.from(browser, logger); } diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index a0741f4c..35718e54 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -5,7 +5,7 @@ */ import type {Dialog, ElementHandle, Page} from 'puppeteer-core'; -import type z from 'zod'; +import z from 'zod'; import type {TraceResult} from '../trace-processing/parse.js'; @@ -72,7 +72,7 @@ export type Context = Readonly<{ setCpuThrottlingRate(rate: number): void; saveTemporaryFile( data: Uint8Array, - mimeType: 'image/png' | 'image/jpeg', + mimeType: 'image/png' | 'image/jpeg' | 'image/webp', ): Promise<{filename: string}>; waitForEventsAfterAction(action: () => Promise): Promise; }>; @@ -85,3 +85,16 @@ export function defineTool( export const CLOSE_PAGE_ERROR = 'The last open page cannot be closed. It is fine to keep it open.'; + +export const timeoutSchema = { + timeout: z + .number() + .int() + .optional() + .describe( + `Maximum wait time in milliseconds. If set to 0, the default timeout will be used.`, + ) + .transform(value => { + return value && value <= 0 ? undefined : value; + }), +}; diff --git a/src/tools/input.ts b/src/tools/input.ts index 541e9414..eda04e80 100644 --- a/src/tools/input.ts +++ b/src/tools/input.ts @@ -197,7 +197,7 @@ export const uploadFile = defineTool({ // a type=file element. In this case, we want to default to // Page.waitForFileChooser() and upload the file this way. try { - const page = await context.getSelectedPage(); + const page = context.getSelectedPage(); const [fileChooser] = await Promise.all([ page.waitForFileChooser({timeout: 3000}), handle.asLocator().click(), diff --git a/src/tools/network.ts b/src/tools/network.ts index 6ff2bb92..5943b0f5 100644 --- a/src/tools/network.ts +++ b/src/tools/network.ts @@ -84,6 +84,5 @@ export const getNetworkRequest = defineTool({ }, handler: async (request, response, _context) => { response.attachNetworkRequest(request.params.url); - response.setIncludeNetworkRequests(true); }, }); diff --git a/src/tools/pages.ts b/src/tools/pages.ts index c9962e7c..65d2b093 100644 --- a/src/tools/pages.ts +++ b/src/tools/pages.ts @@ -9,7 +9,7 @@ import z from 'zod'; import {logger} from '../logger.js'; import {ToolCategories} from './categories.js'; -import {CLOSE_PAGE_ERROR, defineTool} from './ToolDefinition.js'; +import {CLOSE_PAGE_ERROR, defineTool, timeoutSchema} from './ToolDefinition.js'; export const listPages = defineTool({ name: 'list_pages', @@ -83,12 +83,15 @@ export const newPage = defineTool({ }, schema: { url: z.string().describe('URL to load in a new page.'), + ...timeoutSchema, }, handler: async (request, response, context) => { const page = await context.newPage(); await context.waitForEventsAfterAction(async () => { - await page.goto(request.params.url); + await page.goto(request.params.url, { + timeout: request.params.timeout, + }); }); response.setIncludePages(true); @@ -104,12 +107,15 @@ export const navigatePage = defineTool({ }, schema: { url: z.string().describe('URL to navigate the page to'), + ...timeoutSchema, }, handler: async (request, response, context) => { const page = context.getSelectedPage(); await context.waitForEventsAfterAction(async () => { - await page.goto(request.params.url); + await page.goto(request.params.url, { + timeout: request.params.timeout, + }); }); response.setIncludePages(true); @@ -129,15 +135,18 @@ export const navigatePageHistory = defineTool({ .describe( 'Whether to navigate back or navigate forward in the selected pages history', ), + ...timeoutSchema, }, handler: async (request, response, context) => { const page = context.getSelectedPage(); - + const options = { + timeout: request.params.timeout, + }; try { if (request.params.navigate === 'back') { - await page.goBack(); + await page.goBack(options); } else { - await page.goForward(); + await page.goForward(options); } } catch { response.appendResponseLine( diff --git a/src/tools/screenshot.ts b/src/tools/screenshot.ts index ae1f98bf..1a24eff8 100644 --- a/src/tools/screenshot.ts +++ b/src/tools/screenshot.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +import {writeFile} from 'node:fs/promises'; + import type {ElementHandle, Page} from 'puppeteer-core'; import z from 'zod'; @@ -19,7 +21,7 @@ export const screenshot = defineTool({ }, schema: { format: z - .enum(['png', 'jpeg']) + .enum(['png', 'jpeg', 'webp']) .default('png') .describe('Type of format to save the screenshot as. Default is "png"'), quality: z @@ -28,7 +30,7 @@ export const screenshot = defineTool({ .max(100) .optional() .describe( - 'Compression quality for JPEG format (0-100). Higher values mean better quality but larger file sizes. Ignored for PNG format.', + 'Compression quality for JPEG and WebP formats (0-100). Higher values mean better quality but larger file sizes. Ignored for PNG format.', ), uid: z .string() @@ -42,6 +44,12 @@ export const screenshot = defineTool({ .describe( 'If set to true takes a screenshot of the full page instead of the currently visible viewport. Incompatible with uid.', ), + filePath: z + .string() + .optional() + .describe( + 'The absolute path, or a path relative to the current working directory, to save the screenshot to instead of attaching it to the response.', + ), }, handler: async (request, response, context) => { if (request.params.uid && request.params.fullPage) { @@ -76,7 +84,12 @@ export const screenshot = defineTool({ ); } - if (screenshot.length >= 2_000_000) { + if (request.params.filePath) { + await writeFile(request.params.filePath, screenshot); + response.appendResponseLine( + `Saved screenshot to ${request.params.filePath}.`, + ); + } else if (screenshot.length >= 2_000_000) { const {filename} = await context.saveTemporaryFile( screenshot, `image/${request.params.format}`, diff --git a/src/tools/snapshot.ts b/src/tools/snapshot.ts index f84d6d44..427e4f79 100644 --- a/src/tools/snapshot.ts +++ b/src/tools/snapshot.ts @@ -8,7 +8,7 @@ import {Locator} from 'puppeteer-core'; import z from 'zod'; import {ToolCategories} from './categories.js'; -import {defineTool} from './ToolDefinition.js'; +import {defineTool, timeoutSchema} from './ToolDefinition.js'; export const takeSnapshot = defineTool({ name: 'take_snapshot', @@ -33,14 +33,24 @@ export const waitFor = defineTool({ }, schema: { text: z.string().describe('Text to appear on the page'), + ...timeoutSchema, }, handler: async (request, response, context) => { const page = context.getSelectedPage(); + const frames = page.frames(); - await Locator.race([ - page.locator(`aria/${request.params.text}`), - page.locator(`text/${request.params.text}`), - ]).wait(); + const locator = Locator.race( + frames.flatMap(frame => [ + frame.locator(`aria/${request.params.text}`), + frame.locator(`text/${request.params.text}`), + ]), + ); + + if (request.params.timeout) { + locator.setTimeout(request.params.timeout); + } + + await locator.wait(); response.appendResponseLine( `Element with text "${request.params.text}" found.`, diff --git a/tests/McpResponse.test.ts b/tests/McpResponse.test.ts index 43f70eb3..87529102 100644 --- a/tests/McpResponse.test.ts +++ b/tests/McpResponse.test.ts @@ -105,7 +105,7 @@ uid=1_0 RootWebArea "My test page" `# test response ## Network emulation Emulating: Slow 3G -Navigation timeout set to 100000 ms`, +Default navigation timeout set to 100000 ms`, ); }); }); diff --git a/tests/PageCollector.test.ts b/tests/PageCollector.test.ts index ce61ac8e..0e8248ce 100644 --- a/tests/PageCollector.test.ts +++ b/tests/PageCollector.test.ts @@ -6,25 +6,37 @@ import assert from 'node:assert'; import {describe, it} from 'node:test'; -import type {Browser, Frame, Page, PageEvents} from 'puppeteer-core'; +import type {Browser, Frame, Page, Target} from 'puppeteer-core'; import {PageCollector} from '../src/PageCollector.js'; import {getMockRequest} from './utils.js'; -function getMockPage(): Page { - const listeners: Record void> = {}; - const mainFrame = {} as Frame; +function mockListener() { + const listeners: Record void>> = {}; return { - on(eventName, listener) { - listeners[eventName] = listener; + on(eventName: string, listener: (data: unknown) => void) { + if (listeners[eventName]) { + listeners[eventName].push(listener); + } else { + listeners[eventName] = [listener]; + } }, - emit(eventName, data) { - listeners[eventName]?.(data); + emit(eventName: string, data: unknown) { + for (const listener of listeners[eventName] ?? []) { + listener(data); + } }, + }; +} + +function getMockPage(): Page { + const mainFrame = {} as Frame; + return { mainFrame() { return mainFrame; }, + ...mockListener(), } as Page; } @@ -34,9 +46,7 @@ function getMockBrowser(): Browser { pages() { return Promise.resolve(pages); }, - on(_type, _handler) { - // Mock - }, + ...mockListener(), } as Browser; } @@ -113,4 +123,34 @@ describe('PageCollector', () => { assert.equal(collector.getData(page).length, 1); }); + + it('should only subscribe once', async () => { + const browser = getMockBrowser(); + const page = (await browser.pages())[0]; + const request = getMockRequest(); + const collector = new PageCollector(browser, (pageListener, collect) => { + pageListener.on('request', req => { + collect(req); + }); + }); + await collector.init(); + browser.emit('targetcreated', { + page() { + return Promise.resolve(page); + }, + } as Target); + + // The page inside part is async so we need to await some time + await new Promise(res => res()); + + assert.equal(collector.getData(page).length, 0); + + page.emit('request', request); + + assert.equal(collector.getData(page).length, 1); + + page.emit('request', request); + + assert.equal(collector.getData(page).length, 2); + }); }); diff --git a/tests/browser.test.ts b/tests/browser.test.ts index 139a3b59..3dc8eff0 100644 --- a/tests/browser.test.ts +++ b/tests/browser.test.ts @@ -42,4 +42,31 @@ describe('browser', () => { await browser1.close(); } }); + + it('launches with the initial viewport', async () => { + const tmpDir = os.tmpdir(); + const folderPath = path.join(tmpDir, `temp-folder-${crypto.randomUUID()}`); + const browser = await launch({ + headless: true, + isolated: false, + userDataDir: folderPath, + executablePath: executablePath(), + viewport: { + width: 700, + height: 500, + }, + }); + try { + const [page] = await browser.pages(); + const result = await page.evaluate(() => { + return {width: window.innerWidth, height: window.innerHeight}; + }); + assert.deepStrictEqual(result, { + width: 700, + height: 500, + }); + } finally { + await browser.close(); + } + }); }); diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 35088622..014faf95 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -55,4 +55,24 @@ describe('cli args parsing', () => { executablePath: '/tmp/test 123/chrome', }); }); + + it('parses viewport', async () => { + const args = parseArguments('1.0.0', [ + 'node', + 'main.js', + '--viewport', + '888x777', + ]); + assert.deepStrictEqual(args, { + _: [], + headless: false, + isolated: false, + $0: 'npx chrome-devtools-mcp@latest', + channel: 'stable', + viewport: { + width: 888, + height: 777, + }, + }); + }); }); diff --git a/tests/tools/network.test.ts b/tests/tools/network.test.ts index 4dc21e49..c53cbc1d 100644 --- a/tests/tools/network.test.ts +++ b/tests/tools/network.test.ts @@ -38,5 +38,17 @@ describe('network', () => { ); }); }); + it('should not add the request list', async () => { + await withBrowser(async (response, context) => { + const page = await context.getSelectedPage(); + await page.goto('data:text/html,
Hello MCP
'); + await getNetworkRequest.handler( + {params: {url: 'data:text/html,
Hello MCP
'}}, + response, + context, + ); + assert(!response.includeNetworkRequests); + }); + }); }); }); diff --git a/tests/tools/screenshot.test.ts b/tests/tools/screenshot.test.ts index e6cdda17..d369f2ca 100644 --- a/tests/tools/screenshot.test.ts +++ b/tests/tools/screenshot.test.ts @@ -4,6 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ import assert from 'node:assert'; +import {rm, stat, mkdir, chmod, writeFile} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; import {describe, it} from 'node:test'; import {screenshot} from '../../src/tools/screenshot.js'; @@ -39,6 +42,18 @@ describe('screenshot', () => { ); }); }); + it('with webp', async () => { + await withBrowser(async (response, context) => { + await screenshot.handler({params: {format: 'webp'}}, response, context); + + assert.equal(response.images.length, 1); + assert.equal(response.images[0].mimeType, 'image/webp'); + assert.equal( + response.responseLines.at(0), + "Took a screenshot of the current page's viewport.", + ); + }); + }); it('with full page', async () => { await withBrowser(async (response, context) => { const fixture = screenshots.viewportOverflow; @@ -108,5 +123,111 @@ describe('screenshot', () => { ); }); }); + + it('with filePath', async () => { + await withBrowser(async (response, context) => { + const filePath = join(tmpdir(), 'test-screenshot.png'); + try { + const fixture = screenshots.basic; + const page = context.getSelectedPage(); + await page.setContent(fixture.html); + await screenshot.handler( + {params: {format: 'png', filePath}}, + response, + context, + ); + + assert.equal(response.images.length, 0); + assert.equal( + response.responseLines.at(0), + "Took a screenshot of the current page's viewport.", + ); + assert.equal( + response.responseLines.at(1), + `Saved screenshot to ${filePath}.`, + ); + + const stats = await stat(filePath); + assert.ok(stats.isFile()); + assert.ok(stats.size > 0); + } finally { + await rm(filePath, {force: true}); + } + }); + }); + + it('with unwritable filePath', async () => { + if (process.platform === 'win32') { + const filePath = join( + tmpdir(), + 'readonly-file-for-screenshot-test.png', + ); + // Create the file and make it read-only. + await writeFile(filePath, ''); + await chmod(filePath, 0o400); + + try { + await withBrowser(async (response, context) => { + const fixture = screenshots.basic; + const page = context.getSelectedPage(); + await page.setContent(fixture.html); + await assert.rejects( + screenshot.handler( + {params: {format: 'png', filePath}}, + response, + context, + ), + ); + }); + } finally { + // Make the file writable again so it can be deleted. + await chmod(filePath, 0o600); + await rm(filePath, {force: true}); + } + } else { + const dir = join(tmpdir(), 'readonly-dir-for-screenshot-test'); + await mkdir(dir, {recursive: true}); + await chmod(dir, 0o500); + const filePath = join(dir, 'test-screenshot.png'); + + try { + await withBrowser(async (response, context) => { + const fixture = screenshots.basic; + const page = context.getSelectedPage(); + await page.setContent(fixture.html); + await assert.rejects( + screenshot.handler( + {params: {format: 'png', filePath}}, + response, + context, + ), + ); + }); + } finally { + await chmod(dir, 0o700); + await rm(dir, {recursive: true, force: true}); + } + } + }); + + it('with malformed filePath', async () => { + await withBrowser(async (response, context) => { + // Use a platform-specific invalid character. + // On Windows, characters like '<', '>', ':', '"', '/', '\', '|', '?', '*' are invalid. + // On POSIX, the null byte is invalid. + const invalidChar = process.platform === 'win32' ? '>' : '\0'; + const filePath = `malformed${invalidChar}path.png`; + const fixture = screenshots.basic; + const page = context.getSelectedPage(); + await page.setContent(fixture.html); + await assert.rejects( + screenshot.handler( + {params: {format: 'png', filePath}}, + response, + context, + ), + ); + }); + }); }); }); diff --git a/tests/tools/snapshot.test.ts b/tests/tools/snapshot.test.ts index a44a0bf2..31857ff5 100644 --- a/tests/tools/snapshot.test.ts +++ b/tests/tools/snapshot.test.ts @@ -95,5 +95,32 @@ describe('snapshot', () => { assert.ok(response.includeSnapshot); }); }); + + it('should work with iframe content', async () => { + await withBrowser(async (response, context) => { + const page = await context.getSelectedPage(); + + await page.setContent( + html`

Top level

+ `, + ); + + await waitFor.handler( + { + params: { + text: 'Hello iframe', + }, + }, + response, + context, + ); + + assert.equal( + response.responseLines[0], + 'Element with text "Hello iframe" found.', + ); + assert.ok(response.includeSnapshot); + }); + }); }); });