devtools-fleet-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +9 -0
- package/.claude-plugin/plugin.json +21 -0
- package/CHANGELOG.md +14 -0
- package/LICENSE +21 -0
- package/README.md +364 -0
- package/bin/cli.js +246 -0
- package/package.json +61 -0
- package/skills/devtools-fleet/SKILL.md +41 -0
- package/src/index.js +7 -0
- package/src/lib/cdp.js +105 -0
- package/src/lib/chrome.js +222 -0
- package/src/lib/config.js +103 -0
- package/src/lib/fs-utils.js +31 -0
- package/src/lib/guard.js +93 -0
- package/src/lib/lock.js +54 -0
- package/src/lib/lockdown.js +38 -0
- package/src/lib/login.js +177 -0
- package/src/lib/origins.js +104 -0
- package/src/lib/policy.js +82 -0
- package/src/lib/process-tree.js +109 -0
- package/src/lib/reaper.js +184 -0
- package/src/lib/registry.js +89 -0
- package/src/lib/session.js +466 -0
- package/src/lib/states.js +135 -0
- package/src/lib/storage.js +137 -0
- package/src/lib/upstream.js +98 -0
- package/src/reaper-main.js +10 -0
- package/src/server.js +85 -0
- package/src/tools/fleet-tools.js +116 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pluginslab-devtools-fleet",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "devtools-fleet: chrome-devtools-mcp for ten agents at once",
|
|
5
|
+
"owner": { "name": "PluginsLab" },
|
|
6
|
+
"plugins": [
|
|
7
|
+
{ "name": "devtools-fleet", "source": "./", "description": "One isolated Chrome per agent, running Google's chrome-devtools-mcp tools, with saved login states and origin allowlists" }
|
|
8
|
+
]
|
|
9
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "devtools-fleet",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "One isolated Chrome per agent, running Google's chrome-devtools-mcp tools, with saved login states and origin allowlists",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "Marcel Schmitz",
|
|
7
|
+
"url": "https://github.com/pluginslab"
|
|
8
|
+
},
|
|
9
|
+
"homepage": "https://github.com/pluginslab/devtools-fleet-mcp",
|
|
10
|
+
"repository": "https://github.com/pluginslab/devtools-fleet-mcp",
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"mcpServers": {
|
|
13
|
+
"devtools-fleet": {
|
|
14
|
+
"command": "npx",
|
|
15
|
+
"args": [
|
|
16
|
+
"-y",
|
|
17
|
+
"devtools-fleet-mcp"
|
|
18
|
+
]
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.1.0] - 2026-10-01
|
|
4
|
+
|
|
5
|
+
First version, built against `SCOPE.md`.
|
|
6
|
+
|
|
7
|
+
- One isolated Chrome per agent session, running chrome-devtools-mcp's tools unchanged (`--browserUrl` passthrough).
|
|
8
|
+
- Browsers outlive the MCP connection and are re-adopted by the same client session; a background reaper closes the rest.
|
|
9
|
+
- Saved login states (cookies + localStorage, Playwright storageState format) via `devtools-fleet login`; restore never contacts the site.
|
|
10
|
+
- Per-state origin allowlists, enforced before the call and inside the browser. Strict mode locks the whole network in Chrome itself (dead proxy + bypass list): fetch, WebSockets, WebRTC, workers, also while no agent is connected.
|
|
11
|
+
- No `file:` URLs or file arguments into `~/.devtools-fleet`; no extra browser contexts under an allowlist; client MCP roots passed through to chrome-devtools-mcp.
|
|
12
|
+
- Recovery from Chrome or chrome-devtools-mcp crashes.
|
|
13
|
+
- CLI: `ls`, `show` (live DevTools inspector), `kill`, `gc`, `login`, `states`, `state show|rm|import`, `doctor`, `config`.
|
|
14
|
+
- Claude Code plugin manifest and skill.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 PluginsLab
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
<!-- TODO: banner at assets/banner.jpg, same style as the WordPress trio, then restore:
|
|
2
|
+
<p align="center">
|
|
3
|
+
<img src="assets/banner.jpg" alt="devtools-fleet-mcp banner" />
|
|
4
|
+
</p>
|
|
5
|
+
-->
|
|
6
|
+
|
|
7
|
+
**Give every one of your AI agents its own Chrome, running Google's full DevTools toolset, already logged in, and fenced to the sites it should touch.**
|
|
8
|
+
|
|
9
|
+
## Why This Exists
|
|
10
|
+
|
|
11
|
+
Google's [chrome-devtools-mcp](https://github.com/ChromeDevTools/chrome-devtools-mcp) is the best browser toolset an agent can have when the job is *debugging* a web app: console, network, performance traces, Lighthouse, heap snapshots. It stops working the moment you run more than one agent:
|
|
12
|
+
|
|
13
|
+
- Agent two starts → `The browser is already running for …`, because every server shares one Chrome profile
|
|
14
|
+
- Switch on `--isolated` → every agent starts logged out and fights the wp-admin, staging or SSO login again, every time
|
|
15
|
+
- The MCP connection drops → the browser, its tabs and its login die with it
|
|
16
|
+
- Ten agents are running → ten invisible headless Chromes. Nothing lists them, nothing cleans up after a crash
|
|
17
|
+
|
|
18
|
+
Other multi-agent browser tools solve pooling and logins, but on Playwright or through a browser extension. None of them carries Google's DevTools toolset.
|
|
19
|
+
|
|
20
|
+
## The Solution
|
|
21
|
+
|
|
22
|
+
`devtools-fleet-mcp` sits in front of chrome-devtools-mcp and manages the browsers. It doesn't reimplement a single browser tool: Google's tools are passed through unchanged, under the same names.
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
agent session A ─stdio─▶ devtools-fleet-mcp ─▶ chrome-devtools-mcp --browserUrl ─CDP─▶ Chrome A (own profile)
|
|
26
|
+
agent session B ─stdio─▶ devtools-fleet-mcp ─▶ chrome-devtools-mcp --browserUrl ─CDP─▶ Chrome B (own profile)
|
|
27
|
+
│
|
|
28
|
+
└──▶ ~/.devtools-fleet/ ◀── devtools-fleet CLI: ls, show, kill, gc, login, states
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
1. **One browser per agent, zero config.** The first browser tool call launches that session's own Chrome. Up to 10 at once by default.
|
|
32
|
+
2. **Browsers outlive the connection.** An agent that reconnects inside the same session gets its browser back, tabs open. A small background reaper closes the browsers nobody comes back for.
|
|
33
|
+
3. **Saved logins.** You log in once, by hand, in a real window (2FA, SSO and captchas are fine). Any number of agents can then start from that login in parallel, each in its own browser.
|
|
34
|
+
4. **Origin allowlists.** A browser started from a saved login can only reach that login's origins. Typed URLs, link clicks, redirects and popups elsewhere are blocked. Strict mode locks the whole network in Chrome itself.
|
|
35
|
+
5. **Recovery.** If Chrome or chrome-devtools-mcp dies, the next tool call brings it back and tells the agent what happened.
|
|
36
|
+
6. **Watch any agent live.** `devtools-fleet show <id>` opens Chrome's DevTools inspector on that agent's page without disturbing it.
|
|
37
|
+
|
|
38
|
+
## Quick Start
|
|
39
|
+
|
|
40
|
+
Requires **Node.js 22.12+** and **Google Chrome** (or point `chromePath` at another Chromium build).
|
|
41
|
+
|
|
42
|
+
### Install as a Claude Code plugin (recommended)
|
|
43
|
+
|
|
44
|
+
The plugin installs the MCP server **and** a skill that teaches agents the fleet workflow: check `state_list`, start from a saved login, respect the allowlist, recover after a crash.
|
|
45
|
+
|
|
46
|
+
Inside Claude Code:
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
/plugin marketplace add pluginslab/devtools-fleet-mcp
|
|
50
|
+
/plugin install devtools-fleet@pluginslab-devtools-fleet
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Or from your terminal:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
claude plugin marketplace add pluginslab/devtools-fleet-mcp
|
|
57
|
+
claude plugin install devtools-fleet@pluginslab-devtools-fleet
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Restart Claude Code and check that `devtools-fleet` shows up under `/mcp`.
|
|
61
|
+
|
|
62
|
+
**Already using chrome-devtools-mcp?** Remove it (`claude mcp remove chrome-devtools`, or disable its plugin). devtools-fleet exposes the same tools under the same names, and two servers with identical tool names confuse agents.
|
|
63
|
+
|
|
64
|
+
**Updating:** `/plugin marketplace update pluginslab-devtools-fleet`, then restart. The server runs through `npx`, so it picks up new npm releases on its own.
|
|
65
|
+
|
|
66
|
+
### Install the CLI
|
|
67
|
+
|
|
68
|
+
The plugin gives agents the MCP tools. Saving logins, watching agents and cleaning up are done by **you**, from the CLI:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
npm install -g devtools-fleet-mcp
|
|
72
|
+
devtools-fleet doctor
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
No global install? Every command also works as `npx -p devtools-fleet-mcp devtools-fleet <command>`.
|
|
76
|
+
|
|
77
|
+
### Other MCP clients
|
|
78
|
+
|
|
79
|
+
Claude Code without the plugin:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
claude mcp add devtools-fleet -- npx -y devtools-fleet-mcp
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Any other client (`.mcp.json`, Cursor, Claude Desktop, …):
|
|
86
|
+
|
|
87
|
+
```json
|
|
88
|
+
{
|
|
89
|
+
"mcpServers": {
|
|
90
|
+
"devtools-fleet": {
|
|
91
|
+
"command": "npx",
|
|
92
|
+
"args": ["-y", "devtools-fleet-mcp"]
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### First test
|
|
99
|
+
|
|
100
|
+
Open two Claude Code sessions and ask both:
|
|
101
|
+
|
|
102
|
+
> "Open http://localhost:3000 and check the console for errors."
|
|
103
|
+
|
|
104
|
+
Each gets its own Chrome; neither hits the profile lock. Then, in a terminal:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
devtools-fleet ls
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### First saved login
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
devtools-fleet login staging-admin https://staging.example.com/wp-login.php
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Log in in the window that opens, come back to the terminal, press Enter, confirm the allowlist. Then ask an agent:
|
|
117
|
+
|
|
118
|
+
> "Using the staging-admin state, open the Plugins page and run a Lighthouse audit on it."
|
|
119
|
+
|
|
120
|
+
The agent calls `state_list`, then `browser_start({ state: "staging-admin" })`, and starts already logged in.
|
|
121
|
+
|
|
122
|
+
## MCP Tools
|
|
123
|
+
|
|
124
|
+
devtools-fleet adds six tools. Everything else (`navigate_page`, `take_snapshot`, `click`, `evaluate_script`, `list_network_requests`, `performance_start_trace`, `lighthouse_audit`, …) is chrome-devtools-mcp's, with the same names and arguments, so its documentation and skills apply as-is.
|
|
125
|
+
|
|
126
|
+
### `browser_start`
|
|
127
|
+
|
|
128
|
+
Starts this session's browser, or picks it up again after a reconnect. Optional: any browser tool starts one with defaults.
|
|
129
|
+
|
|
130
|
+
```
|
|
131
|
+
→ browser_start({ state: "staging-admin", url: "https://staging.example.com/wp-admin/" })
|
|
132
|
+
← Started: browser 3f9c2a, state "staging-admin", headless, stable
|
|
133
|
+
allowed origins: https://staging.example.com
|
|
134
|
+
tabs: https://staging.example.com/wp-admin/
|
|
135
|
+
uptime 2s
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
| Parameter | Type | Description |
|
|
139
|
+
|---|---|---|
|
|
140
|
+
| `state` | string | Saved login to start from |
|
|
141
|
+
| `headless` | boolean | Default from config (`true`) |
|
|
142
|
+
| `viewport` | string | e.g. `"1280x720"` |
|
|
143
|
+
| `channel` | string | `stable`, `beta`, `dev` or `canary` |
|
|
144
|
+
| `url` | string | Open this in the first tab |
|
|
145
|
+
| `allowedOrigins` | string[] | Without a state: restrict this browser voluntarily |
|
|
146
|
+
|
|
147
|
+
### `browser_status`
|
|
148
|
+
|
|
149
|
+
This session's browser: id, state, allowlist, mode, open tabs, uptime, blocked requests.
|
|
150
|
+
|
|
151
|
+
```
|
|
152
|
+
→ browser_status()
|
|
153
|
+
← browser 3f9c2a, state "staging-admin", headless, stable
|
|
154
|
+
allowed origins: https://staging.example.com
|
|
155
|
+
tabs: https://staging.example.com/wp-admin/plugins.php
|
|
156
|
+
uptime 312s, 2 request(s) blocked so far
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### `browser_restart`
|
|
160
|
+
|
|
161
|
+
Relaunches keeping cookies, storage and tabs. `{ headless: false }` gives a visible window, for example so a person can solve a captcha.
|
|
162
|
+
|
|
163
|
+
```
|
|
164
|
+
→ browser_restart({ headless: false })
|
|
165
|
+
← Restarted with a visible window; reopened 2 tab(s). Page ids have changed: call list_pages.
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### `browser_stop`
|
|
169
|
+
|
|
170
|
+
Closes the browser and deletes its temporary profile.
|
|
171
|
+
|
|
172
|
+
```
|
|
173
|
+
→ browser_stop()
|
|
174
|
+
← Closed browser 3f9c2a.
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### `state_save`
|
|
178
|
+
|
|
179
|
+
Saves the current browser's login under a name. Parameters: `name`, `allowedOrigins` (defaults to the open tabs' origins), `strict`, `overwrite`. An agent can only narrow its own allowlist, never widen it, and never overwrites a state a person created.
|
|
180
|
+
|
|
181
|
+
```
|
|
182
|
+
→ state_save({ name: "local-shop" })
|
|
183
|
+
← Saved state "local-shop": 4 cookie(s), localStorage for 1 origin(s). Allowed: http://localhost:3000.
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### `state_list`
|
|
187
|
+
|
|
188
|
+
Saved states with origins, cookie counts and expiry. Never values.
|
|
189
|
+
|
|
190
|
+
```
|
|
191
|
+
→ state_list()
|
|
192
|
+
← staging-admin: https://staging.example.com | 6 cookies (0 expired) | saved 2026-10-01T09:12:44.512Z by cli
|
|
193
|
+
local-shop: http://localhost:3000 | 4 cookies (0 expired) | saved 2026-10-01T10:03:10.087Z by agent
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## CLI Reference
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
# See every fleet browser: id, status, session, state, mode, current tab
|
|
200
|
+
devtools-fleet ls
|
|
201
|
+
devtools-fleet ls --json
|
|
202
|
+
|
|
203
|
+
# Watch an agent's browser live in Chrome's DevTools inspector (doesn't disturb it)
|
|
204
|
+
devtools-fleet show 3f9c2a
|
|
205
|
+
devtools-fleet show 3f9c2a --tab 2 --print
|
|
206
|
+
|
|
207
|
+
# Close browsers
|
|
208
|
+
devtools-fleet kill 3f9c2a
|
|
209
|
+
devtools-fleet kill --all
|
|
210
|
+
|
|
211
|
+
# Close browsers whose session is gone; --detached also closes ones waiting for a reconnect
|
|
212
|
+
devtools-fleet gc
|
|
213
|
+
devtools-fleet gc --detached
|
|
214
|
+
|
|
215
|
+
# Log in by hand and save a state
|
|
216
|
+
devtools-fleet login staging-admin https://staging.example.com/wp-login.php
|
|
217
|
+
devtools-fleet login shop http://localhost:3000/login --allow https://cdn.example.com --strict
|
|
218
|
+
|
|
219
|
+
# Manage states (never prints cookie or storage values)
|
|
220
|
+
devtools-fleet states
|
|
221
|
+
devtools-fleet state show staging-admin
|
|
222
|
+
devtools-fleet state rm staging-admin
|
|
223
|
+
devtools-fleet state import shop ./storageState.json --allow http://localhost:3000
|
|
224
|
+
|
|
225
|
+
# Check Node, Chrome, permissions and config; print effective config
|
|
226
|
+
devtools-fleet doctor
|
|
227
|
+
devtools-fleet config
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Browser statuses: `active` (an agent is connected), `detached` (the connection dropped; waiting for that session to reconnect), `orphan` (the session is gone; closed on the next cleanup), `starting`.
|
|
231
|
+
|
|
232
|
+
## How It Works
|
|
233
|
+
|
|
234
|
+
### Process Management
|
|
235
|
+
|
|
236
|
+
On the first browser tool call, devtools-fleet:
|
|
237
|
+
|
|
238
|
+
1. **Checks the cap** under a machine-wide lock (`maxBrowsers`, default 10). A full fleet gets a clear error naming the running sessions and `devtools-fleet gc`
|
|
239
|
+
2. **Launches Chrome** itself, on a port it picks, with its own temporary profile
|
|
240
|
+
3. **Restores the state**, if one was asked for: cookies over CDP, `localStorage` through a throwaway tab whose request devtools-fleet answers itself, so the site is never contacted
|
|
241
|
+
4. **Spawns chrome-devtools-mcp** from its own pinned `node_modules` with `--browserUrl`, and re-exports its tool list unchanged
|
|
242
|
+
5. **Registers the browser** in `~/.devtools-fleet/browsers/<id>.json`
|
|
243
|
+
|
|
244
|
+
When the connection drops, Chrome keeps running. When the same client session reconnects, it finds its browser in the registry and re-adopts it, tabs intact. A reaper process (one per machine, started on demand, gone when idle) closes orphans at once and detached browsers after `orphanTimeoutMinutes`.
|
|
245
|
+
|
|
246
|
+
### How Sessions Are Recognised
|
|
247
|
+
|
|
248
|
+
MCP clients start servers through wrappers (`npx`, `npm exec`, `node`, shells) and restart that whole chain on reconnect, while the client session itself (the `claude` process, an editor's extension host) keeps running. devtools-fleet walks up the process tree past the wrappers to that session process. The same session reconnecting finds the same process and gets its browser back; a second session in the same directory finds a different one and never shares.
|
|
249
|
+
|
|
250
|
+
`DEVTOOLS_FLEET_SESSION=<label>` replaces the lookup with a fixed label, useful in CI or containers.
|
|
251
|
+
|
|
252
|
+
### Saved Logins
|
|
253
|
+
|
|
254
|
+
`devtools-fleet login` opens a real Chrome window. You log in, come back to the terminal, press Enter. devtools-fleet proposes an allowlist of where you started and where you ended up. Origins you only passed through, such as an SSO provider, are listed but left out: saving their cookies would hand agents your whole SSO session. Add one with `--allow` if the app really needs it.
|
|
255
|
+
|
|
256
|
+
What gets saved: cookies and `localStorage` for the allowed origins, in [Playwright's `storageState` format](https://playwright.dev/docs/auth). Playwright loads these files directly, and `state import` takes Playwright or agent-browser files the other way.
|
|
257
|
+
|
|
258
|
+
Rules that keep states safe to hand to an agent:
|
|
259
|
+
|
|
260
|
+
- States are only written outside git work trees, with mode `0600` in a `0700` directory.
|
|
261
|
+
- No listing, CLI or MCP, ever shows cookie or storage values.
|
|
262
|
+
- A state is always bound to an allowlist. There is no "use these cookies anywhere" mode.
|
|
263
|
+
|
|
264
|
+
### The Allowlist
|
|
265
|
+
|
|
266
|
+
Allowlist entries are full origins:
|
|
267
|
+
|
|
268
|
+
| Entry | Matches |
|
|
269
|
+
|---|---|
|
|
270
|
+
| `https://app.example.com` | exactly that scheme, host and port |
|
|
271
|
+
| `http://127.0.0.1:3000` | ports matter: `:3001` is a different origin |
|
|
272
|
+
| `https://*.example.com` | any subdomain, not `example.com` itself |
|
|
273
|
+
|
|
274
|
+
Two layers enforce it:
|
|
275
|
+
|
|
276
|
+
1. **Argument check.** `new_page` and `navigate_page` are checked before anything reaches the browser; the agent gets a plain error.
|
|
277
|
+
2. **Navigation interception.** devtools-fleet's own CDP connection intercepts navigations in every tab, popup and frame and fails the ones outside the list. A tab that can't be put under interception is closed rather than left unguarded.
|
|
278
|
+
|
|
279
|
+
A browser with an allowlist also refuses `file:` URLs and extra browser contexts (`new_page`'s `isolatedContext`), since pages there would sit outside the guard.
|
|
280
|
+
|
|
281
|
+
**Strict mode** (`login --strict`) is enforced by Chrome itself: the browser launches with a proxy that goes nowhere, and only the allowed origins bypass it. Every other request fails in Chrome's network stack: `fetch()`, XHR, beacons, workers, WebSockets, QUIC. WebRTC is pinned to the dead proxy and DNS prefetching is off. The lock holds even while no agent is connected. It's off by default because it also blocks CDNs you haven't listed. At that layer an entry means host and port, so `http` and `https` on the same port aren't told apart there; the navigation guard still tells them apart.
|
|
282
|
+
|
|
283
|
+
What the allowlist does **not** do:
|
|
284
|
+
|
|
285
|
+
- It limits *where* the agent goes, not what it does there. On an allowed origin, `evaluate_script` can read anything the page can, including non-httpOnly cookies. Treat a saved login like handing someone your session.
|
|
286
|
+
- Without strict mode, only navigations are guarded, and only while an agent is connected. A detached browser can still be navigated away by a script already running in its pages.
|
|
287
|
+
|
|
288
|
+
### Files and Paths
|
|
289
|
+
|
|
290
|
+
chrome-devtools-mcp only reads and writes files (screenshots, traces, uploads) inside the client's workspace roots and the temp directory. devtools-fleet passes your client's roots through, so `take_screenshot({ filePath: "<project>/shot.png" })` works as usual. Nothing may point into `~/.devtools-fleet`: not a `filePath`, not an `upload_file`, not a `file:` URL, symlinks included.
|
|
291
|
+
|
|
292
|
+
### Data Storage
|
|
293
|
+
|
|
294
|
+
```
|
|
295
|
+
~/.devtools-fleet/
|
|
296
|
+
config.json # Optional configuration (see below)
|
|
297
|
+
browsers/
|
|
298
|
+
<id>.json # One registry entry per running browser
|
|
299
|
+
profiles/ # Temporary Chrome profiles, deleted on close
|
|
300
|
+
states/ # Saved logins (0700 dir, 0600 files)
|
|
301
|
+
<name>.json
|
|
302
|
+
locks/ # mkdir locks for the cap check and adoption
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
`DEVTOOLS_FLEET_HOME` moves the whole directory.
|
|
306
|
+
|
|
307
|
+
## Configuration
|
|
308
|
+
|
|
309
|
+
`~/.devtools-fleet/config.json`, every key optional. Environment variables override the file.
|
|
310
|
+
|
|
311
|
+
| Key | Env | Default | |
|
|
312
|
+
|---|---|---|---|
|
|
313
|
+
| `headless` | `DEVTOOLS_FLEET_HEADLESS` | `true` | |
|
|
314
|
+
| `viewport` | `DEVTOOLS_FLEET_VIEWPORT` | none | e.g. `"1280x720"` |
|
|
315
|
+
| `channel` | `DEVTOOLS_FLEET_CHANNEL` | `stable` | |
|
|
316
|
+
| `chromePath` | `DEVTOOLS_FLEET_CHROME_PATH` | detected | |
|
|
317
|
+
| `maxBrowsers` | `DEVTOOLS_FLEET_MAX_BROWSERS` | `10` | across the machine |
|
|
318
|
+
| `orphanTimeoutMinutes` | `DEVTOOLS_FLEET_ORPHAN_TIMEOUT_MINUTES` | `15` | how long a dropped connection's browser waits for a reconnect |
|
|
319
|
+
| `launchTimeoutSeconds` | `DEVTOOLS_FLEET_LAUNCH_TIMEOUT_SECONDS` | `30` | |
|
|
320
|
+
| `upstreamArgs` | | `[]` | extra chrome-devtools-mcp flags |
|
|
321
|
+
| `chromeArgs` | | `[]` | extra Chrome flags, e.g. `["--no-sandbox"]` in containers |
|
|
322
|
+
|
|
323
|
+
chrome-devtools-mcp collects usage statistics by default and may send performance trace URLs to Google's CrUX API. devtools-fleet keeps its defaults. Opt out with `"upstreamArgs": ["--no-usage-statistics", "--no-performance-crux"]`.
|
|
324
|
+
|
|
325
|
+
## Limits
|
|
326
|
+
|
|
327
|
+
- **Tested on macOS and Linux.** Windows should work apart from reconnect re-adoption, but is untested.
|
|
328
|
+
- **Saved logins cover cookies and `localStorage`.** Not IndexedDB, `sessionStorage` or service worker caches.
|
|
329
|
+
- **Sites with device-bound sessions** (Device Bound Session Credentials) won't accept copied cookies.
|
|
330
|
+
- **Google accounts are not supported.** Google blocks automated sign-in and binds sessions to devices.
|
|
331
|
+
- **Headless can't switch to a window in place.** `browser_restart` relaunches, and the pages reload.
|
|
332
|
+
- **The debugging port is local-only but not authenticated,** the same as chrome-devtools-mcp and every CDP tool: other processes running as your user can drive these browsers.
|
|
333
|
+
|
|
334
|
+
## Companion Tools
|
|
335
|
+
|
|
336
|
+
devtools-fleet is the *look* step for the [PluginsLab](https://github.com/pluginslab) WordPress MCPs:
|
|
337
|
+
|
|
338
|
+
| MCP | Purpose |
|
|
339
|
+
|-----|---------|
|
|
340
|
+
| [wp-devdocs-mcp](https://github.com/pluginslab/wp-devdocs-mcp) | Verified hooks/filters/APIs for writing plugin **code** |
|
|
341
|
+
| [wp-blockmarkup-mcp](https://github.com/pluginslab/wp-blockmarkup-mcp) | Verified block schemas for generating **content** |
|
|
342
|
+
| [wp-playground-mcp](https://github.com/pluginslab/wp-playground-mcp) | Ephemeral WordPress instances for **testing** |
|
|
343
|
+
| **devtools-fleet-mcp** (this) | One DevTools-equipped Chrome per agent for **looking** at the result |
|
|
344
|
+
|
|
345
|
+
Together: **author → validate → test → look**.
|
|
346
|
+
|
|
347
|
+
## Requirements
|
|
348
|
+
|
|
349
|
+
- **Node.js 22.12+**
|
|
350
|
+
- **Google Chrome** (stable, beta, dev or canary), or any Chromium via `chromePath`
|
|
351
|
+
- macOS or Linux
|
|
352
|
+
|
|
353
|
+
## Development
|
|
354
|
+
|
|
355
|
+
```bash
|
|
356
|
+
npm install
|
|
357
|
+
npm test # unit tests, no browser
|
|
358
|
+
npm run test:integration # launches real Chrome; ~1 minute
|
|
359
|
+
npm run lint
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
## License
|
|
363
|
+
|
|
364
|
+
MIT
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import { statSync } from 'node:fs';
|
|
6
|
+
import { PATHS, loadConfig } from '../src/lib/config.js';
|
|
7
|
+
import { listEntries, readEntry, removeEntry, classify } from '../src/lib/registry.js';
|
|
8
|
+
import { isChromeAlive, listPages, inspectorUrl, resolveChromePath } from '../src/lib/chrome.js';
|
|
9
|
+
import { reapOnce, closeEntryBrowser } from '../src/lib/reaper.js';
|
|
10
|
+
import { listStates, readState, summarizeState, deleteState, importState } from '../src/lib/states.js';
|
|
11
|
+
import { login } from '../src/lib/login.js';
|
|
12
|
+
import { UPSTREAM_VERSION } from '../src/lib/upstream.js';
|
|
13
|
+
import { withLock } from '../src/lib/lock.js';
|
|
14
|
+
|
|
15
|
+
const { version } = createRequire(import.meta.url)('../package.json');
|
|
16
|
+
const program = new Command();
|
|
17
|
+
|
|
18
|
+
program
|
|
19
|
+
.name('devtools-fleet')
|
|
20
|
+
.description('See and manage the Chrome browsers devtools-fleet-mcp runs for your agents, and the login states they use.')
|
|
21
|
+
.version(`${version} (chrome-devtools-mcp ${UPSTREAM_VERSION})`);
|
|
22
|
+
|
|
23
|
+
// ------------------------------------------------------------------ browsers
|
|
24
|
+
|
|
25
|
+
program
|
|
26
|
+
.command('ls')
|
|
27
|
+
.description('List every fleet browser')
|
|
28
|
+
.option('--json', 'machine-readable output')
|
|
29
|
+
.action(async ({ json }) => {
|
|
30
|
+
const rows = [];
|
|
31
|
+
for (const e of listEntries()) {
|
|
32
|
+
const alive = await isChromeAlive(e);
|
|
33
|
+
const pages = alive ? await listPages(e).catch(() => []) : [];
|
|
34
|
+
rows.push({
|
|
35
|
+
id: e.id,
|
|
36
|
+
kind: e.kind,
|
|
37
|
+
status: alive ? classify(e) : 'dead',
|
|
38
|
+
session: `${e.anchorLabel ?? '?'}${e.anchorPid ? `:${e.anchorPid}` : ''}`,
|
|
39
|
+
state: e.state ?? null,
|
|
40
|
+
allowedOrigins: e.allowedOrigins,
|
|
41
|
+
mode: e.headless ? 'headless' : 'window',
|
|
42
|
+
tabs: pages.filter((p) => /^https?:/.test(p.url)).map((p) => p.url),
|
|
43
|
+
cwd: e.cwd,
|
|
44
|
+
idleSeconds: Math.round((Date.now() - e.lastSeen) / 1000),
|
|
45
|
+
port: e.port,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
if (json) return console.log(JSON.stringify(rows, null, 2));
|
|
49
|
+
if (!rows.length) return console.log('No fleet browsers running.');
|
|
50
|
+
table(rows.map((r) => ({
|
|
51
|
+
ID: r.id,
|
|
52
|
+
STATUS: r.status,
|
|
53
|
+
SESSION: r.session,
|
|
54
|
+
STATE: r.state ?? '-',
|
|
55
|
+
MODE: r.mode,
|
|
56
|
+
TAB: r.tabs[0] ? truncate(r.tabs[0], 50) + (r.tabs.length > 1 ? ` (+${r.tabs.length - 1})` : '') : '-',
|
|
57
|
+
CWD: truncate(r.cwd ?? '', 40),
|
|
58
|
+
})));
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
program
|
|
62
|
+
.command('kill')
|
|
63
|
+
.description('Close fleet browsers')
|
|
64
|
+
.argument('[ids...]', 'browser ids from `ls`')
|
|
65
|
+
.option('--all', 'close every fleet browser')
|
|
66
|
+
.action(async (ids, { all }) => {
|
|
67
|
+
if (!all && !ids.length) fail('Pass one or more ids, or --all.');
|
|
68
|
+
const targets = all ? listEntries() : ids.map((id) => readEntry(id) ?? fail(`No browser with id ${id}`));
|
|
69
|
+
// Deregister under the lock so nothing re-adopts them, then close.
|
|
70
|
+
const claimed = await withLock('registry', async () => targets.map((t) => readEntry(t.id)).filter(Boolean).map((e) => { removeEntry(e.id); return e; }));
|
|
71
|
+
for (const e of claimed) {
|
|
72
|
+
await closeEntryBrowser(e);
|
|
73
|
+
console.log(`closed ${e.id}${e.chromePid ? '' : ' (it had no Chrome yet)'}`);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
program
|
|
78
|
+
.command('gc')
|
|
79
|
+
.description('Close orphaned browsers now (sessions that ended), and detached ones past the timeout')
|
|
80
|
+
.option('--detached', 'also close browsers whose MCP connection dropped, without waiting for the timeout')
|
|
81
|
+
.action(async ({ detached }) => {
|
|
82
|
+
const config = loadConfig();
|
|
83
|
+
const actions = await reapOnce({ orphanTimeoutMinutes: config.orphanTimeoutMinutes, closeDetached: detached });
|
|
84
|
+
if (!actions.length) return console.log('Nothing to clean up.');
|
|
85
|
+
for (const { id, action } of actions) console.log(`${id} ${action}`);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
program
|
|
89
|
+
.command('show')
|
|
90
|
+
.description("Watch a browser live: opens Chrome's DevTools inspector with a screencast of the page. Doesn't disturb the agent")
|
|
91
|
+
.argument('<id>', 'browser id from `ls`')
|
|
92
|
+
.option('--tab <n>', 'which tab (1-based, from the list printed)', (v) => Number(v))
|
|
93
|
+
.option('--print', 'print the URL instead of opening it')
|
|
94
|
+
.action(async (id, { tab, print }) => {
|
|
95
|
+
const e = readEntry(id) ?? fail(`No browser with id ${id}`);
|
|
96
|
+
if (!(await isChromeAlive(e))) fail(`Browser ${id} is not running.`);
|
|
97
|
+
const pages = await listPages(e);
|
|
98
|
+
if (!pages.length) fail(`Browser ${id} has no open tabs.`);
|
|
99
|
+
pages.forEach((p, i) => console.log(`${i + 1}. ${p.title || '(untitled)'} ${p.url}`));
|
|
100
|
+
const index = tab ? tab - 1 : Math.max(0, pages.findIndex((p) => /^https?:/.test(p.url)));
|
|
101
|
+
const page = pages[index] ?? fail(`No tab ${tab}.`);
|
|
102
|
+
const url = inspectorUrl(e, page.id);
|
|
103
|
+
if (print) return console.log(url);
|
|
104
|
+
console.log(`\nOpening tab ${index + 1} in your default browser:\n${url}`);
|
|
105
|
+
openInBrowser(url);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// -------------------------------------------------------------------- states
|
|
109
|
+
|
|
110
|
+
program
|
|
111
|
+
.command('login')
|
|
112
|
+
.description('Open a visible browser, log in by hand, and save the session as a named state agents can use')
|
|
113
|
+
.argument('<name>', 'state name, e.g. staging-admin')
|
|
114
|
+
.argument('<url>', 'where to start, e.g. https://staging.example.com/login')
|
|
115
|
+
.option('--allow <origin...>', 'extra origins the state may be used on (visited origins are added automatically)')
|
|
116
|
+
.option('--strict', 'when used, block every request outside the allowlist, not just navigations')
|
|
117
|
+
.option('--overwrite', 'replace an existing state')
|
|
118
|
+
.option('-y, --yes', 'save without confirming the allowlist')
|
|
119
|
+
.option('--headless', 'no window (for scripted logins and tests)')
|
|
120
|
+
.action(async (name, url, opts) => {
|
|
121
|
+
await login({ name, url, allow: opts.allow ?? [], strict: Boolean(opts.strict), overwrite: Boolean(opts.overwrite), yes: Boolean(opts.yes), headless: Boolean(opts.headless), config: loadConfig() });
|
|
122
|
+
process.exit(0);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
program
|
|
126
|
+
.command('states')
|
|
127
|
+
.description('List saved login states (never shows cookie values)')
|
|
128
|
+
.option('--json', 'machine-readable output')
|
|
129
|
+
.action(({ json }) => {
|
|
130
|
+
const states = listStates();
|
|
131
|
+
if (json) return console.log(JSON.stringify(states, null, 2));
|
|
132
|
+
if (!states.length) return console.log('No saved states. Create one with `devtools-fleet login <name> <url>`.');
|
|
133
|
+
table(states.map((s) => s.error ? { NAME: s.name, ORIGINS: `unreadable: ${s.error}` } : {
|
|
134
|
+
NAME: s.name,
|
|
135
|
+
ORIGINS: s.allowedOrigins.join(', ') + (s.strict ? ' [strict]' : ''),
|
|
136
|
+
COOKIES: `${s.cookies}${s.expiredCookies ? ` (${s.expiredCookies} expired)` : ''}`,
|
|
137
|
+
SAVED: s.savedAt.slice(0, 16).replace('T', ' '),
|
|
138
|
+
BY: s.createdBy,
|
|
139
|
+
}));
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const state = program.command('state').description('Manage one saved state');
|
|
143
|
+
|
|
144
|
+
state
|
|
145
|
+
.command('show')
|
|
146
|
+
.argument('<name>')
|
|
147
|
+
.description('Metadata for a state (never cookie values)')
|
|
148
|
+
.action((name) => console.log(JSON.stringify(summarizeState(readState(name)), null, 2)));
|
|
149
|
+
|
|
150
|
+
state
|
|
151
|
+
.command('rm')
|
|
152
|
+
.argument('<name>')
|
|
153
|
+
.description('Delete a state')
|
|
154
|
+
.action((name) => {
|
|
155
|
+
deleteState(name);
|
|
156
|
+
console.log(`Deleted state "${name}".`);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
state
|
|
160
|
+
.command('import')
|
|
161
|
+
.argument('<name>')
|
|
162
|
+
.argument('<file>', 'a Playwright / agent-browser storageState JSON file')
|
|
163
|
+
.requiredOption('--allow <origin...>', 'origins the state may be used on')
|
|
164
|
+
.option('--strict', 'block every request outside the allowlist, not just navigations')
|
|
165
|
+
.description('Import a storageState file as a fleet state')
|
|
166
|
+
.action((name, file, { allow, strict }) => {
|
|
167
|
+
const { summary, droppedCookies } = importState({ name, file, allowedOrigins: allow, strict: Boolean(strict) });
|
|
168
|
+
console.log(`Imported "${summary.name}": ${summary.cookies} cookie(s)${droppedCookies ? `, ${droppedCookies} dropped (outside the allowlist)` : ''}.`);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// --------------------------------------------------------------------- misc
|
|
172
|
+
|
|
173
|
+
program
|
|
174
|
+
.command('doctor')
|
|
175
|
+
.description('Check Chrome, Node, permissions and configuration')
|
|
176
|
+
.action(async () => {
|
|
177
|
+
let ok = true;
|
|
178
|
+
const check = (label, fn) => {
|
|
179
|
+
try {
|
|
180
|
+
const detail = fn();
|
|
181
|
+
console.log(`ok ${label}${detail ? `: ${detail}` : ''}`);
|
|
182
|
+
} catch (err) {
|
|
183
|
+
ok = false;
|
|
184
|
+
console.log(`FAIL ${label}: ${err.message}`);
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
let config;
|
|
188
|
+
check('config', () => { config = loadConfig(); return PATHS.config; });
|
|
189
|
+
check('node', () => {
|
|
190
|
+
const [major, minor] = process.versions.node.split('.').map(Number);
|
|
191
|
+
if (major < 22 || (major === 22 && minor < 12)) throw new Error(`${process.versions.node}; need 22.12 or newer`);
|
|
192
|
+
return process.versions.node;
|
|
193
|
+
});
|
|
194
|
+
if (config) check(`chrome (${config.channel})`, () => resolveChromePath(config));
|
|
195
|
+
check('chrome-devtools-mcp', () => UPSTREAM_VERSION);
|
|
196
|
+
check('fleet home', () => {
|
|
197
|
+
try {
|
|
198
|
+
const mode = statSync(PATHS.home).mode & 0o777;
|
|
199
|
+
if (mode & 0o077) throw new Error(`${PATHS.home} is readable by others (mode ${mode.toString(8)}); run chmod 700`);
|
|
200
|
+
} catch (err) {
|
|
201
|
+
if (err.code !== 'ENOENT') throw err;
|
|
202
|
+
return `${PATHS.home} (created on first use)`;
|
|
203
|
+
}
|
|
204
|
+
return PATHS.home;
|
|
205
|
+
});
|
|
206
|
+
check('states', () => `${listStates().length} saved`);
|
|
207
|
+
check('browsers', () => `${listEntries().length} registered`);
|
|
208
|
+
process.exitCode = ok ? 0 : 1;
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
program
|
|
212
|
+
.command('config')
|
|
213
|
+
.description('Print the effective configuration and where it comes from')
|
|
214
|
+
.action(() => {
|
|
215
|
+
console.log(`config file: ${PATHS.config}`);
|
|
216
|
+
console.log(`fleet home: ${PATHS.home}`);
|
|
217
|
+
console.log(JSON.stringify(loadConfig(), null, 2));
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
program.parseAsync().catch((err) => fail(err.message));
|
|
221
|
+
|
|
222
|
+
// -------------------------------------------------------------------- helpers
|
|
223
|
+
|
|
224
|
+
function fail(message) {
|
|
225
|
+
console.error(`devtools-fleet: ${message}`);
|
|
226
|
+
process.exit(1);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function truncate(s, n) {
|
|
230
|
+
return s.length > n ? `${s.slice(0, n - 1)}…` : s;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function table(rows) {
|
|
234
|
+
const cols = Object.keys(rows[0]);
|
|
235
|
+
const widths = cols.map((c) => Math.max(c.length, ...rows.map((r) => String(r[c] ?? '').length)));
|
|
236
|
+
const line = (cells) => cells.map((cell, i) => String(cell ?? '').padEnd(widths[i])).join(' ').trimEnd();
|
|
237
|
+
console.log(line(cols));
|
|
238
|
+
for (const r of rows) console.log(line(cols.map((c) => r[c])));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function openInBrowser(url) {
|
|
242
|
+
const [cmd, args] = process.platform === 'darwin' ? ['open', [url]]
|
|
243
|
+
: process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]]
|
|
244
|
+
: ['xdg-open', [url]];
|
|
245
|
+
spawn(cmd, args, { detached: true, stdio: 'ignore' }).unref();
|
|
246
|
+
}
|