refactor: 统一四个预警历史页面布局结构
- blood-oxygen-alerts.html:更新 stats-card 结构,添加最近预警时间显示 - temperature-alerts.html:完全重构为标准格式,采用与 heart-rate-alerts.html 相同的结构 - stress-alerts.html:已符合标准格式 - 四个页面现采用统一的 stats-card + alert-card 布局,保持各自的颜色主题 - 新增 health-monitor 模块 CHANGELOG.md 文档 Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
+16
@@ -0,0 +1,16 @@
|
||||
#!/bin/sh
|
||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*|*MINGW*|*MSYS*)
|
||||
if command -v cygpath > /dev/null 2>&1; then
|
||||
basedir=`cygpath -w "$basedir"`
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../chrome-devtools-mcp/build/src/bin/chrome-devtools.js" "$@"
|
||||
else
|
||||
exec node "$basedir/../chrome-devtools-mcp/build/src/bin/chrome-devtools.js" "$@"
|
||||
fi
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
#!/bin/sh
|
||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*|*MINGW*|*MSYS*)
|
||||
if command -v cygpath > /dev/null 2>&1; then
|
||||
basedir=`cygpath -w "$basedir"`
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../chrome-devtools-mcp/build/src/bin/chrome-devtools-mcp.js" "$@"
|
||||
else
|
||||
exec node "$basedir/../chrome-devtools-mcp/build/src/bin/chrome-devtools-mcp.js" "$@"
|
||||
fi
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
@ECHO off
|
||||
GOTO start
|
||||
:find_dp0
|
||||
SET dp0=%~dp0
|
||||
EXIT /b
|
||||
:start
|
||||
SETLOCAL
|
||||
CALL :find_dp0
|
||||
|
||||
IF EXIST "%dp0%\node.exe" (
|
||||
SET "_prog=%dp0%\node.exe"
|
||||
) ELSE (
|
||||
SET "_prog=node"
|
||||
SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||
)
|
||||
|
||||
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\chrome-devtools-mcp\build\src\bin\chrome-devtools-mcp.js" %*
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env pwsh
|
||||
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
|
||||
|
||||
$exe=""
|
||||
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
||||
# Fix case when both the Windows and Linux builds of Node
|
||||
# are installed in the same directory
|
||||
$exe=".exe"
|
||||
}
|
||||
$ret=0
|
||||
if (Test-Path "$basedir/node$exe") {
|
||||
# Support pipeline input
|
||||
if ($MyInvocation.ExpectingInput) {
|
||||
$input | & "$basedir/node$exe" "$basedir/../chrome-devtools-mcp/build/src/bin/chrome-devtools-mcp.js" $args
|
||||
} else {
|
||||
& "$basedir/node$exe" "$basedir/../chrome-devtools-mcp/build/src/bin/chrome-devtools-mcp.js" $args
|
||||
}
|
||||
$ret=$LASTEXITCODE
|
||||
} else {
|
||||
# Support pipeline input
|
||||
if ($MyInvocation.ExpectingInput) {
|
||||
$input | & "node$exe" "$basedir/../chrome-devtools-mcp/build/src/bin/chrome-devtools-mcp.js" $args
|
||||
} else {
|
||||
& "node$exe" "$basedir/../chrome-devtools-mcp/build/src/bin/chrome-devtools-mcp.js" $args
|
||||
}
|
||||
$ret=$LASTEXITCODE
|
||||
}
|
||||
exit $ret
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
@ECHO off
|
||||
GOTO start
|
||||
:find_dp0
|
||||
SET dp0=%~dp0
|
||||
EXIT /b
|
||||
:start
|
||||
SETLOCAL
|
||||
CALL :find_dp0
|
||||
|
||||
IF EXIST "%dp0%\node.exe" (
|
||||
SET "_prog=%dp0%\node.exe"
|
||||
) ELSE (
|
||||
SET "_prog=node"
|
||||
SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||
)
|
||||
|
||||
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\chrome-devtools-mcp\build\src\bin\chrome-devtools.js" %*
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env pwsh
|
||||
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
|
||||
|
||||
$exe=""
|
||||
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
||||
# Fix case when both the Windows and Linux builds of Node
|
||||
# are installed in the same directory
|
||||
$exe=".exe"
|
||||
}
|
||||
$ret=0
|
||||
if (Test-Path "$basedir/node$exe") {
|
||||
# Support pipeline input
|
||||
if ($MyInvocation.ExpectingInput) {
|
||||
$input | & "$basedir/node$exe" "$basedir/../chrome-devtools-mcp/build/src/bin/chrome-devtools.js" $args
|
||||
} else {
|
||||
& "$basedir/node$exe" "$basedir/../chrome-devtools-mcp/build/src/bin/chrome-devtools.js" $args
|
||||
}
|
||||
$ret=$LASTEXITCODE
|
||||
} else {
|
||||
# Support pipeline input
|
||||
if ($MyInvocation.ExpectingInput) {
|
||||
$input | & "node$exe" "$basedir/../chrome-devtools-mcp/build/src/bin/chrome-devtools.js" $args
|
||||
} else {
|
||||
& "node$exe" "$basedir/../chrome-devtools-mcp/build/src/bin/chrome-devtools.js" $args
|
||||
}
|
||||
$ret=$LASTEXITCODE
|
||||
}
|
||||
exit $ret
|
||||
+15
@@ -3,10 +3,24 @@
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"node_modules/chrome-devtools-mcp": {
|
||||
"version": "0.21.0",
|
||||
"resolved": "https://registry.npmjs.org/chrome-devtools-mcp/-/chrome-devtools-mcp-0.21.0.tgz",
|
||||
"integrity": "sha512-d+iqrRmcwpRFV3Q4DRCF2LCoq+WCRU3GhISKQ9v8g+1C2Uh8upj3urkjxNO4QIjhBMIYei/VQ1OQLFceby80Og==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"chrome-devtools": "build/src/bin/chrome-devtools.js",
|
||||
"chrome-devtools-mcp": "build/src/bin/chrome-devtools-mcp.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=23"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
|
||||
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.59.1"
|
||||
@@ -25,6 +39,7 @@
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
|
||||
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
+770
@@ -0,0 +1,770 @@
|
||||
# Chrome DevTools MCP
|
||||
|
||||
[](https://npmjs.org/package/chrome-devtools-mcp)
|
||||
|
||||
`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.
|
||||
|
||||
## [Tool reference](./docs/tool-reference.md) | [Changelog](./CHANGELOG.md) | [Contributing](./CONTRIBUTING.md) | [Troubleshooting](./docs/troubleshooting.md) | [Design Principles](./docs/design-principles.md)
|
||||
|
||||
## Key features
|
||||
|
||||
- **Get performance insights**: Uses [Chrome
|
||||
DevTools](https://github.com/ChromeDevTools/devtools-frontend) to record
|
||||
traces and extract actionable performance insights.
|
||||
- **Advanced browser debugging**: Analyze network requests, take screenshots and
|
||||
check browser console messages (with source-mapped stack traces).
|
||||
- **Reliable automation**. Uses
|
||||
[puppeteer](https://github.com/puppeteer/puppeteer) to automate actions in
|
||||
Chrome and automatically wait for action results.
|
||||
|
||||
## Disclaimers
|
||||
|
||||
`chrome-devtools-mcp` exposes content of the browser instance to the MCP clients
|
||||
allowing them to inspect, debug, and modify any data in the browser or DevTools.
|
||||
Avoid sharing sensitive or personal information that you don't want to share with
|
||||
MCP clients.
|
||||
|
||||
`chrome-devtools-mcp` officially supports Google Chrome and [Chrome for Testing](https://developer.chrome.com/blog/chrome-for-testing/) only.
|
||||
Other Chromium-based browser may work, but this is not guaranteed, and you may encounter unexpected behavior. Use at your own discretion.
|
||||
We are committed to providing fixes and support for the latest version of [Extended Stable Chrome](https://chromiumdash.appspot.com/schedule).
|
||||
|
||||
Performance tools may send trace URLs to the Google CrUX API to fetch real-user
|
||||
experience data. This helps provide a holistic performance picture by
|
||||
presenting field data alongside lab data. This data is collected by the [Chrome
|
||||
User Experience Report (CrUX)](https://developer.chrome.com/docs/crux). To disable
|
||||
this, run with the `--no-performance-crux` flag.
|
||||
|
||||
## **Usage statistics**
|
||||
|
||||
Google collects usage statistics (such as tool invocation success rates, latency, and environment information) to improve the reliability and performance of Chrome DevTools MCP.
|
||||
|
||||
Data collection is **enabled by default**. You can opt-out by passing the `--no-usage-statistics` flag when starting the server:
|
||||
|
||||
```json
|
||||
"args": ["-y", "chrome-devtools-mcp@latest", "--no-usage-statistics"]
|
||||
```
|
||||
|
||||
Google handles this data in accordance with the [Google Privacy Policy](https://policies.google.com/privacy).
|
||||
|
||||
Google's collection of usage statistics for Chrome DevTools MCP is independent from the Chrome browser's usage statistics. Opting out of Chrome metrics does not automatically opt you out of this tool, and vice-versa.
|
||||
|
||||
Collection is disabled if CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS or CI env variables are set.
|
||||
|
||||
## Requirements
|
||||
|
||||
- [Node.js](https://nodejs.org/) v20.19 or a newer [latest maintenance LTS](https://github.com/nodejs/Release#release-schedule) version.
|
||||
- [Chrome](https://www.google.com/chrome/) current stable version or newer.
|
||||
- [npm](https://www.npmjs.com/)
|
||||
|
||||
## Getting started
|
||||
|
||||
Add the following config to your MCP client:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"chrome-devtools": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "chrome-devtools-mcp@latest"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> Using `chrome-devtools-mcp@latest` ensures that your MCP client will always use the latest version of the Chrome DevTools MCP server.
|
||||
|
||||
If you are interested in doing only basic browser tasks, use the `--slim` mode:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"chrome-devtools": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "chrome-devtools-mcp@latest", "--slim", "--headless"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See [Slim tool reference](./docs/slim-tool-reference.md).
|
||||
|
||||
### MCP Client configuration
|
||||
|
||||
<details>
|
||||
<summary>Amp</summary>
|
||||
Follow https://ampcode.com/manual#mcp and use the config provided above. You can also install the Chrome DevTools MCP server using the CLI:
|
||||
|
||||
```bash
|
||||
amp mcp add chrome-devtools -- npx chrome-devtools-mcp@latest
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Antigravity</summary>
|
||||
|
||||
To use the Chrome DevTools MCP server follow the instructions from <a href="https://antigravity.google/docs/mcp">Antigravity's docs</a> to install a custom MCP server. Add the following config to the MCP servers config:
|
||||
|
||||
```bash
|
||||
{
|
||||
"mcpServers": {
|
||||
"chrome-devtools": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"chrome-devtools-mcp@latest",
|
||||
"--browser-url=http://127.0.0.1:9222",
|
||||
"-y"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This will make the Chrome DevTools MCP server automatically connect to the browser that Antigravity is using. If you are not using port 9222, make sure to adjust accordingly.
|
||||
|
||||
Chrome DevTools MCP will not start the browser instance automatically using this approach because the Chrome DevTools MCP server connects to Antigravity's built-in browser. If the browser is not already running, you have to start it first by clicking the Chrome icon at the top right corner.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Claude Code</summary>
|
||||
|
||||
**Install via CLI (MCP only)**
|
||||
|
||||
Use the Claude Code CLI to add the Chrome DevTools MCP server (<a href="https://code.claude.com/docs/en/mcp">guide</a>):
|
||||
|
||||
```bash
|
||||
claude mcp add chrome-devtools --scope user npx chrome-devtools-mcp@latest
|
||||
```
|
||||
|
||||
**Install as a Plugin (MCP + Skills)**
|
||||
|
||||
> [!NOTE]
|
||||
> If you already had Chrome DevTools MCP installed previously for Claude Code, make sure to remove it first from your installation and configuration files.
|
||||
|
||||
To install Chrome DevTools MCP with skills, add the marketplace registry in Claude Code:
|
||||
|
||||
```sh
|
||||
/plugin marketplace add ChromeDevTools/chrome-devtools-mcp
|
||||
```
|
||||
|
||||
Then, install the plugin:
|
||||
|
||||
```sh
|
||||
/plugin install chrome-devtools-mcp
|
||||
```
|
||||
|
||||
Restart Claude Code to have the MCP server and skills load (check with `/skills`).
|
||||
|
||||
> [!TIP]
|
||||
> If the plugin installation fails with a `Failed to clone repository` error (e.g., HTTPS connectivity issues behind a corporate firewall), see the [troubleshooting guide](./docs/troubleshooting.md#claude-code-plugin-installation-fails-with-failed-to-clone-repository) for workarounds, or use the CLI installation method above instead.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Cline</summary>
|
||||
Follow https://docs.cline.bot/mcp/configuring-mcp-servers and use the config provided above.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Codex</summary>
|
||||
Follow the <a href="https://developers.openai.com/codex/mcp/#configure-with-the-cli">configure MCP guide</a>
|
||||
using the standard config from above. You can also install the Chrome DevTools MCP server using the Codex CLI:
|
||||
|
||||
```bash
|
||||
codex mcp add chrome-devtools -- npx chrome-devtools-mcp@latest
|
||||
```
|
||||
|
||||
**On Windows 11**
|
||||
|
||||
Configure the Chrome install location and increase the startup timeout by updating `.codex/config.toml` and adding the following `env` and `startup_timeout_ms` parameters:
|
||||
|
||||
```
|
||||
[mcp_servers.chrome-devtools]
|
||||
command = "cmd"
|
||||
args = [
|
||||
"/c",
|
||||
"npx",
|
||||
"-y",
|
||||
"chrome-devtools-mcp@latest",
|
||||
]
|
||||
env = { SystemRoot="C:\\Windows", PROGRAMFILES="C:\\Program Files" }
|
||||
startup_timeout_ms = 20_000
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Command Code</summary>
|
||||
|
||||
Use the Command Code CLI to add the Chrome DevTools MCP server (<a href="https://commandcode.ai/docs/mcp">MCP guide</a>):
|
||||
|
||||
```bash
|
||||
cmd mcp add chrome-devtools --scope user npx chrome-devtools-mcp@latest
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Copilot CLI</summary>
|
||||
|
||||
Start Copilot CLI:
|
||||
|
||||
```
|
||||
copilot
|
||||
```
|
||||
|
||||
Start the dialog to add a new MCP server by running:
|
||||
|
||||
```
|
||||
/mcp add
|
||||
```
|
||||
|
||||
Configure the following fields and press `CTRL+S` to save the configuration:
|
||||
|
||||
- **Server name:** `chrome-devtools`
|
||||
- **Server Type:** `[1] Local`
|
||||
- **Command:** `npx -y chrome-devtools-mcp@latest`
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Copilot / VS Code</summary>
|
||||
|
||||
**Click the button to install:**
|
||||
|
||||
[<img src="https://img.shields.io/badge/VS_Code-VS_Code?style=flat-square&label=Install%20Server&color=0098FF" alt="Install in VS Code">](https://vscode.dev/redirect/mcp/install?name=io.github.ChromeDevTools%2Fchrome-devtools-mcp&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22chrome-devtools-mcp%22%5D%2C%22env%22%3A%7B%7D%7D)
|
||||
|
||||
[<img src="https://img.shields.io/badge/VS_Code_Insiders-VS_Code_Insiders?style=flat-square&label=Install%20Server&color=24bfa5" alt="Install in VS Code Insiders">](https://insiders.vscode.dev/redirect?url=vscode-insiders%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522io.github.ChromeDevTools%252Fchrome-devtools-mcp%2522%252C%2522config%2522%253A%257B%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522-y%2522%252C%2522chrome-devtools-mcp%2522%255D%252C%2522env%2522%253A%257B%257D%257D%257D)
|
||||
|
||||
**Or install manually:**
|
||||
|
||||
Follow the MCP install <a href="https://code.visualstudio.com/docs/copilot/chat/mcp-servers#_add-an-mcp-server">guide</a>,
|
||||
with the standard config from above. You can also install the Chrome DevTools MCP server using the VS Code CLI:
|
||||
|
||||
For macOS and Linux:
|
||||
|
||||
```bash
|
||||
code --add-mcp '{"name":"io.github.ChromeDevTools/chrome-devtools-mcp","command":"npx","args":["-y","chrome-devtools-mcp"],"env":{}}'
|
||||
```
|
||||
|
||||
For Windows (PowerShell):
|
||||
|
||||
```powershell
|
||||
code --add-mcp '{"""name""":"""io.github.ChromeDevTools/chrome-devtools-mcp""","""command""":"""npx""","""args""":["""-y""","""chrome-devtools-mcp"""]}'
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Cursor</summary>
|
||||
|
||||
**Click the button to install:**
|
||||
|
||||
[<img src="https://cursor.com/deeplink/mcp-install-dark.svg" alt="Install in Cursor">](https://cursor.com/en/install-mcp?name=chrome-devtools&config=eyJjb21tYW5kIjoibnB4IC15IGNocm9tZS1kZXZ0b29scy1tY3BAbGF0ZXN0In0%3D)
|
||||
|
||||
**Or install manually:**
|
||||
|
||||
Go to `Cursor Settings` -> `MCP` -> `New MCP Server`. Use the config provided above.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Factory CLI</summary>
|
||||
Use the Factory CLI to add the Chrome DevTools MCP server (<a href="https://docs.factory.ai/cli/configuration/mcp">guide</a>):
|
||||
|
||||
```bash
|
||||
droid mcp add chrome-devtools "npx -y chrome-devtools-mcp@latest"
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Gemini CLI</summary>
|
||||
Install the Chrome DevTools MCP server using the Gemini CLI.
|
||||
|
||||
**Project wide:**
|
||||
|
||||
```bash
|
||||
# Either MCP only:
|
||||
gemini mcp add chrome-devtools npx chrome-devtools-mcp@latest
|
||||
# Or as a Gemini extension (MCP+Skills):
|
||||
gemini extensions install --auto-update https://github.com/ChromeDevTools/chrome-devtools-mcp
|
||||
```
|
||||
|
||||
**Globally:**
|
||||
|
||||
```bash
|
||||
gemini mcp add -s user chrome-devtools npx chrome-devtools-mcp@latest
|
||||
```
|
||||
|
||||
Alternatively, follow the <a href="https://github.com/google-gemini/gemini-cli/blob/main/docs/tools/mcp-server.md#how-to-set-up-your-mcp-server">MCP guide</a> and use the standard config from above.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Gemini Code Assist</summary>
|
||||
Follow the <a href="https://cloud.google.com/gemini/docs/codeassist/use-agentic-chat-pair-programmer#configure-mcp-servers">configure MCP guide</a>
|
||||
using the standard config from above.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>JetBrains AI Assistant & Junie</summary>
|
||||
|
||||
Go to `Settings | Tools | AI Assistant | Model Context Protocol (MCP)` -> `Add`. Use the config provided above.
|
||||
The same way chrome-devtools-mcp can be configured for JetBrains Junie in `Settings | Tools | Junie | MCP Settings` -> `Add`. Use the config provided above.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Kiro</summary>
|
||||
|
||||
In **Kiro Settings**, go to `Configure MCP` > `Open Workspace or User MCP Config` > Use the configuration snippet provided above.
|
||||
|
||||
Or, from the IDE **Activity Bar** > `Kiro` > `MCP Servers` > `Click Open MCP Config`. Use the configuration snippet provided above.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Katalon Studio</summary>
|
||||
|
||||
The Chrome DevTools MCP server can be used with <a href="https://docs.katalon.com/katalon-studio/studioassist/mcp-servers/setting-up-chrome-devtools-mcp-server-for-studioassist">Katalon StudioAssist</a> via an MCP proxy.
|
||||
|
||||
**Step 1:** Install the MCP proxy by following the <a href="https://docs.katalon.com/katalon-studio/studioassist/mcp-servers/setting-up-mcp-proxy-for-stdio-mcp-servers">MCP proxy setup guide</a>.
|
||||
|
||||
**Step 2:** Start the Chrome DevTools MCP server with the proxy:
|
||||
|
||||
```bash
|
||||
mcp-proxy --transport streamablehttp --port 8080 -- npx -y chrome-devtools-mcp@latest
|
||||
```
|
||||
|
||||
**Note:** You may need to pick another port if 8080 is already in use.
|
||||
|
||||
**Step 3:** In Katalon Studio, add the server to StudioAssist with the following settings:
|
||||
|
||||
- **Connection URL:** `http://127.0.0.1:8080/mcp`
|
||||
- **Transport type:** `HTTP`
|
||||
|
||||
Once connected, the Chrome DevTools MCP tools will be available in StudioAssist.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>OpenCode</summary>
|
||||
|
||||
Add the following configuration to your `opencode.json` file. If you don't have one, create it at `~/.config/opencode/opencode.json` (<a href="https://opencode.ai/docs/mcp-servers">guide</a>):
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"mcp": {
|
||||
"chrome-devtools": {
|
||||
"type": "local",
|
||||
"command": ["npx", "-y", "chrome-devtools-mcp@latest"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Qoder</summary>
|
||||
|
||||
In **Qoder Settings**, go to `MCP Server` > `+ Add` > Use the configuration snippet provided above.
|
||||
|
||||
Alternatively, follow the <a href="https://docs.qoder.com/user-guide/chat/model-context-protocol">MCP guide</a> and use the standard config from above.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Qoder CLI</summary>
|
||||
|
||||
Install the Chrome DevTools MCP server using the Qoder CLI (<a href="https://docs.qoder.com/cli/using-cli#mcp-servers">guide</a>):
|
||||
|
||||
**Project wide:**
|
||||
|
||||
```bash
|
||||
qodercli mcp add chrome-devtools -- npx chrome-devtools-mcp@latest
|
||||
```
|
||||
|
||||
**Globally:**
|
||||
|
||||
```bash
|
||||
qodercli mcp add -s user chrome-devtools -- npx chrome-devtools-mcp@latest
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Visual Studio</summary>
|
||||
|
||||
**Click the button to install:**
|
||||
|
||||
[<img src="https://img.shields.io/badge/Visual_Studio-Install-C16FDE?logo=visualstudio&logoColor=white" alt="Install in Visual Studio">](https://vs-open.link/mcp-install?%7B%22name%22%3A%22chrome-devtools%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22chrome-devtools-mcp%40latest%22%5D%7D)
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Warp</summary>
|
||||
|
||||
Go to `Settings | AI | Manage MCP Servers` -> `+ Add` to [add an MCP Server](https://docs.warp.dev/knowledge-and-collaboration/mcp#adding-an-mcp-server). Use the config provided above.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Windsurf</summary>
|
||||
Follow the <a href="https://docs.windsurf.com/windsurf/cascade/mcp#mcp-config-json">configure MCP guide</a>
|
||||
using the standard config from above.
|
||||
</details>
|
||||
|
||||
### Your first prompt
|
||||
|
||||
Enter the following prompt in your MCP Client to check if everything is working:
|
||||
|
||||
```
|
||||
Check the performance of https://developers.chrome.com
|
||||
```
|
||||
|
||||
Your MCP client should open the browser and record a performance trace.
|
||||
|
||||
> [!NOTE]
|
||||
> The MCP server will start the browser automatically once the MCP client uses a tool that requires a running browser instance. Connecting to the Chrome DevTools MCP server on its own will not automatically start the browser.
|
||||
|
||||
## Tools
|
||||
|
||||
If you run into any issues, checkout our [troubleshooting guide](./docs/troubleshooting.md).
|
||||
|
||||
<!-- BEGIN AUTO GENERATED TOOLS -->
|
||||
|
||||
- **Input automation** (9 tools)
|
||||
- [`click`](docs/tool-reference.md#click)
|
||||
- [`drag`](docs/tool-reference.md#drag)
|
||||
- [`fill`](docs/tool-reference.md#fill)
|
||||
- [`fill_form`](docs/tool-reference.md#fill_form)
|
||||
- [`handle_dialog`](docs/tool-reference.md#handle_dialog)
|
||||
- [`hover`](docs/tool-reference.md#hover)
|
||||
- [`press_key`](docs/tool-reference.md#press_key)
|
||||
- [`type_text`](docs/tool-reference.md#type_text)
|
||||
- [`upload_file`](docs/tool-reference.md#upload_file)
|
||||
- **Navigation automation** (6 tools)
|
||||
- [`close_page`](docs/tool-reference.md#close_page)
|
||||
- [`list_pages`](docs/tool-reference.md#list_pages)
|
||||
- [`navigate_page`](docs/tool-reference.md#navigate_page)
|
||||
- [`new_page`](docs/tool-reference.md#new_page)
|
||||
- [`select_page`](docs/tool-reference.md#select_page)
|
||||
- [`wait_for`](docs/tool-reference.md#wait_for)
|
||||
- **Emulation** (2 tools)
|
||||
- [`emulate`](docs/tool-reference.md#emulate)
|
||||
- [`resize_page`](docs/tool-reference.md#resize_page)
|
||||
- **Performance** (4 tools)
|
||||
- [`performance_analyze_insight`](docs/tool-reference.md#performance_analyze_insight)
|
||||
- [`performance_start_trace`](docs/tool-reference.md#performance_start_trace)
|
||||
- [`performance_stop_trace`](docs/tool-reference.md#performance_stop_trace)
|
||||
- [`take_memory_snapshot`](docs/tool-reference.md#take_memory_snapshot)
|
||||
- **Network** (2 tools)
|
||||
- [`get_network_request`](docs/tool-reference.md#get_network_request)
|
||||
- [`list_network_requests`](docs/tool-reference.md#list_network_requests)
|
||||
- **Debugging** (6 tools)
|
||||
- [`evaluate_script`](docs/tool-reference.md#evaluate_script)
|
||||
- [`get_console_message`](docs/tool-reference.md#get_console_message)
|
||||
- [`lighthouse_audit`](docs/tool-reference.md#lighthouse_audit)
|
||||
- [`list_console_messages`](docs/tool-reference.md#list_console_messages)
|
||||
- [`take_screenshot`](docs/tool-reference.md#take_screenshot)
|
||||
- [`take_snapshot`](docs/tool-reference.md#take_snapshot)
|
||||
|
||||
<!-- END AUTO GENERATED TOOLS -->
|
||||
|
||||
## Configuration
|
||||
|
||||
The Chrome DevTools MCP server supports the following configuration option:
|
||||
|
||||
<!-- BEGIN AUTO GENERATED OPTIONS -->
|
||||
|
||||
- **`--autoConnect`/ `--auto-connect`**
|
||||
If specified, automatically connects to a browser (Chrome 144+) running locally from the user data directory identified by the channel param (default channel is stable). Requires the remoted debugging server to be started in the Chrome instance via chrome://inspect/#remote-debugging.
|
||||
- **Type:** boolean
|
||||
- **Default:** `false`
|
||||
|
||||
- **`--browserUrl`/ `--browser-url`, `-u`**
|
||||
Connect to a running, debuggable Chrome instance (e.g. `http://127.0.0.1:9222`). For more details see: https://github.com/ChromeDevTools/chrome-devtools-mcp#connecting-to-a-running-chrome-instance.
|
||||
- **Type:** string
|
||||
|
||||
- **`--wsEndpoint`/ `--ws-endpoint`, `-w`**
|
||||
WebSocket endpoint to connect to a running Chrome instance (e.g., ws://127.0.0.1:9222/devtools/browser/<id>). Alternative to --browserUrl.
|
||||
- **Type:** string
|
||||
|
||||
- **`--wsHeaders`/ `--ws-headers`**
|
||||
Custom headers for WebSocket connection in JSON format (e.g., '{"Authorization":"Bearer token"}'). Only works with --wsEndpoint.
|
||||
- **Type:** string
|
||||
|
||||
- **`--headless`**
|
||||
Whether to run in headless (no UI) mode.
|
||||
- **Type:** boolean
|
||||
- **Default:** `false`
|
||||
|
||||
- **`--executablePath`/ `--executable-path`, `-e`**
|
||||
Path to custom Chrome executable.
|
||||
- **Type:** string
|
||||
|
||||
- **`--isolated`**
|
||||
If specified, creates a temporary user-data-dir that is automatically cleaned up after the browser is closed. Defaults to false.
|
||||
- **Type:** boolean
|
||||
|
||||
- **`--userDataDir`/ `--user-data-dir`**
|
||||
Path to the user data directory for Chrome. Default is $HOME/.cache/chrome-devtools-mcp/chrome-profile$CHANNEL_SUFFIX_IF_NON_STABLE
|
||||
- **Type:** string
|
||||
|
||||
- **`--channel`**
|
||||
Specify a different Chrome channel that should be used. The default is the stable channel version.
|
||||
- **Type:** string
|
||||
- **Choices:** `stable`, `canary`, `beta`, `dev`
|
||||
|
||||
- **`--logFile`/ `--log-file`**
|
||||
Path to a file to write debug logs to. Set the env variable `DEBUG` to `*` to enable verbose logs. Useful for submitting bug reports.
|
||||
- **Type:** string
|
||||
|
||||
- **`--viewport`**
|
||||
Initial viewport size for the Chrome instances started by the server. For example, `1280x720`. In headless mode, max size is 3840x2160px.
|
||||
- **Type:** string
|
||||
|
||||
- **`--proxyServer`/ `--proxy-server`**
|
||||
Proxy server configuration for Chrome passed as --proxy-server when launching the browser. See https://www.chromium.org/developers/design-documents/network-settings/ for details.
|
||||
- **Type:** string
|
||||
|
||||
- **`--acceptInsecureCerts`/ `--accept-insecure-certs`**
|
||||
If enabled, ignores errors relative to self-signed and expired certificates. Use with caution.
|
||||
- **Type:** boolean
|
||||
|
||||
- **`--experimentalScreencast`/ `--experimental-screencast`**
|
||||
Exposes experimental screencast tools (requires ffmpeg). Install ffmpeg https://www.ffmpeg.org/download.html and ensure it is available in the MCP server PATH.
|
||||
- **Type:** boolean
|
||||
|
||||
- **`--chromeArg`/ `--chrome-arg`**
|
||||
Additional arguments for Chrome. Only applies when Chrome is launched by chrome-devtools-mcp.
|
||||
- **Type:** array
|
||||
|
||||
- **`--ignoreDefaultChromeArg`/ `--ignore-default-chrome-arg`**
|
||||
Explicitly disable default arguments for Chrome. Only applies when Chrome is launched by chrome-devtools-mcp.
|
||||
- **Type:** array
|
||||
|
||||
- **`--categoryEmulation`/ `--category-emulation`**
|
||||
Set to false to exclude tools related to emulation.
|
||||
- **Type:** boolean
|
||||
- **Default:** `true`
|
||||
|
||||
- **`--categoryPerformance`/ `--category-performance`**
|
||||
Set to false to exclude tools related to performance.
|
||||
- **Type:** boolean
|
||||
- **Default:** `true`
|
||||
|
||||
- **`--categoryNetwork`/ `--category-network`**
|
||||
Set to false to exclude tools related to network.
|
||||
- **Type:** boolean
|
||||
- **Default:** `true`
|
||||
|
||||
- **`--performanceCrux`/ `--performance-crux`**
|
||||
Set to false to disable sending URLs from performance traces to CrUX API to get field performance data.
|
||||
- **Type:** boolean
|
||||
- **Default:** `true`
|
||||
|
||||
- **`--usageStatistics`/ `--usage-statistics`**
|
||||
Set to false to opt-out of usage statistics collection. Google collects usage data to improve the tool, handled under the Google Privacy Policy (https://policies.google.com/privacy). This is independent from Chrome browser metrics. Disabled if CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS or CI env variables are set.
|
||||
- **Type:** boolean
|
||||
- **Default:** `true`
|
||||
|
||||
- **`--slim`**
|
||||
Exposes a "slim" set of 3 tools covering navigation, script execution and screenshots only. Useful for basic browser tasks.
|
||||
- **Type:** boolean
|
||||
|
||||
<!-- END AUTO GENERATED OPTIONS -->
|
||||
|
||||
Pass them via the `args` property in the JSON configuration. For example:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"chrome-devtools": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"chrome-devtools-mcp@latest",
|
||||
"--channel=canary",
|
||||
"--headless=true",
|
||||
"--isolated=true"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Connecting via WebSocket with custom headers
|
||||
|
||||
You can connect directly to a Chrome WebSocket endpoint and include custom headers (e.g., for authentication):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"chrome-devtools": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"chrome-devtools-mcp@latest",
|
||||
"--wsEndpoint=ws://127.0.0.1:9222/devtools/browser/<id>",
|
||||
"--wsHeaders={\"Authorization\":\"Bearer YOUR_TOKEN\"}"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
To get the WebSocket endpoint from a running Chrome instance, visit `http://127.0.0.1:9222/json/version` and look for the `webSocketDebuggerUrl` field.
|
||||
|
||||
You can also run `npx chrome-devtools-mcp@latest --help` to see all available configuration options.
|
||||
|
||||
## Concepts
|
||||
|
||||
### User data directory
|
||||
|
||||
`chrome-devtools-mcp` starts a Chrome's stable channel instance using the following user
|
||||
data directory:
|
||||
|
||||
- Linux / macOS: `$HOME/.cache/chrome-devtools-mcp/chrome-profile-$CHANNEL`
|
||||
- Windows: `%HOMEPATH%/.cache/chrome-devtools-mcp/chrome-profile-$CHANNEL`
|
||||
|
||||
The user data directory is not cleared between runs and shared across
|
||||
all instances of `chrome-devtools-mcp`. Set the `isolated` option to `true`
|
||||
to use a temporary user data dir instead which will be cleared automatically after
|
||||
the browser is closed.
|
||||
|
||||
### Connecting to a running Chrome instance
|
||||
|
||||
By default, the Chrome DevTools MCP server will start a new Chrome instance with a dedicated profile. This might not be ideal in all situations:
|
||||
|
||||
- If you would like to maintain the same application state when alternating between manual site testing and agent-driven testing.
|
||||
- When the MCP needs to sign into a website. Some accounts may prevent sign-in when the browser is controlled via WebDriver (the default launch mechanism for the Chrome DevTools MCP server).
|
||||
- If you're running your LLM inside a sandboxed environment, but you would like to connect to a Chrome instance that runs outside the sandbox.
|
||||
|
||||
In these cases, start Chrome first and let the Chrome DevTools MCP server connect to it. There are two ways to do so:
|
||||
|
||||
- **Automatic connection (available in Chrome 144)**: best for sharing state between manual and agent-driven testing.
|
||||
- **Manual connection via remote debugging port**: best when running inside a sandboxed environment.
|
||||
|
||||
#### Automatically connecting to a running Chrome instance
|
||||
|
||||
**Step 1:** Set up remote debugging in Chrome
|
||||
|
||||
In Chrome (\>= M144), do the following to set up remote debugging:
|
||||
|
||||
1. Navigate to `chrome://inspect/#remote-debugging` to enable remote debugging.
|
||||
2. Follow the dialog UI to allow or disallow incoming debugging connections.
|
||||
|
||||
**Step 2:** Configure Chrome DevTools MCP server to automatically connect to a running Chrome Instance
|
||||
|
||||
To connect the `chrome-devtools-mcp` server to the running Chrome instance, use
|
||||
`--autoConnect` command line argument for the MCP server.
|
||||
|
||||
The following code snippet is an example configuration for gemini-cli:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"chrome-devtools": {
|
||||
"command": "npx",
|
||||
"args": ["chrome-devtools-mcp@latest", "--autoConnect"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3:** Test your setup
|
||||
|
||||
Make sure your browser is running. Open gemini-cli and run the following prompt:
|
||||
|
||||
```none
|
||||
Check the performance of https://developers.chrome.com
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> The <code>autoConnect</code> option requires the user to start Chrome. If the user has multiple active profiles, the MCP server will connect to the default profile (as determined by Chrome). The MCP server has access to all open windows for the selected profile.
|
||||
|
||||
The Chrome DevTools MCP server will try to connect to your running Chrome
|
||||
instance. It shows a dialog asking for user permission.
|
||||
|
||||
Clicking **Allow** results in the Chrome DevTools MCP server opening
|
||||
[developers.chrome.com](http://developers.chrome.com) and taking a performance
|
||||
trace.
|
||||
|
||||
#### Manual connection using port forwarding
|
||||
|
||||
You can connect to a running Chrome instance by using the `--browser-url` option. This is useful if you are running the MCP server in a sandboxed environment that does not allow starting a new Chrome instance.
|
||||
|
||||
Here is a step-by-step guide on how to connect to a running Chrome instance:
|
||||
|
||||
**Step 1: Configure the MCP client**
|
||||
|
||||
Add the `--browser-url` option to your MCP client configuration. The value of this option should be the URL of the running Chrome instance. `http://127.0.0.1:9222` is a common default.
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"chrome-devtools": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"chrome-devtools-mcp@latest",
|
||||
"--browser-url=http://127.0.0.1:9222"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Start the Chrome browser**
|
||||
|
||||
> [!WARNING]
|
||||
> Enabling the remote debugging port opens up a debugging port on the running browser instance. Any application on your machine can connect to this port and control the browser. Make sure that you are not browsing any sensitive websites while the debugging port is open.
|
||||
|
||||
Start the Chrome browser with the remote debugging port enabled. Make sure to close any running Chrome instances before starting a new one with the debugging port enabled. The port number you choose must be the same as the one you specified in the `--browser-url` option in your MCP client configuration.
|
||||
|
||||
For security reasons, [Chrome requires you to use a non-default user data directory](https://developer.chrome.com/blog/remote-debugging-port) when enabling the remote debugging port. You can specify a custom directory using the `--user-data-dir` flag. This ensures that your regular browsing profile and data are not exposed to the debugging session.
|
||||
|
||||
**macOS**
|
||||
|
||||
```bash
|
||||
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-profile-stable
|
||||
```
|
||||
|
||||
**Linux**
|
||||
|
||||
```bash
|
||||
/usr/bin/google-chrome --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-profile-stable
|
||||
```
|
||||
|
||||
**Windows**
|
||||
|
||||
```bash
|
||||
"C:\Program Files\Google\Chrome\Application\chrome.exe" --remote-debugging-port=9222 --user-data-dir="%TEMP%\chrome-profile-stable"
|
||||
```
|
||||
|
||||
**Step 3: Test your setup**
|
||||
|
||||
After configuring the MCP client and starting the Chrome browser, you can test your setup by running a simple prompt in your MCP client:
|
||||
|
||||
```
|
||||
Check the performance of https://developers.chrome.com
|
||||
```
|
||||
|
||||
Your MCP client should connect to the running Chrome instance and receive a performance report.
|
||||
|
||||
If you hit VM-to-host port forwarding issues, see the “Remote debugging between virtual machine (VM) and host fails” section in [`docs/troubleshooting.md`](./docs/troubleshooting.md#remote-debugging-between-virtual-machine-vm-and-host-fails).
|
||||
|
||||
For more details on remote debugging, see the [Chrome DevTools documentation](https://developer.chrome.com/docs/devtools/remote-debugging/).
|
||||
|
||||
### Debugging Chrome on Android
|
||||
|
||||
Please consult [these instructions](./docs/debugging-android.md).
|
||||
|
||||
## Known limitations
|
||||
|
||||
See [Troubleshooting](./docs/troubleshooting.md).
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { CDPSessionEvent } from './third_party/index.js';
|
||||
/**
|
||||
* This class makes a puppeteer connection look like DevTools CDPConnection.
|
||||
*
|
||||
* Since we connect "root" DevTools targets to specific pages, we scope everything to a puppeteer CDP session.
|
||||
*
|
||||
* We don't have to recursively listen for 'sessionattached' as the "root" CDP session sees all child session attached
|
||||
* events, regardless how deeply nested they are.
|
||||
*/
|
||||
export class PuppeteerDevToolsConnection {
|
||||
#connection;
|
||||
#observers = new Set();
|
||||
#sessionEventHandlers = new Map();
|
||||
constructor(session) {
|
||||
this.#connection = session.connection();
|
||||
session.on(CDPSessionEvent.SessionAttached, this.#startForwardingCdpEvents.bind(this));
|
||||
session.on(CDPSessionEvent.SessionDetached, this.#stopForwardingCdpEvents.bind(this));
|
||||
this.#startForwardingCdpEvents(session);
|
||||
}
|
||||
send(method, params, sessionId) {
|
||||
if (sessionId === undefined) {
|
||||
throw new Error('Attempting to send on the root session. This must not happen');
|
||||
}
|
||||
const session = this.#connection.session(sessionId);
|
||||
if (!session) {
|
||||
throw new Error('Unknown session ' + sessionId);
|
||||
}
|
||||
// Rolled protocol version between puppeteer and DevTools doesn't necessarily match
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
return session
|
||||
.send(method, params)
|
||||
.then(result => ({ result }))
|
||||
.catch(error => ({ error }));
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
}
|
||||
observe(observer) {
|
||||
this.#observers.add(observer);
|
||||
}
|
||||
unobserve(observer) {
|
||||
this.#observers.delete(observer);
|
||||
}
|
||||
#startForwardingCdpEvents(session) {
|
||||
const handler = this.#handleEvent.bind(this, session.id());
|
||||
this.#sessionEventHandlers.set(session.id(), handler);
|
||||
session.on('*', handler);
|
||||
}
|
||||
#stopForwardingCdpEvents(session) {
|
||||
const handler = this.#sessionEventHandlers.get(session.id());
|
||||
if (handler) {
|
||||
session.off('*', handler);
|
||||
}
|
||||
}
|
||||
#handleEvent(sessionId, type, event) {
|
||||
if (typeof type === 'string' &&
|
||||
type !== CDPSessionEvent.SessionAttached &&
|
||||
type !== CDPSessionEvent.SessionDetached) {
|
||||
this.#observers.forEach(observer => observer.onEvent({
|
||||
method: type,
|
||||
sessionId,
|
||||
params: event,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
+294
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { PuppeteerDevToolsConnection } from './DevToolsConnectionAdapter.js';
|
||||
import { Mutex } from './Mutex.js';
|
||||
import { DevTools } from './third_party/index.js';
|
||||
/**
|
||||
* A mock implementation of an issues manager that only implements the methods
|
||||
* that are actually used by the IssuesAggregator
|
||||
*/
|
||||
export class FakeIssuesManager extends DevTools.Common.ObjectWrapper
|
||||
.ObjectWrapper {
|
||||
issues() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
// DevTools CDP errors can get noisy.
|
||||
DevTools.ProtocolClient.InspectorBackend.test.suppressRequestErrors = true;
|
||||
DevTools.I18n.DevToolsLocale.DevToolsLocale.instance({
|
||||
create: true,
|
||||
data: {
|
||||
navigatorLanguage: 'en-US',
|
||||
settingLanguage: 'en-US',
|
||||
lookupClosestDevToolsLocale: l => l,
|
||||
},
|
||||
});
|
||||
DevTools.I18n.i18n.registerLocaleDataForTest('en-US', {});
|
||||
DevTools.Formatter.FormatterWorkerPool.FormatterWorkerPool.instance({
|
||||
forceNew: true,
|
||||
entrypointURL: import.meta
|
||||
.resolve('./third_party/devtools-formatter-worker.js'),
|
||||
});
|
||||
export class UniverseManager {
|
||||
#browser;
|
||||
#createUniverseFor;
|
||||
#universes = new WeakMap();
|
||||
/** Guard access to #universes so we don't create unnecessary universes */
|
||||
#mutex = new Mutex();
|
||||
constructor(browser, factory = DEFAULT_FACTORY) {
|
||||
this.#browser = browser;
|
||||
this.#createUniverseFor = factory;
|
||||
}
|
||||
async init(pages) {
|
||||
try {
|
||||
await this.#mutex.acquire();
|
||||
const promises = [];
|
||||
for (const page of pages) {
|
||||
promises.push(this.#createUniverseFor(page).then(targetUniverse => this.#universes.set(page, targetUniverse)));
|
||||
}
|
||||
this.#browser.on('targetcreated', this.#onTargetCreated);
|
||||
this.#browser.on('targetdestroyed', this.#onTargetDestroyed);
|
||||
await Promise.all(promises);
|
||||
}
|
||||
finally {
|
||||
this.#mutex.release();
|
||||
}
|
||||
}
|
||||
get(page) {
|
||||
return this.#universes.get(page) ?? null;
|
||||
}
|
||||
dispose() {
|
||||
this.#browser.off('targetcreated', this.#onTargetCreated);
|
||||
this.#browser.off('targetdestroyed', this.#onTargetDestroyed);
|
||||
}
|
||||
#onTargetCreated = async (target) => {
|
||||
const page = await target.page();
|
||||
try {
|
||||
await this.#mutex.acquire();
|
||||
if (!page || this.#universes.has(page)) {
|
||||
return;
|
||||
}
|
||||
this.#universes.set(page, await this.#createUniverseFor(page));
|
||||
}
|
||||
finally {
|
||||
this.#mutex.release();
|
||||
}
|
||||
};
|
||||
#onTargetDestroyed = async (target) => {
|
||||
const page = await target.page();
|
||||
try {
|
||||
await this.#mutex.acquire();
|
||||
if (!page || !this.#universes.has(page)) {
|
||||
return;
|
||||
}
|
||||
this.#universes.delete(page);
|
||||
}
|
||||
finally {
|
||||
this.#mutex.release();
|
||||
}
|
||||
};
|
||||
}
|
||||
const DEFAULT_FACTORY = async (page) => {
|
||||
const settingStorage = new DevTools.Common.Settings.SettingsStorage({});
|
||||
const universe = new DevTools.Foundation.Universe.Universe({
|
||||
settingsCreationOptions: {
|
||||
syncedStorage: settingStorage,
|
||||
globalStorage: settingStorage,
|
||||
localStorage: settingStorage,
|
||||
settingRegistrations: DevTools.Common.SettingRegistration.getRegisteredSettings(),
|
||||
},
|
||||
overrideAutoStartModels: new Set([DevTools.DebuggerModel]),
|
||||
});
|
||||
const session = await page.createCDPSession();
|
||||
const connection = new PuppeteerDevToolsConnection(session);
|
||||
const targetManager = universe.context.get(DevTools.TargetManager);
|
||||
targetManager.observeModels(DevTools.DebuggerModel, SKIP_ALL_PAUSES);
|
||||
const target = targetManager.createTarget('main', '', 'frame', // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
/* parentTarget */ null, session.id(), undefined, connection);
|
||||
return { target, universe };
|
||||
};
|
||||
// We don't want to pause any DevTools universe session ever on the MCP side.
|
||||
//
|
||||
// Note that calling `setSkipAllPauses` only affects the session on which it was
|
||||
// sent. This means DevTools can still pause, step and do whatever. We just won't
|
||||
// see the `Debugger.paused`/`Debugger.resumed` events on the MCP side.
|
||||
const SKIP_ALL_PAUSES = {
|
||||
modelAdded(model) {
|
||||
void model.agent.invoke_setSkipAllPauses({ skip: true });
|
||||
},
|
||||
modelRemoved() {
|
||||
// Do nothing.
|
||||
},
|
||||
};
|
||||
/**
|
||||
* Constructed from Runtime.ExceptionDetails of an uncaught error.
|
||||
*
|
||||
* TODO: Also construct from a RemoteObject of subtype 'error'.
|
||||
*
|
||||
* Consists of the message, a fully resolved stack trace and a fully resolved 'cause' chain.
|
||||
*/
|
||||
export class SymbolizedError {
|
||||
message;
|
||||
stackTrace;
|
||||
cause;
|
||||
constructor(message, stackTrace, cause) {
|
||||
this.message = message;
|
||||
this.stackTrace = stackTrace;
|
||||
this.cause = cause;
|
||||
}
|
||||
static async fromDetails(opts) {
|
||||
const message = SymbolizedError.#getMessage(opts.details);
|
||||
if (!opts.includeStackAndCause || !opts.devTools) {
|
||||
return new SymbolizedError(message, opts.resolvedStackTraceForTesting, opts.resolvedCauseForTesting);
|
||||
}
|
||||
let stackTrace;
|
||||
if (opts.resolvedStackTraceForTesting) {
|
||||
stackTrace = opts.resolvedStackTraceForTesting;
|
||||
}
|
||||
else if (opts.details.stackTrace) {
|
||||
try {
|
||||
stackTrace = await createStackTrace(opts.devTools, opts.details.stackTrace, opts.targetId);
|
||||
}
|
||||
catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
// TODO: Turn opts.details.exception into a JSHandle and retrieve the 'cause' property.
|
||||
// If its an Error, recursively create a SymbolizedError.
|
||||
let cause;
|
||||
if (opts.resolvedCauseForTesting) {
|
||||
cause = opts.resolvedCauseForTesting;
|
||||
}
|
||||
else if (opts.details.exception) {
|
||||
try {
|
||||
const causeRemoteObj = await SymbolizedError.#lookupCause(opts.devTools, opts.details.exception, opts.targetId);
|
||||
if (causeRemoteObj) {
|
||||
cause = await SymbolizedError.fromError({
|
||||
devTools: opts.devTools,
|
||||
error: causeRemoteObj,
|
||||
targetId: opts.targetId,
|
||||
});
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
return new SymbolizedError(message, stackTrace, cause);
|
||||
}
|
||||
static async fromError(opts) {
|
||||
const details = await SymbolizedError.#getExceptionDetails(opts.devTools, opts.error, opts.targetId);
|
||||
if (details) {
|
||||
return SymbolizedError.fromDetails({
|
||||
details,
|
||||
devTools: opts.devTools,
|
||||
targetId: opts.targetId,
|
||||
includeStackAndCause: true,
|
||||
});
|
||||
}
|
||||
return new SymbolizedError(SymbolizedError.#getMessageFromException(opts.error));
|
||||
}
|
||||
static #getMessage(details) {
|
||||
// For Runtime.exceptionThrown with a present exception object, `details.text` will be "Uncaught" and
|
||||
// we have to manually parse out the error text from the exception description.
|
||||
// In the case of Runtime.getExceptionDetails, `details.text` has the Error.message.
|
||||
if (details.text === 'Uncaught' && details.exception) {
|
||||
return ('Uncaught ' +
|
||||
SymbolizedError.#getMessageFromException(details.exception));
|
||||
}
|
||||
return details.text;
|
||||
}
|
||||
static #getMessageFromException(error) {
|
||||
const messageWithRest = error.description?.split('\n at ', 2) ?? [];
|
||||
return messageWithRest[0] ?? '';
|
||||
}
|
||||
static async #getExceptionDetails(devTools, error, targetId) {
|
||||
if (!devTools || (error.type !== 'object' && error.subtype !== 'error')) {
|
||||
return null;
|
||||
}
|
||||
const targetManager = devTools.universe.context.get(DevTools.TargetManager);
|
||||
const target = targetId
|
||||
? targetManager.targetById(targetId) || devTools.target
|
||||
: devTools.target;
|
||||
const model = target.model(DevTools.RuntimeModel);
|
||||
return ((await model.getExceptionDetails(error.objectId)) ?? null);
|
||||
}
|
||||
static async #lookupCause(devTools, error, targetId) {
|
||||
if (!devTools || (error.type !== 'object' && error.subtype !== 'error')) {
|
||||
return null;
|
||||
}
|
||||
const targetManager = devTools.universe.context.get(DevTools.TargetManager);
|
||||
const target = targetId
|
||||
? targetManager.targetById(targetId) || devTools.target
|
||||
: devTools.target;
|
||||
const properties = await target.runtimeAgent().invoke_getProperties({
|
||||
objectId: error.objectId,
|
||||
});
|
||||
if (properties.getError()) {
|
||||
return null;
|
||||
}
|
||||
return properties.result.find(prop => prop.name === 'cause')?.value ?? null;
|
||||
}
|
||||
static createForTesting(message, stackTrace, cause) {
|
||||
return new SymbolizedError(message, stackTrace, cause);
|
||||
}
|
||||
}
|
||||
export async function createStackTraceForConsoleMessage(devTools, consoleMessage) {
|
||||
const message = consoleMessage;
|
||||
const rawStackTrace = message._rawStackTrace();
|
||||
if (rawStackTrace) {
|
||||
return createStackTrace(devTools, rawStackTrace, message._targetId());
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
export async function createStackTrace(devTools, rawStackTrace, targetId) {
|
||||
const targetManager = devTools.universe.context.get(DevTools.TargetManager);
|
||||
const target = targetId
|
||||
? targetManager.targetById(targetId) || devTools.target
|
||||
: devTools.target;
|
||||
const model = target.model(DevTools.DebuggerModel);
|
||||
// DevTools doesn't wait for source maps to attach before building a stack trace, rather it'll send
|
||||
// an update event once a source map was attached and the stack trace retranslated. This doesn't
|
||||
// work in the MCP case, so we'll collect all script IDs upfront and wait for any pending source map
|
||||
// loads before creating the stack trace. We might also have to wait for Debugger.ScriptParsed events if
|
||||
// the stack trace is created particularly early.
|
||||
const scriptIds = new Set();
|
||||
for (const frame of rawStackTrace.callFrames) {
|
||||
scriptIds.add(frame.scriptId);
|
||||
}
|
||||
for (let asyncStack = rawStackTrace.parent; asyncStack; asyncStack = asyncStack.parent) {
|
||||
for (const frame of asyncStack.callFrames) {
|
||||
scriptIds.add(frame.scriptId);
|
||||
}
|
||||
}
|
||||
const signal = AbortSignal.timeout(1_000);
|
||||
await Promise.all([...scriptIds].map(id => waitForScript(model, id, signal)
|
||||
.then(script => model.sourceMapManager().sourceMapForClientPromise(script))
|
||||
.catch()));
|
||||
const binding = devTools.universe.context.get(DevTools.DebuggerWorkspaceBinding);
|
||||
// DevTools uses branded types for ScriptId and others. Casting the puppeteer protocol type to the DevTools protocol type is safe.
|
||||
return binding.createStackTraceFromProtocolRuntime(rawStackTrace, target);
|
||||
}
|
||||
// Waits indefinitely for the script so pair it with Promise.race.
|
||||
async function waitForScript(model, scriptId, signal) {
|
||||
while (true) {
|
||||
if (signal.aborted) {
|
||||
throw signal.reason;
|
||||
}
|
||||
const script = model.scriptForId(scriptId);
|
||||
if (script) {
|
||||
return script;
|
||||
}
|
||||
await new Promise((resolve, reject) => {
|
||||
signal.addEventListener('abort', () => reject(signal.reason), {
|
||||
once: true,
|
||||
});
|
||||
void model
|
||||
.once('ParsedScriptSource')
|
||||
.then(resolve);
|
||||
});
|
||||
}
|
||||
}
|
||||
+707
@@ -0,0 +1,707 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { UniverseManager } from './DevtoolsUtils.js';
|
||||
import { McpPage } from './McpPage.js';
|
||||
import { NetworkCollector, ConsoleCollector, } from './PageCollector.js';
|
||||
import { Locator } 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 { ExtensionRegistry, } from './utils/ExtensionRegistry.js';
|
||||
import { saveTemporaryFile } from './utils/files.js';
|
||||
import { WaitForHelper } from './WaitForHelper.js';
|
||||
const DEFAULT_TIMEOUT = 5_000;
|
||||
const NAVIGATION_TIMEOUT = 10_000;
|
||||
function getNetworkMultiplierFromString(condition) {
|
||||
const puppeteerCondition = condition;
|
||||
switch (puppeteerCondition) {
|
||||
case 'Fast 4G':
|
||||
return 1;
|
||||
case 'Slow 4G':
|
||||
return 2.5;
|
||||
case 'Fast 3G':
|
||||
return 5;
|
||||
case 'Slow 3G':
|
||||
return 10;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
export class McpContext {
|
||||
browser;
|
||||
logger;
|
||||
// Maps LLM-provided isolatedContext name → Puppeteer BrowserContext.
|
||||
#isolatedContexts = new Map();
|
||||
// Auto-generated name counter for when no name is provided.
|
||||
#nextIsolatedContextId = 1;
|
||||
#pages = [];
|
||||
#extensionServiceWorkers = [];
|
||||
#mcpPages = new Map();
|
||||
#selectedPage;
|
||||
#networkCollector;
|
||||
#consoleCollector;
|
||||
#devtoolsUniverseManager;
|
||||
#extensionRegistry = new ExtensionRegistry();
|
||||
#isRunningTrace = false;
|
||||
#screenRecorderData = null;
|
||||
#inPageTools;
|
||||
#nextPageId = 1;
|
||||
#extensionPages = new WeakMap();
|
||||
#extensionServiceWorkerMap = new WeakMap();
|
||||
#nextExtensionServiceWorkerId = 1;
|
||||
#nextSnapshotId = 1;
|
||||
#traceResults = [];
|
||||
#locatorClass;
|
||||
#options;
|
||||
constructor(browser, logger, options, locatorClass) {
|
||||
this.browser = browser;
|
||||
this.logger = logger;
|
||||
this.#locatorClass = locatorClass;
|
||||
this.#options = options;
|
||||
this.#networkCollector = new NetworkCollector(this.browser);
|
||||
this.#consoleCollector = new ConsoleCollector(this.browser, collect => {
|
||||
return {
|
||||
console: event => {
|
||||
collect(event);
|
||||
},
|
||||
uncaughtError: event => {
|
||||
collect(event);
|
||||
},
|
||||
issue: event => {
|
||||
collect(event);
|
||||
},
|
||||
};
|
||||
});
|
||||
this.#devtoolsUniverseManager = new UniverseManager(this.browser);
|
||||
}
|
||||
async #init() {
|
||||
const pages = await this.createPagesSnapshot();
|
||||
await this.createExtensionServiceWorkersSnapshot();
|
||||
await this.#networkCollector.init(pages);
|
||||
await this.#consoleCollector.init(pages);
|
||||
await this.#devtoolsUniverseManager.init(pages);
|
||||
}
|
||||
dispose() {
|
||||
this.#networkCollector.dispose();
|
||||
this.#consoleCollector.dispose();
|
||||
this.#devtoolsUniverseManager.dispose();
|
||||
for (const mcpPage of this.#mcpPages.values()) {
|
||||
mcpPage.dispose();
|
||||
}
|
||||
this.#mcpPages.clear();
|
||||
// Isolated contexts are intentionally not closed here.
|
||||
// Either the entire browser will be closed or we disconnect
|
||||
// without destroying browser state.
|
||||
this.#isolatedContexts.clear();
|
||||
}
|
||||
static async from(browser, logger, opts,
|
||||
/* Let tests use unbundled Locator class to avoid overly strict checks within puppeteer that fail when mixing bundled and unbundled class instances */
|
||||
locatorClass = Locator) {
|
||||
const context = new McpContext(browser, logger, opts, locatorClass);
|
||||
await context.#init();
|
||||
return context;
|
||||
}
|
||||
resolveCdpRequestId(page, cdpRequestId) {
|
||||
if (!cdpRequestId) {
|
||||
this.logger('no network request');
|
||||
return;
|
||||
}
|
||||
const request = this.#networkCollector.find(page.pptrPage, request => {
|
||||
// @ts-expect-error id is internal.
|
||||
return request.id === cdpRequestId;
|
||||
});
|
||||
if (!request) {
|
||||
this.logger('no network request for ' + cdpRequestId);
|
||||
return;
|
||||
}
|
||||
return this.#networkCollector.getIdForResource(request);
|
||||
}
|
||||
resolveCdpElementId(page, cdpBackendNodeId) {
|
||||
if (!cdpBackendNodeId) {
|
||||
this.logger('no cdpBackendNodeId');
|
||||
return;
|
||||
}
|
||||
const snapshot = page.textSnapshot;
|
||||
if (!snapshot) {
|
||||
this.logger('no text snapshot');
|
||||
return;
|
||||
}
|
||||
// TODO: index by backendNodeId instead.
|
||||
const queue = [snapshot.root];
|
||||
while (queue.length) {
|
||||
const current = queue.pop();
|
||||
if (current.backendNodeId === cdpBackendNodeId) {
|
||||
return current.id;
|
||||
}
|
||||
for (const child of current.children) {
|
||||
queue.push(child);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
getNetworkRequests(page, includePreservedRequests) {
|
||||
return this.#networkCollector.getData(page.pptrPage, includePreservedRequests);
|
||||
}
|
||||
getConsoleData(page, includePreservedMessages) {
|
||||
return this.#consoleCollector.getData(page.pptrPage, includePreservedMessages);
|
||||
}
|
||||
getDevToolsUniverse(page) {
|
||||
return this.#devtoolsUniverseManager.get(page.pptrPage);
|
||||
}
|
||||
getConsoleMessageStableId(message) {
|
||||
return this.#consoleCollector.getIdForResource(message);
|
||||
}
|
||||
getConsoleMessageById(page, id) {
|
||||
return this.#consoleCollector.getById(page.pptrPage, id);
|
||||
}
|
||||
async newPage(background, isolatedContextName) {
|
||||
let page;
|
||||
if (isolatedContextName !== undefined) {
|
||||
let ctx = this.#isolatedContexts.get(isolatedContextName);
|
||||
if (!ctx) {
|
||||
ctx = await this.browser.createBrowserContext();
|
||||
this.#isolatedContexts.set(isolatedContextName, ctx);
|
||||
}
|
||||
page = await ctx.newPage();
|
||||
}
|
||||
else {
|
||||
page = await this.browser.newPage({ background });
|
||||
}
|
||||
await this.createPagesSnapshot();
|
||||
this.selectPage(this.#getMcpPage(page));
|
||||
this.#networkCollector.addPage(page);
|
||||
this.#consoleCollector.addPage(page);
|
||||
return this.#getMcpPage(page);
|
||||
}
|
||||
async closePage(pageId) {
|
||||
if (this.#pages.length === 1) {
|
||||
throw new Error(CLOSE_PAGE_ERROR);
|
||||
}
|
||||
const page = this.getPageById(pageId);
|
||||
if (page) {
|
||||
page.dispose();
|
||||
this.#mcpPages.delete(page.pptrPage);
|
||||
}
|
||||
await page.pptrPage.close({ runBeforeUnload: false });
|
||||
}
|
||||
getNetworkRequestById(page, reqid) {
|
||||
return this.#networkCollector.getById(page.pptrPage, reqid);
|
||||
}
|
||||
async restoreEmulation(page) {
|
||||
const currentSetting = page.emulationSettings;
|
||||
await this.emulate(currentSetting, page.pptrPage);
|
||||
}
|
||||
async emulate(options, targetPage) {
|
||||
const page = targetPage ?? this.getSelectedPptrPage();
|
||||
const mcpPage = this.#getMcpPage(page);
|
||||
const newSettings = { ...mcpPage.emulationSettings };
|
||||
if (!options.networkConditions) {
|
||||
await page.emulateNetworkConditions(null);
|
||||
delete newSettings.networkConditions;
|
||||
}
|
||||
else if (options.networkConditions === 'Offline') {
|
||||
await page.emulateNetworkConditions({
|
||||
offline: true,
|
||||
download: 0,
|
||||
upload: 0,
|
||||
latency: 0,
|
||||
});
|
||||
newSettings.networkConditions = 'Offline';
|
||||
}
|
||||
else if (options.networkConditions in PredefinedNetworkConditions) {
|
||||
const networkCondition = PredefinedNetworkConditions[options.networkConditions];
|
||||
await page.emulateNetworkConditions(networkCondition);
|
||||
newSettings.networkConditions = options.networkConditions;
|
||||
}
|
||||
if (!options.cpuThrottlingRate) {
|
||||
await page.emulateCPUThrottling(1);
|
||||
delete newSettings.cpuThrottlingRate;
|
||||
}
|
||||
else {
|
||||
await page.emulateCPUThrottling(options.cpuThrottlingRate);
|
||||
newSettings.cpuThrottlingRate = options.cpuThrottlingRate;
|
||||
}
|
||||
if (!options.geolocation) {
|
||||
await page.setGeolocation({ latitude: 0, longitude: 0 });
|
||||
delete newSettings.geolocation;
|
||||
}
|
||||
else {
|
||||
await page.setGeolocation(options.geolocation);
|
||||
newSettings.geolocation = options.geolocation;
|
||||
}
|
||||
if (!options.userAgent) {
|
||||
await page.setUserAgent({ userAgent: undefined });
|
||||
delete newSettings.userAgent;
|
||||
}
|
||||
else {
|
||||
await page.setUserAgent({ userAgent: options.userAgent });
|
||||
newSettings.userAgent = options.userAgent;
|
||||
}
|
||||
if (!options.colorScheme || options.colorScheme === 'auto') {
|
||||
await page.emulateMediaFeatures([
|
||||
{ name: 'prefers-color-scheme', value: '' },
|
||||
]);
|
||||
delete newSettings.colorScheme;
|
||||
}
|
||||
else {
|
||||
await page.emulateMediaFeatures([
|
||||
{ name: 'prefers-color-scheme', value: options.colorScheme },
|
||||
]);
|
||||
newSettings.colorScheme = options.colorScheme;
|
||||
}
|
||||
if (!options.viewport) {
|
||||
await page.setViewport(null);
|
||||
delete newSettings.viewport;
|
||||
}
|
||||
else {
|
||||
const defaults = {
|
||||
deviceScaleFactor: 1,
|
||||
isMobile: false,
|
||||
hasTouch: false,
|
||||
isLandscape: false,
|
||||
};
|
||||
const viewport = { ...defaults, ...options.viewport };
|
||||
await page.setViewport(viewport);
|
||||
newSettings.viewport = viewport;
|
||||
}
|
||||
mcpPage.emulationSettings = Object.keys(newSettings).length
|
||||
? newSettings
|
||||
: {};
|
||||
this.#updateSelectedPageTimeouts();
|
||||
}
|
||||
setIsRunningPerformanceTrace(x) {
|
||||
this.#isRunningTrace = x;
|
||||
}
|
||||
isRunningPerformanceTrace() {
|
||||
return this.#isRunningTrace;
|
||||
}
|
||||
getScreenRecorder() {
|
||||
return this.#screenRecorderData;
|
||||
}
|
||||
setScreenRecorder(data) {
|
||||
this.#screenRecorderData = data;
|
||||
}
|
||||
isCruxEnabled() {
|
||||
return this.#options.performanceCrux;
|
||||
}
|
||||
getSelectedPptrPage() {
|
||||
const page = this.#selectedPage;
|
||||
if (!page) {
|
||||
throw new Error('No page selected');
|
||||
}
|
||||
if (page.pptrPage.isClosed()) {
|
||||
throw new Error(`The selected page has been closed. Call ${listPages().name} to see open pages.`);
|
||||
}
|
||||
return page.pptrPage;
|
||||
}
|
||||
getSelectedMcpPage() {
|
||||
const page = this.getSelectedPptrPage();
|
||||
return this.#getMcpPage(page);
|
||||
}
|
||||
getPageById(pageId) {
|
||||
const page = this.#mcpPages.values().find(mcpPage => mcpPage.id === pageId);
|
||||
if (!page) {
|
||||
throw new Error('No page found');
|
||||
}
|
||||
return page;
|
||||
}
|
||||
getPageId(page) {
|
||||
return this.#mcpPages.get(page)?.id;
|
||||
}
|
||||
#getMcpPage(page) {
|
||||
const mcpPage = this.#mcpPages.get(page);
|
||||
if (!mcpPage) {
|
||||
throw new Error('No McpPage found for the given page.');
|
||||
}
|
||||
return mcpPage;
|
||||
}
|
||||
#getSelectedMcpPage() {
|
||||
return this.#getMcpPage(this.getSelectedPptrPage());
|
||||
}
|
||||
isPageSelected(page) {
|
||||
return this.#selectedPage?.pptrPage === page;
|
||||
}
|
||||
selectPage(newPage) {
|
||||
this.#selectedPage = newPage;
|
||||
this.#updateSelectedPageTimeouts();
|
||||
}
|
||||
setInPageTools(toolGroup) {
|
||||
this.#inPageTools = toolGroup;
|
||||
}
|
||||
getInPageTools() {
|
||||
return this.#inPageTools;
|
||||
}
|
||||
#updateSelectedPageTimeouts() {
|
||||
const page = this.#getSelectedMcpPage();
|
||||
// For waiters 5sec timeout should be sufficient.
|
||||
// Increased in case we throttle the CPU
|
||||
const cpuMultiplier = page.cpuThrottlingRate;
|
||||
page.pptrPage.setDefaultTimeout(DEFAULT_TIMEOUT * cpuMultiplier);
|
||||
// 10sec should be enough for the load event to be emitted during
|
||||
// navigations.
|
||||
// Increased in case we throttle the network requests
|
||||
const networkMultiplier = getNetworkMultiplierFromString(page.networkConditions);
|
||||
page.pptrPage.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT * networkMultiplier);
|
||||
}
|
||||
// Linear scan over per-page snapshots. The page count is small (typically
|
||||
// 2-10) so a reverse index isn't worthwhile given the uid-reuse lifecycle
|
||||
// complexity it would introduce.
|
||||
getAXNodeByUid(uid) {
|
||||
for (const mcpPage of this.#mcpPages.values()) {
|
||||
const node = mcpPage.textSnapshot?.idToNode.get(uid);
|
||||
if (node) {
|
||||
return node;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
/**
|
||||
* Creates a snapshot of the extension service workers.
|
||||
*/
|
||||
async createExtensionServiceWorkersSnapshot() {
|
||||
const allTargets = await this.browser.targets();
|
||||
const serviceWorkers = allTargets.filter(target => {
|
||||
return (target.type() === 'service_worker' &&
|
||||
target.url().includes('chrome-extension://'));
|
||||
});
|
||||
for (const serviceWorker of serviceWorkers) {
|
||||
if (!this.#extensionServiceWorkerMap.has(serviceWorker)) {
|
||||
this.#extensionServiceWorkerMap.set(serviceWorker, 'sw-' + this.#nextExtensionServiceWorkerId++);
|
||||
}
|
||||
}
|
||||
this.#extensionServiceWorkers = serviceWorkers.map(serviceWorker => {
|
||||
return {
|
||||
target: serviceWorker,
|
||||
id: this.#extensionServiceWorkerMap.get(serviceWorker),
|
||||
url: serviceWorker.url(),
|
||||
};
|
||||
});
|
||||
return this.#extensionServiceWorkers;
|
||||
}
|
||||
async createPagesSnapshot() {
|
||||
const { pages: allPages, isolatedContextNames } = await this.#getAllPages();
|
||||
for (const page of allPages) {
|
||||
let mcpPage = this.#mcpPages.get(page);
|
||||
if (!mcpPage) {
|
||||
mcpPage = new McpPage(page, this.#nextPageId++);
|
||||
this.#mcpPages.set(page, mcpPage);
|
||||
// We emulate a focused page for all pages to support multi-agent workflows.
|
||||
void page.emulateFocusedPage(true).catch(error => {
|
||||
this.logger('Error turning on focused page emulation', error);
|
||||
});
|
||||
}
|
||||
mcpPage.isolatedContextName = isolatedContextNames.get(page);
|
||||
}
|
||||
// Prune orphaned #mcpPages entries (pages that no longer exist).
|
||||
const currentPages = new Set(allPages);
|
||||
for (const [page, mcpPage] of this.#mcpPages) {
|
||||
if (!currentPages.has(page)) {
|
||||
mcpPage.dispose();
|
||||
this.#mcpPages.delete(page);
|
||||
}
|
||||
}
|
||||
this.#pages = allPages.filter(page => {
|
||||
return (this.#options.experimentalDevToolsDebugging ||
|
||||
!page.url().startsWith('devtools://'));
|
||||
});
|
||||
if ((!this.#selectedPage ||
|
||||
this.#pages.indexOf(this.#selectedPage.pptrPage) === -1) &&
|
||||
this.#pages[0]) {
|
||||
this.selectPage(this.#getMcpPage(this.#pages[0]));
|
||||
}
|
||||
await this.detectOpenDevToolsWindows();
|
||||
return this.#pages;
|
||||
}
|
||||
async #getAllPages() {
|
||||
const defaultCtx = this.browser.defaultBrowserContext();
|
||||
const allPages = await this.browser.pages(this.#options.experimentalIncludeAllPages);
|
||||
const allTargets = this.browser.targets();
|
||||
const extensionTargets = allTargets.filter(target => {
|
||||
return (target.url().startsWith('chrome-extension://') &&
|
||||
target.type() === 'page');
|
||||
});
|
||||
for (const target of extensionTargets) {
|
||||
// Right now target.page() returns null for popup and side panel pages.
|
||||
let page = await target.page();
|
||||
if (!page) {
|
||||
// We need to cache pages instances for targets because target.asPage()
|
||||
// returns a new page instance every time.
|
||||
page = this.#extensionPages.get(target) ?? null;
|
||||
if (!page) {
|
||||
try {
|
||||
page = await target.asPage();
|
||||
this.#extensionPages.set(target, page);
|
||||
}
|
||||
catch (e) {
|
||||
this.logger('Failed to get page for extension target', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (page && !allPages.includes(page)) {
|
||||
allPages.push(page);
|
||||
}
|
||||
}
|
||||
// Build a reverse lookup from BrowserContext instance → name.
|
||||
const contextToName = new Map();
|
||||
for (const [name, ctx] of this.#isolatedContexts) {
|
||||
contextToName.set(ctx, name);
|
||||
}
|
||||
// Auto-discover BrowserContexts not in our mapping (e.g., externally
|
||||
// created incognito contexts) and assign generated names.
|
||||
const knownContexts = new Set(this.#isolatedContexts.values());
|
||||
for (const ctx of this.browser.browserContexts()) {
|
||||
if (ctx !== defaultCtx && !ctx.closed && !knownContexts.has(ctx)) {
|
||||
const name = `isolated-context-${this.#nextIsolatedContextId++}`;
|
||||
this.#isolatedContexts.set(name, ctx);
|
||||
contextToName.set(ctx, name);
|
||||
}
|
||||
}
|
||||
// Map each page to its isolated context name (if any).
|
||||
const isolatedContextNames = new Map();
|
||||
for (const page of allPages) {
|
||||
const ctx = page.browserContext();
|
||||
const name = contextToName.get(ctx);
|
||||
if (name) {
|
||||
isolatedContextNames.set(page, name);
|
||||
}
|
||||
}
|
||||
return { pages: allPages, isolatedContextNames };
|
||||
}
|
||||
async detectOpenDevToolsWindows() {
|
||||
this.logger('Detecting open DevTools windows');
|
||||
const { pages } = await this.#getAllPages();
|
||||
await Promise.all(pages.map(async (page) => {
|
||||
const mcpPage = this.#mcpPages.get(page);
|
||||
if (!mcpPage) {
|
||||
return;
|
||||
}
|
||||
// Prior to Chrome 144.0.7559.59, the command fails,
|
||||
// Some Electron apps still use older version
|
||||
// Fall back to not exposing DevTools at all.
|
||||
try {
|
||||
if (await page.hasDevTools()) {
|
||||
mcpPage.devToolsPage = await page.openDevTools();
|
||||
}
|
||||
else {
|
||||
mcpPage.devToolsPage = undefined;
|
||||
}
|
||||
}
|
||||
catch {
|
||||
mcpPage.devToolsPage = undefined;
|
||||
}
|
||||
}));
|
||||
}
|
||||
getExtensionServiceWorkers() {
|
||||
return this.#extensionServiceWorkers;
|
||||
}
|
||||
getExtensionServiceWorkerId(extensionServiceWorker) {
|
||||
return this.#extensionServiceWorkerMap.get(extensionServiceWorker.target);
|
||||
}
|
||||
getPages() {
|
||||
return this.#pages;
|
||||
}
|
||||
getIsolatedContextName(page) {
|
||||
return this.#mcpPages.get(page)?.isolatedContextName;
|
||||
}
|
||||
getDevToolsPage(page) {
|
||||
return this.#mcpPages.get(page)?.devToolsPage;
|
||||
}
|
||||
async getDevToolsData(page) {
|
||||
try {
|
||||
this.logger('Getting DevTools UI data');
|
||||
const devtoolsPage = this.getDevToolsPage(page.pptrPage);
|
||||
if (!devtoolsPage) {
|
||||
this.logger('No DevTools page detected');
|
||||
return {};
|
||||
}
|
||||
const { cdpRequestId, cdpBackendNodeId } = await devtoolsPage.evaluate(async () => {
|
||||
// @ts-expect-error no types
|
||||
const UI = await import('/bundled/ui/legacy/legacy.js');
|
||||
// @ts-expect-error no types
|
||||
const SDK = await import('/bundled/core/sdk/sdk.js');
|
||||
const request = UI.Context.Context.instance().flavor(SDK.NetworkRequest.NetworkRequest);
|
||||
const node = UI.Context.Context.instance().flavor(SDK.DOMModel.DOMNode);
|
||||
return {
|
||||
cdpRequestId: request?.requestId(),
|
||||
cdpBackendNodeId: node?.backendNodeId(),
|
||||
};
|
||||
});
|
||||
return { cdpBackendNodeId, cdpRequestId };
|
||||
}
|
||||
catch (err) {
|
||||
this.logger('error getting devtools data', err);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
/**
|
||||
* Creates a text snapshot of a page.
|
||||
*/
|
||||
async createTextSnapshot(page, verbose = false, devtoolsData = undefined) {
|
||||
const rootNode = await page.pptrPage.accessibility.snapshot({
|
||||
includeIframes: true,
|
||||
interestingOnly: !verbose,
|
||||
});
|
||||
if (!rootNode) {
|
||||
return;
|
||||
}
|
||||
const { uniqueBackendNodeIdToMcpId } = page;
|
||||
const snapshotId = this.#nextSnapshotId++;
|
||||
// Iterate through the whole accessibility node tree and assign node ids that
|
||||
// will be used for the tree serialization and mapping ids back to nodes.
|
||||
let idCounter = 0;
|
||||
const idToNode = new Map();
|
||||
const seenUniqueIds = new Set();
|
||||
const assignIds = (node) => {
|
||||
let id = '';
|
||||
// @ts-expect-error untyped loaderId & backendNodeId.
|
||||
const uniqueBackendId = `${node.loaderId}_${node.backendNodeId}`;
|
||||
if (uniqueBackendNodeIdToMcpId.has(uniqueBackendId)) {
|
||||
// Re-use MCP exposed ID if the uniqueId is the same.
|
||||
id = uniqueBackendNodeIdToMcpId.get(uniqueBackendId);
|
||||
}
|
||||
else {
|
||||
// Only generate a new ID if we have not seen the node before.
|
||||
id = `${snapshotId}_${idCounter++}`;
|
||||
uniqueBackendNodeIdToMcpId.set(uniqueBackendId, id);
|
||||
}
|
||||
seenUniqueIds.add(uniqueBackendId);
|
||||
const nodeWithId = {
|
||||
...node,
|
||||
id,
|
||||
children: node.children
|
||||
? node.children.map(child => assignIds(child))
|
||||
: [],
|
||||
};
|
||||
// The AXNode for an option doesn't contain its `value`.
|
||||
// Therefore, set text content of the option as value.
|
||||
if (node.role === 'option') {
|
||||
const optionText = node.name;
|
||||
if (optionText) {
|
||||
nodeWithId.value = optionText.toString();
|
||||
}
|
||||
}
|
||||
idToNode.set(nodeWithId.id, nodeWithId);
|
||||
return nodeWithId;
|
||||
};
|
||||
const rootNodeWithId = assignIds(rootNode);
|
||||
const snapshot = {
|
||||
root: rootNodeWithId,
|
||||
snapshotId: String(snapshotId),
|
||||
idToNode,
|
||||
hasSelectedElement: false,
|
||||
verbose,
|
||||
};
|
||||
page.textSnapshot = snapshot;
|
||||
const data = devtoolsData ?? (await this.getDevToolsData(page));
|
||||
if (data?.cdpBackendNodeId) {
|
||||
snapshot.hasSelectedElement = true;
|
||||
snapshot.selectedElementUid = this.resolveCdpElementId(page, data?.cdpBackendNodeId);
|
||||
}
|
||||
// Clean up unique IDs that we did not see anymore.
|
||||
for (const key of uniqueBackendNodeIdToMcpId.keys()) {
|
||||
if (!seenUniqueIds.has(key)) {
|
||||
uniqueBackendNodeIdToMcpId.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
async saveTemporaryFile(data, filename) {
|
||||
return await saveTemporaryFile(data, filename);
|
||||
}
|
||||
async saveFile(data, filename) {
|
||||
try {
|
||||
const filePath = path.resolve(filename);
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, data);
|
||||
return { filename: filePath };
|
||||
}
|
||||
catch (err) {
|
||||
this.logger(err);
|
||||
throw new Error('Could not save a file', { cause: err });
|
||||
}
|
||||
}
|
||||
storeTraceRecording(result) {
|
||||
// Clear the trace results because we only consume the latest trace currently.
|
||||
this.#traceResults = [];
|
||||
this.#traceResults.push(result);
|
||||
}
|
||||
recordedTraces() {
|
||||
return this.#traceResults;
|
||||
}
|
||||
getWaitForHelper(page, cpuMultiplier, networkMultiplier) {
|
||||
return new WaitForHelper(page, cpuMultiplier, networkMultiplier);
|
||||
}
|
||||
waitForEventsAfterAction(action, options) {
|
||||
const page = this.#getSelectedMcpPage();
|
||||
const cpuMultiplier = page.cpuThrottlingRate;
|
||||
const networkMultiplier = getNetworkMultiplierFromString(page.networkConditions);
|
||||
const waitForHelper = this.getWaitForHelper(page.pptrPage, cpuMultiplier, networkMultiplier);
|
||||
return waitForHelper.waitForEventsAfterAction(action, options);
|
||||
}
|
||||
getNetworkRequestStableId(request) {
|
||||
return this.#networkCollector.getIdForResource(request);
|
||||
}
|
||||
waitForTextOnPage(text, timeout, targetPage) {
|
||||
const page = targetPage ?? this.getSelectedPptrPage();
|
||||
const frames = page.frames();
|
||||
let locator = this.#locatorClass.race(frames.flatMap(frame => text.flatMap(value => [
|
||||
frame.locator(`aria/${value}`),
|
||||
frame.locator(`text/${value}`),
|
||||
])));
|
||||
if (timeout) {
|
||||
locator = locator.setTimeout(timeout);
|
||||
}
|
||||
return locator.wait();
|
||||
}
|
||||
/**
|
||||
* We need to ignore favicon request as they make our test flaky
|
||||
*/
|
||||
async setUpNetworkCollectorForTesting() {
|
||||
this.#networkCollector = new NetworkCollector(this.browser, collect => {
|
||||
return {
|
||||
request: req => {
|
||||
if (req.url().includes('favicon.ico')) {
|
||||
return;
|
||||
}
|
||||
collect(req);
|
||||
},
|
||||
};
|
||||
});
|
||||
const { pages } = await this.#getAllPages();
|
||||
await this.#networkCollector.init(pages);
|
||||
}
|
||||
async installExtension(extensionPath) {
|
||||
const id = await this.browser.installExtension(extensionPath);
|
||||
await this.#extensionRegistry.registerExtension(id, extensionPath);
|
||||
return id;
|
||||
}
|
||||
async uninstallExtension(id) {
|
||||
await this.browser.uninstallExtension(id);
|
||||
this.#extensionRegistry.remove(id);
|
||||
}
|
||||
async triggerExtensionAction(id) {
|
||||
const page = this.getSelectedPptrPage();
|
||||
// @ts-expect-error internal puppeteer api is needed since we don't have a way to get
|
||||
// a tab id at the moment
|
||||
const theTarget = page._tabId;
|
||||
const session = await this.browser.target().createCDPSession();
|
||||
try {
|
||||
await session.send('Extensions.triggerAction', {
|
||||
id,
|
||||
targetId: theTarget,
|
||||
});
|
||||
}
|
||||
finally {
|
||||
await session.detach();
|
||||
}
|
||||
}
|
||||
listExtensions() {
|
||||
return this.#extensionRegistry.list();
|
||||
}
|
||||
getExtension(id) {
|
||||
return this.#extensionRegistry.getById(id);
|
||||
}
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { takeSnapshot } from './tools/snapshot.js';
|
||||
/**
|
||||
* Per-page state wrapper. Consolidates dialog, snapshot, emulation,
|
||||
* and metadata that were previously scattered across Maps in McpContext.
|
||||
*
|
||||
* Internal class consumed only by McpContext. Fields are public for direct
|
||||
* read/write access. The dialog field is private because it requires an
|
||||
* event listener lifecycle managed by the constructor/dispose pair.
|
||||
*/
|
||||
export class McpPage {
|
||||
pptrPage;
|
||||
id;
|
||||
// Snapshot
|
||||
textSnapshot = null;
|
||||
uniqueBackendNodeIdToMcpId = new Map();
|
||||
// Emulation
|
||||
emulationSettings = {};
|
||||
// Metadata
|
||||
isolatedContextName;
|
||||
devToolsPage;
|
||||
// Dialog
|
||||
#dialog;
|
||||
#dialogHandler;
|
||||
constructor(page, id) {
|
||||
this.pptrPage = page;
|
||||
this.id = id;
|
||||
this.#dialogHandler = (dialog) => {
|
||||
this.#dialog = dialog;
|
||||
};
|
||||
page.on('dialog', this.#dialogHandler);
|
||||
}
|
||||
get dialog() {
|
||||
return this.#dialog;
|
||||
}
|
||||
getDialog() {
|
||||
return this.dialog;
|
||||
}
|
||||
clearDialog() {
|
||||
this.#dialog = undefined;
|
||||
}
|
||||
get networkConditions() {
|
||||
return this.emulationSettings.networkConditions ?? null;
|
||||
}
|
||||
get cpuThrottlingRate() {
|
||||
return this.emulationSettings.cpuThrottlingRate ?? 1;
|
||||
}
|
||||
get geolocation() {
|
||||
return this.emulationSettings.geolocation ?? null;
|
||||
}
|
||||
get viewport() {
|
||||
return this.emulationSettings.viewport ?? null;
|
||||
}
|
||||
get userAgent() {
|
||||
return this.emulationSettings.userAgent ?? null;
|
||||
}
|
||||
get colorScheme() {
|
||||
return this.emulationSettings.colorScheme ?? null;
|
||||
}
|
||||
dispose() {
|
||||
this.pptrPage.off('dialog', this.#dialogHandler);
|
||||
}
|
||||
async getElementByUid(uid) {
|
||||
if (!this.textSnapshot) {
|
||||
throw new Error(`No snapshot found for page ${this.id ?? '?'}. Use ${takeSnapshot.name} to capture one.`);
|
||||
}
|
||||
const node = this.textSnapshot.idToNode.get(uid);
|
||||
if (!node) {
|
||||
throw new Error(`Element uid "${uid}" not found on page ${this.id}.`);
|
||||
}
|
||||
return this.#resolveElementHandle(node, uid);
|
||||
}
|
||||
async #resolveElementHandle(node, uid) {
|
||||
const message = `Element with uid ${uid} no longer exists on the page.`;
|
||||
try {
|
||||
const handle = await node.elementHandle();
|
||||
if (!handle) {
|
||||
throw new Error(message);
|
||||
}
|
||||
return handle;
|
||||
}
|
||||
catch (error) {
|
||||
throw new Error(message, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
getAXNodeByUid(uid) {
|
||||
return this.textSnapshot?.idToNode.get(uid);
|
||||
}
|
||||
}
|
||||
+668
@@ -0,0 +1,668 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { ConsoleFormatter } from './formatters/ConsoleFormatter.js';
|
||||
import { IssueFormatter } from './formatters/IssueFormatter.js';
|
||||
import { NetworkFormatter } from './formatters/NetworkFormatter.js';
|
||||
import { SnapshotFormatter } from './formatters/SnapshotFormatter.js';
|
||||
import { UncaughtError } from './PageCollector.js';
|
||||
import { DevTools } from './third_party/index.js';
|
||||
import { handleDialog } from './tools/pages.js';
|
||||
import { getInsightOutput, getTraceSummary } from './trace-processing/parse.js';
|
||||
import { paginate } from './utils/pagination.js';
|
||||
async function getToolGroup(page) {
|
||||
// Check if there is a `devtoolstooldiscovery` event listener
|
||||
const windowHandle = await page.pptrPage.evaluateHandle(() => window);
|
||||
// @ts-expect-error internal API
|
||||
const client = page.pptrPage._client();
|
||||
const { listeners } = await client.send('DOMDebugger.getEventListeners', {
|
||||
objectId: windowHandle.remoteObject().objectId,
|
||||
});
|
||||
if (listeners.find(l => l.type === 'devtoolstooldiscovery') === undefined) {
|
||||
return;
|
||||
}
|
||||
const toolGroup = await page.pptrPage.evaluate(() => {
|
||||
return new Promise(resolve => {
|
||||
const event = new CustomEvent('devtoolstooldiscovery');
|
||||
// @ts-expect-error Adding custom property
|
||||
event.respondWith = (toolGroup) => {
|
||||
if (!window.__dtmcp) {
|
||||
window.__dtmcp = {};
|
||||
}
|
||||
window.__dtmcp.toolGroup = toolGroup;
|
||||
// When receiving a toolGroup for the first time, expose a simple execution helper
|
||||
if (!window.__dtmcp.executeTool) {
|
||||
window.__dtmcp.executeTool = async (toolName, args) => {
|
||||
if (!window.__dtmcp?.toolGroup) {
|
||||
throw new Error('No tools found on the page');
|
||||
}
|
||||
const tool = window.__dtmcp.toolGroup.tools.find(t => t.name === toolName);
|
||||
if (!tool) {
|
||||
throw new Error(`Tool ${toolName} not found`);
|
||||
}
|
||||
return await tool.execute(args);
|
||||
};
|
||||
}
|
||||
resolve(toolGroup);
|
||||
};
|
||||
window.dispatchEvent(event);
|
||||
// If the page does not synchronously call `event.respondWith`, return instead of timing out
|
||||
setTimeout(() => {
|
||||
resolve(undefined);
|
||||
}, 0);
|
||||
});
|
||||
});
|
||||
return toolGroup;
|
||||
}
|
||||
export class McpResponse {
|
||||
#includePages = false;
|
||||
#includeExtensionServiceWorkers = false;
|
||||
#includeExtensionPages = false;
|
||||
#snapshotParams;
|
||||
#attachedNetworkRequestId;
|
||||
#attachedNetworkRequestOptions;
|
||||
#attachedConsoleMessageId;
|
||||
#attachedTraceSummary;
|
||||
#attachedTraceInsight;
|
||||
#attachedLighthouseResult;
|
||||
#textResponseLines = [];
|
||||
#images = [];
|
||||
#networkRequestsOptions;
|
||||
#consoleDataOptions;
|
||||
#listExtensions;
|
||||
#listInPageTools;
|
||||
#devToolsData;
|
||||
#tabId;
|
||||
#args;
|
||||
#page;
|
||||
constructor(args) {
|
||||
this.#args = args;
|
||||
}
|
||||
setPage(page) {
|
||||
this.#page = page;
|
||||
}
|
||||
attachDevToolsData(data) {
|
||||
this.#devToolsData = data;
|
||||
}
|
||||
setTabId(tabId) {
|
||||
this.#tabId = tabId;
|
||||
}
|
||||
setIncludePages(value) {
|
||||
this.#includePages = value;
|
||||
if (this.#args.categoryExtensions) {
|
||||
this.#includeExtensionServiceWorkers = value;
|
||||
this.#includeExtensionPages = value;
|
||||
}
|
||||
}
|
||||
includeSnapshot(params) {
|
||||
this.#snapshotParams = params ?? {
|
||||
verbose: false,
|
||||
};
|
||||
}
|
||||
setListExtensions() {
|
||||
this.#listExtensions = true;
|
||||
}
|
||||
setListInPageTools() {
|
||||
if (this.#args.categoryInPageTools) {
|
||||
this.#listInPageTools = true;
|
||||
}
|
||||
}
|
||||
setIncludeNetworkRequests(value, options) {
|
||||
if (!value) {
|
||||
this.#networkRequestsOptions = undefined;
|
||||
return;
|
||||
}
|
||||
this.#networkRequestsOptions = {
|
||||
include: value,
|
||||
pagination: options?.pageSize || options?.pageIdx
|
||||
? {
|
||||
pageSize: options.pageSize,
|
||||
pageIdx: options.pageIdx,
|
||||
}
|
||||
: undefined,
|
||||
resourceTypes: options?.resourceTypes,
|
||||
includePreservedRequests: options?.includePreservedRequests,
|
||||
networkRequestIdInDevToolsUI: options?.networkRequestIdInDevToolsUI,
|
||||
};
|
||||
}
|
||||
setIncludeConsoleData(value, options) {
|
||||
if (!value) {
|
||||
this.#consoleDataOptions = undefined;
|
||||
return;
|
||||
}
|
||||
this.#consoleDataOptions = {
|
||||
include: value,
|
||||
pagination: options?.pageSize || options?.pageIdx
|
||||
? {
|
||||
pageSize: options.pageSize,
|
||||
pageIdx: options.pageIdx,
|
||||
}
|
||||
: undefined,
|
||||
types: options?.types,
|
||||
includePreservedMessages: options?.includePreservedMessages,
|
||||
};
|
||||
}
|
||||
attachNetworkRequest(reqId, options) {
|
||||
this.#attachedNetworkRequestId = reqId;
|
||||
this.#attachedNetworkRequestOptions = options;
|
||||
}
|
||||
attachConsoleMessage(msgid) {
|
||||
this.#attachedConsoleMessageId = msgid;
|
||||
}
|
||||
attachTraceSummary(result) {
|
||||
this.#attachedTraceSummary = result;
|
||||
}
|
||||
attachTraceInsight(trace, insightSetId, insightName) {
|
||||
this.#attachedTraceInsight = {
|
||||
trace,
|
||||
insightSetId,
|
||||
insightName,
|
||||
};
|
||||
}
|
||||
attachLighthouseResult(result) {
|
||||
this.#attachedLighthouseResult = result;
|
||||
}
|
||||
get includePages() {
|
||||
return this.#includePages;
|
||||
}
|
||||
get attachedTraceSummary() {
|
||||
return this.#attachedTraceSummary;
|
||||
}
|
||||
get attachedTracedInsight() {
|
||||
return this.#attachedTraceInsight;
|
||||
}
|
||||
get attachedLighthouseResult() {
|
||||
return this.#attachedLighthouseResult;
|
||||
}
|
||||
get includeNetworkRequests() {
|
||||
return this.#networkRequestsOptions?.include ?? false;
|
||||
}
|
||||
get includeConsoleData() {
|
||||
return this.#consoleDataOptions?.include ?? false;
|
||||
}
|
||||
get attachedNetworkRequestId() {
|
||||
return this.#attachedNetworkRequestId;
|
||||
}
|
||||
get networkRequestsPageIdx() {
|
||||
return this.#networkRequestsOptions?.pagination?.pageIdx;
|
||||
}
|
||||
get consoleMessagesPageIdx() {
|
||||
return this.#consoleDataOptions?.pagination?.pageIdx;
|
||||
}
|
||||
get consoleMessagesTypes() {
|
||||
return this.#consoleDataOptions?.types;
|
||||
}
|
||||
appendResponseLine(value) {
|
||||
this.#textResponseLines.push(value);
|
||||
}
|
||||
attachImage(value) {
|
||||
this.#images.push(value);
|
||||
}
|
||||
get responseLines() {
|
||||
return this.#textResponseLines;
|
||||
}
|
||||
get images() {
|
||||
return this.#images;
|
||||
}
|
||||
get snapshotParams() {
|
||||
return this.#snapshotParams;
|
||||
}
|
||||
async handle(toolName, context) {
|
||||
if (this.#includePages) {
|
||||
await context.createPagesSnapshot();
|
||||
}
|
||||
if (this.#includeExtensionServiceWorkers) {
|
||||
await context.createExtensionServiceWorkersSnapshot();
|
||||
}
|
||||
let snapshot;
|
||||
if (this.#snapshotParams) {
|
||||
if (!this.#page) {
|
||||
throw new Error('Response must have a page');
|
||||
}
|
||||
await context.createTextSnapshot(this.#page, this.#snapshotParams.verbose, this.#devToolsData);
|
||||
const textSnapshot = this.#page.textSnapshot;
|
||||
if (textSnapshot) {
|
||||
const formatter = new SnapshotFormatter(textSnapshot);
|
||||
if (this.#snapshotParams.filePath) {
|
||||
await context.saveFile(new TextEncoder().encode(formatter.toString()), this.#snapshotParams.filePath);
|
||||
snapshot = this.#snapshotParams.filePath;
|
||||
}
|
||||
else {
|
||||
snapshot = formatter;
|
||||
}
|
||||
}
|
||||
}
|
||||
let detailedNetworkRequest;
|
||||
if (this.#attachedNetworkRequestId) {
|
||||
if (!this.#page) {
|
||||
throw new Error(`Response must have an McpPage`);
|
||||
}
|
||||
const request = context.getNetworkRequestById(this.#page, this.#attachedNetworkRequestId);
|
||||
const formatter = await NetworkFormatter.from(request, {
|
||||
requestId: this.#attachedNetworkRequestId,
|
||||
requestIdResolver: req => context.getNetworkRequestStableId(req),
|
||||
fetchData: true,
|
||||
requestFilePath: this.#attachedNetworkRequestOptions?.requestFilePath,
|
||||
responseFilePath: this.#attachedNetworkRequestOptions?.responseFilePath,
|
||||
saveFile: (data, filename) => context.saveFile(data, filename),
|
||||
});
|
||||
detailedNetworkRequest = formatter;
|
||||
}
|
||||
let detailedConsoleMessage;
|
||||
if (this.#attachedConsoleMessageId) {
|
||||
if (!this.#page) {
|
||||
throw new Error(`Response must have an McpPage`);
|
||||
}
|
||||
const message = context.getConsoleMessageById(this.#page, this.#attachedConsoleMessageId);
|
||||
const consoleMessageStableId = this.#attachedConsoleMessageId;
|
||||
if ('args' in message || message instanceof UncaughtError) {
|
||||
const consoleMessage = message;
|
||||
const devTools = context.getDevToolsUniverse(this.#page);
|
||||
detailedConsoleMessage = await ConsoleFormatter.from(consoleMessage, {
|
||||
id: consoleMessageStableId,
|
||||
fetchDetailedData: true,
|
||||
devTools: devTools ?? undefined,
|
||||
});
|
||||
}
|
||||
else if (message instanceof DevTools.AggregatedIssue) {
|
||||
const formatter = new IssueFormatter(message, {
|
||||
id: consoleMessageStableId,
|
||||
requestIdResolver: context.resolveCdpRequestId.bind(context, this.#page),
|
||||
elementIdResolver: context.resolveCdpElementId.bind(context, this.#page),
|
||||
});
|
||||
if (!formatter.isValid()) {
|
||||
throw new Error("Can't provide details for the msgid " + consoleMessageStableId);
|
||||
}
|
||||
detailedConsoleMessage = formatter;
|
||||
}
|
||||
}
|
||||
let extensions;
|
||||
if (this.#listExtensions) {
|
||||
extensions = context.listExtensions();
|
||||
}
|
||||
let inPageTools;
|
||||
if (this.#listInPageTools) {
|
||||
inPageTools = await getToolGroup(context.getSelectedMcpPage());
|
||||
context.setInPageTools(inPageTools);
|
||||
}
|
||||
let consoleMessages;
|
||||
if (this.#consoleDataOptions?.include) {
|
||||
if (!this.#page) {
|
||||
throw new Error(`Response must have an McpPage`);
|
||||
}
|
||||
const page = this.#page;
|
||||
let messages = context.getConsoleData(this.#page, this.#consoleDataOptions.includePreservedMessages);
|
||||
if (this.#consoleDataOptions.types?.length) {
|
||||
const normalizedTypes = new Set(this.#consoleDataOptions.types);
|
||||
messages = messages.filter(message => {
|
||||
if ('type' in message) {
|
||||
return normalizedTypes.has(message.type());
|
||||
}
|
||||
if (message instanceof DevTools.AggregatedIssue) {
|
||||
return normalizedTypes.has('issue');
|
||||
}
|
||||
return normalizedTypes.has('error');
|
||||
});
|
||||
}
|
||||
consoleMessages = (await Promise.all(messages.map(async (item) => {
|
||||
const consoleMessageStableId = context.getConsoleMessageStableId(item);
|
||||
if ('args' in item || item instanceof UncaughtError) {
|
||||
const consoleMessage = item;
|
||||
const devTools = context.getDevToolsUniverse(page);
|
||||
return await ConsoleFormatter.from(consoleMessage, {
|
||||
id: consoleMessageStableId,
|
||||
fetchDetailedData: false,
|
||||
devTools: devTools ?? undefined,
|
||||
});
|
||||
}
|
||||
if (item instanceof DevTools.AggregatedIssue) {
|
||||
const formatter = new IssueFormatter(item, {
|
||||
id: consoleMessageStableId,
|
||||
});
|
||||
if (!formatter.isValid()) {
|
||||
return null;
|
||||
}
|
||||
return formatter;
|
||||
}
|
||||
return null;
|
||||
}))).filter(item => item !== null);
|
||||
}
|
||||
let networkRequests;
|
||||
if (this.#networkRequestsOptions?.include) {
|
||||
if (!this.#page) {
|
||||
throw new Error(`Response must have an McpPage`);
|
||||
}
|
||||
let requests = context.getNetworkRequests(this.#page, this.#networkRequestsOptions?.includePreservedRequests);
|
||||
// Apply resource type filtering if specified
|
||||
if (this.#networkRequestsOptions.resourceTypes?.length) {
|
||||
const normalizedTypes = new Set(this.#networkRequestsOptions.resourceTypes);
|
||||
requests = requests.filter(request => {
|
||||
const type = request.resourceType();
|
||||
return normalizedTypes.has(type);
|
||||
});
|
||||
}
|
||||
if (requests.length) {
|
||||
networkRequests = await Promise.all(requests.map(request => NetworkFormatter.from(request, {
|
||||
requestId: context.getNetworkRequestStableId(request),
|
||||
selectedInDevToolsUI: context.getNetworkRequestStableId(request) ===
|
||||
this.#networkRequestsOptions?.networkRequestIdInDevToolsUI,
|
||||
fetchData: false,
|
||||
saveFile: (data, filename) => context.saveFile(data, filename),
|
||||
})));
|
||||
}
|
||||
}
|
||||
return this.format(toolName, context, {
|
||||
detailedConsoleMessage,
|
||||
consoleMessages,
|
||||
snapshot,
|
||||
detailedNetworkRequest,
|
||||
networkRequests,
|
||||
traceInsight: this.#attachedTraceInsight,
|
||||
traceSummary: this.#attachedTraceSummary,
|
||||
extensions,
|
||||
lighthouseResult: this.#attachedLighthouseResult,
|
||||
inPageTools,
|
||||
});
|
||||
}
|
||||
format(toolName, context, data) {
|
||||
const structuredContent = {};
|
||||
const response = [];
|
||||
if (this.#textResponseLines.length) {
|
||||
structuredContent.message = this.#textResponseLines.join('\n');
|
||||
response.push(...this.#textResponseLines);
|
||||
}
|
||||
const networkConditions = this.#page?.networkConditions;
|
||||
if (networkConditions) {
|
||||
const timeout = this.#page.pptrPage.getDefaultNavigationTimeout();
|
||||
response.push(`Emulating network conditions: ${networkConditions}`);
|
||||
response.push(`Default navigation timeout set to ${timeout} ms`);
|
||||
structuredContent.networkConditions = networkConditions;
|
||||
structuredContent.navigationTimeout = timeout;
|
||||
}
|
||||
const viewport = this.#page?.viewport;
|
||||
if (viewport) {
|
||||
response.push(`Emulating viewport: ${JSON.stringify(viewport)}`);
|
||||
structuredContent.viewport = viewport;
|
||||
}
|
||||
const userAgent = this.#page?.userAgent;
|
||||
if (userAgent) {
|
||||
response.push(`Emulating user agent: ${userAgent}`);
|
||||
structuredContent.userAgent = userAgent;
|
||||
}
|
||||
const cpuThrottlingRate = this.#page?.cpuThrottlingRate ?? 1;
|
||||
if (cpuThrottlingRate > 1) {
|
||||
response.push(`Emulating CPU throttling: ${cpuThrottlingRate}x slowdown`);
|
||||
structuredContent.cpuThrottlingRate = cpuThrottlingRate;
|
||||
}
|
||||
const colorScheme = this.#page?.colorScheme;
|
||||
if (colorScheme) {
|
||||
response.push(`Emulating color scheme: ${colorScheme}`);
|
||||
structuredContent.colorScheme = colorScheme;
|
||||
}
|
||||
const dialog = this.#page?.getDialog();
|
||||
if (dialog) {
|
||||
const defaultValueIfNeeded = dialog.type() === 'prompt'
|
||||
? ` (default value: "${dialog.defaultValue()}")`
|
||||
: '';
|
||||
response.push(`# Open dialog
|
||||
${dialog.type()}: ${dialog.message()}${defaultValueIfNeeded}.
|
||||
Call ${handleDialog.name} to handle it before continuing.`);
|
||||
structuredContent.dialog = {
|
||||
type: dialog.type(),
|
||||
message: dialog.message(),
|
||||
defaultValue: dialog.defaultValue(),
|
||||
};
|
||||
}
|
||||
if (this.#includePages) {
|
||||
const allPages = context.getPages();
|
||||
const { regularPages, extensionPages } = allPages.reduce((acc, page) => {
|
||||
if (page.url().startsWith('chrome-extension://')) {
|
||||
acc.extensionPages.push(page);
|
||||
}
|
||||
else {
|
||||
acc.regularPages.push(page);
|
||||
}
|
||||
return acc;
|
||||
}, { regularPages: [], extensionPages: [] });
|
||||
if (regularPages.length) {
|
||||
const parts = [`## Pages`];
|
||||
const structuredPages = [];
|
||||
for (const page of regularPages) {
|
||||
const isolatedContextName = context.getIsolatedContextName(page);
|
||||
const contextLabel = isolatedContextName
|
||||
? ` isolatedContext=${isolatedContextName}`
|
||||
: '';
|
||||
parts.push(`${context.getPageId(page)}: ${page.url()}${context.isPageSelected(page) ? ' [selected]' : ''}${contextLabel}`);
|
||||
structuredPages.push(createStructuredPage(page, context));
|
||||
}
|
||||
response.push(...parts);
|
||||
structuredContent.pages = structuredPages;
|
||||
}
|
||||
if (this.#includeExtensionPages) {
|
||||
if (extensionPages.length) {
|
||||
response.push(`## Extension Pages`);
|
||||
const structuredExtensionPages = [];
|
||||
for (const page of extensionPages) {
|
||||
const isolatedContextName = context.getIsolatedContextName(page);
|
||||
const contextLabel = isolatedContextName
|
||||
? ` isolatedContext=${isolatedContextName}`
|
||||
: '';
|
||||
response.push(`${context.getPageId(page)}: ${page.url()}${context.isPageSelected(page) ? ' [selected]' : ''}${contextLabel}`);
|
||||
structuredExtensionPages.push(createStructuredPage(page, context));
|
||||
}
|
||||
structuredContent.extensionPages = structuredExtensionPages;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.#includeExtensionServiceWorkers) {
|
||||
if (context.getExtensionServiceWorkers().length) {
|
||||
response.push(`## Extension Service Workers`);
|
||||
}
|
||||
for (const extensionServiceWorker of context.getExtensionServiceWorkers()) {
|
||||
response.push(`${extensionServiceWorker.id}: ${extensionServiceWorker.url}`);
|
||||
}
|
||||
structuredContent.extensionServiceWorkers = context
|
||||
.getExtensionServiceWorkers()
|
||||
.map(extensionServiceWorker => {
|
||||
return {
|
||||
id: extensionServiceWorker.id,
|
||||
url: extensionServiceWorker.url,
|
||||
};
|
||||
});
|
||||
}
|
||||
if (this.#tabId) {
|
||||
structuredContent.tabId = this.#tabId;
|
||||
}
|
||||
if (data.traceSummary) {
|
||||
const summary = getTraceSummary(data.traceSummary);
|
||||
response.push(summary);
|
||||
structuredContent.traceSummary = summary;
|
||||
structuredContent.traceInsights = [];
|
||||
for (const insightSet of data.traceSummary.insights?.values() ?? []) {
|
||||
for (const [insightName, model] of Object.entries(insightSet.model)) {
|
||||
structuredContent.traceInsights.push({
|
||||
insightName,
|
||||
insightKey: model.insightKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (data.traceInsight) {
|
||||
const insightOutput = getInsightOutput(data.traceInsight.trace, data.traceInsight.insightSetId, data.traceInsight.insightName);
|
||||
if ('error' in insightOutput) {
|
||||
response.push(insightOutput.error);
|
||||
}
|
||||
else {
|
||||
response.push(insightOutput.output);
|
||||
}
|
||||
}
|
||||
if (data.lighthouseResult) {
|
||||
structuredContent.lighthouseResult = data.lighthouseResult;
|
||||
const { summary, reports } = data.lighthouseResult;
|
||||
response.push('## Lighthouse Audit Results');
|
||||
response.push(`Mode: ${summary.mode}`);
|
||||
response.push(`Device: ${summary.device}`);
|
||||
response.push(`URL: ${summary.url}`);
|
||||
response.push('### Category Scores');
|
||||
for (const score of summary.scores) {
|
||||
response.push(`- ${score.title}: ${(score.score ?? 0) * 100} (${score.id})`);
|
||||
}
|
||||
response.push('### Audit Summary');
|
||||
response.push(`Passed: ${summary.audits.passed}`);
|
||||
response.push(`Failed: ${summary.audits.failed}`);
|
||||
response.push(`Total Timing: ${summary.timing.total}ms`);
|
||||
response.push('### Reports');
|
||||
for (const report of reports) {
|
||||
response.push(`- ${report}`);
|
||||
}
|
||||
}
|
||||
if (data.snapshot) {
|
||||
if (typeof data.snapshot === 'string') {
|
||||
response.push(`Saved snapshot to ${data.snapshot}.`);
|
||||
structuredContent.snapshotFilePath = data.snapshot;
|
||||
}
|
||||
else {
|
||||
response.push('## Latest page snapshot');
|
||||
response.push(data.snapshot.toString());
|
||||
structuredContent.snapshot = data.snapshot.toJSON();
|
||||
}
|
||||
}
|
||||
if (data.detailedNetworkRequest) {
|
||||
response.push(data.detailedNetworkRequest.toStringDetailed());
|
||||
structuredContent.networkRequest =
|
||||
data.detailedNetworkRequest.toJSONDetailed();
|
||||
}
|
||||
if (data.detailedConsoleMessage) {
|
||||
response.push(data.detailedConsoleMessage.toStringDetailed());
|
||||
structuredContent.consoleMessage =
|
||||
data.detailedConsoleMessage.toJSONDetailed();
|
||||
}
|
||||
if (data.extensions) {
|
||||
structuredContent.extensions = data.extensions;
|
||||
response.push('## Extensions');
|
||||
if (data.extensions.length === 0) {
|
||||
response.push('No extensions installed.');
|
||||
}
|
||||
else {
|
||||
const extensionsMessage = data.extensions
|
||||
.map(extension => {
|
||||
return `id=${extension.id} "${extension.name}" v${extension.version} ${extension.isEnabled ? 'Enabled' : 'Disabled'}`;
|
||||
})
|
||||
.join('\n');
|
||||
response.push(extensionsMessage);
|
||||
}
|
||||
}
|
||||
if (this.#listInPageTools) {
|
||||
structuredContent.inPageTools = data.inPageTools ?? undefined;
|
||||
response.push('## In-page tools');
|
||||
if (!data.inPageTools || !data.inPageTools.tools) {
|
||||
response.push('No in-page tools available.');
|
||||
}
|
||||
else {
|
||||
const toolGroup = data.inPageTools;
|
||||
response.push(`${toolGroup.name}: ${toolGroup.description}`);
|
||||
response.push('Available tools:');
|
||||
const toolDefinitionsMessage = toolGroup.tools
|
||||
.map(tool => {
|
||||
return `name="${tool.name}", description="${tool.description}", inputSchema=${JSON.stringify(tool.inputSchema)}`;
|
||||
})
|
||||
.join('\n');
|
||||
response.push(toolDefinitionsMessage);
|
||||
}
|
||||
}
|
||||
if (this.#networkRequestsOptions?.include && data.networkRequests) {
|
||||
const requests = data.networkRequests;
|
||||
response.push('## Network requests');
|
||||
if (requests.length) {
|
||||
const paginationData = this.#dataWithPagination(requests, this.#networkRequestsOptions.pagination);
|
||||
structuredContent.pagination = paginationData.pagination;
|
||||
response.push(...paginationData.info);
|
||||
if (data.networkRequests) {
|
||||
structuredContent.networkRequests = [];
|
||||
for (const formatter of paginationData.items) {
|
||||
response.push(formatter.toString());
|
||||
structuredContent.networkRequests.push(formatter.toJSON());
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
response.push('No requests found.');
|
||||
}
|
||||
}
|
||||
if (this.#consoleDataOptions?.include) {
|
||||
const messages = data.consoleMessages ?? [];
|
||||
response.push('## Console messages');
|
||||
if (messages.length) {
|
||||
const paginationData = this.#dataWithPagination(messages, this.#consoleDataOptions.pagination);
|
||||
structuredContent.pagination = paginationData.pagination;
|
||||
response.push(...paginationData.info);
|
||||
response.push(...paginationData.items.map(message => message.toString()));
|
||||
structuredContent.consoleMessages = paginationData.items.map(message => message.toJSON());
|
||||
}
|
||||
else {
|
||||
response.push('<no console messages found>');
|
||||
}
|
||||
}
|
||||
const text = {
|
||||
type: 'text',
|
||||
text: response.join('\n'),
|
||||
};
|
||||
const images = this.#images.map(imageData => {
|
||||
return {
|
||||
type: 'image',
|
||||
...imageData,
|
||||
};
|
||||
});
|
||||
return {
|
||||
content: [text, ...images],
|
||||
structuredContent,
|
||||
};
|
||||
}
|
||||
#dataWithPagination(data, pagination) {
|
||||
const response = [];
|
||||
const paginationResult = paginate(data, pagination);
|
||||
if (paginationResult.invalidPage) {
|
||||
response.push('Invalid page number provided. Showing first page.');
|
||||
}
|
||||
const { startIndex, endIndex, currentPage, totalPages } = paginationResult;
|
||||
response.push(`Showing ${startIndex + 1}-${endIndex} of ${data.length} (Page ${currentPage + 1} of ${totalPages}).`);
|
||||
if (pagination) {
|
||||
if (paginationResult.hasNextPage) {
|
||||
response.push(`Next page: ${currentPage + 1}`);
|
||||
}
|
||||
if (paginationResult.hasPreviousPage) {
|
||||
response.push(`Previous page: ${currentPage - 1}`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
info: response,
|
||||
items: paginationResult.items,
|
||||
pagination: {
|
||||
currentPage: paginationResult.currentPage,
|
||||
totalPages: paginationResult.totalPages,
|
||||
hasNextPage: paginationResult.hasNextPage,
|
||||
hasPreviousPage: paginationResult.hasPreviousPage,
|
||||
startIndex: paginationResult.startIndex,
|
||||
endIndex: paginationResult.endIndex,
|
||||
invalidPage: paginationResult.invalidPage,
|
||||
},
|
||||
};
|
||||
}
|
||||
resetResponseLineForTesting() {
|
||||
this.#textResponseLines = [];
|
||||
}
|
||||
}
|
||||
function createStructuredPage(page, context) {
|
||||
const isolatedContextName = context.getIsolatedContextName(page);
|
||||
const entry = {
|
||||
id: context.getPageId(page),
|
||||
url: page.url(),
|
||||
selected: context.isPageSelected(page),
|
||||
};
|
||||
if (isolatedContextName) {
|
||||
entry.isolatedContext = isolatedContextName;
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google Inc.
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
export class Mutex {
|
||||
static Guard = class Guard {
|
||||
#mutex;
|
||||
constructor(mutex) {
|
||||
this.#mutex = mutex;
|
||||
}
|
||||
dispose() {
|
||||
return this.#mutex.release();
|
||||
}
|
||||
};
|
||||
#locked = false;
|
||||
#acquirers = [];
|
||||
// This is FIFO.
|
||||
async acquire() {
|
||||
if (!this.#locked) {
|
||||
this.#locked = true;
|
||||
return new Mutex.Guard(this);
|
||||
}
|
||||
const { resolve, promise } = Promise.withResolvers();
|
||||
this.#acquirers.push(resolve);
|
||||
await promise;
|
||||
return new Mutex.Guard(this);
|
||||
}
|
||||
release() {
|
||||
const resolve = this.#acquirers.shift();
|
||||
if (!resolve) {
|
||||
this.#locked = false;
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
+310
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { FakeIssuesManager } from './DevtoolsUtils.js';
|
||||
import { logger } from './logger.js';
|
||||
import { DevTools } from './third_party/index.js';
|
||||
export class UncaughtError {
|
||||
details;
|
||||
targetId;
|
||||
constructor(details, targetId) {
|
||||
this.details = details;
|
||||
this.targetId = targetId;
|
||||
}
|
||||
}
|
||||
function createIdGenerator() {
|
||||
let i = 1;
|
||||
return () => {
|
||||
if (i === Number.MAX_SAFE_INTEGER) {
|
||||
i = 0;
|
||||
}
|
||||
return i++;
|
||||
};
|
||||
}
|
||||
export const stableIdSymbol = Symbol('stableIdSymbol');
|
||||
export class PageCollector {
|
||||
#browser;
|
||||
#listenersInitializer;
|
||||
#listeners = new WeakMap();
|
||||
maxNavigationSaved = 3;
|
||||
/**
|
||||
* This maps a Page to a list of navigations with a sub-list
|
||||
* of all collected resources.
|
||||
* The newer navigations come first.
|
||||
*/
|
||||
storage = new WeakMap();
|
||||
constructor(browser, listeners) {
|
||||
this.#browser = browser;
|
||||
this.#listenersInitializer = listeners;
|
||||
}
|
||||
async init(pages) {
|
||||
for (const page of pages) {
|
||||
this.addPage(page);
|
||||
}
|
||||
this.#browser.on('targetcreated', this.#onTargetCreated);
|
||||
this.#browser.on('targetdestroyed', this.#onTargetDestroyed);
|
||||
}
|
||||
dispose() {
|
||||
this.#browser.off('targetcreated', this.#onTargetCreated);
|
||||
this.#browser.off('targetdestroyed', this.#onTargetDestroyed);
|
||||
}
|
||||
#onTargetCreated = async (target) => {
|
||||
try {
|
||||
const page = await target.page();
|
||||
if (!page) {
|
||||
return;
|
||||
}
|
||||
this.addPage(page);
|
||||
}
|
||||
catch (err) {
|
||||
logger('Error getting a page for a target onTargetCreated', err);
|
||||
}
|
||||
};
|
||||
#onTargetDestroyed = async (target) => {
|
||||
try {
|
||||
const page = await target.page();
|
||||
if (!page) {
|
||||
return;
|
||||
}
|
||||
this.cleanupPageDestroyed(page);
|
||||
}
|
||||
catch (err) {
|
||||
logger('Error getting a page for a target onTargetDestroyed', err);
|
||||
}
|
||||
};
|
||||
addPage(page) {
|
||||
this.#initializePage(page);
|
||||
}
|
||||
#initializePage(page) {
|
||||
if (this.storage.has(page)) {
|
||||
return;
|
||||
}
|
||||
const idGenerator = createIdGenerator();
|
||||
const storedLists = [[]];
|
||||
this.storage.set(page, storedLists);
|
||||
const listeners = this.#listenersInitializer(value => {
|
||||
const withId = value;
|
||||
withId[stableIdSymbol] = idGenerator();
|
||||
const navigations = this.storage.get(page) ?? [[]];
|
||||
navigations[0].push(withId);
|
||||
});
|
||||
listeners['framenavigated'] = (frame) => {
|
||||
// Only split the storage on main frame navigation
|
||||
if (frame !== page.mainFrame()) {
|
||||
return;
|
||||
}
|
||||
this.splitAfterNavigation(page);
|
||||
};
|
||||
for (const [name, listener] of Object.entries(listeners)) {
|
||||
page.on(name, listener);
|
||||
}
|
||||
this.#listeners.set(page, listeners);
|
||||
}
|
||||
splitAfterNavigation(page) {
|
||||
const navigations = this.storage.get(page);
|
||||
if (!navigations) {
|
||||
return;
|
||||
}
|
||||
// Add the latest navigation first
|
||||
navigations.unshift([]);
|
||||
navigations.splice(this.maxNavigationSaved);
|
||||
}
|
||||
cleanupPageDestroyed(page) {
|
||||
const listeners = this.#listeners.get(page);
|
||||
if (listeners) {
|
||||
for (const [name, listener] of Object.entries(listeners)) {
|
||||
page.off(name, listener);
|
||||
}
|
||||
}
|
||||
this.storage.delete(page);
|
||||
}
|
||||
getData(page, includePreservedData) {
|
||||
const navigations = this.storage.get(page);
|
||||
if (!navigations) {
|
||||
return [];
|
||||
}
|
||||
if (!includePreservedData) {
|
||||
return navigations[0];
|
||||
}
|
||||
const data = [];
|
||||
for (let index = this.maxNavigationSaved; index >= 0; index--) {
|
||||
if (navigations[index]) {
|
||||
data.push(...navigations[index]);
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
getIdForResource(resource) {
|
||||
return resource[stableIdSymbol] ?? -1;
|
||||
}
|
||||
getById(page, stableId) {
|
||||
const navigations = this.storage.get(page);
|
||||
if (!navigations) {
|
||||
throw new Error('No requests found for selected page');
|
||||
}
|
||||
const item = this.find(page, item => item[stableIdSymbol] === stableId);
|
||||
if (item) {
|
||||
return item;
|
||||
}
|
||||
throw new Error('Request not found for selected page');
|
||||
}
|
||||
find(page, filter) {
|
||||
const navigations = this.storage.get(page);
|
||||
if (!navigations) {
|
||||
return;
|
||||
}
|
||||
for (const navigation of navigations) {
|
||||
const item = navigation.find(filter);
|
||||
if (item) {
|
||||
return item;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
export class ConsoleCollector extends PageCollector {
|
||||
#subscribedPages = new WeakMap();
|
||||
addPage(page) {
|
||||
super.addPage(page);
|
||||
if (!this.#subscribedPages.has(page)) {
|
||||
const subscriber = new PageEventSubscriber(page);
|
||||
this.#subscribedPages.set(page, subscriber);
|
||||
void subscriber.subscribe();
|
||||
}
|
||||
}
|
||||
cleanupPageDestroyed(page) {
|
||||
super.cleanupPageDestroyed(page);
|
||||
this.#subscribedPages.get(page)?.unsubscribe();
|
||||
this.#subscribedPages.delete(page);
|
||||
}
|
||||
}
|
||||
class PageEventSubscriber {
|
||||
#issueManager = new FakeIssuesManager();
|
||||
#issueAggregator = new DevTools.IssueAggregator(this.#issueManager);
|
||||
#seenKeys = new Set();
|
||||
#seenIssues = new Set();
|
||||
#page;
|
||||
#session;
|
||||
#targetId;
|
||||
constructor(page) {
|
||||
this.#page = page;
|
||||
// @ts-expect-error use existing CDP client (internal Puppeteer API).
|
||||
this.#session = this.#page._client();
|
||||
// @ts-expect-error use internal Puppeteer API to get target ID
|
||||
this.#targetId = this.#session.target()._targetId;
|
||||
}
|
||||
#resetIssueAggregator() {
|
||||
this.#issueManager = new FakeIssuesManager();
|
||||
if (this.#issueAggregator) {
|
||||
this.#issueAggregator.removeEventListener("AggregatedIssueUpdated" /* DevTools.IssueAggregatorEvents.AGGREGATED_ISSUE_UPDATED */, this.#onAggregatedIssue);
|
||||
}
|
||||
this.#issueAggregator = new DevTools.IssueAggregator(this.#issueManager);
|
||||
this.#issueAggregator.addEventListener("AggregatedIssueUpdated" /* DevTools.IssueAggregatorEvents.AGGREGATED_ISSUE_UPDATED */, this.#onAggregatedIssue);
|
||||
}
|
||||
async subscribe() {
|
||||
this.#resetIssueAggregator();
|
||||
this.#page.on('framenavigated', this.#onFrameNavigated);
|
||||
this.#session.on('Audits.issueAdded', this.#onIssueAdded);
|
||||
this.#session.on('Runtime.exceptionThrown', this.#onExceptionThrown);
|
||||
try {
|
||||
await this.#session.send('Audits.enable');
|
||||
}
|
||||
catch (error) {
|
||||
logger('Error subscribing to issues', error);
|
||||
}
|
||||
}
|
||||
unsubscribe() {
|
||||
this.#seenKeys.clear();
|
||||
this.#seenIssues.clear();
|
||||
this.#page.off('framenavigated', this.#onFrameNavigated);
|
||||
this.#session.off('Audits.issueAdded', this.#onIssueAdded);
|
||||
this.#session.off('Runtime.exceptionThrown', this.#onExceptionThrown);
|
||||
if (this.#issueAggregator) {
|
||||
this.#issueAggregator.removeEventListener("AggregatedIssueUpdated" /* DevTools.IssueAggregatorEvents.AGGREGATED_ISSUE_UPDATED */, this.#onAggregatedIssue);
|
||||
}
|
||||
void this.#session.send('Audits.disable').catch(() => {
|
||||
// might fail.
|
||||
});
|
||||
}
|
||||
#onAggregatedIssue = (event) => {
|
||||
if (this.#seenIssues.has(event.data)) {
|
||||
return;
|
||||
}
|
||||
this.#seenIssues.add(event.data);
|
||||
this.#page.emit('issue', event.data);
|
||||
};
|
||||
#onExceptionThrown = (event) => {
|
||||
this.#page.emit('uncaughtError', new UncaughtError(event.exceptionDetails, this.#targetId));
|
||||
};
|
||||
// On navigation, we reset issue aggregation.
|
||||
#onFrameNavigated = (frame) => {
|
||||
// Only split the storage on main frame navigation
|
||||
if (frame !== frame.page().mainFrame()) {
|
||||
return;
|
||||
}
|
||||
this.#seenKeys.clear();
|
||||
this.#seenIssues.clear();
|
||||
this.#resetIssueAggregator();
|
||||
};
|
||||
#onIssueAdded = (data) => {
|
||||
try {
|
||||
const inspectorIssue = data.issue;
|
||||
const issue = DevTools.createIssuesFromProtocolIssue(null,
|
||||
// @ts-expect-error Protocol types diverge.
|
||||
inspectorIssue)[0];
|
||||
if (!issue) {
|
||||
logger('No issue mapping for for the issue: ', inspectorIssue.code);
|
||||
return;
|
||||
}
|
||||
const primaryKey = issue.primaryKey();
|
||||
if (this.#seenKeys.has(primaryKey)) {
|
||||
return;
|
||||
}
|
||||
this.#seenKeys.add(primaryKey);
|
||||
this.#issueManager.dispatchEventToListeners("IssueAdded" /* DevTools.IssuesManagerEvents.ISSUE_ADDED */, {
|
||||
issue,
|
||||
// @ts-expect-error We don't care that issues model is null
|
||||
issuesModel: null,
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
logger('Error creating a new issue', error);
|
||||
}
|
||||
};
|
||||
}
|
||||
export class NetworkCollector extends PageCollector {
|
||||
constructor(browser, listeners = collect => {
|
||||
return {
|
||||
request: req => {
|
||||
collect(req);
|
||||
},
|
||||
};
|
||||
}) {
|
||||
super(browser, listeners);
|
||||
}
|
||||
splitAfterNavigation(page) {
|
||||
const navigations = this.storage.get(page) ?? [];
|
||||
if (!navigations) {
|
||||
return;
|
||||
}
|
||||
const requests = navigations[0];
|
||||
const lastRequestIdx = requests.findLastIndex(request => {
|
||||
return request.frame() === page.mainFrame()
|
||||
? request.isNavigationRequest()
|
||||
: false;
|
||||
});
|
||||
// Keep all requests since the last navigation request including that
|
||||
// navigation request itself.
|
||||
// Keep the reference
|
||||
if (lastRequestIdx !== -1) {
|
||||
const fromCurrentNavigation = requests.splice(lastRequestIdx);
|
||||
navigations.unshift(fromCurrentNavigation);
|
||||
}
|
||||
else {
|
||||
navigations.unshift([]);
|
||||
}
|
||||
navigations.splice(this.maxNavigationSaved);
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { McpResponse } from './McpResponse.js';
|
||||
export class SlimMcpResponse extends McpResponse {
|
||||
async handle(_toolName, _context) {
|
||||
const text = {
|
||||
type: 'text',
|
||||
text: this.responseLines.join('\n'),
|
||||
};
|
||||
return {
|
||||
content: [text],
|
||||
structuredContent: text,
|
||||
};
|
||||
}
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { logger } from './logger.js';
|
||||
export class WaitForHelper {
|
||||
#abortController = new AbortController();
|
||||
#page;
|
||||
#stableDomTimeout;
|
||||
#stableDomFor;
|
||||
#expectNavigationIn;
|
||||
#navigationTimeout;
|
||||
constructor(page, cpuTimeoutMultiplier, networkTimeoutMultiplier) {
|
||||
this.#stableDomTimeout = 3000 * cpuTimeoutMultiplier;
|
||||
this.#stableDomFor = 100 * cpuTimeoutMultiplier;
|
||||
this.#expectNavigationIn = 100 * cpuTimeoutMultiplier;
|
||||
this.#navigationTimeout = 3000 * networkTimeoutMultiplier;
|
||||
this.#page = page;
|
||||
}
|
||||
/**
|
||||
* A wrapper that executes a action and waits for
|
||||
* a potential navigation, after which it waits
|
||||
* for the DOM to be stable before returning.
|
||||
*/
|
||||
async waitForStableDom() {
|
||||
const stableDomObserver = await this.#page.evaluateHandle(timeout => {
|
||||
let timeoutId;
|
||||
function callback() {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => {
|
||||
domObserver.resolver.resolve();
|
||||
domObserver.observer.disconnect();
|
||||
}, timeout);
|
||||
}
|
||||
const domObserver = {
|
||||
resolver: Promise.withResolvers(),
|
||||
observer: new MutationObserver(callback),
|
||||
};
|
||||
// It's possible that the DOM is not gonna change so we
|
||||
// need to start the timeout initially.
|
||||
callback();
|
||||
domObserver.observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
});
|
||||
return domObserver;
|
||||
}, this.#stableDomFor);
|
||||
this.#abortController.signal.addEventListener('abort', async () => {
|
||||
try {
|
||||
await stableDomObserver.evaluate(observer => {
|
||||
observer.observer.disconnect();
|
||||
observer.resolver.resolve();
|
||||
});
|
||||
await stableDomObserver.dispose();
|
||||
}
|
||||
catch {
|
||||
// Ignored cleanup errors
|
||||
}
|
||||
});
|
||||
return Promise.race([
|
||||
stableDomObserver.evaluate(async (observer) => {
|
||||
return await observer.resolver.promise;
|
||||
}),
|
||||
this.timeout(this.#stableDomTimeout).then(() => {
|
||||
throw new Error('Timeout');
|
||||
}),
|
||||
]);
|
||||
}
|
||||
async waitForNavigationStarted() {
|
||||
// Currently Puppeteer does not have API
|
||||
// For when a navigation is about to start
|
||||
const navigationStartedPromise = new Promise(resolve => {
|
||||
const listener = (event) => {
|
||||
if ([
|
||||
'historySameDocument',
|
||||
'historyDifferentDocument',
|
||||
'sameDocument',
|
||||
].includes(event.navigationType)) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
resolve(true);
|
||||
};
|
||||
this.#page._client().on('Page.frameStartedNavigating', listener);
|
||||
this.#abortController.signal.addEventListener('abort', () => {
|
||||
resolve(false);
|
||||
this.#page._client().off('Page.frameStartedNavigating', listener);
|
||||
});
|
||||
});
|
||||
return await Promise.race([
|
||||
navigationStartedPromise,
|
||||
this.timeout(this.#expectNavigationIn).then(() => false),
|
||||
]);
|
||||
}
|
||||
timeout(time) {
|
||||
return new Promise(res => {
|
||||
const id = setTimeout(res, time);
|
||||
this.#abortController.signal.addEventListener('abort', () => {
|
||||
res();
|
||||
clearTimeout(id);
|
||||
});
|
||||
});
|
||||
}
|
||||
async waitForEventsAfterAction(action, options) {
|
||||
const navigationFinished = this.waitForNavigationStarted()
|
||||
.then(navigationStated => {
|
||||
if (navigationStated) {
|
||||
return this.#page.waitForNavigation({
|
||||
timeout: options?.timeout ?? this.#navigationTimeout,
|
||||
signal: this.#abortController.signal,
|
||||
});
|
||||
}
|
||||
return;
|
||||
})
|
||||
.catch(error => logger(error));
|
||||
try {
|
||||
await action();
|
||||
}
|
||||
catch (error) {
|
||||
// Clear up pending promises
|
||||
this.#abortController.abort();
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await navigationFinished;
|
||||
// Wait for stable dom after navigation so we execute in
|
||||
// the correct context
|
||||
await this.waitForStableDom();
|
||||
}
|
||||
catch (error) {
|
||||
logger(error);
|
||||
}
|
||||
finally {
|
||||
this.#abortController.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
+651
@@ -0,0 +1,651 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
export const commands = {
|
||||
click: {
|
||||
description: 'Clicks on the provided element',
|
||||
category: 'Input automation',
|
||||
args: {
|
||||
uid: {
|
||||
name: 'uid',
|
||||
type: 'string',
|
||||
description: 'The uid of an element on the page from the page content snapshot',
|
||||
required: true,
|
||||
},
|
||||
dblClick: {
|
||||
name: 'dblClick',
|
||||
type: 'boolean',
|
||||
description: 'Set to true for double clicks. Default is false.',
|
||||
required: false,
|
||||
},
|
||||
includeSnapshot: {
|
||||
name: 'includeSnapshot',
|
||||
type: 'boolean',
|
||||
description: 'Whether to include a snapshot in the response. Default is false.',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
close_page: {
|
||||
description: 'Closes the page by its index. The last open page cannot be closed.',
|
||||
category: 'Navigation automation',
|
||||
args: {
|
||||
pageId: {
|
||||
name: 'pageId',
|
||||
type: 'number',
|
||||
description: 'The ID of the page to close. Call list_pages to list pages.',
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
drag: {
|
||||
description: 'Drag an element onto another element',
|
||||
category: 'Input automation',
|
||||
args: {
|
||||
from_uid: {
|
||||
name: 'from_uid',
|
||||
type: 'string',
|
||||
description: 'The uid of the element to drag',
|
||||
required: true,
|
||||
},
|
||||
to_uid: {
|
||||
name: 'to_uid',
|
||||
type: 'string',
|
||||
description: 'The uid of the element to drop into',
|
||||
required: true,
|
||||
},
|
||||
includeSnapshot: {
|
||||
name: 'includeSnapshot',
|
||||
type: 'boolean',
|
||||
description: 'Whether to include a snapshot in the response. Default is false.',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
emulate: {
|
||||
description: 'Emulates various features on the selected page.',
|
||||
category: 'Emulation',
|
||||
args: {
|
||||
networkConditions: {
|
||||
name: 'networkConditions',
|
||||
type: 'string',
|
||||
description: 'Throttle network. Omit to disable throttling.',
|
||||
required: false,
|
||||
enum: ['Offline', 'Slow 3G', 'Fast 3G', 'Slow 4G', 'Fast 4G'],
|
||||
},
|
||||
cpuThrottlingRate: {
|
||||
name: 'cpuThrottlingRate',
|
||||
type: 'number',
|
||||
description: 'Represents the CPU slowdown factor. Omit or set the rate to 1 to disable throttling',
|
||||
required: false,
|
||||
},
|
||||
geolocation: {
|
||||
name: 'geolocation',
|
||||
type: 'string',
|
||||
description: 'Geolocation (`<latitude>x<longitude>`) to emulate. Latitude between -90 and 90. Longitude between -180 and 180. Omit clear the geolocation override.',
|
||||
required: false,
|
||||
},
|
||||
userAgent: {
|
||||
name: 'userAgent',
|
||||
type: 'string',
|
||||
description: 'User agent to emulate. Set to empty string to clear the user agent override.',
|
||||
required: false,
|
||||
},
|
||||
colorScheme: {
|
||||
name: 'colorScheme',
|
||||
type: 'string',
|
||||
description: 'Emulate the dark or the light mode. Set to "auto" to reset to the default.',
|
||||
required: false,
|
||||
enum: ['dark', 'light', 'auto'],
|
||||
},
|
||||
viewport: {
|
||||
name: 'viewport',
|
||||
type: 'string',
|
||||
description: "Emulate device viewports '<width>x<height>x<devicePixelRatio>[,mobile][,touch][,landscape]'. 'touch' and 'mobile' to emulate mobile devices. 'landscape' to emulate landscape mode.",
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
evaluate_script: {
|
||||
description: 'Evaluate a JavaScript function inside the currently selected page. Returns the response as JSON,\nso returned values have to be JSON-serializable.',
|
||||
category: 'Debugging',
|
||||
args: {
|
||||
function: {
|
||||
name: 'function',
|
||||
type: 'string',
|
||||
description: 'A JavaScript function declaration to be executed by the tool in the currently selected page.\nExample without arguments: `() => {\n return document.title\n}` or `async () => {\n return await fetch("example.com")\n}`.\nExample with arguments: `(el) => {\n return el.innerText;\n}`\n',
|
||||
required: true,
|
||||
},
|
||||
args: {
|
||||
name: 'args',
|
||||
type: 'array',
|
||||
description: 'An optional list of arguments to pass to the function.',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
fill: {
|
||||
description: 'Type text into a input, text area or select an option from a <select> element.',
|
||||
category: 'Input automation',
|
||||
args: {
|
||||
uid: {
|
||||
name: 'uid',
|
||||
type: 'string',
|
||||
description: 'The uid of an element on the page from the page content snapshot',
|
||||
required: true,
|
||||
},
|
||||
value: {
|
||||
name: 'value',
|
||||
type: 'string',
|
||||
description: 'The value to fill in',
|
||||
required: true,
|
||||
},
|
||||
includeSnapshot: {
|
||||
name: 'includeSnapshot',
|
||||
type: 'boolean',
|
||||
description: 'Whether to include a snapshot in the response. Default is false.',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
fill_form: {
|
||||
description: 'Fill out multiple form elements at once',
|
||||
category: 'Input automation',
|
||||
args: {
|
||||
elements: {
|
||||
name: 'elements',
|
||||
type: 'array',
|
||||
description: 'Elements from snapshot to fill out.',
|
||||
required: true,
|
||||
},
|
||||
includeSnapshot: {
|
||||
name: 'includeSnapshot',
|
||||
type: 'boolean',
|
||||
description: 'Whether to include a snapshot in the response. Default is false.',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
get_console_message: {
|
||||
description: 'Gets a console message by its ID. You can get all messages by calling list_console_messages.',
|
||||
category: 'Debugging',
|
||||
args: {
|
||||
msgid: {
|
||||
name: 'msgid',
|
||||
type: 'number',
|
||||
description: 'The msgid of a console message on the page from the listed console messages',
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
get_network_request: {
|
||||
description: 'Gets a network request by an optional reqid, if omitted returns the currently selected request in the DevTools Network panel.',
|
||||
category: 'Network',
|
||||
args: {
|
||||
reqid: {
|
||||
name: 'reqid',
|
||||
type: 'number',
|
||||
description: 'The reqid of the network request. If omitted returns the currently selected request in the DevTools Network panel.',
|
||||
required: false,
|
||||
},
|
||||
requestFilePath: {
|
||||
name: 'requestFilePath',
|
||||
type: 'string',
|
||||
description: 'The absolute or relative path to save the request body to. If omitted, the body is returned inline.',
|
||||
required: false,
|
||||
},
|
||||
responseFilePath: {
|
||||
name: 'responseFilePath',
|
||||
type: 'string',
|
||||
description: 'The absolute or relative path to save the response body to. If omitted, the body is returned inline.',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
handle_dialog: {
|
||||
description: 'If a browser dialog was opened, use this command to handle it',
|
||||
category: 'Input automation',
|
||||
args: {
|
||||
action: {
|
||||
name: 'action',
|
||||
type: 'string',
|
||||
description: 'Whether to dismiss or accept the dialog',
|
||||
required: true,
|
||||
enum: ['accept', 'dismiss'],
|
||||
},
|
||||
promptText: {
|
||||
name: 'promptText',
|
||||
type: 'string',
|
||||
description: 'Optional prompt text to enter into the dialog.',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
hover: {
|
||||
description: 'Hover over the provided element',
|
||||
category: 'Input automation',
|
||||
args: {
|
||||
uid: {
|
||||
name: 'uid',
|
||||
type: 'string',
|
||||
description: 'The uid of an element on the page from the page content snapshot',
|
||||
required: true,
|
||||
},
|
||||
includeSnapshot: {
|
||||
name: 'includeSnapshot',
|
||||
type: 'boolean',
|
||||
description: 'Whether to include a snapshot in the response. Default is false.',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
lighthouse_audit: {
|
||||
description: 'Get Lighthouse score and reports for accessibility, SEO and best practices. This excludes performance. For performance audits, run performance_start_trace',
|
||||
category: 'Debugging',
|
||||
args: {
|
||||
mode: {
|
||||
name: 'mode',
|
||||
type: 'string',
|
||||
description: '"navigation" reloads & audits. "snapshot" analyzes current state.',
|
||||
required: false,
|
||||
default: 'navigation',
|
||||
enum: ['navigation', 'snapshot'],
|
||||
},
|
||||
device: {
|
||||
name: 'device',
|
||||
type: 'string',
|
||||
description: 'Device to emulate.',
|
||||
required: false,
|
||||
default: 'desktop',
|
||||
enum: ['desktop', 'mobile'],
|
||||
},
|
||||
outputDirPath: {
|
||||
name: 'outputDirPath',
|
||||
type: 'string',
|
||||
description: 'Directory for reports. If omitted, uses temporary files.',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
list_console_messages: {
|
||||
description: 'List all console messages for the currently selected page since the last navigation.',
|
||||
category: 'Debugging',
|
||||
args: {
|
||||
pageSize: {
|
||||
name: 'pageSize',
|
||||
type: 'integer',
|
||||
description: 'Maximum number of messages to return. When omitted, returns all requests.',
|
||||
required: false,
|
||||
},
|
||||
pageIdx: {
|
||||
name: 'pageIdx',
|
||||
type: 'integer',
|
||||
description: 'Page number to return (0-based). When omitted, returns the first page.',
|
||||
required: false,
|
||||
},
|
||||
types: {
|
||||
name: 'types',
|
||||
type: 'array',
|
||||
description: 'Filter messages to only return messages of the specified resource types. When omitted or empty, returns all messages.',
|
||||
required: false,
|
||||
},
|
||||
includePreservedMessages: {
|
||||
name: 'includePreservedMessages',
|
||||
type: 'boolean',
|
||||
description: 'Set to true to return the preserved messages over the last 3 navigations.',
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
list_network_requests: {
|
||||
description: 'List all requests for the currently selected page since the last navigation.',
|
||||
category: 'Network',
|
||||
args: {
|
||||
pageSize: {
|
||||
name: 'pageSize',
|
||||
type: 'integer',
|
||||
description: 'Maximum number of requests to return. When omitted, returns all requests.',
|
||||
required: false,
|
||||
},
|
||||
pageIdx: {
|
||||
name: 'pageIdx',
|
||||
type: 'integer',
|
||||
description: 'Page number to return (0-based). When omitted, returns the first page.',
|
||||
required: false,
|
||||
},
|
||||
resourceTypes: {
|
||||
name: 'resourceTypes',
|
||||
type: 'array',
|
||||
description: 'Filter requests to only return requests of the specified resource types. When omitted or empty, returns all requests.',
|
||||
required: false,
|
||||
},
|
||||
includePreservedRequests: {
|
||||
name: 'includePreservedRequests',
|
||||
type: 'boolean',
|
||||
description: 'Set to true to return the preserved requests over the last 3 navigations.',
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
list_pages: {
|
||||
description: 'Get a list of pages open in the browser.',
|
||||
category: 'Navigation automation',
|
||||
args: {},
|
||||
},
|
||||
navigate_page: {
|
||||
description: 'Go to a URL, or back, forward, or reload. Use project URL if not specified otherwise.',
|
||||
category: 'Navigation automation',
|
||||
args: {
|
||||
type: {
|
||||
name: 'type',
|
||||
type: 'string',
|
||||
description: 'Navigate the page by URL, back or forward in history, or reload.',
|
||||
required: false,
|
||||
enum: ['url', 'back', 'forward', 'reload'],
|
||||
},
|
||||
url: {
|
||||
name: 'url',
|
||||
type: 'string',
|
||||
description: 'Target URL (only type=url)',
|
||||
required: false,
|
||||
},
|
||||
ignoreCache: {
|
||||
name: 'ignoreCache',
|
||||
type: 'boolean',
|
||||
description: 'Whether to ignore cache on reload.',
|
||||
required: false,
|
||||
},
|
||||
handleBeforeUnload: {
|
||||
name: 'handleBeforeUnload',
|
||||
type: 'string',
|
||||
description: 'Whether to auto accept or beforeunload dialogs triggered by this navigation. Default is accept.',
|
||||
required: false,
|
||||
enum: ['accept', 'decline'],
|
||||
},
|
||||
initScript: {
|
||||
name: 'initScript',
|
||||
type: 'string',
|
||||
description: 'A JavaScript script to be executed on each new document before any other scripts for the next navigation.',
|
||||
required: false,
|
||||
},
|
||||
timeout: {
|
||||
name: 'timeout',
|
||||
type: 'integer',
|
||||
description: 'Maximum wait time in milliseconds. If set to 0, the default timeout will be used.',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
new_page: {
|
||||
description: 'Open a new tab and load a URL. Use project URL if not specified otherwise.',
|
||||
category: 'Navigation automation',
|
||||
args: {
|
||||
url: {
|
||||
name: 'url',
|
||||
type: 'string',
|
||||
description: 'URL to load in a new page.',
|
||||
required: true,
|
||||
},
|
||||
background: {
|
||||
name: 'background',
|
||||
type: 'boolean',
|
||||
description: 'Whether to open the page in the background without bringing it to the front. Default is false (foreground).',
|
||||
required: false,
|
||||
},
|
||||
isolatedContext: {
|
||||
name: 'isolatedContext',
|
||||
type: 'string',
|
||||
description: 'If specified, the page is created in an isolated browser context with the given name. Pages in the same browser context share cookies and storage. Pages in different browser contexts are fully isolated.',
|
||||
required: false,
|
||||
},
|
||||
timeout: {
|
||||
name: 'timeout',
|
||||
type: 'integer',
|
||||
description: 'Maximum wait time in milliseconds. If set to 0, the default timeout will be used.',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
performance_analyze_insight: {
|
||||
description: 'Provides more detailed information on a specific Performance Insight of an insight set that was highlighted in the results of a trace recording.',
|
||||
category: 'Performance',
|
||||
args: {
|
||||
insightSetId: {
|
||||
name: 'insightSetId',
|
||||
type: 'string',
|
||||
description: 'The id for the specific insight set. Only use the ids given in the "Available insight sets" list.',
|
||||
required: true,
|
||||
},
|
||||
insightName: {
|
||||
name: 'insightName',
|
||||
type: 'string',
|
||||
description: 'The name of the Insight you want more information on. For example: "DocumentLatency" or "LCPBreakdown"',
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
performance_start_trace: {
|
||||
description: 'Start a performance trace on the selected webpage. Use to find frontend performance issues, Core Web Vitals (LCP, INP, CLS), and improve page load speed.',
|
||||
category: 'Performance',
|
||||
args: {
|
||||
reload: {
|
||||
name: 'reload',
|
||||
type: 'boolean',
|
||||
description: 'Determines if, once tracing has started, the current selected page should be automatically reloaded. Navigate the page to the right URL using the navigate_page tool BEFORE starting the trace if reload or autoStop is set to true.',
|
||||
required: false,
|
||||
default: true,
|
||||
},
|
||||
autoStop: {
|
||||
name: 'autoStop',
|
||||
type: 'boolean',
|
||||
description: 'Determines if the trace recording should be automatically stopped.',
|
||||
required: false,
|
||||
default: true,
|
||||
},
|
||||
filePath: {
|
||||
name: 'filePath',
|
||||
type: 'string',
|
||||
description: 'The absolute file path, or a file path relative to the current working directory, to save the raw trace data. For example, trace.json.gz (compressed) or trace.json (uncompressed).',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
performance_stop_trace: {
|
||||
description: 'Stop the active performance trace recording on the selected webpage.',
|
||||
category: 'Performance',
|
||||
args: {
|
||||
filePath: {
|
||||
name: 'filePath',
|
||||
type: 'string',
|
||||
description: 'The absolute file path, or a file path relative to the current working directory, to save the raw trace data. For example, trace.json.gz (compressed) or trace.json (uncompressed).',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
press_key: {
|
||||
description: 'Press a key or key combination. Use this when other input methods like fill() cannot be used (e.g., keyboard shortcuts, navigation keys, or special key combinations).',
|
||||
category: 'Input automation',
|
||||
args: {
|
||||
key: {
|
||||
name: 'key',
|
||||
type: 'string',
|
||||
description: 'A key or a combination (e.g., "Enter", "Control+A", "Control++", "Control+Shift+R"). Modifiers: Control, Shift, Alt, Meta',
|
||||
required: true,
|
||||
},
|
||||
includeSnapshot: {
|
||||
name: 'includeSnapshot',
|
||||
type: 'boolean',
|
||||
description: 'Whether to include a snapshot in the response. Default is false.',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
resize_page: {
|
||||
description: "Resizes the selected page's window so that the page has specified dimension",
|
||||
category: 'Emulation',
|
||||
args: {
|
||||
width: {
|
||||
name: 'width',
|
||||
type: 'number',
|
||||
description: 'Page width',
|
||||
required: true,
|
||||
},
|
||||
height: {
|
||||
name: 'height',
|
||||
type: 'number',
|
||||
description: 'Page height',
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
select_page: {
|
||||
description: 'Select a page as a context for future tool calls.',
|
||||
category: 'Navigation automation',
|
||||
args: {
|
||||
pageId: {
|
||||
name: 'pageId',
|
||||
type: 'number',
|
||||
description: 'The ID of the page to select. Call list_pages to get available pages.',
|
||||
required: true,
|
||||
},
|
||||
bringToFront: {
|
||||
name: 'bringToFront',
|
||||
type: 'boolean',
|
||||
description: 'Whether to focus the page and bring it to the top.',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
take_memory_snapshot: {
|
||||
description: 'Capture a memory heapsnapshot of the currently selected page to memory leak debugging',
|
||||
category: 'Performance',
|
||||
args: {
|
||||
filePath: {
|
||||
name: 'filePath',
|
||||
type: 'string',
|
||||
description: 'A path to a .heapsnapshot file to save the heapsnapshot to.',
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
take_screenshot: {
|
||||
description: 'Take a screenshot of the page or element.',
|
||||
category: 'Debugging',
|
||||
args: {
|
||||
format: {
|
||||
name: 'format',
|
||||
type: 'string',
|
||||
description: 'Type of format to save the screenshot as. Default is "png"',
|
||||
required: false,
|
||||
default: 'png',
|
||||
enum: ['png', 'jpeg', 'webp'],
|
||||
},
|
||||
quality: {
|
||||
name: 'quality',
|
||||
type: 'number',
|
||||
description: 'Compression quality for JPEG and WebP formats (0-100). Higher values mean better quality but larger file sizes. Ignored for PNG format.',
|
||||
required: false,
|
||||
},
|
||||
uid: {
|
||||
name: 'uid',
|
||||
type: 'string',
|
||||
description: 'The uid of an element on the page from the page content snapshot. If omitted takes a pages screenshot.',
|
||||
required: false,
|
||||
},
|
||||
fullPage: {
|
||||
name: 'fullPage',
|
||||
type: 'boolean',
|
||||
description: 'If set to true takes a screenshot of the full page instead of the currently visible viewport. Incompatible with uid.',
|
||||
required: false,
|
||||
},
|
||||
filePath: {
|
||||
name: 'filePath',
|
||||
type: 'string',
|
||||
description: 'The absolute path, or a path relative to the current working directory, to save the screenshot to instead of attaching it to the response.',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
take_snapshot: {
|
||||
description: 'Take a text snapshot of the currently selected page based on the a11y tree. The snapshot lists page elements along with a unique\nidentifier (uid). Always use the latest snapshot. Prefer taking a snapshot over taking a screenshot. The snapshot indicates the element selected\nin the DevTools Elements panel (if any).',
|
||||
category: 'Debugging',
|
||||
args: {
|
||||
verbose: {
|
||||
name: 'verbose',
|
||||
type: 'boolean',
|
||||
description: 'Whether to include all possible information available in the full a11y tree. Default is false.',
|
||||
required: false,
|
||||
},
|
||||
filePath: {
|
||||
name: 'filePath',
|
||||
type: 'string',
|
||||
description: 'The absolute path, or a path relative to the current working directory, to save the snapshot to instead of attaching it to the response.',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
type_text: {
|
||||
description: 'Type text using keyboard into a previously focused input',
|
||||
category: 'Input automation',
|
||||
args: {
|
||||
text: {
|
||||
name: 'text',
|
||||
type: 'string',
|
||||
description: 'The text to type',
|
||||
required: true,
|
||||
},
|
||||
submitKey: {
|
||||
name: 'submitKey',
|
||||
type: 'string',
|
||||
description: 'Optional key to press after typing. E.g., "Enter", "Tab", "Escape"',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
upload_file: {
|
||||
description: 'Upload a file through a provided element.',
|
||||
category: 'Input automation',
|
||||
args: {
|
||||
uid: {
|
||||
name: 'uid',
|
||||
type: 'string',
|
||||
description: 'The uid of the file input element or an element that will open file chooser on the page from the page content snapshot',
|
||||
required: true,
|
||||
},
|
||||
filePath: {
|
||||
name: 'filePath',
|
||||
type: 'string',
|
||||
description: 'The local path of the file to upload',
|
||||
required: true,
|
||||
},
|
||||
includeSnapshot: {
|
||||
name: 'includeSnapshot',
|
||||
type: 'boolean',
|
||||
description: 'Whether to include a snapshot in the response. Default is false.',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
wait_for: {
|
||||
description: 'Wait for the specified text to appear on the selected page.',
|
||||
category: 'Navigation automation',
|
||||
args: {
|
||||
text: {
|
||||
name: 'text',
|
||||
type: 'array',
|
||||
description: 'Non-empty list of texts. Resolves when any value appears on the page.',
|
||||
required: true,
|
||||
},
|
||||
timeout: {
|
||||
name: 'timeout',
|
||||
type: 'integer',
|
||||
description: 'Maximum wait time in milliseconds. If set to 0, the default timeout will be used.',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
Generated
Vendored
+322
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { yargs, hideBin } from '../third_party/index.js';
|
||||
export const cliOptions = {
|
||||
autoConnect: {
|
||||
type: 'boolean',
|
||||
description: 'If specified, automatically connects to a browser (Chrome 144+) running locally from the user data directory identified by the channel param (default channel is stable). Requires the remoted debugging server to be started in the Chrome instance via chrome://inspect/#remote-debugging.',
|
||||
conflicts: ['isolated', 'executablePath', 'categoryExtensions'],
|
||||
default: false,
|
||||
coerce: (value) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
return value;
|
||||
},
|
||||
},
|
||||
browserUrl: {
|
||||
type: 'string',
|
||||
description: 'Connect to a running, debuggable Chrome instance (e.g. `http://127.0.0.1:9222`). For more details see: https://github.com/ChromeDevTools/chrome-devtools-mcp#connecting-to-a-running-chrome-instance.',
|
||||
alias: 'u',
|
||||
conflicts: ['wsEndpoint', 'categoryExtensions'],
|
||||
coerce: (url) => {
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
new URL(url);
|
||||
}
|
||||
catch {
|
||||
throw new Error(`Provided browserUrl ${url} is not valid URL.`);
|
||||
}
|
||||
return url;
|
||||
},
|
||||
},
|
||||
wsEndpoint: {
|
||||
type: 'string',
|
||||
description: 'WebSocket endpoint to connect to a running Chrome instance (e.g., ws://127.0.0.1:9222/devtools/browser/<id>). Alternative to --browserUrl.',
|
||||
alias: 'w',
|
||||
conflicts: ['browserUrl', 'categoryExtensions'],
|
||||
coerce: (url) => {
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:') {
|
||||
throw new Error(`Provided wsEndpoint ${url} must use ws:// or wss:// protocol.`);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
catch (error) {
|
||||
if (error.message.includes('ws://')) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error(`Provided wsEndpoint ${url} is not valid URL.`);
|
||||
}
|
||||
},
|
||||
},
|
||||
wsHeaders: {
|
||||
type: 'string',
|
||||
description: 'Custom headers for WebSocket connection in JSON format (e.g., \'{"Authorization":"Bearer token"}\'). Only works with --wsEndpoint.',
|
||||
implies: 'wsEndpoint',
|
||||
coerce: (val) => {
|
||||
if (!val) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(val);
|
||||
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error('Headers must be a JSON object');
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
catch (error) {
|
||||
throw new Error(`Invalid JSON for wsHeaders: ${error.message}`);
|
||||
}
|
||||
},
|
||||
},
|
||||
headless: {
|
||||
type: 'boolean',
|
||||
description: 'Whether to run in headless (no UI) mode.',
|
||||
default: false,
|
||||
},
|
||||
executablePath: {
|
||||
type: 'string',
|
||||
description: 'Path to custom Chrome executable.',
|
||||
conflicts: ['browserUrl', 'wsEndpoint'],
|
||||
alias: 'e',
|
||||
},
|
||||
isolated: {
|
||||
type: 'boolean',
|
||||
description: 'If specified, creates a temporary user-data-dir that is automatically cleaned up after the browser is closed. Defaults to false.',
|
||||
},
|
||||
userDataDir: {
|
||||
type: 'string',
|
||||
description: 'Path to the user data directory for Chrome. Default is $HOME/.cache/chrome-devtools-mcp/chrome-profile$CHANNEL_SUFFIX_IF_NON_STABLE',
|
||||
conflicts: ['browserUrl', 'wsEndpoint', 'isolated'],
|
||||
},
|
||||
channel: {
|
||||
type: 'string',
|
||||
description: 'Specify a different Chrome channel that should be used. The default is the stable channel version.',
|
||||
choices: ['stable', 'canary', 'beta', 'dev'],
|
||||
conflicts: ['browserUrl', 'wsEndpoint', 'executablePath'],
|
||||
},
|
||||
logFile: {
|
||||
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 Chrome instances started by the server. For example, `1280x720`. In headless mode, max size is 3840x2160px.',
|
||||
coerce: (arg) => {
|
||||
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.`,
|
||||
},
|
||||
experimentalPageIdRouting: {
|
||||
type: 'boolean',
|
||||
describe: 'Whether to expose pageId on page-scoped tools and route requests by page ID.',
|
||||
hidden: true,
|
||||
},
|
||||
experimentalDevtools: {
|
||||
type: 'boolean',
|
||||
describe: 'Whether to enable automation over DevTools targets',
|
||||
hidden: true,
|
||||
},
|
||||
experimentalVision: {
|
||||
type: 'boolean',
|
||||
describe: 'Whether to enable vision tools',
|
||||
hidden: true,
|
||||
},
|
||||
experimentalStructuredContent: {
|
||||
type: 'boolean',
|
||||
describe: 'Whether to output structured formatted content.',
|
||||
hidden: true,
|
||||
},
|
||||
experimentalIncludeAllPages: {
|
||||
type: 'boolean',
|
||||
describe: 'Whether to include all kinds of pages such as webviews or background pages as pages.',
|
||||
hidden: true,
|
||||
},
|
||||
experimentalInteropTools: {
|
||||
type: 'boolean',
|
||||
describe: 'Whether to enable interoperability tools',
|
||||
hidden: true,
|
||||
},
|
||||
experimentalScreencast: {
|
||||
type: 'boolean',
|
||||
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.',
|
||||
},
|
||||
chromeArg: {
|
||||
type: 'array',
|
||||
describe: 'Additional arguments for Chrome. Only applies when Chrome is launched by chrome-devtools-mcp.',
|
||||
},
|
||||
ignoreDefaultChromeArg: {
|
||||
type: 'array',
|
||||
describe: 'Explicitly disable default arguments for Chrome. Only applies when Chrome is launched by chrome-devtools-mcp.',
|
||||
},
|
||||
categoryEmulation: {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
describe: 'Set to false to exclude tools related to emulation.',
|
||||
},
|
||||
categoryPerformance: {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
describe: 'Set to false to exclude tools related to performance.',
|
||||
},
|
||||
categoryNetwork: {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
describe: 'Set to false to exclude tools related to network.',
|
||||
},
|
||||
categoryExtensions: {
|
||||
type: 'boolean',
|
||||
hidden: true,
|
||||
conflicts: ['browserUrl', 'autoConnect', 'wsEndpoint'],
|
||||
describe: 'Set to true to include tools related to extensions. Note: This feature is only supported with a pipe connection. autoConnect is not supported.',
|
||||
},
|
||||
categoryInPageTools: {
|
||||
type: 'boolean',
|
||||
hidden: true,
|
||||
describe: 'Set to true to enable tools exposed by the inspected page itself',
|
||||
},
|
||||
performanceCrux: {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
describe: 'Set to false to disable sending URLs from performance traces to CrUX API to get field performance data.',
|
||||
},
|
||||
usageStatistics: {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
describe: 'Set to false to opt-out of usage statistics collection. Google collects usage data to improve the tool, handled under the Google Privacy Policy (https://policies.google.com/privacy). This is independent from Chrome browser metrics. Disabled if CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS or CI env variables are set.',
|
||||
},
|
||||
clearcutEndpoint: {
|
||||
type: 'string',
|
||||
hidden: true,
|
||||
describe: 'Endpoint for Clearcut telemetry.',
|
||||
},
|
||||
clearcutForceFlushIntervalMs: {
|
||||
type: 'number',
|
||||
hidden: true,
|
||||
describe: 'Force flush interval in milliseconds (for testing).',
|
||||
},
|
||||
clearcutIncludePidHeader: {
|
||||
type: 'boolean',
|
||||
hidden: true,
|
||||
describe: 'Include watchdog PID in Clearcut request headers (for testing).',
|
||||
},
|
||||
slim: {
|
||||
type: 'boolean',
|
||||
describe: 'Exposes a "slim" set of 3 tools covering navigation, script execution and screenshots only. Useful for basic browser tasks.',
|
||||
},
|
||||
viaCli: {
|
||||
type: 'boolean',
|
||||
describe: 'Set by Chrome DevTools CLI if the MCP server is started via the CLI client (this arg exists for usage stats)',
|
||||
hidden: true,
|
||||
},
|
||||
};
|
||||
export function parseArguments(version, argv = process.argv) {
|
||||
const yargsInstance = yargs(hideBin(argv))
|
||||
.scriptName('npx chrome-devtools-mcp@latest')
|
||||
.options(cliOptions)
|
||||
.check(args => {
|
||||
// We can't set default in the options else
|
||||
// Yargs will complain
|
||||
if (!args.channel &&
|
||||
!args.browserUrl &&
|
||||
!args.wsEndpoint &&
|
||||
!args.executablePath) {
|
||||
args.channel = 'stable';
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.example([
|
||||
[
|
||||
'$0 --browserUrl http://127.0.0.1:9222',
|
||||
'Connect to an existing browser instance via HTTP',
|
||||
],
|
||||
[
|
||||
'$0 --wsEndpoint ws://127.0.0.1:9222/devtools/browser/abc123',
|
||||
'Connect to an existing browser instance via WebSocket',
|
||||
],
|
||||
[
|
||||
`$0 --wsEndpoint ws://127.0.0.1:9222/devtools/browser/abc123 --wsHeaders '{"Authorization":"Bearer token"}'`,
|
||||
'Connect via WebSocket with custom headers',
|
||||
],
|
||||
['$0 --channel beta', 'Use Chrome Beta installed on this system'],
|
||||
['$0 --channel canary', 'Use Chrome Canary installed on this system'],
|
||||
['$0 --channel dev', 'Use Chrome Dev installed on this system'],
|
||||
['$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',
|
||||
],
|
||||
[
|
||||
`$0 --chrome-arg='--no-sandbox' --chrome-arg='--disable-setuid-sandbox'`,
|
||||
'Launch Chrome without sandboxes. Use with caution.',
|
||||
],
|
||||
[
|
||||
`$0 --ignore-default-chrome-arg='--disable-extensions'`,
|
||||
'Disable the default arguments provided by Puppeteer. Use with caution.',
|
||||
],
|
||||
['$0 --no-category-emulation', 'Disable tools in the emulation category'],
|
||||
[
|
||||
'$0 --no-category-performance',
|
||||
'Disable tools in the performance category',
|
||||
],
|
||||
['$0 --no-category-network', 'Disable tools in the network category'],
|
||||
[
|
||||
'$0 --user-data-dir=/tmp/user-data-dir',
|
||||
'Use a custom user data directory',
|
||||
],
|
||||
[
|
||||
'$0 --auto-connect',
|
||||
'Connect to a stable Chrome instance (Chrome 144+) running instead of launching a new instance',
|
||||
],
|
||||
[
|
||||
'$0 --auto-connect --channel=canary',
|
||||
'Connect to a canary Chrome instance (Chrome 144+) running instead of launching a new instance',
|
||||
],
|
||||
[
|
||||
'$0 --no-usage-statistics',
|
||||
'Do not send usage statistics https://github.com/ChromeDevTools/chrome-devtools-mcp#usage-statistics.',
|
||||
],
|
||||
[
|
||||
'$0 --no-performance-crux',
|
||||
'Disable CrUX (field data) integration in performance tools.',
|
||||
],
|
||||
[
|
||||
'$0 --slim',
|
||||
'Only 3 tools: navigation, JavaScript execution and screenshot',
|
||||
],
|
||||
]);
|
||||
return yargsInstance
|
||||
.wrap(Math.min(120, yargsInstance.terminalWidth()))
|
||||
.help()
|
||||
.version(version)
|
||||
.parseSync();
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import '../polyfill.js';
|
||||
import process from 'node:process';
|
||||
import { createMcpServer, logDisclaimers } from '../index.js';
|
||||
import { logger, saveLogsToFile } from '../logger.js';
|
||||
import { computeFlagUsage } from '../telemetry/flagUtils.js';
|
||||
import { StdioServerTransport } from '../third_party/index.js';
|
||||
import { VERSION } from '../version.js';
|
||||
import { cliOptions, parseArguments } from './chrome-devtools-mcp-cli-options.js';
|
||||
export const args = parseArguments(VERSION);
|
||||
const logFile = args.logFile ? saveLogsToFile(args.logFile) : undefined;
|
||||
if (process.env['CI'] ||
|
||||
process.env['CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS']) {
|
||||
console.error("turning off usage statistics. process.env['CI'] || process.env['CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS'] is set.");
|
||||
args.usageStatistics = false;
|
||||
}
|
||||
if (process.env['CHROME_DEVTOOLS_MCP_CRASH_ON_UNCAUGHT'] !== 'true') {
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
logger('Unhandled promise rejection', promise, reason);
|
||||
});
|
||||
}
|
||||
logger(`Starting Chrome DevTools MCP Server v${VERSION}`);
|
||||
const { server, clearcutLogger } = await createMcpServer(args, {
|
||||
logFile,
|
||||
});
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
logger('Chrome DevTools MCP Server connected');
|
||||
logDisclaimers(args);
|
||||
void clearcutLogger?.logDailyActiveIfNeeded();
|
||||
void clearcutLogger?.logServerStart(computeFlagUsage(args, cliOptions));
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
process.title = 'chrome-devtools-mcp';
|
||||
import { version } from 'node:process';
|
||||
const [major, minor] = version.substring(1).split('.').map(Number);
|
||||
if (major === 20 && minor < 19) {
|
||||
console.error(`ERROR: \`chrome-devtools-mcp\` does not support Node ${process.version}. Please upgrade to Node 20.19.0 LTS or a newer LTS.`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (major === 22 && minor < 12) {
|
||||
console.error(`ERROR: \`chrome-devtools-mcp\` does not support Node ${process.version}. Please upgrade to Node 22.12.0 LTS or a newer LTS.`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (major < 20) {
|
||||
console.error(`ERROR: \`chrome-devtools-mcp\` does not support Node ${process.version}. Please upgrade to Node 20.19.0 LTS or a newer LTS.`);
|
||||
process.exit(1);
|
||||
}
|
||||
await import('./chrome-devtools-mcp-main.js');
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
process.title = 'chrome-devtools';
|
||||
import process from 'node:process';
|
||||
import { startDaemon, stopDaemon, sendCommand, handleResponse, } from '../daemon/client.js';
|
||||
import { isDaemonRunning, serializeArgs } from '../daemon/utils.js';
|
||||
import { logDisclaimers } from '../index.js';
|
||||
import { hideBin, yargs } from '../third_party/index.js';
|
||||
import { VERSION } from '../version.js';
|
||||
import { commands } from './chrome-devtools-cli-options.js';
|
||||
import { cliOptions, parseArguments } from './chrome-devtools-mcp-cli-options.js';
|
||||
async function start(args) {
|
||||
const combinedArgs = [...args, ...defaultArgs];
|
||||
await startDaemon(combinedArgs);
|
||||
logDisclaimers(parseArguments(VERSION, combinedArgs));
|
||||
}
|
||||
const defaultArgs = ['--viaCli', '--experimentalStructuredContent'];
|
||||
const startCliOptions = {
|
||||
...cliOptions,
|
||||
};
|
||||
// Not supported in CLI on purpose.
|
||||
delete startCliOptions.autoConnect;
|
||||
// Missing CLI serialization.
|
||||
delete startCliOptions.viewport;
|
||||
// CLI is generated based on the default tool definitions. To enable conditional
|
||||
// tools, they need to be enabled during CLI generation.
|
||||
delete startCliOptions.experimentalPageIdRouting;
|
||||
delete startCliOptions.experimentalVision;
|
||||
delete startCliOptions.experimentalInteropTools;
|
||||
delete startCliOptions.experimentalScreencast;
|
||||
delete startCliOptions.categoryEmulation;
|
||||
delete startCliOptions.categoryPerformance;
|
||||
delete startCliOptions.categoryNetwork;
|
||||
delete startCliOptions.categoryExtensions;
|
||||
// Always on in CLI.
|
||||
delete startCliOptions.experimentalStructuredContent;
|
||||
// Change the defaults.
|
||||
if (!('default' in cliOptions.headless)) {
|
||||
throw new Error('headless cli option unexpectedly does not have a default');
|
||||
}
|
||||
if ('default' in cliOptions.isolated) {
|
||||
throw new Error('isolated cli option unexpectedly has a default');
|
||||
}
|
||||
startCliOptions.headless.default = true;
|
||||
startCliOptions.isolated.description =
|
||||
'If specified, creates a temporary user-data-dir that is automatically cleaned up after the browser is closed. Defaults to true unless userDataDir is provided.';
|
||||
const y = yargs(hideBin(process.argv))
|
||||
.scriptName('chrome-devtools')
|
||||
.showHelpOnFail(true)
|
||||
.usage('chrome-devtools <command> [...args] --flags')
|
||||
.usage(`Run 'chrome-devtools <command> --help' for help on the specific command.`)
|
||||
.demandCommand()
|
||||
.version(VERSION)
|
||||
.strict()
|
||||
.help(true)
|
||||
.wrap(120);
|
||||
y.command('start', 'Start or restart chrome-devtools-mcp', y => y
|
||||
.options(startCliOptions)
|
||||
.example('$0 start --browserUrl http://localhost:9222', 'Start the server connecting to an existing browser')
|
||||
.strict(), async (argv) => {
|
||||
if (isDaemonRunning()) {
|
||||
await stopDaemon();
|
||||
}
|
||||
// Defaults but we do not want to affect the yargs conflict resolution.
|
||||
if (argv.isolated === undefined && argv.userDataDir === undefined) {
|
||||
argv.isolated = true;
|
||||
}
|
||||
if (argv.headless === undefined) {
|
||||
argv.headless = true;
|
||||
}
|
||||
const args = serializeArgs(cliOptions, argv);
|
||||
await start(args);
|
||||
process.exit(0);
|
||||
}).strict(); // Re-enable strict validation for other commands; this is applied to the yargs instance itself
|
||||
y.command('status', 'Checks if chrome-devtools-mcp is running', async () => {
|
||||
if (isDaemonRunning()) {
|
||||
console.log('chrome-devtools-mcp daemon is running.');
|
||||
const response = await sendCommand({
|
||||
method: 'status',
|
||||
});
|
||||
if (response.success) {
|
||||
const data = JSON.parse(response.result);
|
||||
console.log(`pid=${data.pid} socket=${data.socketPath} start-date=${data.startDate} version=${data.version}`);
|
||||
console.log(`args=${JSON.stringify(data.args)}`);
|
||||
}
|
||||
else {
|
||||
console.error('Error:', response.error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
else {
|
||||
console.log('chrome-devtools-mcp daemon is not running.');
|
||||
}
|
||||
process.exit(0);
|
||||
});
|
||||
y.command('stop', 'Stop chrome-devtools-mcp if any', async () => {
|
||||
if (!isDaemonRunning()) {
|
||||
process.exit(0);
|
||||
}
|
||||
await stopDaemon();
|
||||
process.exit(0);
|
||||
});
|
||||
for (const [commandName, commandDef] of Object.entries(commands)) {
|
||||
const args = commandDef.args;
|
||||
const requiredArgNames = Object.keys(args).filter(name => args[name].required);
|
||||
const optionalArgNames = Object.keys(args).filter(name => !args[name].required);
|
||||
let commandStr = commandName;
|
||||
for (const arg of requiredArgNames) {
|
||||
commandStr += ` <${arg}>`;
|
||||
}
|
||||
for (const arg of optionalArgNames) {
|
||||
commandStr += ` [--${arg}]`;
|
||||
}
|
||||
y.command(commandStr, commandDef.description, y => {
|
||||
y.option('output-format', {
|
||||
choices: ['md', 'json'],
|
||||
default: 'md',
|
||||
});
|
||||
for (const [argName, opt] of Object.entries(args)) {
|
||||
const type = opt.type === 'integer' || opt.type === 'number'
|
||||
? 'number'
|
||||
: opt.type === 'boolean'
|
||||
? 'boolean'
|
||||
: opt.type === 'array'
|
||||
? 'array'
|
||||
: 'string';
|
||||
if (opt.required) {
|
||||
const options = {
|
||||
describe: opt.description,
|
||||
type: type,
|
||||
};
|
||||
if (opt.default !== undefined) {
|
||||
options.default = opt.default;
|
||||
}
|
||||
if (opt.enum) {
|
||||
options.choices = opt.enum;
|
||||
}
|
||||
y.positional(argName, options);
|
||||
}
|
||||
else {
|
||||
const options = {
|
||||
describe: opt.description,
|
||||
type: type,
|
||||
};
|
||||
if (opt.default !== undefined) {
|
||||
options.default = opt.default;
|
||||
}
|
||||
if (opt.enum) {
|
||||
options.choices = opt.enum;
|
||||
}
|
||||
y.option(argName, options);
|
||||
}
|
||||
}
|
||||
}, async (argv) => {
|
||||
try {
|
||||
if (!isDaemonRunning()) {
|
||||
await start([]);
|
||||
}
|
||||
const commandArgs = {};
|
||||
for (const argName of Object.keys(args)) {
|
||||
if (argName in argv) {
|
||||
commandArgs[argName] = argv[argName];
|
||||
}
|
||||
}
|
||||
const response = await sendCommand({
|
||||
method: 'invoke_tool',
|
||||
tool: commandName,
|
||||
args: commandArgs,
|
||||
});
|
||||
if (response.success) {
|
||||
console.log(await handleResponse(JSON.parse(response.result), argv['output-format']));
|
||||
}
|
||||
else {
|
||||
console.error('Error:', response.error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to execute command:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
await y.parse();
|
||||
+615
@@ -0,0 +1,615 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
export const commands = {
|
||||
click: {
|
||||
description: 'Clicks on the provided element',
|
||||
category: 'Input automation',
|
||||
args: {
|
||||
uid: {
|
||||
name: 'uid',
|
||||
type: 'string',
|
||||
description: 'The uid of an element on the page from the page content snapshot',
|
||||
required: true,
|
||||
},
|
||||
dblClick: {
|
||||
name: 'dblClick',
|
||||
type: 'boolean',
|
||||
description: 'Set to true for double clicks. Default is false.',
|
||||
required: false,
|
||||
},
|
||||
includeSnapshot: {
|
||||
name: 'includeSnapshot',
|
||||
type: 'boolean',
|
||||
description: 'Whether to include a snapshot in the response. Default is false.',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
close_page: {
|
||||
description: 'Closes the page by its index. The last open page cannot be closed.',
|
||||
category: 'Navigation automation',
|
||||
args: {
|
||||
pageId: {
|
||||
name: 'pageId',
|
||||
type: 'number',
|
||||
description: 'The ID of the page to close. Call list_pages to list pages.',
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
drag: {
|
||||
description: 'Drag an element onto another element',
|
||||
category: 'Input automation',
|
||||
args: {
|
||||
from_uid: {
|
||||
name: 'from_uid',
|
||||
type: 'string',
|
||||
description: 'The uid of the element to drag',
|
||||
required: true,
|
||||
},
|
||||
to_uid: {
|
||||
name: 'to_uid',
|
||||
type: 'string',
|
||||
description: 'The uid of the element to drop into',
|
||||
required: true,
|
||||
},
|
||||
includeSnapshot: {
|
||||
name: 'includeSnapshot',
|
||||
type: 'boolean',
|
||||
description: 'Whether to include a snapshot in the response. Default is false.',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
emulate: {
|
||||
description: 'Emulates various features on the selected page.',
|
||||
category: 'Emulation',
|
||||
args: {
|
||||
networkConditions: {
|
||||
name: 'networkConditions',
|
||||
type: 'string',
|
||||
description: 'Throttle network. Omit to disable throttling.',
|
||||
required: false,
|
||||
enum: ['Offline', 'Slow 3G', 'Fast 3G', 'Slow 4G', 'Fast 4G'],
|
||||
},
|
||||
cpuThrottlingRate: {
|
||||
name: 'cpuThrottlingRate',
|
||||
type: 'number',
|
||||
description: 'Represents the CPU slowdown factor. Omit or set the rate to 1 to disable throttling',
|
||||
required: false,
|
||||
},
|
||||
geolocation: {
|
||||
name: 'geolocation',
|
||||
type: 'string',
|
||||
description: 'Geolocation (`<latitude>x<longitude>`) to emulate. Latitude between -90 and 90. Longitude between -180 and 180. Omit clear the geolocation override.',
|
||||
required: false,
|
||||
},
|
||||
userAgent: {
|
||||
name: 'userAgent',
|
||||
type: 'string',
|
||||
description: 'User agent to emulate. Set to empty string to clear the user agent override.',
|
||||
required: false,
|
||||
},
|
||||
colorScheme: {
|
||||
name: 'colorScheme',
|
||||
type: 'string',
|
||||
description: 'Emulate the dark or the light mode. Set to "auto" to reset to the default.',
|
||||
required: false,
|
||||
enum: ['dark', 'light', 'auto'],
|
||||
},
|
||||
viewport: {
|
||||
name: 'viewport',
|
||||
type: 'string',
|
||||
description: "Emulate device viewports '<width>x<height>x<devicePixelRatio>[,mobile][,touch][,landscape]'. 'touch' and 'mobile' to emulate mobile devices. 'landscape' to emulate landscape mode.",
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
evaluate_script: {
|
||||
description: 'Evaluate a JavaScript function inside the currently selected page. Returns the response as JSON,\nso returned values have to be JSON-serializable.',
|
||||
category: 'Debugging',
|
||||
args: {
|
||||
function: {
|
||||
name: 'function',
|
||||
type: 'string',
|
||||
description: 'A JavaScript function declaration to be executed by the tool in the currently selected page.\nExample without arguments: `() => {\n return document.title\n}` or `async () => {\n return await fetch("example.com")\n}`.\nExample with arguments: `(el) => {\n return el.innerText;\n}`\n',
|
||||
required: true,
|
||||
},
|
||||
args: {
|
||||
name: 'args',
|
||||
type: 'array',
|
||||
description: 'An optional list of arguments to pass to the function.',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
fill: {
|
||||
description: 'Type text into a input, text area or select an option from a <select> element.',
|
||||
category: 'Input automation',
|
||||
args: {
|
||||
uid: {
|
||||
name: 'uid',
|
||||
type: 'string',
|
||||
description: 'The uid of an element on the page from the page content snapshot',
|
||||
required: true,
|
||||
},
|
||||
value: {
|
||||
name: 'value',
|
||||
type: 'string',
|
||||
description: 'The value to fill in',
|
||||
required: true,
|
||||
},
|
||||
includeSnapshot: {
|
||||
name: 'includeSnapshot',
|
||||
type: 'boolean',
|
||||
description: 'Whether to include a snapshot in the response. Default is false.',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
get_console_message: {
|
||||
description: 'Gets a console message by its ID. You can get all messages by calling list_console_messages.',
|
||||
category: 'Debugging',
|
||||
args: {
|
||||
msgid: {
|
||||
name: 'msgid',
|
||||
type: 'number',
|
||||
description: 'The msgid of a console message on the page from the listed console messages',
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
get_network_request: {
|
||||
description: 'Gets a network request by an optional reqid, if omitted returns the currently selected request in the DevTools Network panel.',
|
||||
category: 'Network',
|
||||
args: {
|
||||
reqid: {
|
||||
name: 'reqid',
|
||||
type: 'number',
|
||||
description: 'The reqid of the network request. If omitted returns the currently selected request in the DevTools Network panel.',
|
||||
required: false,
|
||||
},
|
||||
requestFilePath: {
|
||||
name: 'requestFilePath',
|
||||
type: 'string',
|
||||
description: 'The absolute or relative path to save the request body to. If omitted, the body is returned inline.',
|
||||
required: false,
|
||||
},
|
||||
responseFilePath: {
|
||||
name: 'responseFilePath',
|
||||
type: 'string',
|
||||
description: 'The absolute or relative path to save the response body to. If omitted, the body is returned inline.',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
handle_dialog: {
|
||||
description: 'If a browser dialog was opened, use this command to handle it',
|
||||
category: 'Input automation',
|
||||
args: {
|
||||
action: {
|
||||
name: 'action',
|
||||
type: 'string',
|
||||
description: 'Whether to dismiss or accept the dialog',
|
||||
required: true,
|
||||
enum: ['accept', 'dismiss'],
|
||||
},
|
||||
promptText: {
|
||||
name: 'promptText',
|
||||
type: 'string',
|
||||
description: 'Optional prompt text to enter into the dialog.',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
hover: {
|
||||
description: 'Hover over the provided element',
|
||||
category: 'Input automation',
|
||||
args: {
|
||||
uid: {
|
||||
name: 'uid',
|
||||
type: 'string',
|
||||
description: 'The uid of an element on the page from the page content snapshot',
|
||||
required: true,
|
||||
},
|
||||
includeSnapshot: {
|
||||
name: 'includeSnapshot',
|
||||
type: 'boolean',
|
||||
description: 'Whether to include a snapshot in the response. Default is false.',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
lighthouse_audit: {
|
||||
description: 'Get Lighthouse score and reports for accessibility, SEO and best practices. This excludes performance. For performance audits, run performance_start_trace',
|
||||
category: 'Debugging',
|
||||
args: {
|
||||
mode: {
|
||||
name: 'mode',
|
||||
type: 'string',
|
||||
description: '"navigation" reloads & audits. "snapshot" analyzes current state.',
|
||||
required: false,
|
||||
default: 'navigation',
|
||||
enum: ['navigation', 'snapshot'],
|
||||
},
|
||||
device: {
|
||||
name: 'device',
|
||||
type: 'string',
|
||||
description: 'Device to emulate.',
|
||||
required: false,
|
||||
default: 'desktop',
|
||||
enum: ['desktop', 'mobile'],
|
||||
},
|
||||
outputDirPath: {
|
||||
name: 'outputDirPath',
|
||||
type: 'string',
|
||||
description: 'Directory for reports. If omitted, uses temporary files.',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
list_console_messages: {
|
||||
description: 'List all console messages for the currently selected page since the last navigation.',
|
||||
category: 'Debugging',
|
||||
args: {
|
||||
pageSize: {
|
||||
name: 'pageSize',
|
||||
type: 'integer',
|
||||
description: 'Maximum number of messages to return. When omitted, returns all requests.',
|
||||
required: false,
|
||||
},
|
||||
pageIdx: {
|
||||
name: 'pageIdx',
|
||||
type: 'integer',
|
||||
description: 'Page number to return (0-based). When omitted, returns the first page.',
|
||||
required: false,
|
||||
},
|
||||
types: {
|
||||
name: 'types',
|
||||
type: 'array',
|
||||
description: 'Filter messages to only return messages of the specified resource types. When omitted or empty, returns all messages.',
|
||||
required: false,
|
||||
},
|
||||
includePreservedMessages: {
|
||||
name: 'includePreservedMessages',
|
||||
type: 'boolean',
|
||||
description: 'Set to true to return the preserved messages over the last 3 navigations.',
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
list_network_requests: {
|
||||
description: 'List all requests for the currently selected page since the last navigation.',
|
||||
category: 'Network',
|
||||
args: {
|
||||
pageSize: {
|
||||
name: 'pageSize',
|
||||
type: 'integer',
|
||||
description: 'Maximum number of requests to return. When omitted, returns all requests.',
|
||||
required: false,
|
||||
},
|
||||
pageIdx: {
|
||||
name: 'pageIdx',
|
||||
type: 'integer',
|
||||
description: 'Page number to return (0-based). When omitted, returns the first page.',
|
||||
required: false,
|
||||
},
|
||||
resourceTypes: {
|
||||
name: 'resourceTypes',
|
||||
type: 'array',
|
||||
description: 'Filter requests to only return requests of the specified resource types. When omitted or empty, returns all requests.',
|
||||
required: false,
|
||||
},
|
||||
includePreservedRequests: {
|
||||
name: 'includePreservedRequests',
|
||||
type: 'boolean',
|
||||
description: 'Set to true to return the preserved requests over the last 3 navigations.',
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
list_pages: {
|
||||
description: 'Get a list of pages open in the browser.',
|
||||
category: 'Navigation automation',
|
||||
args: {},
|
||||
},
|
||||
navigate_page: {
|
||||
description: 'Go to a URL, or back, forward, or reload. Use project URL if not specified otherwise.',
|
||||
category: 'Navigation automation',
|
||||
args: {
|
||||
type: {
|
||||
name: 'type',
|
||||
type: 'string',
|
||||
description: 'Navigate the page by URL, back or forward in history, or reload.',
|
||||
required: false,
|
||||
enum: ['url', 'back', 'forward', 'reload'],
|
||||
},
|
||||
url: {
|
||||
name: 'url',
|
||||
type: 'string',
|
||||
description: 'Target URL (only type=url)',
|
||||
required: false,
|
||||
},
|
||||
ignoreCache: {
|
||||
name: 'ignoreCache',
|
||||
type: 'boolean',
|
||||
description: 'Whether to ignore cache on reload.',
|
||||
required: false,
|
||||
},
|
||||
handleBeforeUnload: {
|
||||
name: 'handleBeforeUnload',
|
||||
type: 'string',
|
||||
description: 'Whether to auto accept or beforeunload dialogs triggered by this navigation. Default is accept.',
|
||||
required: false,
|
||||
enum: ['accept', 'decline'],
|
||||
},
|
||||
initScript: {
|
||||
name: 'initScript',
|
||||
type: 'string',
|
||||
description: 'A JavaScript script to be executed on each new document before any other scripts for the next navigation.',
|
||||
required: false,
|
||||
},
|
||||
timeout: {
|
||||
name: 'timeout',
|
||||
type: 'integer',
|
||||
description: 'Maximum wait time in milliseconds. If set to 0, the default timeout will be used.',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
new_page: {
|
||||
description: 'Open a new tab and load a URL. Use project URL if not specified otherwise.',
|
||||
category: 'Navigation automation',
|
||||
args: {
|
||||
url: {
|
||||
name: 'url',
|
||||
type: 'string',
|
||||
description: 'URL to load in a new page.',
|
||||
required: true,
|
||||
},
|
||||
background: {
|
||||
name: 'background',
|
||||
type: 'boolean',
|
||||
description: 'Whether to open the page in the background without bringing it to the front. Default is false (foreground).',
|
||||
required: false,
|
||||
},
|
||||
isolatedContext: {
|
||||
name: 'isolatedContext',
|
||||
type: 'string',
|
||||
description: 'If specified, the page is created in an isolated browser context with the given name. Pages in the same browser context share cookies and storage. Pages in different browser contexts are fully isolated.',
|
||||
required: false,
|
||||
},
|
||||
timeout: {
|
||||
name: 'timeout',
|
||||
type: 'integer',
|
||||
description: 'Maximum wait time in milliseconds. If set to 0, the default timeout will be used.',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
performance_analyze_insight: {
|
||||
description: 'Provides more detailed information on a specific Performance Insight of an insight set that was highlighted in the results of a trace recording.',
|
||||
category: 'Performance',
|
||||
args: {
|
||||
insightSetId: {
|
||||
name: 'insightSetId',
|
||||
type: 'string',
|
||||
description: 'The id for the specific insight set. Only use the ids given in the "Available insight sets" list.',
|
||||
required: true,
|
||||
},
|
||||
insightName: {
|
||||
name: 'insightName',
|
||||
type: 'string',
|
||||
description: 'The name of the Insight you want more information on. For example: "DocumentLatency" or "LCPBreakdown"',
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
performance_start_trace: {
|
||||
description: 'Start a performance trace on the selected webpage. Use to find frontend performance issues, Core Web Vitals (LCP, INP, CLS), and improve page load speed.',
|
||||
category: 'Performance',
|
||||
args: {
|
||||
reload: {
|
||||
name: 'reload',
|
||||
type: 'boolean',
|
||||
description: 'Determines if, once tracing has started, the current selected page should be automatically reloaded. Navigate the page to the right URL using the navigate_page tool BEFORE starting the trace if reload or autoStop is set to true.',
|
||||
required: false,
|
||||
default: true,
|
||||
},
|
||||
autoStop: {
|
||||
name: 'autoStop',
|
||||
type: 'boolean',
|
||||
description: 'Determines if the trace recording should be automatically stopped.',
|
||||
required: false,
|
||||
default: true,
|
||||
},
|
||||
filePath: {
|
||||
name: 'filePath',
|
||||
type: 'string',
|
||||
description: 'The absolute file path, or a file path relative to the current working directory, to save the raw trace data. For example, trace.json.gz (compressed) or trace.json (uncompressed).',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
performance_stop_trace: {
|
||||
description: 'Stop the active performance trace recording on the selected webpage.',
|
||||
category: 'Performance',
|
||||
args: {
|
||||
filePath: {
|
||||
name: 'filePath',
|
||||
type: 'string',
|
||||
description: 'The absolute file path, or a file path relative to the current working directory, to save the raw trace data. For example, trace.json.gz (compressed) or trace.json (uncompressed).',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
press_key: {
|
||||
description: 'Press a key or key combination. Use this when other input methods like fill() cannot be used (e.g., keyboard shortcuts, navigation keys, or special key combinations).',
|
||||
category: 'Input automation',
|
||||
args: {
|
||||
key: {
|
||||
name: 'key',
|
||||
type: 'string',
|
||||
description: 'A key or a combination (e.g., "Enter", "Control+A", "Control++", "Control+Shift+R"). Modifiers: Control, Shift, Alt, Meta',
|
||||
required: true,
|
||||
},
|
||||
includeSnapshot: {
|
||||
name: 'includeSnapshot',
|
||||
type: 'boolean',
|
||||
description: 'Whether to include a snapshot in the response. Default is false.',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
resize_page: {
|
||||
description: "Resizes the selected page's window so that the page has specified dimension",
|
||||
category: 'Emulation',
|
||||
args: {
|
||||
width: {
|
||||
name: 'width',
|
||||
type: 'number',
|
||||
description: 'Page width',
|
||||
required: true,
|
||||
},
|
||||
height: {
|
||||
name: 'height',
|
||||
type: 'number',
|
||||
description: 'Page height',
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
select_page: {
|
||||
description: 'Select a page as a context for future tool calls.',
|
||||
category: 'Navigation automation',
|
||||
args: {
|
||||
pageId: {
|
||||
name: 'pageId',
|
||||
type: 'number',
|
||||
description: 'The ID of the page to select. Call list_pages to get available pages.',
|
||||
required: true,
|
||||
},
|
||||
bringToFront: {
|
||||
name: 'bringToFront',
|
||||
type: 'boolean',
|
||||
description: 'Whether to focus the page and bring it to the top.',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
take_memory_snapshot: {
|
||||
description: 'Capture a heap snapshot of the currently selected page. Use to analyze the memory distribution of JavaScript objects and debug memory leaks.',
|
||||
category: 'Performance',
|
||||
args: {
|
||||
filePath: {
|
||||
name: 'filePath',
|
||||
type: 'string',
|
||||
description: 'A path to a .heapsnapshot file to save the heapsnapshot to.',
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
take_screenshot: {
|
||||
description: 'Take a screenshot of the page or element.',
|
||||
category: 'Debugging',
|
||||
args: {
|
||||
format: {
|
||||
name: 'format',
|
||||
type: 'string',
|
||||
description: 'Type of format to save the screenshot as. Default is "png"',
|
||||
required: false,
|
||||
default: 'png',
|
||||
enum: ['png', 'jpeg', 'webp'],
|
||||
},
|
||||
quality: {
|
||||
name: 'quality',
|
||||
type: 'number',
|
||||
description: 'Compression quality for JPEG and WebP formats (0-100). Higher values mean better quality but larger file sizes. Ignored for PNG format.',
|
||||
required: false,
|
||||
},
|
||||
uid: {
|
||||
name: 'uid',
|
||||
type: 'string',
|
||||
description: 'The uid of an element on the page from the page content snapshot. If omitted takes a pages screenshot.',
|
||||
required: false,
|
||||
},
|
||||
fullPage: {
|
||||
name: 'fullPage',
|
||||
type: 'boolean',
|
||||
description: 'If set to true takes a screenshot of the full page instead of the currently visible viewport. Incompatible with uid.',
|
||||
required: false,
|
||||
},
|
||||
filePath: {
|
||||
name: 'filePath',
|
||||
type: 'string',
|
||||
description: 'The absolute path, or a path relative to the current working directory, to save the screenshot to instead of attaching it to the response.',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
take_snapshot: {
|
||||
description: 'Take a text snapshot of the currently selected page based on the a11y tree. The snapshot lists page elements along with a unique\nidentifier (uid). Always use the latest snapshot. Prefer taking a snapshot over taking a screenshot. The snapshot indicates the element selected\nin the DevTools Elements panel (if any).',
|
||||
category: 'Debugging',
|
||||
args: {
|
||||
verbose: {
|
||||
name: 'verbose',
|
||||
type: 'boolean',
|
||||
description: 'Whether to include all possible information available in the full a11y tree. Default is false.',
|
||||
required: false,
|
||||
},
|
||||
filePath: {
|
||||
name: 'filePath',
|
||||
type: 'string',
|
||||
description: 'The absolute path, or a path relative to the current working directory, to save the snapshot to instead of attaching it to the response.',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
type_text: {
|
||||
description: 'Type text using keyboard into a previously focused input',
|
||||
category: 'Input automation',
|
||||
args: {
|
||||
text: {
|
||||
name: 'text',
|
||||
type: 'string',
|
||||
description: 'The text to type',
|
||||
required: true,
|
||||
},
|
||||
submitKey: {
|
||||
name: 'submitKey',
|
||||
type: 'string',
|
||||
description: 'Optional key to press after typing. E.g., "Enter", "Tab", "Escape"',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
upload_file: {
|
||||
description: 'Upload a file through a provided element.',
|
||||
category: 'Input automation',
|
||||
args: {
|
||||
uid: {
|
||||
name: 'uid',
|
||||
type: 'string',
|
||||
description: 'The uid of the file input element or an element that will open file chooser on the page from the page content snapshot',
|
||||
required: true,
|
||||
},
|
||||
filePath: {
|
||||
name: 'filePath',
|
||||
type: 'string',
|
||||
description: 'The local path of the file to upload',
|
||||
required: true,
|
||||
},
|
||||
includeSnapshot: {
|
||||
name: 'includeSnapshot',
|
||||
type: 'boolean',
|
||||
description: 'Whether to include a snapshot in the response. Default is false.',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { logger } from './logger.js';
|
||||
import { puppeteer } from './third_party/index.js';
|
||||
let browser;
|
||||
function makeTargetFilter(enableExtensions = false) {
|
||||
const ignoredPrefixes = new Set(['chrome://', 'chrome-untrusted://']);
|
||||
if (!enableExtensions) {
|
||||
ignoredPrefixes.add('chrome-extension://');
|
||||
}
|
||||
return function targetFilter(target) {
|
||||
if (target.url() === 'chrome://newtab/') {
|
||||
return true;
|
||||
}
|
||||
// Could be the only page opened in the browser.
|
||||
if (target.url().startsWith('chrome://inspect')) {
|
||||
return true;
|
||||
}
|
||||
for (const prefix of ignoredPrefixes) {
|
||||
if (target.url().startsWith(prefix)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
}
|
||||
export async function ensureBrowserConnected(options) {
|
||||
const { channel, enableExtensions } = options;
|
||||
if (browser?.connected) {
|
||||
return browser;
|
||||
}
|
||||
const connectOptions = {
|
||||
targetFilter: makeTargetFilter(enableExtensions),
|
||||
defaultViewport: null,
|
||||
handleDevToolsAsPage: true,
|
||||
};
|
||||
let autoConnect = false;
|
||||
if (options.wsEndpoint) {
|
||||
connectOptions.browserWSEndpoint = options.wsEndpoint;
|
||||
if (options.wsHeaders) {
|
||||
connectOptions.headers = options.wsHeaders;
|
||||
}
|
||||
}
|
||||
else if (options.browserURL) {
|
||||
connectOptions.browserURL = options.browserURL;
|
||||
}
|
||||
else if (channel || options.userDataDir) {
|
||||
const userDataDir = options.userDataDir;
|
||||
if (userDataDir) {
|
||||
autoConnect = true;
|
||||
// TODO: re-expose this logic via Puppeteer.
|
||||
const portPath = path.join(userDataDir, 'DevToolsActivePort');
|
||||
try {
|
||||
const fileContent = await fs.promises.readFile(portPath, 'utf8');
|
||||
const [rawPort, rawPath] = fileContent
|
||||
.split('\n')
|
||||
.map(line => {
|
||||
return line.trim();
|
||||
})
|
||||
.filter(line => {
|
||||
return !!line;
|
||||
});
|
||||
if (!rawPort || !rawPath) {
|
||||
throw new Error(`Invalid DevToolsActivePort '${fileContent}' found`);
|
||||
}
|
||||
const port = parseInt(rawPort, 10);
|
||||
if (isNaN(port) || port <= 0 || port > 65535) {
|
||||
throw new Error(`Invalid port '${rawPort}' found`);
|
||||
}
|
||||
const browserWSEndpoint = `ws://127.0.0.1:${port}${rawPath}`;
|
||||
connectOptions.browserWSEndpoint = browserWSEndpoint;
|
||||
}
|
||||
catch (error) {
|
||||
throw new Error(`Could not connect to Chrome in ${userDataDir}. Check if Chrome is running and remote debugging is enabled by going to chrome://inspect/#remote-debugging.`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (!channel) {
|
||||
throw new Error('Channel must be provided if userDataDir is missing');
|
||||
}
|
||||
connectOptions.channel = (channel === 'stable' ? 'chrome' : `chrome-${channel}`);
|
||||
}
|
||||
}
|
||||
else {
|
||||
throw new Error('Either browserURL, wsEndpoint, channel or userDataDir must be provided');
|
||||
}
|
||||
logger('Connecting Puppeteer to ', JSON.stringify(connectOptions));
|
||||
try {
|
||||
browser = await puppeteer.connect(connectOptions);
|
||||
}
|
||||
catch (err) {
|
||||
throw new Error(`Could not connect to Chrome. ${autoConnect ? `Check if Chrome is running and remote debugging is enabled by going to chrome://inspect/#remote-debugging.` : `Check if Chrome is running.`}`, {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
logger('Connected Puppeteer');
|
||||
return browser;
|
||||
}
|
||||
export function detectDisplay() {
|
||||
// Only detect display on Linux/UNIX.
|
||||
if (os.platform() === 'win32' || os.platform() === 'darwin') {
|
||||
return;
|
||||
}
|
||||
if (!process.env['DISPLAY']) {
|
||||
try {
|
||||
const result = execSync(`ps -u $(id -u) -o pid= | xargs -I{} cat /proc/{}/environ 2>/dev/null | tr '\\0' '\\n' | grep -m1 '^DISPLAY=' | cut -d= -f2`);
|
||||
const display = result.toString('utf8').trim();
|
||||
process.env['DISPLAY'] = display;
|
||||
}
|
||||
catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
}
|
||||
export async function launch(options) {
|
||||
const { channel, executablePath, headless, isolated } = options;
|
||||
const profileDirName = channel && channel !== 'stable'
|
||||
? `chrome-profile-${channel}`
|
||||
: 'chrome-profile';
|
||||
let userDataDir = options.userDataDir;
|
||||
if (!isolated && !userDataDir) {
|
||||
userDataDir = path.join(os.homedir(), '.cache', options.viaCli ? 'chrome-devtools-mcp-cli' : 'chrome-devtools-mcp', profileDirName);
|
||||
await fs.promises.mkdir(userDataDir, {
|
||||
recursive: true,
|
||||
});
|
||||
}
|
||||
const args = [
|
||||
...(options.chromeArgs ?? []),
|
||||
'--hide-crash-restore-bubble',
|
||||
];
|
||||
const ignoreDefaultArgs = options.ignoreDefaultChromeArgs ?? false;
|
||||
if (headless) {
|
||||
args.push('--screen-info={3840x2160}');
|
||||
}
|
||||
let puppeteerChannel;
|
||||
if (options.devtools) {
|
||||
args.push('--auto-open-devtools-for-tabs');
|
||||
}
|
||||
if (!executablePath) {
|
||||
puppeteerChannel =
|
||||
channel && channel !== 'stable'
|
||||
? `chrome-${channel}`
|
||||
: 'chrome';
|
||||
}
|
||||
if (!headless) {
|
||||
detectDisplay();
|
||||
}
|
||||
try {
|
||||
const browser = await puppeteer.launch({
|
||||
channel: puppeteerChannel,
|
||||
targetFilter: makeTargetFilter(options.enableExtensions),
|
||||
executablePath,
|
||||
defaultViewport: null,
|
||||
userDataDir,
|
||||
pipe: true,
|
||||
headless,
|
||||
args,
|
||||
ignoreDefaultArgs: ignoreDefaultArgs,
|
||||
acceptInsecureCerts: options.acceptInsecureCerts,
|
||||
handleDevToolsAsPage: true,
|
||||
enableExtensions: options.enableExtensions,
|
||||
});
|
||||
if (options.logFile) {
|
||||
// FIXME: we are probably subscribing too late to catch startup logs. We
|
||||
// should expose the process earlier or expose the getRecentLogs() getter.
|
||||
browser.process()?.stderr?.pipe(options.logFile);
|
||||
browser.process()?.stdout?.pipe(options.logFile);
|
||||
}
|
||||
if (options.viewport) {
|
||||
const [page] = await browser.pages();
|
||||
await page?.resize({
|
||||
contentWidth: options.viewport.width,
|
||||
contentHeight: options.viewport.height,
|
||||
});
|
||||
}
|
||||
return browser;
|
||||
}
|
||||
catch (error) {
|
||||
if (userDataDir &&
|
||||
error.message.includes('The browser is already running')) {
|
||||
throw new Error(`The browser is already running for ${userDataDir}. Use --isolated to run multiple browser instances.`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
export async function ensureBrowserLaunched(options) {
|
||||
if (browser?.connected) {
|
||||
return browser;
|
||||
}
|
||||
browser = await launch(options);
|
||||
return browser;
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import { logger } from '../logger.js';
|
||||
import { PipeTransport } from '../third_party/index.js';
|
||||
import { saveTemporaryFile } from '../utils/files.js';
|
||||
import { DAEMON_SCRIPT_PATH, getSocketPath, getPidFilePath, isDaemonRunning, } from './utils.js';
|
||||
const FILE_TIMEOUT = 10_000;
|
||||
/**
|
||||
* Waits for a file to be created and populated (removed = false) or removed (removed = true).
|
||||
*/
|
||||
function waitForFile(filePath, removed = false) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const check = () => {
|
||||
const exists = fs.existsSync(filePath);
|
||||
if (removed) {
|
||||
return !exists;
|
||||
}
|
||||
if (!exists) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return fs.statSync(filePath).size > 0;
|
||||
}
|
||||
catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
if (check()) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
fs.unwatchFile(filePath);
|
||||
reject(new Error(`Timeout: file ${filePath} ${removed ? 'not removed' : 'not found'} within ${FILE_TIMEOUT}ms`));
|
||||
}, FILE_TIMEOUT);
|
||||
fs.watchFile(filePath, { interval: 500 }, () => {
|
||||
if (check()) {
|
||||
clearTimeout(timer);
|
||||
fs.unwatchFile(filePath);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
export async function startDaemon(mcpArgs = []) {
|
||||
if (isDaemonRunning()) {
|
||||
logger('Daemon is already running');
|
||||
return;
|
||||
}
|
||||
const pidFilePath = getPidFilePath();
|
||||
if (fs.existsSync(pidFilePath)) {
|
||||
fs.unlinkSync(pidFilePath);
|
||||
}
|
||||
logger('Starting daemon...', ...mcpArgs);
|
||||
const child = spawn(process.execPath, [DAEMON_SCRIPT_PATH, ...mcpArgs], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
env: process.env,
|
||||
cwd: process.cwd(),
|
||||
windowsHide: true,
|
||||
});
|
||||
child.unref();
|
||||
await waitForFile(pidFilePath);
|
||||
}
|
||||
const SEND_COMMAND_TIMEOUT = 60_000; // ms
|
||||
/**
|
||||
* `sendCommand` opens a socket connection sends a single command and disconnects.
|
||||
*/
|
||||
export async function sendCommand(command) {
|
||||
const socketPath = getSocketPath();
|
||||
const socket = net.createConnection({
|
||||
path: socketPath,
|
||||
});
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
socket.destroy();
|
||||
reject(new Error('Timeout waiting for daemon response'));
|
||||
}, SEND_COMMAND_TIMEOUT);
|
||||
const transport = new PipeTransport(socket, socket);
|
||||
transport.onmessage = async (message) => {
|
||||
clearTimeout(timer);
|
||||
logger('onmessage', message);
|
||||
resolve(JSON.parse(message));
|
||||
};
|
||||
socket.on('error', error => {
|
||||
clearTimeout(timer);
|
||||
logger('Socket error:', error);
|
||||
reject(error);
|
||||
});
|
||||
socket.on('close', () => {
|
||||
clearTimeout(timer);
|
||||
logger('Socket closed:');
|
||||
reject(new Error('Socket closed'));
|
||||
});
|
||||
logger('Sending message', command);
|
||||
transport.send(JSON.stringify(command));
|
||||
});
|
||||
}
|
||||
export async function stopDaemon() {
|
||||
if (!isDaemonRunning()) {
|
||||
logger('Daemon is not running');
|
||||
return;
|
||||
}
|
||||
const pidFilePath = getPidFilePath();
|
||||
await sendCommand({ method: 'stop' });
|
||||
await waitForFile(pidFilePath, /*removed=*/ true);
|
||||
}
|
||||
export async function handleResponse(response, format) {
|
||||
if (response.isError) {
|
||||
return JSON.stringify(response.content);
|
||||
}
|
||||
if (format === 'json') {
|
||||
if (response.structuredContent) {
|
||||
return JSON.stringify(response.structuredContent);
|
||||
}
|
||||
// Fall-through to text for backward compatibility.
|
||||
}
|
||||
const chunks = [];
|
||||
for (const content of response.content) {
|
||||
if (content.type === 'text') {
|
||||
chunks.push(content.text);
|
||||
}
|
||||
else if (content.type === 'image') {
|
||||
const imageData = content.data;
|
||||
const mimeType = content.mimeType;
|
||||
let extension = '.png';
|
||||
switch (mimeType) {
|
||||
case 'image/jpg':
|
||||
case 'image/jpeg':
|
||||
extension = '.jpeg';
|
||||
break;
|
||||
case 'webp':
|
||||
extension = '.webp';
|
||||
break;
|
||||
}
|
||||
const data = Buffer.from(imageData, 'base64');
|
||||
const name = crypto.randomUUID();
|
||||
const { filepath } = await saveTemporaryFile(data, `${name}${extension}`);
|
||||
chunks.push(`Saved to ${filepath}.`);
|
||||
}
|
||||
else {
|
||||
throw new Error('Not supported response content type');
|
||||
}
|
||||
}
|
||||
return format === 'md' ? chunks.join(' ') : JSON.stringify(chunks);
|
||||
}
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import { createServer } from 'node:net';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { logger } from '../logger.js';
|
||||
import { Client, PipeTransport, StdioClientTransport, } from '../third_party/index.js';
|
||||
import { VERSION } from '../version.js';
|
||||
import { DAEMON_CLIENT_NAME, getDaemonPid, getPidFilePath, getSocketPath, INDEX_SCRIPT_PATH, IS_WINDOWS, isDaemonRunning, } from './utils.js';
|
||||
const pid = getDaemonPid();
|
||||
if (isDaemonRunning(pid)) {
|
||||
logger('Another daemon process is running.');
|
||||
process.exit(1);
|
||||
}
|
||||
const pidFilePath = getPidFilePath();
|
||||
fs.mkdirSync(path.dirname(pidFilePath), {
|
||||
recursive: true,
|
||||
});
|
||||
fs.writeFileSync(pidFilePath, process.pid.toString());
|
||||
logger(`Writing ${process.pid.toString()} to ${pidFilePath}`);
|
||||
const socketPath = getSocketPath();
|
||||
const startDate = new Date();
|
||||
const mcpServerArgs = process.argv.slice(2);
|
||||
let mcpClient = null;
|
||||
let mcpTransport = null;
|
||||
let server = null;
|
||||
async function setupMCPClient() {
|
||||
console.log('Setting up MCP client connection...');
|
||||
// Create stdio transport for chrome-devtools-mcp
|
||||
// Workaround for https://github.com/modelcontextprotocol/typescript-sdk/blob/v1.x/src/client/stdio.ts#L128
|
||||
// which causes the console window to show on Windows.
|
||||
// @ts-expect-error no types for type.
|
||||
process.type = 'mcp-client';
|
||||
mcpTransport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [INDEX_SCRIPT_PATH, ...mcpServerArgs],
|
||||
env: process.env,
|
||||
});
|
||||
mcpClient = new Client({
|
||||
name: DAEMON_CLIENT_NAME,
|
||||
version: VERSION,
|
||||
}, {
|
||||
capabilities: {},
|
||||
});
|
||||
await mcpClient.connect(mcpTransport);
|
||||
console.log('MCP client connected');
|
||||
}
|
||||
async function handleRequest(msg) {
|
||||
try {
|
||||
if (msg.method === 'invoke_tool') {
|
||||
if (!mcpClient) {
|
||||
throw new Error('MCP client not initialized');
|
||||
}
|
||||
const { tool, args } = msg;
|
||||
const result = (await mcpClient.callTool({
|
||||
name: tool,
|
||||
arguments: args || {},
|
||||
}));
|
||||
return {
|
||||
success: true,
|
||||
result: JSON.stringify(result),
|
||||
};
|
||||
}
|
||||
else if (msg.method === 'stop') {
|
||||
// Ensure we are not interrupting in-progress starting.
|
||||
await started;
|
||||
// Trigger cleanup asynchronously.
|
||||
setImmediate(() => {
|
||||
void cleanup();
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
message: 'stopping',
|
||||
};
|
||||
}
|
||||
else if (msg.method === 'status') {
|
||||
return {
|
||||
success: true,
|
||||
result: JSON.stringify({
|
||||
pid: process.pid,
|
||||
socketPath,
|
||||
startDate: startDate.toISOString(),
|
||||
version: VERSION,
|
||||
args: mcpServerArgs,
|
||||
}),
|
||||
};
|
||||
}
|
||||
{
|
||||
return {
|
||||
success: false,
|
||||
error: `Unknown method: ${JSON.stringify(msg, null, 2)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
};
|
||||
}
|
||||
}
|
||||
async function startSocketServer() {
|
||||
// Remove existing socket file if it exists (only on non-Windows)
|
||||
if (!IS_WINDOWS) {
|
||||
try {
|
||||
fs.unlinkSync(socketPath);
|
||||
}
|
||||
catch {
|
||||
// ignore errors.
|
||||
}
|
||||
}
|
||||
return await new Promise((resolve, reject) => {
|
||||
server = createServer(socket => {
|
||||
const transport = new PipeTransport(socket, socket);
|
||||
transport.onmessage = async (message) => {
|
||||
logger('onmessage', message);
|
||||
const response = await handleRequest(JSON.parse(message));
|
||||
transport.send(JSON.stringify(response));
|
||||
socket.end();
|
||||
};
|
||||
socket.on('error', error => {
|
||||
logger('Socket error:', error);
|
||||
});
|
||||
});
|
||||
server.listen({
|
||||
path: socketPath,
|
||||
readableAll: false,
|
||||
writableAll: false,
|
||||
}, async () => {
|
||||
console.log(`Daemon server listening on ${socketPath}`);
|
||||
try {
|
||||
// Setup MCP client
|
||||
await setupMCPClient();
|
||||
resolve();
|
||||
}
|
||||
catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
server.on('error', error => {
|
||||
logger('Server error:', error);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
async function cleanup() {
|
||||
console.log('Cleaning up daemon...');
|
||||
try {
|
||||
await mcpClient?.close();
|
||||
}
|
||||
catch (error) {
|
||||
logger('Error closing MCP client:', error);
|
||||
}
|
||||
try {
|
||||
await mcpTransport?.close();
|
||||
}
|
||||
catch (error) {
|
||||
logger('Error closing MCP transport:', error);
|
||||
}
|
||||
if (server) {
|
||||
await new Promise(resolve => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
}
|
||||
if (!IS_WINDOWS) {
|
||||
try {
|
||||
fs.unlinkSync(socketPath);
|
||||
}
|
||||
catch {
|
||||
// ignore errors
|
||||
}
|
||||
}
|
||||
logger(`unlinking ${pidFilePath}`);
|
||||
if (fs.existsSync(pidFilePath)) {
|
||||
fs.unlinkSync(pidFilePath);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
// Handle shutdown signals
|
||||
process.on('SIGTERM', () => {
|
||||
void cleanup();
|
||||
});
|
||||
process.on('SIGINT', () => {
|
||||
void cleanup();
|
||||
});
|
||||
process.on('SIGHUP', () => {
|
||||
void cleanup();
|
||||
});
|
||||
// Handle uncaught errors
|
||||
process.on('uncaughtException', error => {
|
||||
logger('Uncaught exception:', error);
|
||||
});
|
||||
process.on('unhandledRejection', error => {
|
||||
logger('Unhandled rejection:', error);
|
||||
});
|
||||
// Start the server
|
||||
const started = startSocketServer().catch(error => {
|
||||
logger('Failed to start daemon server:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
export {};
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { logger } from '../logger.js';
|
||||
export const DAEMON_SCRIPT_PATH = path.join(import.meta.dirname, 'daemon.js');
|
||||
export const INDEX_SCRIPT_PATH = path.join(import.meta.dirname, '..', 'bin', 'chrome-devtools-mcp.js');
|
||||
const APP_NAME = 'chrome-devtools-mcp';
|
||||
export const DAEMON_CLIENT_NAME = 'chrome-devtools-cli-daemon';
|
||||
// Using these paths due to strict limits on the POSIX socket path length.
|
||||
export function getSocketPath() {
|
||||
const uid = os.userInfo().uid;
|
||||
if (IS_WINDOWS) {
|
||||
// Windows uses Named Pipes, not file paths.
|
||||
// This format is required for server.listen()
|
||||
return path.join('\\\\.\\pipe', APP_NAME, 'server.sock');
|
||||
}
|
||||
// 1. Try XDG_RUNTIME_DIR (Linux standard, sometimes macOS)
|
||||
if (process.env.XDG_RUNTIME_DIR) {
|
||||
return path.join(process.env.XDG_RUNTIME_DIR, APP_NAME, 'server.sock');
|
||||
}
|
||||
// 2. macOS/Unix Fallback: Use /tmp/
|
||||
// We use /tmp/ because it is much shorter than ~/Library/Application Support/
|
||||
// and keeps us well under the 104-character limit.
|
||||
return path.join('/tmp', `${APP_NAME}-${uid}.sock`);
|
||||
}
|
||||
export function getRuntimeHome() {
|
||||
const platform = os.platform();
|
||||
const uid = os.userInfo().uid;
|
||||
// 1. Check for the modern Unix standard
|
||||
if (process.env.XDG_RUNTIME_DIR) {
|
||||
return path.join(process.env.XDG_RUNTIME_DIR, APP_NAME);
|
||||
}
|
||||
// 2. Fallback for macOS and older Linux
|
||||
if (platform === 'darwin' || platform === 'linux') {
|
||||
// /tmp is cleared on boot, making it perfect for PIDs
|
||||
return path.join('/tmp', `${APP_NAME}-${uid}`);
|
||||
}
|
||||
// 3. Windows Fallback
|
||||
return path.join(os.tmpdir(), APP_NAME);
|
||||
}
|
||||
export const IS_WINDOWS = os.platform() === 'win32';
|
||||
export function getPidFilePath() {
|
||||
const runtimeDir = getRuntimeHome();
|
||||
return path.join(runtimeDir, 'daemon.pid');
|
||||
}
|
||||
export function getDaemonPid() {
|
||||
try {
|
||||
const pidFile = getPidFilePath();
|
||||
logger(`Daemon pid file ${pidFile}`);
|
||||
if (!fs.existsSync(pidFile)) {
|
||||
return null;
|
||||
}
|
||||
const pidContent = fs.readFileSync(pidFile, 'utf-8');
|
||||
const pid = parseInt(pidContent.trim(), 10);
|
||||
logger(`Daemon pid: ${pid}`);
|
||||
if (isNaN(pid)) {
|
||||
return null;
|
||||
}
|
||||
return pid;
|
||||
}
|
||||
catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
export function isDaemonRunning(pid = getDaemonPid()) {
|
||||
if (pid) {
|
||||
try {
|
||||
process.kill(pid, 0); // Throws if process doesn't exist
|
||||
return true;
|
||||
}
|
||||
catch {
|
||||
// Process is dead, stale PID file. Proceed with startup.
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
export function serializeArgs(options, argv) {
|
||||
const args = [];
|
||||
for (const key of Object.keys(options)) {
|
||||
if (argv[key] === undefined || argv[key] === null) {
|
||||
continue;
|
||||
}
|
||||
const value = argv[key];
|
||||
const kebabKey = key.replace(/[A-Z]/g, m => `-${m.toLowerCase()}`);
|
||||
if (typeof value === 'boolean') {
|
||||
if (value) {
|
||||
args.push(`--${kebabKey}`);
|
||||
}
|
||||
else {
|
||||
args.push(`--no-${kebabKey}`);
|
||||
}
|
||||
}
|
||||
else if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
args.push(`--${kebabKey}`, String(item));
|
||||
}
|
||||
}
|
||||
else {
|
||||
args.push(`--${kebabKey}`, String(value));
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { createStackTraceForConsoleMessage, SymbolizedError, } from '../DevtoolsUtils.js';
|
||||
import { UncaughtError } from '../PageCollector.js';
|
||||
import * as DevTools from '../third_party/index.js';
|
||||
export class ConsoleFormatter {
|
||||
#id;
|
||||
#type;
|
||||
#text;
|
||||
#argCount;
|
||||
#resolvedArgs;
|
||||
#stack;
|
||||
#cause;
|
||||
isIgnored;
|
||||
constructor(params) {
|
||||
this.#id = params.id;
|
||||
this.#type = params.type;
|
||||
this.#text = params.text;
|
||||
this.#argCount = params.argCount ?? 0;
|
||||
this.#resolvedArgs = params.resolvedArgs ?? [];
|
||||
this.#stack = params.stack;
|
||||
this.#cause = params.cause;
|
||||
this.isIgnored = params.isIgnored;
|
||||
}
|
||||
static async from(msg, options) {
|
||||
const ignoreListManager = options?.devTools?.universe.context.get(DevTools.DevTools.IgnoreListManager);
|
||||
const isIgnored = options.isIgnoredForTesting ||
|
||||
(frame => {
|
||||
if (!ignoreListManager) {
|
||||
return false;
|
||||
}
|
||||
if (frame.uiSourceCode) {
|
||||
return ignoreListManager.isUserOrSourceMapIgnoreListedUISourceCode(frame.uiSourceCode);
|
||||
}
|
||||
if (frame.url) {
|
||||
return ignoreListManager.isUserIgnoreListedURL(frame.url);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (msg instanceof UncaughtError) {
|
||||
const error = await SymbolizedError.fromDetails({
|
||||
devTools: options?.devTools,
|
||||
details: msg.details,
|
||||
targetId: msg.targetId,
|
||||
includeStackAndCause: options?.fetchDetailedData,
|
||||
resolvedStackTraceForTesting: options?.resolvedStackTraceForTesting,
|
||||
resolvedCauseForTesting: options?.resolvedCauseForTesting,
|
||||
});
|
||||
return new ConsoleFormatter({
|
||||
id: options.id,
|
||||
type: 'error',
|
||||
text: error.message,
|
||||
stack: error.stackTrace,
|
||||
cause: error.cause,
|
||||
isIgnored,
|
||||
});
|
||||
}
|
||||
let resolvedArgs = [];
|
||||
if (options.resolvedArgsForTesting) {
|
||||
resolvedArgs = options.resolvedArgsForTesting;
|
||||
}
|
||||
else if (options.fetchDetailedData) {
|
||||
resolvedArgs = await Promise.all(msg.args().map(async (arg, i) => {
|
||||
try {
|
||||
const remoteObject = arg.remoteObject();
|
||||
if (remoteObject.type === 'object' &&
|
||||
remoteObject.subtype === 'error') {
|
||||
return await SymbolizedError.fromError({
|
||||
devTools: options.devTools,
|
||||
error: remoteObject,
|
||||
// @ts-expect-error Internal ConsoleMessage API
|
||||
targetId: msg._targetId(),
|
||||
});
|
||||
}
|
||||
return await arg.jsonValue();
|
||||
}
|
||||
catch {
|
||||
return `<error: Argument ${i} is no longer available>`;
|
||||
}
|
||||
}));
|
||||
}
|
||||
let stack;
|
||||
if (options.resolvedStackTraceForTesting) {
|
||||
stack = options.resolvedStackTraceForTesting;
|
||||
}
|
||||
else if (options.fetchDetailedData && options.devTools) {
|
||||
try {
|
||||
stack = await createStackTraceForConsoleMessage(options.devTools, msg);
|
||||
}
|
||||
catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return new ConsoleFormatter({
|
||||
id: options.id,
|
||||
type: msg.type(),
|
||||
text: msg.text(),
|
||||
argCount: resolvedArgs.length || msg.args().length,
|
||||
resolvedArgs,
|
||||
stack,
|
||||
isIgnored,
|
||||
});
|
||||
}
|
||||
// The short format for a console message.
|
||||
toString() {
|
||||
return convertConsoleMessageConciseToString(this.toJSON());
|
||||
}
|
||||
// The verbose format for a console message, including all details.
|
||||
toStringDetailed() {
|
||||
return convertConsoleMessageConciseDetailedToString(this.toJSONDetailed());
|
||||
}
|
||||
#getArgs() {
|
||||
if (this.#resolvedArgs.length > 0) {
|
||||
const args = [...this.#resolvedArgs];
|
||||
// If there is no text, the first argument serves as text (see formatMessage).
|
||||
if (!this.#text) {
|
||||
args.shift();
|
||||
}
|
||||
return args;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
toJSON() {
|
||||
return {
|
||||
type: this.#type,
|
||||
text: this.#text,
|
||||
argsCount: this.#argCount,
|
||||
id: this.#id,
|
||||
};
|
||||
}
|
||||
toJSONDetailed() {
|
||||
return {
|
||||
id: this.#id,
|
||||
type: this.#type,
|
||||
text: this.#text,
|
||||
argsCount: this.#argCount,
|
||||
args: this.#getArgs().map(arg => formatArg(arg, this)),
|
||||
stackTrace: this.#stack
|
||||
? formatStackTrace(this.#stack, this.#cause, this)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
function convertConsoleMessageConciseToString(msg) {
|
||||
return `msgid=${msg.id} [${msg.type}] ${msg.text} (${msg.argsCount} args)`;
|
||||
}
|
||||
function convertConsoleMessageConciseDetailedToString(msg) {
|
||||
const result = [
|
||||
`ID: ${msg.id}`,
|
||||
`Message: ${msg.type}> ${msg.text}`,
|
||||
formatArgs(msg),
|
||||
...(msg.stackTrace ? ['### Stack trace', msg.stackTrace] : []),
|
||||
].filter(line => !!line);
|
||||
return result.join('\n');
|
||||
}
|
||||
function formatArgs(msg) {
|
||||
const args = msg.args;
|
||||
if (!args.length) {
|
||||
return '';
|
||||
}
|
||||
const result = ['### Arguments'];
|
||||
for (const [key, arg] of args.entries()) {
|
||||
result.push(`Arg #${key}: ${arg}`);
|
||||
}
|
||||
return result.join('\n');
|
||||
}
|
||||
function formatArg(arg, formatter) {
|
||||
if (arg instanceof SymbolizedError) {
|
||||
return [
|
||||
arg.message,
|
||||
arg.stackTrace
|
||||
? formatStackTrace(arg.stackTrace, arg.cause, formatter)
|
||||
: undefined,
|
||||
]
|
||||
.filter(line => !!line)
|
||||
.join('\n');
|
||||
}
|
||||
return typeof arg === 'object' ? JSON.stringify(arg) : String(arg);
|
||||
}
|
||||
const STACK_TRACE_MAX_LINES = 50;
|
||||
function formatStackTrace(stackTrace, cause, formatter) {
|
||||
const lines = formatStackTraceInner(stackTrace, cause, formatter);
|
||||
const includedLines = lines.slice(0, STACK_TRACE_MAX_LINES);
|
||||
const reminderCount = lines.length - includedLines.length;
|
||||
return [
|
||||
...includedLines,
|
||||
reminderCount > 0 ? `... and ${reminderCount} more frames` : '',
|
||||
'Note: line and column numbers use 1-based indexing',
|
||||
]
|
||||
.filter(line => !!line)
|
||||
.join('\n');
|
||||
}
|
||||
function formatStackTraceInner(stackTrace, cause, formatter) {
|
||||
if (!stackTrace) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
...formatFragment(stackTrace.syncFragment, formatter),
|
||||
...stackTrace.asyncFragments
|
||||
.map(item => formatAsyncFragment(item, formatter))
|
||||
.flat(),
|
||||
...formatCause(cause, formatter),
|
||||
];
|
||||
}
|
||||
function formatFragment(fragment, formatter) {
|
||||
const frames = fragment.frames.filter(frame => !formatter.isIgnored(frame));
|
||||
return frames.map(formatFrame);
|
||||
}
|
||||
function formatAsyncFragment(fragment, formatter) {
|
||||
const formattedFrames = formatFragment(fragment, formatter);
|
||||
if (formattedFrames.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const separatorLineLength = 40;
|
||||
const prefix = `--- ${fragment.description || 'async'} `;
|
||||
const separator = prefix + '-'.repeat(separatorLineLength - prefix.length);
|
||||
return [separator, ...formattedFrames];
|
||||
}
|
||||
function formatFrame(frame) {
|
||||
let result = `at ${frame.name ?? '<anonymous>'}`;
|
||||
if (frame.uiSourceCode) {
|
||||
const location = frame.uiSourceCode.uiLocation(frame.line, frame.column);
|
||||
result += ` (${location.linkText(/* skipTrim */ false, /* showColumnNumber */ true)})`;
|
||||
}
|
||||
else if (frame.url) {
|
||||
result += ` (${frame.url}:${frame.line}:${frame.column})`;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function formatCause(cause, formatter) {
|
||||
if (!cause) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
`Caused by: ${cause.message}`,
|
||||
...formatStackTraceInner(cause.stackTrace, cause.cause, formatter),
|
||||
];
|
||||
}
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { ISSUE_UTILS } from '../issue-descriptions.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { DevTools } from '../third_party/index.js';
|
||||
export class IssueFormatter {
|
||||
#issue;
|
||||
#options;
|
||||
constructor(issue, options) {
|
||||
this.#issue = issue;
|
||||
this.#options = options;
|
||||
}
|
||||
toString() {
|
||||
return convertIssueConciseToString(this.toJSON());
|
||||
}
|
||||
toStringDetailed() {
|
||||
return convertIssueDetailedToString(this.toJSONDetailed());
|
||||
}
|
||||
toJSON() {
|
||||
return {
|
||||
type: 'issue',
|
||||
title: this.#getTitle(),
|
||||
count: this.#issue.getAggregatedIssuesCount(),
|
||||
id: this.#options.id,
|
||||
};
|
||||
}
|
||||
toJSONDetailed() {
|
||||
return {
|
||||
id: this.#options.id,
|
||||
type: 'issue',
|
||||
count: this.#issue.getAggregatedIssuesCount(),
|
||||
title: this.#getTitle(),
|
||||
description: this.#getDescription(),
|
||||
links: this.#issue.getDescription()?.links,
|
||||
affectedResources: this.#getAffectedResources(),
|
||||
};
|
||||
}
|
||||
#getAffectedResources() {
|
||||
const issues = this.#issue.getAllIssues();
|
||||
const affectedResources = [];
|
||||
for (const singleIssue of issues) {
|
||||
const details = singleIssue.details();
|
||||
if (!details) {
|
||||
continue;
|
||||
}
|
||||
// We send the remaining details as untyped JSON because the DevTools
|
||||
// frontend code is currently not re-usable.
|
||||
const data = structuredClone(details);
|
||||
let uid;
|
||||
let request;
|
||||
if ('violatingNodeId' in details &&
|
||||
details.violatingNodeId &&
|
||||
this.#options.elementIdResolver) {
|
||||
uid = this.#options.elementIdResolver(details.violatingNodeId);
|
||||
delete data.violatingNodeId;
|
||||
}
|
||||
if ('nodeId' in details &&
|
||||
details.nodeId &&
|
||||
this.#options.elementIdResolver) {
|
||||
uid = this.#options.elementIdResolver(details.nodeId);
|
||||
delete data.nodeId;
|
||||
}
|
||||
if ('documentNodeId' in details &&
|
||||
details.documentNodeId &&
|
||||
this.#options.elementIdResolver) {
|
||||
uid = this.#options.elementIdResolver(details.documentNodeId);
|
||||
delete data.documentNodeId;
|
||||
}
|
||||
if ('request' in details && details.request) {
|
||||
request = details.request.url;
|
||||
if (details.request.requestId && this.#options.requestIdResolver) {
|
||||
const resolvedId = this.#options.requestIdResolver(details.request.requestId);
|
||||
if (resolvedId) {
|
||||
request = resolvedId;
|
||||
const requestData = data.request;
|
||||
delete requestData.requestId;
|
||||
}
|
||||
}
|
||||
}
|
||||
// These fields has no use for the MCP client (redundant or irrelevant).
|
||||
delete data.errorType;
|
||||
delete data.frameId;
|
||||
affectedResources.push({
|
||||
uid,
|
||||
data: data,
|
||||
request,
|
||||
});
|
||||
}
|
||||
return affectedResources;
|
||||
}
|
||||
isValid() {
|
||||
return this.#getTitle() !== undefined;
|
||||
}
|
||||
// Helper to extract title
|
||||
#getTitle() {
|
||||
const markdownDescription = this.#issue.getDescription();
|
||||
const filename = markdownDescription?.file;
|
||||
if (!filename) {
|
||||
logger(`no description found for issue:` + this.#issue.code());
|
||||
return undefined;
|
||||
}
|
||||
// We already have the description logic in #getDescription, but title extraction is separate
|
||||
// We can reuse the logic or cache it.
|
||||
// Ideally we should process markdown once.
|
||||
const rawMarkdown = ISSUE_UTILS.getIssueDescription(filename);
|
||||
if (!rawMarkdown) {
|
||||
logger(`no markdown ${filename} found for issue:` + this.#issue.code());
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const processedMarkdown = DevTools.MarkdownIssueDescription.substitutePlaceholders(rawMarkdown, markdownDescription?.substitutions);
|
||||
const markdownAst = DevTools.Marked.Marked.lexer(processedMarkdown);
|
||||
const title = DevTools.MarkdownIssueDescription.findTitleFromMarkdownAst(markdownAst);
|
||||
if (!title) {
|
||||
logger('cannot read issue title from ' + filename);
|
||||
return undefined;
|
||||
}
|
||||
return title;
|
||||
}
|
||||
catch {
|
||||
logger('error parsing markdown for issue ' + this.#issue.code());
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
#getDescription() {
|
||||
const markdownDescription = this.#issue.getDescription();
|
||||
const filename = markdownDescription?.file;
|
||||
if (!filename) {
|
||||
return undefined;
|
||||
}
|
||||
const rawMarkdown = ISSUE_UTILS.getIssueDescription(filename);
|
||||
if (!rawMarkdown) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return DevTools.MarkdownIssueDescription.substitutePlaceholders(rawMarkdown, markdownDescription?.substitutions);
|
||||
}
|
||||
catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
function convertIssueConciseToString(issue) {
|
||||
return `msgid=${issue.id} [issue] ${issue.title} (count: ${issue.count})`;
|
||||
}
|
||||
function convertIssueDetailedToString(issue) {
|
||||
const result = [];
|
||||
result.push(`ID: ${issue.id}`);
|
||||
const bodyParts = [];
|
||||
const description = issue.description;
|
||||
let processedMarkdown = description?.trim();
|
||||
// Remove heading in order not to conflict with the whole console message response markdown
|
||||
if (processedMarkdown?.startsWith('# ')) {
|
||||
processedMarkdown = processedMarkdown.substring(2).trimStart();
|
||||
}
|
||||
if (processedMarkdown) {
|
||||
bodyParts.push(processedMarkdown);
|
||||
}
|
||||
else {
|
||||
bodyParts.push(issue.title ?? 'Unknown Issue');
|
||||
}
|
||||
const links = issue.links;
|
||||
if (links && links.length > 0) {
|
||||
bodyParts.push('Learn more:');
|
||||
for (const link of links) {
|
||||
bodyParts.push(`[${link.linkTitle}](${link.link})`);
|
||||
}
|
||||
}
|
||||
const affectedResources = issue.affectedResources;
|
||||
if (affectedResources.length) {
|
||||
bodyParts.push('### Affected resources');
|
||||
bodyParts.push(...affectedResources.map(item => {
|
||||
const details = [];
|
||||
if (item.uid) {
|
||||
details.push(`uid=${item.uid}`);
|
||||
}
|
||||
if (item.request) {
|
||||
details.push((typeof item.request === 'number' ? `reqid=` : 'url=') +
|
||||
item.request);
|
||||
}
|
||||
if (item.data) {
|
||||
details.push(`data=${JSON.stringify(item.data)}`);
|
||||
}
|
||||
return details.join(' ');
|
||||
}));
|
||||
}
|
||||
result.push(`Message: issue> ${bodyParts.join('\n')}`);
|
||||
return result.join('\n');
|
||||
}
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* */
|
||||
import { isUtf8 } from 'node:buffer';
|
||||
const BODY_CONTEXT_SIZE_LIMIT = 10000;
|
||||
export class NetworkFormatter {
|
||||
#request;
|
||||
#options;
|
||||
#requestBody;
|
||||
#responseBody;
|
||||
#requestBodyFilePath;
|
||||
#responseBodyFilePath;
|
||||
constructor(request, options) {
|
||||
this.#request = request;
|
||||
this.#options = options;
|
||||
}
|
||||
static async from(request, options) {
|
||||
const instance = new NetworkFormatter(request, options);
|
||||
if (options.fetchData) {
|
||||
await instance.#loadDetailedData();
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
async #loadDetailedData() {
|
||||
// Load Request Body
|
||||
if (this.#request.hasPostData()) {
|
||||
let data;
|
||||
try {
|
||||
data =
|
||||
this.#request.postData() ?? (await this.#request.fetchPostData());
|
||||
}
|
||||
catch {
|
||||
// Ignore parsing errors
|
||||
}
|
||||
const requestBodyNotAvailableMessage = '<Request body not available anymore>';
|
||||
if (this.#options.requestFilePath) {
|
||||
if (!this.#options.saveFile) {
|
||||
throw new Error('saveFile is not provided');
|
||||
}
|
||||
if (data) {
|
||||
await this.#options.saveFile(Buffer.from(data), this.#options.requestFilePath);
|
||||
this.#requestBodyFilePath = this.#options.requestFilePath;
|
||||
}
|
||||
else {
|
||||
this.#requestBody = requestBodyNotAvailableMessage;
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (data) {
|
||||
this.#requestBody = getSizeLimitedString(data, BODY_CONTEXT_SIZE_LIMIT);
|
||||
}
|
||||
else {
|
||||
this.#requestBody = requestBodyNotAvailableMessage;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Load Response Body
|
||||
const response = this.#request.response();
|
||||
if (response) {
|
||||
const responseBodyNotAvailableMessage = '<Response body not available anymore>';
|
||||
if (this.#options.responseFilePath) {
|
||||
try {
|
||||
const buffer = await response.buffer();
|
||||
if (!this.#options.saveFile) {
|
||||
throw new Error('saveFile is not provided');
|
||||
}
|
||||
await this.#options.saveFile(buffer, this.#options.responseFilePath);
|
||||
this.#responseBodyFilePath = this.#options.responseFilePath;
|
||||
}
|
||||
catch {
|
||||
// Flatten error handling for buffer() failure and save failure
|
||||
}
|
||||
if (!this.#responseBodyFilePath) {
|
||||
this.#responseBody = responseBodyNotAvailableMessage;
|
||||
}
|
||||
}
|
||||
else {
|
||||
this.#responseBody = await this.#getFormattedResponseBody(response, BODY_CONTEXT_SIZE_LIMIT);
|
||||
}
|
||||
}
|
||||
}
|
||||
toString() {
|
||||
return convertNetworkRequestConciseToString(this.toJSON());
|
||||
}
|
||||
toStringDetailed() {
|
||||
return converNetworkRequestDetailedToStringDetailed(this.toJSONDetailed());
|
||||
}
|
||||
toJSON() {
|
||||
return {
|
||||
requestId: this.#options.requestId,
|
||||
method: this.#request.method(),
|
||||
url: this.#request.url(),
|
||||
status: this.#getStatusFromRequest(this.#request),
|
||||
selectedInDevToolsUI: this.#options.selectedInDevToolsUI,
|
||||
};
|
||||
}
|
||||
toJSONDetailed() {
|
||||
const redirectChain = this.#request.redirectChain();
|
||||
const formattedRedirectChain = redirectChain.reverse().map(request => {
|
||||
const id = this.#options.requestIdResolver
|
||||
? this.#options.requestIdResolver(request)
|
||||
: undefined;
|
||||
const formatter = new NetworkFormatter(request, {
|
||||
requestId: id,
|
||||
saveFile: this.#options.saveFile,
|
||||
});
|
||||
return formatter.toJSON();
|
||||
});
|
||||
return {
|
||||
...this.toJSON(),
|
||||
requestHeaders: this.#request.headers(),
|
||||
requestBody: this.#requestBody,
|
||||
requestBodyFilePath: this.#requestBodyFilePath,
|
||||
responseHeaders: this.#request.response()?.headers(),
|
||||
responseBody: this.#responseBody,
|
||||
responseBodyFilePath: this.#responseBodyFilePath,
|
||||
failure: this.#request.failure()?.errorText,
|
||||
redirectChain: formattedRedirectChain.length
|
||||
? formattedRedirectChain
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
#getStatusFromRequest(request) {
|
||||
const httpResponse = request.response();
|
||||
const failure = request.failure();
|
||||
let status;
|
||||
if (httpResponse) {
|
||||
status = httpResponse.status().toString();
|
||||
}
|
||||
else if (failure) {
|
||||
status = failure.errorText;
|
||||
}
|
||||
else {
|
||||
status = 'pending';
|
||||
}
|
||||
return status;
|
||||
}
|
||||
async #getFormattedResponseBody(httpResponse, sizeLimit = BODY_CONTEXT_SIZE_LIMIT) {
|
||||
try {
|
||||
const responseBuffer = await httpResponse.buffer();
|
||||
if (isUtf8(responseBuffer)) {
|
||||
const responseAsTest = responseBuffer.toString('utf-8');
|
||||
if (responseAsTest.length === 0) {
|
||||
return '<empty response>';
|
||||
}
|
||||
return getSizeLimitedString(responseAsTest, sizeLimit);
|
||||
}
|
||||
return '<binary data>';
|
||||
}
|
||||
catch {
|
||||
return '<not available anymore>';
|
||||
}
|
||||
}
|
||||
}
|
||||
function getSizeLimitedString(text, sizeLimit) {
|
||||
if (text.length > sizeLimit) {
|
||||
return text.substring(0, sizeLimit) + '... <truncated>';
|
||||
}
|
||||
return text;
|
||||
}
|
||||
function convertNetworkRequestConciseToString(data) {
|
||||
// TODO truncate the URL
|
||||
return `reqid=${data.requestId} ${data.method} ${data.url} [${data.status}]${data.selectedInDevToolsUI ? ` [selected in the DevTools Network panel]` : ''}`;
|
||||
}
|
||||
function formatHeadlers(headers) {
|
||||
const response = [];
|
||||
for (const [name, value] of Object.entries(headers)) {
|
||||
response.push(`- ${name}:${value}`);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
function converNetworkRequestDetailedToStringDetailed(data) {
|
||||
const response = [];
|
||||
response.push(`## Request ${data.url}`);
|
||||
response.push(`Status: ${data.status}`);
|
||||
response.push(`### Request Headers`);
|
||||
for (const line of formatHeadlers(data.requestHeaders)) {
|
||||
response.push(line);
|
||||
}
|
||||
if (data.requestBody) {
|
||||
response.push(`### Request Body`);
|
||||
response.push(data.requestBody);
|
||||
}
|
||||
else if (data.requestBodyFilePath) {
|
||||
response.push(`### Request Body`);
|
||||
response.push(`Saved to ${data.requestBodyFilePath}.`);
|
||||
}
|
||||
if (data.responseHeaders) {
|
||||
response.push(`### Response Headers`);
|
||||
for (const line of formatHeadlers(data.responseHeaders)) {
|
||||
response.push(line);
|
||||
}
|
||||
}
|
||||
if (data.responseBody) {
|
||||
response.push(`### Response Body`);
|
||||
response.push(data.responseBody);
|
||||
}
|
||||
else if (data.responseBodyFilePath) {
|
||||
response.push(`### Response Body`);
|
||||
response.push(`Saved to ${data.responseBodyFilePath}.`);
|
||||
}
|
||||
if (data.failure) {
|
||||
response.push(`### Request failed with`);
|
||||
response.push(data.failure);
|
||||
}
|
||||
const redirectChain = data.redirectChain;
|
||||
if (redirectChain?.length) {
|
||||
response.push(`### Redirect chain`);
|
||||
let indent = 0;
|
||||
for (const request of redirectChain.reverse()) {
|
||||
response.push(`${' '.repeat(indent)}${convertNetworkRequestConciseToString(request)})}`);
|
||||
indent++;
|
||||
}
|
||||
}
|
||||
return response.join('\n');
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
export class SnapshotFormatter {
|
||||
#snapshot;
|
||||
constructor(snapshot) {
|
||||
this.#snapshot = snapshot;
|
||||
}
|
||||
toString() {
|
||||
const chunks = [];
|
||||
const root = this.#snapshot.root;
|
||||
// Top-level content of the snapshot.
|
||||
if (this.#snapshot.verbose &&
|
||||
this.#snapshot.hasSelectedElement &&
|
||||
!this.#snapshot.selectedElementUid) {
|
||||
chunks.push(`Note: there is a selected element in the DevTools Elements panel but it is not included into the current a11y tree snapshot.
|
||||
Get a verbose snapshot to include all elements if you are interested in the selected element.\n\n`);
|
||||
}
|
||||
chunks.push(this.#formatNode(root, 0));
|
||||
return chunks.join('');
|
||||
}
|
||||
toJSON() {
|
||||
return this.#nodeToJSON(this.#snapshot.root);
|
||||
}
|
||||
#formatNode(node, depth = 0) {
|
||||
const chunks = [];
|
||||
const attributes = this.#getAttributes(node);
|
||||
const line = ' '.repeat(depth * 2) +
|
||||
attributes.join(' ') +
|
||||
(node.id === this.#snapshot.selectedElementUid
|
||||
? ' [selected in the DevTools Elements panel]'
|
||||
: '') +
|
||||
'\n';
|
||||
chunks.push(line);
|
||||
for (const child of node.children) {
|
||||
chunks.push(this.#formatNode(child, depth + 1));
|
||||
}
|
||||
return chunks.join('');
|
||||
}
|
||||
#nodeToJSON(node) {
|
||||
const rawAttrs = this.#getAttributesMap(node);
|
||||
const children = node.children.map(child => this.#nodeToJSON(child));
|
||||
const result = structuredClone(rawAttrs);
|
||||
if (children.length > 0) {
|
||||
result.children = children;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
#getAttributes(serializedAXNodeRoot) {
|
||||
const attributes = [`uid=${serializedAXNodeRoot.id}`];
|
||||
if (serializedAXNodeRoot.role) {
|
||||
attributes.push(serializedAXNodeRoot.role === 'none'
|
||||
? 'ignored'
|
||||
: serializedAXNodeRoot.role);
|
||||
}
|
||||
if (serializedAXNodeRoot.name) {
|
||||
attributes.push(`"${serializedAXNodeRoot.name}"`);
|
||||
}
|
||||
const simpleAttrs = this.#getAttributesMap(serializedAXNodeRoot,
|
||||
/* excludeSpecial */ true);
|
||||
for (const attr of Object.keys(serializedAXNodeRoot).sort()) {
|
||||
if (excludedAttributes.has(attr)) {
|
||||
continue;
|
||||
}
|
||||
const mapped = booleanPropertyMap[attr];
|
||||
if (mapped && simpleAttrs[mapped]) {
|
||||
attributes.push(mapped);
|
||||
}
|
||||
const val = simpleAttrs[attr];
|
||||
if (val === true) {
|
||||
attributes.push(attr);
|
||||
}
|
||||
else if (typeof val === 'string' || typeof val === 'number') {
|
||||
attributes.push(`${attr}="${val}"`);
|
||||
}
|
||||
}
|
||||
return attributes;
|
||||
}
|
||||
#getAttributesMap(node, excludeSpecial = false) {
|
||||
const result = {};
|
||||
if (!excludeSpecial) {
|
||||
result.id = node.id;
|
||||
if (node.role) {
|
||||
result.role = node.role;
|
||||
}
|
||||
if (node.name) {
|
||||
result.name = node.name;
|
||||
}
|
||||
}
|
||||
// Re-implementing the exact logic from original function for #getAttributes to be safe:
|
||||
return {
|
||||
...result,
|
||||
...this.#extractedAttributes(node),
|
||||
};
|
||||
}
|
||||
#extractedAttributes(node) {
|
||||
const result = {};
|
||||
for (const attr of Object.keys(node).sort()) {
|
||||
if (excludedAttributes.has(attr)) {
|
||||
continue;
|
||||
}
|
||||
const value = node[attr];
|
||||
if (typeof value === 'boolean') {
|
||||
if (booleanPropertyMap[attr]) {
|
||||
result[booleanPropertyMap[attr]] = true;
|
||||
}
|
||||
if (value) {
|
||||
result[attr] = true;
|
||||
}
|
||||
}
|
||||
else if (typeof value === 'string' || typeof value === 'number') {
|
||||
result[attr] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
const booleanPropertyMap = {
|
||||
disabled: 'disableable',
|
||||
expanded: 'expandable',
|
||||
focused: 'focusable',
|
||||
selected: 'selectable',
|
||||
};
|
||||
const excludedAttributes = new Set([
|
||||
'id',
|
||||
'role',
|
||||
'name',
|
||||
'elementHandle',
|
||||
'children',
|
||||
'backendNodeId',
|
||||
'loaderId',
|
||||
]);
|
||||
+219
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { ensureBrowserConnected, ensureBrowserLaunched } from './browser.js';
|
||||
import { loadIssueDescriptions } from './issue-descriptions.js';
|
||||
import { logger } from './logger.js';
|
||||
import { McpContext } from './McpContext.js';
|
||||
import { McpResponse } from './McpResponse.js';
|
||||
import { Mutex } from './Mutex.js';
|
||||
import { SlimMcpResponse } from './SlimMcpResponse.js';
|
||||
import { ClearcutLogger } from './telemetry/ClearcutLogger.js';
|
||||
import { bucketizeLatency } from './telemetry/metricUtils.js';
|
||||
import { McpServer, SetLevelRequestSchema, } from './third_party/index.js';
|
||||
import { ToolCategory } from './tools/categories.js';
|
||||
import { pageIdSchema } from './tools/ToolDefinition.js';
|
||||
import { createTools } from './tools/tools.js';
|
||||
import { VERSION } from './version.js';
|
||||
export async function createMcpServer(serverArgs, options) {
|
||||
let clearcutLogger;
|
||||
if (serverArgs.usageStatistics) {
|
||||
clearcutLogger = new ClearcutLogger({
|
||||
logFile: serverArgs.logFile,
|
||||
appVersion: VERSION,
|
||||
clearcutEndpoint: serverArgs.clearcutEndpoint,
|
||||
clearcutForceFlushIntervalMs: serverArgs.clearcutForceFlushIntervalMs,
|
||||
clearcutIncludePidHeader: serverArgs.clearcutIncludePidHeader,
|
||||
});
|
||||
}
|
||||
const server = new McpServer({
|
||||
name: 'chrome_devtools',
|
||||
title: 'Chrome DevTools MCP server',
|
||||
version: VERSION,
|
||||
}, { capabilities: { logging: {} } });
|
||||
server.server.setRequestHandler(SetLevelRequestSchema, () => {
|
||||
return {};
|
||||
});
|
||||
server.server.oninitialized = () => {
|
||||
const clientName = server.server.getClientVersion()?.name;
|
||||
if (clientName) {
|
||||
clearcutLogger?.setClientName(clientName);
|
||||
}
|
||||
};
|
||||
let context;
|
||||
async function getContext() {
|
||||
const chromeArgs = (serverArgs.chromeArg ?? []).map(String);
|
||||
const ignoreDefaultChromeArgs = (serverArgs.ignoreDefaultChromeArg ?? []).map(String);
|
||||
if (serverArgs.proxyServer) {
|
||||
chromeArgs.push(`--proxy-server=${serverArgs.proxyServer}`);
|
||||
}
|
||||
const devtools = serverArgs.experimentalDevtools ?? false;
|
||||
const browser = serverArgs.browserUrl || serverArgs.wsEndpoint || serverArgs.autoConnect
|
||||
? await ensureBrowserConnected({
|
||||
browserURL: serverArgs.browserUrl,
|
||||
wsEndpoint: serverArgs.wsEndpoint,
|
||||
wsHeaders: serverArgs.wsHeaders,
|
||||
// Important: only pass channel, if autoConnect is true.
|
||||
channel: serverArgs.autoConnect
|
||||
? serverArgs.channel
|
||||
: undefined,
|
||||
userDataDir: serverArgs.userDataDir,
|
||||
devtools,
|
||||
})
|
||||
: await ensureBrowserLaunched({
|
||||
headless: serverArgs.headless,
|
||||
executablePath: serverArgs.executablePath,
|
||||
channel: serverArgs.channel,
|
||||
isolated: serverArgs.isolated ?? false,
|
||||
userDataDir: serverArgs.userDataDir,
|
||||
logFile: options.logFile,
|
||||
viewport: serverArgs.viewport,
|
||||
chromeArgs,
|
||||
ignoreDefaultChromeArgs,
|
||||
acceptInsecureCerts: serverArgs.acceptInsecureCerts,
|
||||
devtools,
|
||||
enableExtensions: serverArgs.categoryExtensions,
|
||||
viaCli: serverArgs.viaCli,
|
||||
});
|
||||
if (context?.browser !== browser) {
|
||||
context = await McpContext.from(browser, logger, {
|
||||
experimentalDevToolsDebugging: devtools,
|
||||
experimentalIncludeAllPages: serverArgs.experimentalIncludeAllPages,
|
||||
performanceCrux: serverArgs.performanceCrux,
|
||||
});
|
||||
}
|
||||
return context;
|
||||
}
|
||||
const toolMutex = new Mutex();
|
||||
function registerTool(tool) {
|
||||
if (tool.annotations.category === ToolCategory.EMULATION &&
|
||||
serverArgs.categoryEmulation === false) {
|
||||
return;
|
||||
}
|
||||
if (tool.annotations.category === ToolCategory.PERFORMANCE &&
|
||||
serverArgs.categoryPerformance === false) {
|
||||
return;
|
||||
}
|
||||
if (tool.annotations.category === ToolCategory.NETWORK &&
|
||||
serverArgs.categoryNetwork === false) {
|
||||
return;
|
||||
}
|
||||
if (tool.annotations.category === ToolCategory.EXTENSIONS &&
|
||||
!serverArgs.categoryExtensions) {
|
||||
return;
|
||||
}
|
||||
if (tool.annotations.category === ToolCategory.IN_PAGE &&
|
||||
!serverArgs.categoryInPageTools) {
|
||||
return;
|
||||
}
|
||||
if (tool.annotations.conditions?.includes('computerVision') &&
|
||||
!serverArgs.experimentalVision) {
|
||||
return;
|
||||
}
|
||||
if (tool.annotations.conditions?.includes('experimentalInteropTools') &&
|
||||
!serverArgs.experimentalInteropTools) {
|
||||
return;
|
||||
}
|
||||
if (tool.annotations.conditions?.includes('screencast') &&
|
||||
!serverArgs.experimentalScreencast) {
|
||||
return;
|
||||
}
|
||||
const schema = 'pageScoped' in tool &&
|
||||
tool.pageScoped &&
|
||||
serverArgs.experimentalPageIdRouting &&
|
||||
!serverArgs.slim
|
||||
? { ...tool.schema, ...pageIdSchema }
|
||||
: tool.schema;
|
||||
server.registerTool(tool.name, {
|
||||
description: tool.description,
|
||||
inputSchema: schema,
|
||||
annotations: tool.annotations,
|
||||
}, async (params) => {
|
||||
const guard = await toolMutex.acquire();
|
||||
const startTime = Date.now();
|
||||
let success = false;
|
||||
try {
|
||||
logger(`${tool.name} request: ${JSON.stringify(params, null, ' ')}`);
|
||||
const context = await getContext();
|
||||
logger(`${tool.name} context: resolved`);
|
||||
await context.detectOpenDevToolsWindows();
|
||||
const response = serverArgs.slim
|
||||
? new SlimMcpResponse(serverArgs)
|
||||
: new McpResponse(serverArgs);
|
||||
if ('pageScoped' in tool && tool.pageScoped) {
|
||||
const page = serverArgs.experimentalPageIdRouting &&
|
||||
params.pageId &&
|
||||
!serverArgs.slim
|
||||
? context.getPageById(params.pageId)
|
||||
: context.getSelectedMcpPage();
|
||||
response.setPage(page);
|
||||
await tool.handler({
|
||||
params,
|
||||
page,
|
||||
}, response, context);
|
||||
}
|
||||
else {
|
||||
await tool.handler(
|
||||
// @ts-expect-error types do not match.
|
||||
{
|
||||
params,
|
||||
}, response, context);
|
||||
}
|
||||
const { content, structuredContent } = await response.handle(tool.name, context);
|
||||
const result = {
|
||||
content,
|
||||
};
|
||||
success = true;
|
||||
if (serverArgs.experimentalStructuredContent) {
|
||||
result.structuredContent = structuredContent;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
catch (err) {
|
||||
logger(`${tool.name} error:`, err, err?.stack);
|
||||
let errorText = err && 'message' in err ? err.message : String(err);
|
||||
if ('cause' in err && err.cause) {
|
||||
errorText += `\nCause: ${err.cause.message}`;
|
||||
}
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: errorText,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
finally {
|
||||
void clearcutLogger?.logToolInvocation({
|
||||
toolName: tool.name,
|
||||
success,
|
||||
latencyMs: bucketizeLatency(Date.now() - startTime),
|
||||
});
|
||||
guard.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
const tools = createTools(serverArgs);
|
||||
for (const tool of tools) {
|
||||
registerTool(tool);
|
||||
}
|
||||
await loadIssueDescriptions();
|
||||
return { server, clearcutLogger };
|
||||
}
|
||||
export const logDisclaimers = (args) => {
|
||||
console.error(`chrome-devtools-mcp exposes content of the browser instance to the MCP clients allowing them to inspect,
|
||||
debug, and modify any data in the browser or DevTools.
|
||||
Avoid sharing sensitive or personal information that you do not want to share with MCP clients.`);
|
||||
if (!args.slim && args.performanceCrux) {
|
||||
console.error(`Performance tools may send trace URLs to the Google CrUX API to fetch real-user experience data. To disable, run with --no-performance-crux.`);
|
||||
}
|
||||
if (!args.slim && args.usageStatistics) {
|
||||
console.error(`
|
||||
Google collects usage statistics to improve Chrome DevTools MCP. To opt-out, run with --no-usage-statistics.
|
||||
For more details, visit: https://github.com/ChromeDevTools/chrome-devtools-mcp#usage-statistics`);
|
||||
}
|
||||
};
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
const DESCRIPTIONS_PATH = path.join(import.meta.dirname, 'third_party/issue-descriptions');
|
||||
let issueDescriptions = {};
|
||||
/**
|
||||
* Reads all issue descriptions from the filesystem into memory.
|
||||
*/
|
||||
export async function loadIssueDescriptions() {
|
||||
if (Object.keys(issueDescriptions).length > 0) {
|
||||
return;
|
||||
}
|
||||
const files = await fs.promises.readdir(DESCRIPTIONS_PATH);
|
||||
const descriptions = {};
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.md')) {
|
||||
continue;
|
||||
}
|
||||
const content = await fs.promises.readFile(path.join(DESCRIPTIONS_PATH, file), 'utf-8');
|
||||
descriptions[file] = content;
|
||||
}
|
||||
issueDescriptions = descriptions;
|
||||
}
|
||||
/**
|
||||
* Gets an issue description from the in-memory cache.
|
||||
* @param fileName The file name of the issue description.
|
||||
* @returns The description of the issue, or null if it doesn't exist.
|
||||
*/
|
||||
export function getIssueDescription(fileName) {
|
||||
return issueDescriptions[fileName] ?? null;
|
||||
}
|
||||
export const ISSUE_UTILS = {
|
||||
loadIssueDescriptions,
|
||||
getIssueDescription,
|
||||
};
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import { debug } from './third_party/index.js';
|
||||
const mcpDebugNamespace = 'mcp:log';
|
||||
const namespacesToEnable = [
|
||||
mcpDebugNamespace,
|
||||
...(process.env['DEBUG'] ? [process.env['DEBUG']] : []),
|
||||
];
|
||||
export function saveLogsToFile(fileName) {
|
||||
// Enable overrides everything so we need to add them
|
||||
debug.enable(namespacesToEnable.join(','));
|
||||
const logFile = fs.createWriteStream(fileName, { flags: 'a+' });
|
||||
debug.log = function (...chunks) {
|
||||
logFile.write(`${chunks.join(' ')}\n`);
|
||||
};
|
||||
logFile.on('error', function (error) {
|
||||
console.error(`Error when opening/writing to log file: ${error.message}`);
|
||||
logFile.end();
|
||||
process.exit(1);
|
||||
});
|
||||
return logFile;
|
||||
}
|
||||
export function flushLogs(logFile, timeoutMs = 2000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(reject, timeoutMs);
|
||||
logFile.end(() => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
export const logger = debug(mcpDebugNamespace);
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google Inc.
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
// polyfills are now bundled with all other dependencies
|
||||
import './third_party/index.js';
|
||||
+219
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import process from 'node:process';
|
||||
import { DAEMON_CLIENT_NAME } from '../daemon/utils.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { FilePersistence } from './persistence.js';
|
||||
import { McpClient, WatchdogMessageType, OsType, } from './types.js';
|
||||
import { WatchdogClient } from './WatchdogClient.js';
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
const PARAM_BLOCKLIST = new Set(['uid']);
|
||||
const SUPPORTED_ZOD_TYPES = [
|
||||
'ZodString',
|
||||
'ZodNumber',
|
||||
'ZodBoolean',
|
||||
'ZodArray',
|
||||
'ZodEnum',
|
||||
];
|
||||
function isZodType(type) {
|
||||
return SUPPORTED_ZOD_TYPES.includes(type);
|
||||
}
|
||||
function getZodType(zodType) {
|
||||
const def = zodType._def;
|
||||
const typeName = def.typeName;
|
||||
if (typeName === 'ZodOptional' ||
|
||||
typeName === 'ZodDefault' ||
|
||||
typeName === 'ZodNullable') {
|
||||
return getZodType(def.innerType);
|
||||
}
|
||||
if (typeName === 'ZodEffects') {
|
||||
return getZodType(def.schema);
|
||||
}
|
||||
if (isZodType(typeName)) {
|
||||
return typeName;
|
||||
}
|
||||
throw new Error(`Unsupported zod type for tool parameter: ${typeName}`);
|
||||
}
|
||||
function transformName(zodType, name) {
|
||||
if (zodType === 'ZodString') {
|
||||
return `${name}_length`;
|
||||
}
|
||||
else if (zodType === 'ZodArray') {
|
||||
return `${name}_count`;
|
||||
}
|
||||
else {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
function transformValue(zodType, value) {
|
||||
if (zodType === 'ZodString') {
|
||||
return value.length;
|
||||
}
|
||||
else if (zodType === 'ZodArray') {
|
||||
return value.length;
|
||||
}
|
||||
else {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
function hasEquivalentType(zodType, value) {
|
||||
if (zodType === 'ZodString') {
|
||||
return typeof value === 'string';
|
||||
}
|
||||
else if (zodType === 'ZodArray') {
|
||||
return Array.isArray(value);
|
||||
}
|
||||
else if (zodType === 'ZodNumber') {
|
||||
return typeof value === 'number';
|
||||
}
|
||||
else if (zodType === 'ZodBoolean') {
|
||||
return typeof value === 'boolean';
|
||||
}
|
||||
else if (zodType === 'ZodEnum') {
|
||||
return (typeof value === 'string' ||
|
||||
typeof value === 'number' ||
|
||||
typeof value === 'boolean');
|
||||
}
|
||||
else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
export function sanitizeParams(params, schema) {
|
||||
const transformed = {};
|
||||
for (const [name, value] of Object.entries(params)) {
|
||||
if (PARAM_BLOCKLIST.has(name)) {
|
||||
continue;
|
||||
}
|
||||
const zodType = getZodType(schema[name]);
|
||||
if (!hasEquivalentType(zodType, value)) {
|
||||
throw new Error(`parameter ${name} has type ${zodType} but value ${value} is not of equivalent type`);
|
||||
}
|
||||
const transformedName = transformName(zodType, name);
|
||||
const transformedValue = transformValue(zodType, value);
|
||||
transformed[transformedName] = transformedValue;
|
||||
}
|
||||
return transformed;
|
||||
}
|
||||
function detectOsType() {
|
||||
switch (process.platform) {
|
||||
case 'win32':
|
||||
return OsType.OS_TYPE_WINDOWS;
|
||||
case 'darwin':
|
||||
return OsType.OS_TYPE_MACOS;
|
||||
case 'linux':
|
||||
return OsType.OS_TYPE_LINUX;
|
||||
default:
|
||||
return OsType.OS_TYPE_UNSPECIFIED;
|
||||
}
|
||||
}
|
||||
export class ClearcutLogger {
|
||||
#persistence;
|
||||
#watchdog;
|
||||
#mcpClient;
|
||||
constructor(options) {
|
||||
this.#persistence = options.persistence ?? new FilePersistence();
|
||||
this.#watchdog =
|
||||
options.watchdogClient ??
|
||||
new WatchdogClient({
|
||||
parentPid: process.pid,
|
||||
appVersion: options.appVersion,
|
||||
osType: detectOsType(),
|
||||
logFile: options.logFile,
|
||||
clearcutEndpoint: options.clearcutEndpoint,
|
||||
clearcutForceFlushIntervalMs: options.clearcutForceFlushIntervalMs,
|
||||
clearcutIncludePidHeader: options.clearcutIncludePidHeader,
|
||||
});
|
||||
this.#mcpClient = McpClient.MCP_CLIENT_UNSPECIFIED;
|
||||
}
|
||||
setClientName(clientName) {
|
||||
const lowerName = clientName.toLowerCase();
|
||||
if (lowerName.includes('claude')) {
|
||||
this.#mcpClient = McpClient.MCP_CLIENT_CLAUDE_CODE;
|
||||
}
|
||||
else if (lowerName.includes('gemini')) {
|
||||
this.#mcpClient = McpClient.MCP_CLIENT_GEMINI_CLI;
|
||||
}
|
||||
else if (clientName === DAEMON_CLIENT_NAME) {
|
||||
this.#mcpClient = McpClient.MCP_CLIENT_DT_MCP_CLI;
|
||||
}
|
||||
else if (lowerName.includes('openclaw')) {
|
||||
this.#mcpClient = McpClient.MCP_CLIENT_OPENCLAW;
|
||||
}
|
||||
else if (lowerName.includes('codex')) {
|
||||
this.#mcpClient = McpClient.MCP_CLIENT_CODEX;
|
||||
}
|
||||
else if (lowerName.includes('antigravity')) {
|
||||
this.#mcpClient = McpClient.MCP_CLIENT_ANTIGRAVITY;
|
||||
}
|
||||
else {
|
||||
this.#mcpClient = McpClient.MCP_CLIENT_OTHER;
|
||||
}
|
||||
}
|
||||
async logToolInvocation(args) {
|
||||
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,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
async logServerStart(flagUsage) {
|
||||
this.#watchdog.send({
|
||||
type: WatchdogMessageType.LOG_EVENT,
|
||||
payload: {
|
||||
mcp_client: this.#mcpClient,
|
||||
server_start: {
|
||||
flag_usage: flagUsage,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
async logDailyActiveIfNeeded() {
|
||||
try {
|
||||
const state = await this.#persistence.loadState();
|
||||
if (this.#shouldLogDailyActive(state)) {
|
||||
let daysSince = -1;
|
||||
if (state.lastActive) {
|
||||
const lastActiveDate = new Date(state.lastActive);
|
||||
const now = new Date();
|
||||
const diffTime = Math.abs(now.getTime() - lastActiveDate.getTime());
|
||||
daysSince = Math.ceil(diffTime / MS_PER_DAY);
|
||||
}
|
||||
this.#watchdog.send({
|
||||
type: WatchdogMessageType.LOG_EVENT,
|
||||
payload: {
|
||||
mcp_client: this.#mcpClient,
|
||||
daily_active: {
|
||||
days_since_last_active: daysSince,
|
||||
},
|
||||
},
|
||||
});
|
||||
state.lastActive = new Date().toISOString();
|
||||
await this.#persistence.saveState(state);
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
logger('Error in logDailyActiveIfNeeded:', err);
|
||||
}
|
||||
}
|
||||
#shouldLogDailyActive(state) {
|
||||
if (!state.lastActive) {
|
||||
return true;
|
||||
}
|
||||
const lastActiveDate = new Date(state.lastActive);
|
||||
const now = new Date();
|
||||
// Compare UTC dates
|
||||
const isSameDay = lastActiveDate.getUTCFullYear() === now.getUTCFullYear() &&
|
||||
lastActiveDate.getUTCMonth() === now.getUTCMonth() &&
|
||||
lastActiveDate.getUTCDate() === now.getUTCDate();
|
||||
return !isSameDay;
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { logger } from '../logger.js';
|
||||
export class WatchdogClient {
|
||||
#childProcess;
|
||||
constructor(config, options) {
|
||||
const watchdogPath = fileURLToPath(new URL('./watchdog/main.js', import.meta.url));
|
||||
const args = [
|
||||
watchdogPath,
|
||||
`--parent-pid=${config.parentPid}`,
|
||||
`--app-version=${config.appVersion}`,
|
||||
`--os-type=${config.osType}`,
|
||||
];
|
||||
if (config.logFile) {
|
||||
args.push(`--log-file=${config.logFile}`);
|
||||
}
|
||||
if (config.clearcutEndpoint) {
|
||||
args.push(`--clearcut-endpoint=${config.clearcutEndpoint}`);
|
||||
}
|
||||
if (config.clearcutForceFlushIntervalMs) {
|
||||
args.push(`--clearcut-force-flush-interval-ms=${config.clearcutForceFlushIntervalMs}`);
|
||||
}
|
||||
if (config.clearcutIncludePidHeader) {
|
||||
args.push('--clearcut-include-pid-header');
|
||||
}
|
||||
const spawner = options?.spawn ?? spawn;
|
||||
this.#childProcess = spawner(process.execPath, args, {
|
||||
stdio: ['pipe', 'ignore', 'ignore'],
|
||||
detached: true,
|
||||
});
|
||||
this.#childProcess.unref();
|
||||
this.#childProcess.on('error', err => {
|
||||
logger('Watchdog process error:', err);
|
||||
});
|
||||
this.#childProcess.on('exit', (code, signal) => {
|
||||
logger(`Watchdog exited with code ${code} and signal ${signal}`);
|
||||
});
|
||||
}
|
||||
send(message) {
|
||||
if (this.#childProcess.stdin &&
|
||||
!this.#childProcess.stdin.destroyed &&
|
||||
this.#childProcess.pid) {
|
||||
try {
|
||||
const line = JSON.stringify(message) + '\n';
|
||||
this.#childProcess.stdin.write(line);
|
||||
}
|
||||
catch (err) {
|
||||
logger('Failed to write to watchdog stdin', err);
|
||||
}
|
||||
}
|
||||
else {
|
||||
logger('Watchdog stdin not available, dropping message');
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { toSnakeCase } from '../utils/string.js';
|
||||
/**
|
||||
* Computes telemetry flag usage from parsed arguments and CLI options.
|
||||
*
|
||||
* Iterates over the defined CLI options to construct a payload:
|
||||
* - Flag names are converted to snake_case (e.g. `browserUrl` -> `browser_url`).
|
||||
* - A flag is logged as `{flag_name}_present` if:
|
||||
* - It has no default value, OR
|
||||
* - The provided value differs from the default value.
|
||||
* - Boolean flags are logged with their literal value.
|
||||
* - String flags with defined `choices` (Enums) are logged as their uppercase value.
|
||||
*/
|
||||
export function computeFlagUsage(args, options) {
|
||||
const usage = {};
|
||||
for (const [flagName, config] of Object.entries(options)) {
|
||||
const value = args[flagName];
|
||||
const snakeCaseName = toSnakeCase(flagName);
|
||||
// If there isn't a default value provided for the flag,
|
||||
// we're going to log whether it's present on the args user
|
||||
// provided or not. If there is a default value, we only log presence
|
||||
// if the value differs from the default, implying explicit user intent.
|
||||
if (!('default' in config) || value !== config.default) {
|
||||
usage[`${snakeCaseName}_present`] = value !== undefined && value !== null;
|
||||
}
|
||||
if (config.type === 'boolean' && typeof value === 'boolean') {
|
||||
// For boolean options, we're going to log the value directly.
|
||||
usage[snakeCaseName] = value;
|
||||
}
|
||||
else if (config.type === 'string' &&
|
||||
typeof value === 'string' &&
|
||||
'choices' in config &&
|
||||
config.choices) {
|
||||
// For enums, log the value as uppercase
|
||||
// We're going to have an enum for such flags with choices represented
|
||||
// as an `enum` where the keys of the enum will map to the uppercase `choice`.
|
||||
usage[snakeCaseName] = `${snakeCaseName}_${value}`.toUpperCase();
|
||||
}
|
||||
}
|
||||
return usage;
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
const LATENCY_BUCKETS = [50, 100, 250, 500, 1000, 2500, 5000, 10000];
|
||||
export function bucketizeLatency(latencyMs) {
|
||||
for (const bucket of LATENCY_BUCKETS) {
|
||||
if (latencyMs <= bucket) {
|
||||
return bucket;
|
||||
}
|
||||
}
|
||||
return LATENCY_BUCKETS[LATENCY_BUCKETS.length - 1];
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { logger } from '../logger.js';
|
||||
const STATE_FILE_NAME = 'telemetry_state.json';
|
||||
function getDataFolder() {
|
||||
const homedir = os.homedir();
|
||||
const { env } = process;
|
||||
const name = 'chrome-devtools-mcp';
|
||||
if (process.platform === 'darwin') {
|
||||
return path.join(homedir, 'Library', 'Application Support', name);
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
const localAppData = env.LOCALAPPDATA || path.join(homedir, 'AppData', 'Local');
|
||||
return path.join(localAppData, name, 'Data');
|
||||
}
|
||||
return path.join(env.XDG_DATA_HOME || path.join(homedir, '.local', 'share'), name);
|
||||
}
|
||||
export class FilePersistence {
|
||||
#dataFolder;
|
||||
constructor(dataFolderOverride) {
|
||||
this.#dataFolder = dataFolderOverride ?? getDataFolder();
|
||||
}
|
||||
async loadState() {
|
||||
try {
|
||||
const filePath = path.join(this.#dataFolder, STATE_FILE_NAME);
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
return JSON.parse(content);
|
||||
}
|
||||
catch {
|
||||
return {
|
||||
lastActive: '',
|
||||
};
|
||||
}
|
||||
}
|
||||
async saveState(state) {
|
||||
const filePath = path.join(this.#dataFolder, STATE_FILE_NAME);
|
||||
try {
|
||||
await fs.mkdir(this.#dataFolder, { recursive: true });
|
||||
await fs.writeFile(filePath, JSON.stringify(state, null, 2), 'utf-8');
|
||||
}
|
||||
catch (error) {
|
||||
// Ignore errors during state saving to avoid crashing the server
|
||||
logger(`Failed to save telemetry state to ${filePath}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
// Enums
|
||||
export var OsType;
|
||||
(function (OsType) {
|
||||
OsType[OsType["OS_TYPE_UNSPECIFIED"] = 0] = "OS_TYPE_UNSPECIFIED";
|
||||
OsType[OsType["OS_TYPE_WINDOWS"] = 1] = "OS_TYPE_WINDOWS";
|
||||
OsType[OsType["OS_TYPE_MACOS"] = 2] = "OS_TYPE_MACOS";
|
||||
OsType[OsType["OS_TYPE_LINUX"] = 3] = "OS_TYPE_LINUX";
|
||||
})(OsType || (OsType = {}));
|
||||
export var ChromeChannel;
|
||||
(function (ChromeChannel) {
|
||||
ChromeChannel[ChromeChannel["CHROME_CHANNEL_UNSPECIFIED"] = 0] = "CHROME_CHANNEL_UNSPECIFIED";
|
||||
ChromeChannel[ChromeChannel["CHROME_CHANNEL_CANARY"] = 1] = "CHROME_CHANNEL_CANARY";
|
||||
ChromeChannel[ChromeChannel["CHROME_CHANNEL_DEV"] = 2] = "CHROME_CHANNEL_DEV";
|
||||
ChromeChannel[ChromeChannel["CHROME_CHANNEL_BETA"] = 3] = "CHROME_CHANNEL_BETA";
|
||||
ChromeChannel[ChromeChannel["CHROME_CHANNEL_STABLE"] = 4] = "CHROME_CHANNEL_STABLE";
|
||||
})(ChromeChannel || (ChromeChannel = {}));
|
||||
export var McpClient;
|
||||
(function (McpClient) {
|
||||
McpClient[McpClient["MCP_CLIENT_UNSPECIFIED"] = 0] = "MCP_CLIENT_UNSPECIFIED";
|
||||
McpClient[McpClient["MCP_CLIENT_CLAUDE_CODE"] = 1] = "MCP_CLIENT_CLAUDE_CODE";
|
||||
McpClient[McpClient["MCP_CLIENT_GEMINI_CLI"] = 2] = "MCP_CLIENT_GEMINI_CLI";
|
||||
McpClient[McpClient["MCP_CLIENT_DT_MCP_CLI"] = 4] = "MCP_CLIENT_DT_MCP_CLI";
|
||||
McpClient[McpClient["MCP_CLIENT_OPENCLAW"] = 5] = "MCP_CLIENT_OPENCLAW";
|
||||
McpClient[McpClient["MCP_CLIENT_CODEX"] = 6] = "MCP_CLIENT_CODEX";
|
||||
McpClient[McpClient["MCP_CLIENT_ANTIGRAVITY"] = 7] = "MCP_CLIENT_ANTIGRAVITY";
|
||||
McpClient[McpClient["MCP_CLIENT_OTHER"] = 3] = "MCP_CLIENT_OTHER";
|
||||
})(McpClient || (McpClient = {}));
|
||||
// IPC types for messages between the main process and the
|
||||
// telemetry watchdog process.
|
||||
export var WatchdogMessageType;
|
||||
(function (WatchdogMessageType) {
|
||||
WatchdogMessageType["LOG_EVENT"] = "log-event";
|
||||
})(WatchdogMessageType || (WatchdogMessageType = {}));
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import crypto from 'node:crypto';
|
||||
import { logger } from '../../logger.js';
|
||||
const MAX_BUFFER_SIZE = 1000;
|
||||
const DEFAULT_CLEARCUT_ENDPOINT = 'https://play.googleapis.com/log?format=json_proto';
|
||||
const DEFAULT_FLUSH_INTERVAL_MS = 15 * 60 * 1000;
|
||||
const LOG_SOURCE = 2839;
|
||||
const CLIENT_TYPE = 47;
|
||||
const MIN_RATE_LIMIT_WAIT_MS = 30_000;
|
||||
const REQUEST_TIMEOUT_MS = 30_000;
|
||||
const SHUTDOWN_TIMEOUT_MS = 5_000;
|
||||
const SESSION_ROTATION_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||
export class ClearcutSender {
|
||||
#appVersion;
|
||||
#osType;
|
||||
#clearcutEndpoint;
|
||||
#flushIntervalMs;
|
||||
#includePidHeader;
|
||||
#sessionId;
|
||||
#sessionCreated;
|
||||
#buffer = [];
|
||||
#flushTimer = null;
|
||||
#isFlushing = false;
|
||||
#timerStarted = false;
|
||||
constructor(config) {
|
||||
this.#appVersion = config.appVersion;
|
||||
this.#osType = config.osType;
|
||||
this.#clearcutEndpoint =
|
||||
config.clearcutEndpoint ?? DEFAULT_CLEARCUT_ENDPOINT;
|
||||
this.#flushIntervalMs =
|
||||
config.forceFlushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS;
|
||||
this.#includePidHeader = config.includePidHeader ?? false;
|
||||
this.#sessionId = crypto.randomUUID();
|
||||
this.#sessionCreated = Date.now();
|
||||
}
|
||||
enqueueEvent(event) {
|
||||
if (Date.now() - this.#sessionCreated > SESSION_ROTATION_INTERVAL_MS) {
|
||||
this.#sessionId = crypto.randomUUID();
|
||||
this.#sessionCreated = Date.now();
|
||||
}
|
||||
logger('Enqueing telemetry event', JSON.stringify(event, null, 2));
|
||||
this.#addToBuffer({
|
||||
...event,
|
||||
session_id: this.#sessionId,
|
||||
app_version: this.#appVersion,
|
||||
os_type: this.#osType,
|
||||
});
|
||||
if (!this.#timerStarted) {
|
||||
this.#timerStarted = true;
|
||||
this.#scheduleFlush(this.#flushIntervalMs);
|
||||
}
|
||||
}
|
||||
async sendShutdownEvent() {
|
||||
if (this.#flushTimer) {
|
||||
clearTimeout(this.#flushTimer);
|
||||
this.#flushTimer = null;
|
||||
}
|
||||
const shutdownEvent = {
|
||||
server_shutdown: {},
|
||||
};
|
||||
this.enqueueEvent(shutdownEvent);
|
||||
try {
|
||||
await Promise.race([
|
||||
this.#finalFlush(),
|
||||
new Promise(resolve => setTimeout(resolve, SHUTDOWN_TIMEOUT_MS)),
|
||||
]);
|
||||
logger('Final flush completed');
|
||||
}
|
||||
catch (error) {
|
||||
logger('Final flush failed:', error);
|
||||
}
|
||||
}
|
||||
async #flush() {
|
||||
if (this.#isFlushing) {
|
||||
return;
|
||||
}
|
||||
if (this.#buffer.length === 0) {
|
||||
this.#scheduleFlush(this.#flushIntervalMs);
|
||||
return;
|
||||
}
|
||||
this.#isFlushing = true;
|
||||
let nextDelayMs = this.#flushIntervalMs;
|
||||
// Optimistically remove events from buffer before sending.
|
||||
// This prevents race conditions where a simultaneous #finalFlush would include these same events.
|
||||
const eventsToSend = [...this.#buffer];
|
||||
this.#buffer = [];
|
||||
try {
|
||||
const result = await this.#sendBatch(eventsToSend);
|
||||
if (result.success) {
|
||||
if (result.nextRequestWaitMs !== undefined) {
|
||||
nextDelayMs = Math.max(result.nextRequestWaitMs, MIN_RATE_LIMIT_WAIT_MS);
|
||||
}
|
||||
}
|
||||
else if (result.isPermanentError) {
|
||||
logger('Permanent error, dropped batch of', eventsToSend.length, 'events');
|
||||
}
|
||||
else {
|
||||
// Transient error: Requeue events at the front of the buffer
|
||||
// to maintain order and retry them later.
|
||||
this.#buffer = [...eventsToSend, ...this.#buffer];
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
// Safety catch for unexpected errors, requeue events
|
||||
this.#buffer = [...eventsToSend, ...this.#buffer];
|
||||
logger('Flush failed unexpectedly:', error);
|
||||
}
|
||||
finally {
|
||||
this.#isFlushing = false;
|
||||
this.#scheduleFlush(nextDelayMs);
|
||||
}
|
||||
}
|
||||
#addToBuffer(event) {
|
||||
if (this.#buffer.length >= MAX_BUFFER_SIZE) {
|
||||
this.#buffer.shift();
|
||||
logger('Telemetry buffer overflow: dropped oldest event');
|
||||
}
|
||||
this.#buffer.push({
|
||||
event,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
#scheduleFlush(delayMs) {
|
||||
logger(`Scheduling flush in ${delayMs}`);
|
||||
if (this.#flushTimer) {
|
||||
clearTimeout(this.#flushTimer);
|
||||
}
|
||||
this.#flushTimer = setTimeout(() => {
|
||||
this.#flush().catch(err => {
|
||||
logger('Flush error:', err);
|
||||
});
|
||||
}, delayMs);
|
||||
}
|
||||
async #sendBatch(events) {
|
||||
logger(`Sending batch of ${events.length}`);
|
||||
const requestBody = {
|
||||
log_source: LOG_SOURCE,
|
||||
request_time_ms: Date.now().toString(),
|
||||
client_info: {
|
||||
client_type: CLIENT_TYPE,
|
||||
},
|
||||
log_event: events.map(({ event, timestamp }) => ({
|
||||
event_time_ms: timestamp.toString(),
|
||||
source_extension_json: JSON.stringify(event),
|
||||
})),
|
||||
};
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetch(this.#clearcutEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// Used in E2E tests to confirm that the watchdog process is killed
|
||||
...(this.#includePidHeader
|
||||
? { 'X-Watchdog-Pid': process.pid.toString() }
|
||||
: {}),
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
if (response.ok) {
|
||||
const data = (await response.json());
|
||||
return {
|
||||
success: true,
|
||||
nextRequestWaitMs: data.next_request_wait_millis,
|
||||
};
|
||||
}
|
||||
const status = response.status;
|
||||
if (status >= 500 || status === 429) {
|
||||
return { success: false };
|
||||
}
|
||||
logger('Telemetry permanent error:', status);
|
||||
return { success: false, isPermanentError: true };
|
||||
}
|
||||
catch {
|
||||
clearTimeout(timeoutId);
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
async #finalFlush() {
|
||||
if (this.#buffer.length === 0) {
|
||||
return;
|
||||
}
|
||||
const eventsToSend = [...this.#buffer];
|
||||
await this.#sendBatch(eventsToSend);
|
||||
}
|
||||
stopForTesting() {
|
||||
if (this.#flushTimer) {
|
||||
clearTimeout(this.#flushTimer);
|
||||
this.#flushTimer = null;
|
||||
}
|
||||
this.#timerStarted = false;
|
||||
}
|
||||
get bufferSizeForTesting() {
|
||||
return this.#buffer.length;
|
||||
}
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import process from 'node:process';
|
||||
import readline from 'node:readline';
|
||||
import { parseArgs } from 'node:util';
|
||||
import { logger, flushLogs, saveLogsToFile } from '../../logger.js';
|
||||
import { WatchdogMessageType } from '../types.js';
|
||||
import { ClearcutSender } from './ClearcutSender.js';
|
||||
function parseWatchdogArgs() {
|
||||
const { values } = parseArgs({
|
||||
options: {
|
||||
'parent-pid': { type: 'string' },
|
||||
'app-version': { type: 'string' },
|
||||
'os-type': { type: 'string' },
|
||||
'log-file': { type: 'string' },
|
||||
'clearcut-endpoint': { type: 'string' },
|
||||
'clearcut-force-flush-interval-ms': { type: 'string' },
|
||||
'clearcut-include-pid-header': { type: 'boolean' },
|
||||
},
|
||||
strict: true,
|
||||
});
|
||||
// Verify required arguments
|
||||
const parentPid = parseInt(values['parent-pid'] ?? '', 10);
|
||||
const appVersion = values['app-version'];
|
||||
const osType = parseInt(values['os-type'] ?? '', 10);
|
||||
if (isNaN(parentPid) || !appVersion || isNaN(osType)) {
|
||||
console.error('Invalid arguments provided for watchdog process: ', JSON.stringify({ parentPid, appVersion, osType }));
|
||||
process.exit(1);
|
||||
}
|
||||
// Parse Optional Arguments
|
||||
const logFile = values['log-file'];
|
||||
const clearcutEndpoint = values['clearcut-endpoint'];
|
||||
const clearcutIncludePidHeader = values['clearcut-include-pid-header'];
|
||||
let clearcutForceFlushIntervalMs;
|
||||
if (values['clearcut-force-flush-interval-ms']) {
|
||||
const parsed = parseInt(values['clearcut-force-flush-interval-ms'], 10);
|
||||
if (!isNaN(parsed)) {
|
||||
clearcutForceFlushIntervalMs = parsed;
|
||||
}
|
||||
}
|
||||
return {
|
||||
parentPid,
|
||||
appVersion,
|
||||
osType,
|
||||
logFile,
|
||||
clearcutEndpoint,
|
||||
clearcutForceFlushIntervalMs,
|
||||
clearcutIncludePidHeader,
|
||||
};
|
||||
}
|
||||
function main() {
|
||||
const { parentPid, appVersion, osType, logFile, clearcutEndpoint, clearcutForceFlushIntervalMs, clearcutIncludePidHeader, } = parseWatchdogArgs();
|
||||
let logStream;
|
||||
if (logFile) {
|
||||
logStream = saveLogsToFile(logFile);
|
||||
}
|
||||
const exit = (code) => {
|
||||
if (!logStream) {
|
||||
process.exit(code);
|
||||
}
|
||||
void flushLogs(logStream).finally(() => {
|
||||
process.exit(code);
|
||||
});
|
||||
};
|
||||
logger('Watchdog started', JSON.stringify({
|
||||
pid: process.pid,
|
||||
parentPid,
|
||||
version: appVersion,
|
||||
osType,
|
||||
}, null, 2));
|
||||
const sender = new ClearcutSender({
|
||||
appVersion,
|
||||
osType: osType,
|
||||
clearcutEndpoint,
|
||||
forceFlushIntervalMs: clearcutForceFlushIntervalMs,
|
||||
includePidHeader: clearcutIncludePidHeader,
|
||||
});
|
||||
let isShuttingDown = false;
|
||||
function onParentDeath(reason) {
|
||||
if (isShuttingDown) {
|
||||
return;
|
||||
}
|
||||
isShuttingDown = true;
|
||||
logger(`Parent death detected (${reason}). Sending shutdown event...`);
|
||||
sender
|
||||
.sendShutdownEvent()
|
||||
.then(() => {
|
||||
logger('Shutdown event sent. Exiting.');
|
||||
exit(0);
|
||||
})
|
||||
.catch(err => {
|
||||
logger('Failed to send shutdown event', err);
|
||||
exit(1);
|
||||
});
|
||||
}
|
||||
process.stdin.on('end', () => onParentDeath('stdin end'));
|
||||
process.stdin.on('close', () => onParentDeath('stdin close'));
|
||||
process.on('disconnect', () => onParentDeath('ipc disconnect'));
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
terminal: false,
|
||||
});
|
||||
rl.on('line', line => {
|
||||
try {
|
||||
if (!line.trim()) {
|
||||
return;
|
||||
}
|
||||
const msg = JSON.parse(line);
|
||||
if (msg.type === WatchdogMessageType.LOG_EVENT && msg.payload) {
|
||||
sender.enqueueEvent(msg.payload);
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
logger('Failed to parse IPC message', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
try {
|
||||
main();
|
||||
}
|
||||
catch (err) {
|
||||
console.error('Watchdog fatal error:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
+4297
File diff suppressed because it is too large
Load Diff
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"@modelcontextprotocol/sdk": "1.28.0",
|
||||
"chrome-devtools-frontend": "1.0.1602348",
|
||||
"core-js": "3.49.0",
|
||||
"debug": "4.4.3",
|
||||
"lighthouse": "13.0.3",
|
||||
"yargs": "18.0.0",
|
||||
"puppeteer-core": "24.40.0"
|
||||
}
|
||||
Generated
Vendored
+15436
File diff suppressed because it is too large
Load Diff
+181796
File diff suppressed because one or more lines are too long
Generated
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
# An iframe navigation to a document with a Cross-Origin Opener Policy was blocked
|
||||
|
||||
A document with a Cross-Origin Opener Policy (COOP) was blocked from loading in an iframe, because the iframe specifies a sandbox attribute.
|
||||
This protects COOP-enabled documents from inheriting properties from its opener.
|
||||
Generated
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
# Specify a more permissive Cross-Origin Resource Policy to prevent a resource from being blocked
|
||||
|
||||
Your site tries to access an external resource that only allows same-origin usage.
|
||||
This behavior prevents a document from loading any non-same-origin resources which don’t explicitly grant permission to be loaded.
|
||||
|
||||
To solve this, add the following to the resource’s HTML response header:
|
||||
* `Cross-Origin-Resource-Policy: same-site` if the resource and your site are served from the same site.
|
||||
* `Cross-Origin-Resource-Policy: cross-origin` if the resource is served from another location than your website. ⚠️If you set this header, any website can embed this resource.
|
||||
Generated
Vendored
+18
@@ -0,0 +1,18 @@
|
||||
# Specify a Cross-Origin Resource Policy to prevent a resource from being blocked
|
||||
|
||||
Because your site has the Cross-Origin Embedder Policy (COEP) enabled, each
|
||||
resource must specify a suitable Cross-Origin Resource Policy (CORP). This
|
||||
behavior prevents a document from loading cross-origin resources which don’t
|
||||
explicitly grant permission to be loaded.
|
||||
|
||||
To solve this, add the following to the resource’ response header:
|
||||
* `Cross-Origin-Resource-Policy: same-site` if the resource and your site are
|
||||
served from the same site.
|
||||
* `Cross-Origin-Resource-Policy: cross-origin` if the resource is served from
|
||||
another location than your website. ⚠️If you set this header, any website can
|
||||
embed this resource.
|
||||
|
||||
Alternatively, the document can use the variant: `Cross-Origin-Embedder-Policy:
|
||||
credentialless` instead of `require-corp`. It allows loading the resource,
|
||||
despite the missing CORP header, at the cost of requesting it without
|
||||
credentials like Cookies.
|
||||
Generated
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
# Specify a more permissive Cross-Origin Resource Policy to prevent a resource from being blocked
|
||||
|
||||
Your site tries to access an external resource that only allows same-site usage.
|
||||
This behavior prevents a document from loading any non-same-site resources which don’t explicitly grant permission to be loaded.
|
||||
|
||||
To solve this, add the following to the resource’s HTML response header: `Cross-Origin-Resource-Policy: cross-origin`
|
||||
⚠️If you set this header, any website can embed this resource.
|
||||
Generated
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
# Specify a Cross-Origin Embedder Policy to prevent this frame from being blocked
|
||||
|
||||
Because your site has the Cross-Origin Embedder Policy (COEP) enabled, each
|
||||
embedded iframe must also specify this policy. This behavior protects private
|
||||
data from being exposed to untrusted third party sites.
|
||||
|
||||
To solve this, add one of following to the embedded frame’s HTML response
|
||||
header:
|
||||
* `Cross-Origin-Embedder-Policy: require-corp`
|
||||
* `Cross-Origin-Embedder-Policy: credentialless` (Chrome > 96)
|
||||
node_modules/chrome-devtools-mcp/build/src/third_party/issue-descriptions/CompatibilityModeQuirks.md
Generated
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
# Page layout may be unexpected due to Quirks Mode
|
||||
|
||||
One or more documents in this page is in Quirks Mode, which will render the affected document(s) with quirks incompatible with the current HTML and CSS specifications.
|
||||
|
||||
Quirks Mode exists mostly due to historical reasons. If this is not intentional, you can [add or modify the DOCTYPE to be `<!DOCTYPE html>`](issueQuirksModeDoctype) to render the page in No Quirks Mode.
|
||||
Generated
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
# Ensure cookie attribute values don’t exceed 1024 characters
|
||||
|
||||
Cookie attribute values exceeding 1024 characters in size will result in the attribute being ignored. This could lead to unexpected behavior since the cookie will be processed as if the offending attribute / attribute value pair were not present.
|
||||
|
||||
Resolve this issue by ensuring that cookie attribute values don’t exceed 1024 characters.
|
||||
Generated
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
# Users may have difficulties reading text content due to insufficient color contrast
|
||||
|
||||
Low-contrast text is difficult or impossible for users to read. A [minimum contrast ratio (AA) of 4.5](issuesContrastWCAG21AA) is recommended for all text. Since font size and weight affect color perception, an exception is made for very large or bold text — in this case, a contrast ratio of 3.0 is allowed. The [enhanced conformance level (AAA)](issuesContrastWCAG21AAA) requires the contrast ratio to be above 7.0 for regular text and 4.5 for large text.
|
||||
|
||||
Update colors or change the font size or weight to achieve sufficient contrast. You can use the [“Suggest color” feature](issuesContrastSuggestColor) in the DevTools color picker to automatically select a better text color.
|
||||
Generated
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
# Migrate entirely to HTTPS to have cookies sent to same-site subresources
|
||||
|
||||
A cookie was not sent to {PLACEHOLDER_destination} origin from {PLACEHOLDER_origin} context.
|
||||
Because this cookie would have been sent across schemes on the same site, it was not sent.
|
||||
This behavior enhances the `SameSite` attribute’s protection of user data from request forgery by network attackers.
|
||||
|
||||
Resolve this issue by migrating your site (as defined by the eTLD+1) entirely to HTTPS.
|
||||
It is also recommended to mark the cookie with the `Secure` attribute if that is not already the case.
|
||||
Generated
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
# Migrate entirely to HTTPS to allow cookies to be set by same-site subresources
|
||||
|
||||
A cookie was not set by {PLACEHOLDER_origin} origin in {PLACEHOLDER_destination} context.
|
||||
Because this cookie would have been set across schemes on the same site, it was blocked.
|
||||
This behavior enhances the `SameSite` attribute’s protection of user data from request forgery by network attackers.
|
||||
|
||||
Resolve this issue by migrating your site (as defined by the eTLD+1) entirely to HTTPS.
|
||||
It is also recommended to mark the cookie with the `Secure` attribute if that is not already the case.
|
||||
Generated
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
# Migrate entirely to HTTPS to have cookies sent on same-site requests
|
||||
|
||||
A cookie was not sent to {PLACEHOLDER_destination} origin from {PLACEHOLDER_origin} context on a navigation.
|
||||
Because this cookie would have been sent across schemes on the same site, it was not sent.
|
||||
This behavior enhances the `SameSite` attribute’s protection of user data from request forgery by network attackers.
|
||||
|
||||
Resolve this issue by migrating your site (as defined by the eTLD+1) entirely to HTTPS.
|
||||
It is also recommended to mark the cookie with the `Secure` attribute if that is not already the case.
|
||||
Generated
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
# Mark cross-site cookies as Secure to allow them to be sent in cross-site requests
|
||||
|
||||
Cookies marked with `SameSite=None` must also be marked with `Secure` to get sent in cross-site requests.
|
||||
This behavior protects user data from being sent over an insecure connection.
|
||||
|
||||
Resolve this issue by updating the attributes of the cookie:
|
||||
* Specify `SameSite=None` and `Secure` if the cookie should be sent in cross-site requests. This enables third-party use.
|
||||
* Specify `SameSite=Strict` or `SameSite=Lax` if the cookie should not be sent in cross-site requests.
|
||||
Generated
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
# Mark cross-site cookies as Secure to allow setting them in cross-site contexts
|
||||
|
||||
Cookies marked with `SameSite=None` must also be marked with `Secure` to allow setting them in a cross-site context.
|
||||
This behavior protects user data from being sent over an insecure connection.
|
||||
|
||||
Resolve this issue by updating the attributes of the cookie:
|
||||
* Specify `SameSite=None` and `Secure` if the cookie is intended to be set in cross-site contexts. Note that only cookies sent over HTTPS may use the `Secure` attribute.
|
||||
* Specify `SameSite=Strict` or `SameSite=Lax` if the cookie should not be set by cross-site requests.
|
||||
Generated
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
# Mark cross-site cookies as Secure to allow them to be sent in cross-site requests
|
||||
|
||||
In a future version of the browser, cookies marked with `SameSite=None` must also be marked with `Secure` to get sent in cross-site requests.
|
||||
This behavior protects user data from being sent over an insecure connection.
|
||||
|
||||
Resolve this issue by updating the attributes of the cookie:
|
||||
* Specify `SameSite=None` and `Secure` if the cookie should be sent in cross-site requests. This enables third-party use.
|
||||
* Specify `SameSite=Strict` or `SameSite=Lax` if the cookie should not be sent in cross-site requests.
|
||||
Generated
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
# Mark cross-site cookies as Secure to allow setting them in cross-site contexts
|
||||
|
||||
In a future version of the browser, cookies marked with `SameSite=None` must also be marked with `Secure` to allow setting them in a cross-site context.
|
||||
This behavior protects user data from being sent over an insecure connection.
|
||||
|
||||
Resolve this issue by updating the attributes of the cookie:
|
||||
* Specify `SameSite=None` and `Secure` if the cookie is intended to be set in cross-site contexts. Note that only cookies sent over HTTPS may use the `Secure` attribute.
|
||||
* Specify `SameSite=Strict` or `SameSite=Lax` if the cookie should not be set by cross-site requests.
|
||||
Generated
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
# Indicate whether to send a cookie in a cross-site request by specifying its SameSite attribute
|
||||
|
||||
Because a cookie’s `SameSite` attribute was not set or is invalid, it defaults to `SameSite=Lax`,
|
||||
which will prevent the cookie from being sent in a cross-site request in a future version of the browser.
|
||||
This behavior protects user data from accidentally leaking to third parties and cross-site request forgery.
|
||||
|
||||
Resolve this issue by updating the attributes of the cookie:
|
||||
* Specify `SameSite=None` and `Secure` if the cookie should be sent in cross-site requests. This enables third-party use.
|
||||
* Specify `SameSite=Strict` or `SameSite=Lax` if the cookie should not be sent in cross-site requests.
|
||||
Generated
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
# Indicate whether a cookie is intended to be set in cross-site context by specifying its SameSite attribute
|
||||
|
||||
Because a cookie’s `SameSite` attribute was not set or is invalid, it defaults to `SameSite=Lax`,
|
||||
which will prevents the cookie from being set in a cross-site context in a future version of the browser.
|
||||
This behavior protects user data from accidentally leaking to third parties and cross-site request forgery.
|
||||
|
||||
Resolve this issue by updating the attributes of the cookie:
|
||||
* Specify `SameSite=None` and `Secure` if the cookie is intended to be set in cross-site contexts. Note that only cookies sent over HTTPS may use the `Secure` attribute.
|
||||
* Specify `SameSite=Strict` or `SameSite=Lax` if the cookie should not be set by cross-site requests.
|
||||
Generated
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
# Migrate entirely to HTTPS to continue having cookies sent to same-site subresources
|
||||
|
||||
A cookie is being sent to {PLACEHOLDER_destination} origin from {PLACEHOLDER_origin} context.
|
||||
Because this cookie is being sent across schemes on the same site, it will not be sent in a future version of Chrome.
|
||||
This behavior enhances the `SameSite` attribute’s protection of user data from request forgery by network attackers.
|
||||
|
||||
Resolve this issue by migrating your site (as defined by the eTLD+1) entirely to HTTPS.
|
||||
It is also recommended to mark the cookie with the `Secure` attribute if that is not already the case.
|
||||
Generated
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
# Migrate entirely to HTTPS to continue allowing cookies to be set by same-site subresources
|
||||
|
||||
A cookie is being set by {PLACEHOLDER_origin} origin in {PLACEHOLDER_destination} context.
|
||||
Because this cookie is being set across schemes on the same site, it will be blocked in a future version of Chrome.
|
||||
This behavior enhances the `SameSite` attribute’s protection of user data from request forgery by network attackers.
|
||||
|
||||
Resolve this issue by migrating your site (as defined by the eTLD+1) entirely to HTTPS.
|
||||
It is also recommended to mark the cookie with the `Secure` attribute if that is not already the case.
|
||||
Generated
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
# Migrate entirely to HTTPS to continue having cookies sent on same-site requests
|
||||
|
||||
A cookie is being sent to {PLACEHOLDER_destination} origin from {PLACEHOLDER_origin} context on a navigation.
|
||||
Because this cookie is being sent across schemes on the same site, it will not be sent in a future version of Chrome.
|
||||
This behavior enhances the `SameSite` attribute’s protection of user data from request forgery by network attackers.
|
||||
|
||||
Resolve this issue by migrating your site (as defined by the eTLD+1) entirely to HTTPS.
|
||||
It is also recommended to mark the cookie with the `Secure` attribute if that is not already the case.
|
||||
Generated
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
# Ensure that the attribution registration context is secure
|
||||
|
||||
This page tried to register a source or trigger using the Attribution Reporting
|
||||
API but failed because the page that initiated the registration was not secure.
|
||||
|
||||
The registration context must use HTTPS unless it is `localhost` or
|
||||
`127.0.0.1`.
|
||||
Generated
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
# Ensure that the `Attribution-Reporting-Info` header is valid
|
||||
|
||||
This page tried to register a source or trigger using the Attribution Reporting
|
||||
API but failed because an `Attribution-Reporting-Info` response header was
|
||||
invalid.
|
||||
Generated
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
# Ensure that the `Attribution-Reporting-Register-OS-Source` header is valid
|
||||
|
||||
This page tried to register an OS source using the Attribution Reporting API
|
||||
but failed because an `Attribution-Reporting-Register-OS-Source` response
|
||||
header was invalid.
|
||||
Generated
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
# Ensure that the `Attribution-Reporting-Register-OS-Trigger` header is valid
|
||||
|
||||
This page tried to register an OS trigger using the Attribution Reporting API
|
||||
but failed because an `Attribution-Reporting-Register-OS-Trigger` response
|
||||
header was invalid.
|
||||
Generated
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
# Ensure that the `Attribution-Reporting-Register-Source` header is valid
|
||||
|
||||
This page tried to register a source using the Attribution Reporting API but
|
||||
failed because an `Attribution-Reporting-Register-Source` response header was
|
||||
invalid.
|
||||
Generated
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
# Ensure that the `Attribution-Reporting-Register-Trigger` header is valid
|
||||
|
||||
This page tried to register a trigger using the Attribution Reporting API but
|
||||
failed because an `Attribution-Reporting-Register-Trigger` response header was
|
||||
invalid.
|
||||
Generated
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
# Ensure that multiple sources associated with the same navigation have the same attribution scopes
|
||||
|
||||
The page tried to register a source using Attribution Reporting API, but the
|
||||
source was rejected because a previous source associated with the same
|
||||
navigation and reporting origin used a different set of attribution scopes.
|
||||
Generated
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
# Ensure that navigation-source registrations are initiated by a user gesture
|
||||
|
||||
This page tried to register a navigation source using the Attribution Reporting
|
||||
API but failed because the navigation was not initiated by a user gesture.
|
||||
Compared to event sources, navigation sources can release more cross-site
|
||||
information, and are therefore subject to this additional privacy control.
|
||||
Generated
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
# OS attribution source expected but corresponding header not found
|
||||
|
||||
The page indicated, via the `Attribution-Reporting-Info` header, that it
|
||||
intended to register an OS source using the Attribution Reporting API, but the
|
||||
corresponding `Attribution-Reporting-Register-OS-Source` header was missing.
|
||||
Generated
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
# OS attribution trigger expected but corresponding header not found
|
||||
|
||||
The page indicated, via the `Attribution-Reporting-Info` header, that it
|
||||
intended to register an OS trigger using the Attribution Reporting API, but the
|
||||
corresponding `Attribution-Reporting-Register-OS-Trigger` header was missing.
|
||||
Generated
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
# Web attribution source expected but corresponding header not found
|
||||
|
||||
The page indicated, via the `Attribution-Reporting-Info` header, that it
|
||||
intended to register a web source using the Attribution Reporting API, but the
|
||||
corresponding `Attribution-Reporting-Register-Source` header was missing.
|
||||
Generated
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
# Web attribution trigger expected but corresponding header not found
|
||||
|
||||
The page indicated, via the `Attribution-Reporting-Info` header, that it
|
||||
intended to register a web trigger using the Attribution Reporting API, but the
|
||||
corresponding `Attribution-Reporting-Register-Trigger` header was missing.
|
||||
Generated
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
# No web or OS support for Attribution Reporting
|
||||
|
||||
The page tried to send an attributionsrc request, but there was neither web nor
|
||||
OS support for the Attribution Reporting API, so the request was skipped.
|
||||
Generated
Vendored
+18
@@ -0,0 +1,18 @@
|
||||
# An attribution OS source registration was ignored because the request was ineligible
|
||||
|
||||
This page tried to register an OS source using the Attribution Reporting API,
|
||||
but the request was ineligible to do so, so the OS source registration was
|
||||
ignored.
|
||||
|
||||
A request is eligible for OS source registration if it has all of the following:
|
||||
|
||||
- An `Attribution-Reporting-Eligible` header whose value is a structured
|
||||
dictionary that contains the key `navigation-source` or `event-source`
|
||||
- An `Attribution-Reporting-Support` header whose value is a structured
|
||||
dictionary that contains the key `os`
|
||||
|
||||
Otherwise, any `Attribution-Reporting-Register-OS-Source` response header will
|
||||
be ignored.
|
||||
|
||||
Additionally, a single HTTP redirect chain may register only all sources or all
|
||||
triggers, not a combination of both.
|
||||
Generated
Vendored
+19
@@ -0,0 +1,19 @@
|
||||
# An attribution OS trigger registration was ignored because the request was ineligible
|
||||
|
||||
This page tried to register an OS trigger using the Attribution Reporting API,
|
||||
but the request was ineligible to do so, so the OS trigger registration was
|
||||
ignored.
|
||||
|
||||
A request is eligible for OS trigger registration if it has all of the following:
|
||||
|
||||
- No `Attribution-Reporting-Eligible` header or an
|
||||
`Attribution-Reporting-Eligible` header whose value is a structured
|
||||
dictionary that contains the key `trigger`
|
||||
- An `Attribution-Reporting-Support` header whose value is a structured
|
||||
dictionary that contains the key `os`
|
||||
|
||||
Otherwise, any `Attribution-Reporting-Register-OS-Trigger` response header will
|
||||
be ignored.
|
||||
|
||||
Additionally, a single HTTP redirect chain may register only all sources or all
|
||||
triggers, not a combination of both.
|
||||
Generated
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
# The Attribution Reporting API can’t be used because Permissions Policy has been disabled
|
||||
|
||||
This page tried to use the Attribution Reporting API but failed because the
|
||||
`attribution-reporting` Permission Policy was explicitly disabled.
|
||||
|
||||
This API is currently enabled by default for top-level and cross-origin frames,
|
||||
but it is still possible for frames to have the permission disabled by their
|
||||
parent, e.g. with `<iframe src="…" allow="attribution-reporting 'none'">`.
|
||||
Generated
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
# Ensure that attribution responses contain either source or trigger, not both
|
||||
|
||||
This page tried to register a source and a trigger in the same HTTP response
|
||||
using the Attribution Reporting API, which is prohibited.
|
||||
|
||||
The corresponding request was eligible to register either a source or a
|
||||
trigger, but the response may only set either the
|
||||
`Attribution-Reporting-Register-Source` header or the
|
||||
`Attribution-Reporting-Register-Trigger` header, not both.
|
||||
Generated
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
# An attribution source registration was ignored because the request was ineligible
|
||||
|
||||
This page tried to register a source using the Attribution Reporting API, but
|
||||
the request was ineligible to do so, so the source registration was ignored.
|
||||
|
||||
A request is eligible for source registration if it has an
|
||||
`Attribution-Reporting-Eligible` header whose value is a structured dictionary
|
||||
that contains the key `navigation-source` or `event-source`. If the header is
|
||||
absent or does not contain one of those keys, any
|
||||
`Attribution-Reporting-Register-Source` response header will be ignored.
|
||||
|
||||
Additionally, a single HTTP redirect chain may register only all sources or all
|
||||
triggers, not a combination of both.
|
||||
Generated
Vendored
+12
@@ -0,0 +1,12 @@
|
||||
# An attribution trigger registration was ignored because the request was ineligible
|
||||
|
||||
This page tried to register a trigger using the Attribution Reporting API, but
|
||||
the request was ineligible to do so, so the trigger registration was ignored.
|
||||
|
||||
A request is eligible for trigger registration if it has an
|
||||
`Attribution-Reporting-Eligible` header whose value is a structured dictionary
|
||||
that contains the key `trigger`, or if the header is absent. Otherwise, any
|
||||
`Attribution-Reporting-Register-Trigger` response header will be ignored.
|
||||
|
||||
Additionally, a single HTTP redirect chain may register only all sources or all
|
||||
triggers, not a combination of both.
|
||||
Generated
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
# Ensure that attribution reporting origins are trustworthy
|
||||
|
||||
This page tried to register a source or trigger using the Attribution Reporting
|
||||
API but failed because the reporting origin was not potentially trustworthy.
|
||||
|
||||
The reporting origin is typically the server that sets the
|
||||
`Attribution-Reporting-Register-Source` or
|
||||
`Attribution-Reporting-Register-Trigger` header.
|
||||
|
||||
The reporting origin must use HTTPS unless it is `localhost` or `127.0.0.1`.
|
||||
Generated
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
# Ensure that attribution responses contain either web or OS headers, not both
|
||||
|
||||
This page included web and OS Attribution Reporting API headers in the same
|
||||
HTTP response, which is prohibited.
|
||||
|
||||
The response may set at most one of the following headers:
|
||||
|
||||
- `Attribution-Reporting-Register-OS-Source`
|
||||
- `Attribution-Reporting-Register-OS-Trigger`
|
||||
- `Attribution-Reporting-Register-Source`
|
||||
- `Attribution-Reporting-Register-Trigger`
|
||||
Generated
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
# Chrome may soon delete state for intermediate websites in a recent navigation chain
|
||||
|
||||
In a recent navigation chain, one or more websites without prior user interaction were visited. If these websites don't get such an interaction soon, Chrome will delete their state.
|
||||
Generated
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
# Client Hint meta tag contained invalid origin
|
||||
|
||||
Items in the delegate-ch meta tag allow list must be valid origins.
|
||||
No special values (e.g. self, none, and *) are permitted.
|
||||
Generated
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
# Client Hint meta tag modified by javascript
|
||||
|
||||
Only delegate-ch meta tags in the original HTML sent from the server
|
||||
are respected. Any injected via javascript (or other means) are ignored.
|
||||
Generated
Vendored
+12
@@ -0,0 +1,12 @@
|
||||
# An item in the `Connection-Allowlist` header is invalid.
|
||||
|
||||
Each item in the `Connection-Allowlist`'s header's [Inner List](sfInnerList)
|
||||
must be a [String](sfString) representing a [URL Pattern](urlPatternSpec), or
|
||||
the [Token](sfToken) `response-origin`.
|
||||
|
||||
For example, the following header allows connections to (only)
|
||||
`https://example.com/` and the origin from which the response was delivered:
|
||||
|
||||
```
|
||||
Connection-Allowlist: ("https://example.com" response-origin)
|
||||
```
|
||||
Generated
Vendored
+12
@@ -0,0 +1,12 @@
|
||||
# The `Connection-Allowlist` header is not formatted as a Structured Field List.
|
||||
|
||||
Responses' `Connection-Allowlist` header should be formatted as a [List](sfList)
|
||||
containing a single [Inner List](sfInnerList) that declares the allowed set of
|
||||
[URL Patterns](urlPatternSpec) for a given context.
|
||||
|
||||
For example, the following header allows connections to (only)
|
||||
`https://example.com/`:
|
||||
|
||||
```
|
||||
Connection-Allowlist: ("https://example.com")
|
||||
```
|
||||
Generated
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
# An item in the `Connection-Allowlist` header is not a valid URL pattern.
|
||||
|
||||
Each item in the `Connection-Allowlist` header must be a valid
|
||||
[URL Pattern](urlPatternSpec) that can be used to match against the request's
|
||||
origin.
|
||||
|
||||
Note that our current implementation does not allow regular expressions to be
|
||||
used as part of the pattern.
|
||||
Generated
Vendored
+12
@@ -0,0 +1,12 @@
|
||||
# An item in the `Connection-Allowlist` header is not an Inner List.
|
||||
|
||||
Responses' `Connection-Allowlist` header should be formatted as a [List](sfList)
|
||||
containing a single [Inner List](sfInnerList) that declares the allowed set of
|
||||
[URL Patterns](urlPatternSpec) for a given context.
|
||||
|
||||
For example, the following header allows connections to (only)
|
||||
`https://example.com/`:
|
||||
|
||||
```
|
||||
Connection-Allowlist: ("https://example.com")
|
||||
```
|
||||
Generated
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
# `Connection-Allowlist` has multiple items.
|
||||
|
||||
Responses' `Connection-Allowlist` header should be formatted as a [List](sfList)
|
||||
containing a single [Inner List](sfInnerList) that declares the allowed set of
|
||||
[URL Patterns](urlPatternSpec) for a given context. This response was a
|
||||
[List](sfList) containing more than one item: all but the first have been
|
||||
ignored.
|
||||
Generated
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
# The `report-to` parameter in the `Connection-Allowlist` header is not a token.
|
||||
|
||||
If provided, the `report-to` parameter must be a [Token](sfToken)
|
||||
naming a reporting endpoint.
|
||||
|
||||
For example:
|
||||
|
||||
```
|
||||
Connection-Allowlist: ("https://example.com");report-to=endpoint
|
||||
```
|
||||
Generated
Vendored
+12
@@ -0,0 +1,12 @@
|
||||
# Cookie is blocked due to a cross-site redirect chain
|
||||
|
||||
The cookie was blocked because the URL redirect chain was not fully same-site,
|
||||
meaning the final request was treated as a cross-site request.
|
||||
Like other cross-site requests, this blocks cookies with `SameSite=Lax` or
|
||||
`SameSite=Strict`.
|
||||
|
||||
For example: If site A redirects to site B which then redirects back to site A,
|
||||
the final request to site A will be a cross-site request.
|
||||
|
||||
If this behavior is causing breakage, please file a bug report with the link
|
||||
below.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user