cdp-toolkit 1.9.0 → 1.9.4
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/CHANGELOG.md +507 -0
- package/CONTRACT.md +62 -5
- package/README.md +74 -10
- package/dist/cli.js +2940 -138
- package/dist/index.js +994 -90
- package/dist/mcp.js +1221 -137
- package/package.json +2 -1
- package/src/backend.ts +156 -21
- package/src/bidi/client.ts +48 -10
- package/src/bidi/driver.ts +228 -33
- package/src/bidi/launch.ts +23 -13
- package/src/bidi-tools.ts +21 -0
- package/src/cdp/driver.ts +594 -9
- package/src/cli.ts +111 -12
- package/src/driver.ts +254 -3
- package/src/manifest.ts +42 -14
- package/src/mcp.ts +21 -12
- package/src/png.ts +809 -0
- package/src/shared-tools.ts +313 -11
- package/src/tools/console.ts +24 -7
- package/src/tools/network.ts +21 -7
- package/src/tools/recorder.ts +110 -10
- package/src/types.ts +8 -6
- package/src/workers.ts +69 -7
package/CONTRACT.md
CHANGED
|
@@ -18,9 +18,9 @@ writing a module.
|
|
|
18
18
|
|
|
19
19
|
**`claim_page{target}` is a deliberate, reviewed exception to this rule, not a violation of it.** Its target resolution goes through `pickPage` (`src/shared-tools.ts`), which is exported *specifically* for this one caller and does **not** call `assertLeaseOk`. Routing a claim through `resolvePage` instead would, under strict mode, auto-acquire a lease on the way in and then collide with the explicit claim right behind it, one call claiming the same tab against itself twice. The protection is not dropped, only relocated one layer down: `claimLease`'s own conflict check still refuses a tab any live process holds, auto-acquired or explicit, so a stranger's tab is still refused, only later and by a different function. `pickPage` must never gain a second caller without re-deriving this argument for that caller too; if it does not need gate-free resolution, it needs `resolvePage`, not `pickPage`.
|
|
20
20
|
|
|
21
|
-
**A worker-typed hit inside `resolveTarget` is the other deliberate exception, added in 1.9.0.** A `worker:<substring>` selector (Chrome only, capability `worker.targets
|
|
21
|
+
**A worker-typed hit inside `resolveTarget` is the other deliberate exception, added in 1.9.0.** A `worker:<substring>` selector (Chrome only, capability `worker.targets`; one tool in 1.9.0, `evaluate_script` — four as of 1.9.1, see `WORKER_CAPABLE_TOOLS` in `src/workers.ts`) resolves to a service/shared worker target, and `resolveTarget` returns it before reaching `assertLeaseOk` — see the comment at the top of that branch in `src/client.ts`. A lease answers "who is driving this tab"; a worker is not a tab, has no reap-meaningful close, and strict-mode auto-acquire on it would mint a lease file keyed on an id that vanishes the moment Chrome idles the worker out, garbage the page-only reap set (`ReapInput.livePageIds`) can never collect. This also cannot be used to bypass a lease on an actual tab: there is no tab a worker resolution could stand in for. **The three console/network reader tools take a parallel path, not this one** — `resolveRecorderTarget`/`resolveWorkerSelector` in `src/tools/recorder.ts`/`src/cdp/workers.ts` — but the same reasoning holds: neither touches `assertLeaseOk`, tested by asserting zero lease files on a worker read with a page control in the same strict-mode window.
|
|
22
22
|
|
|
23
|
-
**The `TargetSelector` grammar itself also has three independent copies, a separate fact from the lease choke points above** — `pickTarget` (`src/client.ts`, what `resolveTarget` calls, and what most tools reach through), `pickPage` (`src/shared-tools.ts`), and `pickContext` (`src/bidi/driver.ts`, Firefox). A prefix arm added to one does not exist for callers routed through another; `label:` (1.9.0) was first wired into only `pickPage` and passed every unit test while `evaluate_script` still rejected it, because `evaluate_script` calls `pickTarget`, not `pickPage`, and only the live smoke caught the gap. The shared lookup core for an arm that needs identical semantics everywhere (as `label:` does) belongs in its own module — see `resolveLiveLabel` in `src/origins.ts` — wired into all three call sites, each keeping its own error class (`CdpError` / `SharedToolError` / `DriverError`). `test/manifest-grammar-drift.test.ts` structurally asserts a completeness property (every `title:` mention also mentions `label:`) and, separately, an exclusion property for `worker:` (
|
|
23
|
+
**The `TargetSelector` grammar itself also has three independent copies, a separate fact from the lease choke points above** — `pickTarget` (`src/client.ts`, what `resolveTarget` calls, and what most tools reach through), `pickPage` (`src/shared-tools.ts`), and `pickContext` (`src/bidi/driver.ts`, Firefox). A prefix arm added to one does not exist for callers routed through another; `label:` (1.9.0) was first wired into only `pickPage` and passed every unit test while `evaluate_script` still rejected it, because `evaluate_script` calls `pickTarget`, not `pickPage`, and only the live smoke caught the gap. The shared lookup core for an arm that needs identical semantics everywhere (as `label:` does) belongs in its own module — see `resolveLiveLabel` in `src/origins.ts` — wired into all three call sites, each keeping its own error class (`CdpError` / `SharedToolError` / `DriverError`). `test/manifest-grammar-drift.test.ts` structurally asserts a completeness property (every `title:` mention also mentions `label:`) and, separately, an exclusion property for `worker:` (only a tool in the `WORKER_CAPABLE_TOOLS` allowlist — four as of 1.9.1 — may name it) — grep `startsWith("title:")` across `src/` before adding a new arm, and extend or scope that test for it.
|
|
24
24
|
|
|
25
25
|
7. **Never dispatch `Input.*` outside `sendInput`.** As of 1.8.0 there is exactly one function that writes a synthesized input command to a Chrome connection: `sendInput(conn, backend, targetId, method, params)` in `src/activity.ts`. `CdpPageDriver.dispatchInput` (the per-driver choke point every interaction method already had to go through) delegates to it, and any tool built outside the Driver abstraction (`dispatch_mouse`, in `src/tools/dispatch-mouse.ts`, is the precedent) must call it directly rather than opening its own `conn.send("Input....", ...)`. This is what keeps the activity beacon's human/agent discrimination (`src/activity.ts`'s dispatch log) correct: a new input path that bypasses `sendInput` will typecheck, pass its own unit tests, and pass `bun test` in full, while silently making the toolkit's own input read as a human using the tab — exactly the 1.8.0 merge bug `test/input-dispatch-wiring.test.ts` now exists to catch (it walks every `.ts` under `src/`, strips comment-only lines, and fails on any line that both sends and names `Input.`). On the Firefox side, `BidiPageDriver.performActions` is the twin choke point and already calls `recordDispatch`; a new BiDi input path goes through it the same way `CdpPageDriver.dispatchInput` is used on Chrome. Mode-toggle commands that dispatch no page input themselves (`Input.setInterceptDrags`'s arm/disarm around HTML5 drag) are still sent through `sendInput` but are on a denylist that skips recording them — classify a new such command explicitly; the default for anything unclassified is to be logged, since over-warning a human is recoverable and silently misattributing their input to "agent" is not.
|
|
26
26
|
|
|
@@ -68,15 +68,72 @@ export async function click(args: ClickArgs): Promise<unknown>;
|
|
|
68
68
|
|
|
69
69
|
## Artifact + state locations
|
|
70
70
|
|
|
71
|
-
- Artifacts (PNG, trace, heapsnapshot, lighthouse report): write under `ARTIFACT_DIR` = `process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit"`. `mkdir -p` it. Filenames: `<tool>-<targetIdShort>-<isoish>.<ext>` where you derive a stamp from `Date.now()` at call time (allowed in normal runtime; only the Workflow *script* sandbox forbids it, modules run normally).
|
|
71
|
+
- Artifacts (PNG, trace, heapsnapshot, lighthouse report): write under `ARTIFACT_DIR` = `process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit"`. `mkdir -p` it. Filenames: `<tool>-<targetIdShort>-<isoish>.<ext>` where you derive a stamp from `Date.now()` at call time (allowed in normal runtime; only the Workflow *script* sandbox forbids it, modules run normally). **`mkdir -p` the DIRNAME OF THE PATH BEING WRITTEN, not `ARTIFACT_DIR` unconditionally** (1.9.2): a tool taking a caller-supplied destination (`take_screenshot`'s `savePath`) otherwise creates a directory nobody writes to and then fails `ENOENT` on the one that matters.
|
|
72
72
|
- Selected-target state file (for `select_page`): `process.env.CDP_STATE_DIR ?? "/tmp/cdp-toolkit"` + `/selected`. Contains a bare targetId. `resolveTarget` does NOT read it; tools may read it as a fallback default if you choose, but the simplest correct behavior is fine.
|
|
73
73
|
- Recorder buffers: `${ARTIFACT_DIR}/rec-<targetId>.jsonl`.
|
|
74
74
|
- Screencast spool: `${ARTIFACT_DIR}/screencast-<targetIdShort>-<isoish>/` holding `frame-NNNNNN.jpg|png` plus the generated `frames.ffconcat`. Deleted on a successful encode; **kept, and named in the error, when ffmpeg fails**, so the frames can be re-encoded by hand instead of lost. Output video: `${ARTIFACT_DIR}/screen-recording-<targetIdShort>-<isoish>.mp4`.
|
|
75
75
|
- Lease files: `${ARTIFACT_DIR}/lease-<backend>-<targetId>.json`, one per leased tab, backend is `chrome` or `firefox`. Written with the `wx` exclusive-create flag so two simultaneous claims cannot both win. Keyed by backend plus id because a CDP targetId and a BiDi context id are not disjoint by construction. Carries an `auto?: boolean` field (absent, and read as `false`, on any record older than 1.7.0): `true` means the gate itself acquired it under `CDP_REQUIRE_LEASE` and it is usable by any call from the owning pid with no token; `false` means it was claimed explicitly (`claim_page` or `new_page{claim:true}`) and demands its token even from that same pid.
|
|
76
|
+
- Viewport records (`src/driver.ts`, added 1.9.2): `${ARTIFACT_DIR}/viewport-<scheme>-<targetId>.json`, one per target whose device-metrics override this toolkit applied, written by both drivers' `emulate()` and cleared by a confirmed `clearOverrides`. Keyed by uid scheme for the same reason lease files are keyed by backend: a CDP targetId and a BiDi context id are not disjoint by construction. It exists because `take_screenshot`'s `renderWidth`/`renderHeight` must restore the caller's PREVIOUS override, and neither a private field nor the connection can hold that: a `PageDriver` is `lifetime: "per-call"` (under the CLI, one process per call) while a device-metrics override **outlives the connection that set it** — measured, an override set in one process reads back in another. Note the honest limit this record defines: the driver can only restore an override it recorded, so one applied by the DevTools UI, another client, or a pre-1.9.2 build leaves no file and a render-size capture resets it to the real device. There is no CDP or BiDi command that reads a device-metrics override back, so a driver genuinely cannot discover it.
|
|
76
77
|
- Origin files (`src/origins.ts`): `${ARTIFACT_DIR}/origin-<backend>-<targetId>.json`, one per tab this toolkit itself created (never written for a tab merely claimed). Same exclusive-create discipline as lease files, same directory, and it deliberately OUTLIVES the lease: `release_page`, expiry, and reclamation never touch it, only the tab closing does (reaped on the next `list_pages`/`list_origins` read). This is what `release_page` and reap both consult to decide whether closing a given tab is this toolkit's call to make; see `src/reap.ts`'s header for the four conditions that gate a reap close. As of 1.8.0 an `expired` lease qualifies for that close only past `ttlMs + CDP_REAP_GRACE_MS` (default 45 extra minutes) — `list_leases`' `stale`/reclaimable reading is unchanged at `ttlMs`; only the destructive close moved later. `dead-pid` is unaffected by grace.
|
|
77
78
|
- Beacon sessions (`src/activity.ts`, `src/cdp/driver.ts`): a `BeaconSessions` pool of held Chrome connections, one per tab with an installed activity beacon, bounded at 32 with oldest-first eviction — the same module-scope pattern `recorder.ts`/`network_mock.ts` already use for their own persistent per-target sessions. Exists only because `Page.addScriptToEvaluateOnNewDocument`'s registration dies with the connection that made it (verified against a live browser, not assumed), so surviving navigation under this toolkit's per-call lifetime requires holding a connection open; eviction degrades (the current document's listeners keep answering, only the next navigation's re-arm is lost) rather than breaking. Firefox needs no equivalent: its `LifetimeModel` is `"session"`, so a `script.addPreloadScript` registration on the one memoized BiDi connection per port already lives as long as that connection does.
|
|
78
79
|
- The browser-session connection (`src/tools/browser-session.ts`): one lazily-opened, module-scoped connection to the CDP browser endpoint (not a page endpoint), held open for the process's life, backing `wait_for_download` and `grant_permissions`. Opened only on first use of either tool, so a session that never touches downloads or permissions pays nothing. Exists because `Browser.setDownloadBehavior` and `Browser.grantPermissions` were measured — probed against a real browser before either tool was written — to NOT persist past the arming/granting client's disconnect, which this toolkit's per-call connection lifetime would otherwise hit on every single call. Both tools are therefore MCP-server-only capabilities, the same category as `performance_start_trace`/`stop_screen_recording`'s persistent-connection requirement, for the same underlying reason.
|
|
79
80
|
|
|
81
|
+
## Firefox backend: launch vs. attach (`src/backend.ts`, `src/bidi/driver.ts`)
|
|
82
|
+
|
|
83
|
+
The Firefox/BiDi backend runs in one of two modes, selected by `resolveBackend(argv, env)`
|
|
84
|
+
(`src/backend.ts`) and shared by `cli.ts` and `mcp.ts`. The two modes differ in exactly one
|
|
85
|
+
thing — process ownership — and that difference must never blur:
|
|
86
|
+
|
|
87
|
+
- **LAUNCH** (default, no endpoint resolved): `startFirefoxSession()` calls `launchFirefox()`
|
|
88
|
+
(`src/bidi/launch.ts`), which owns the spawned process end to end. `dispose()` closes the BiDi
|
|
89
|
+
driver **then** kills the process, in that order, so no invocation can leak a process.
|
|
90
|
+
- **ATTACH** (an endpoint was resolved): `startFirefoxSession({ endpoint })` calls
|
|
91
|
+
`createFirefoxDriverForEndpoint(wsUrl)` (`src/bidi/driver.ts`) directly — no `launchFirefox`,
|
|
92
|
+
no process handle at all. `dispose()` is `driver.dispose()` only: it sends `session.end` (see
|
|
93
|
+
below) then closes the socket. **It must never call a process `close()`; there is no process
|
|
94
|
+
to close.** The Firefox the user started outlives every attach session against it.
|
|
95
|
+
|
|
96
|
+
**Config surface** (`resolveFirefoxEndpoint`, `normalizeBidiEndpoint`, `resolveBackend` in
|
|
97
|
+
`src/backend.ts`):
|
|
98
|
+
|
|
99
|
+
- env `CDP_FIREFOX_ENDPOINT`, CLI flag `--connect <endpoint>` — flag beats env (same precedence
|
|
100
|
+
as `--browser`/`CDP_BROWSER`). An empty env value counts as unset, so an exported-but-blank
|
|
101
|
+
`CDP_FIREFOX_ENDPOINT` does not force attach mode.
|
|
102
|
+
- `<endpoint>` accepts three spellings, all normalized by `normalizeBidiEndpoint` to a ws URL:
|
|
103
|
+
bare port (`9223` → `ws://127.0.0.1:9223/session`), `host:port` (`127.0.0.1:9223` → same
|
|
104
|
+
shape), or a full `ws://`/`wss://` URL (used verbatim; a bare origin with no path or `/` gets
|
|
105
|
+
`/session` appended, since Firefox serves BiDi there). Invalid input (bad URL, non-numeric,
|
|
106
|
+
port outside 1–65535) throws with the accepted spellings named in the message rather than
|
|
107
|
+
reaching the transport as an opaque connect timeout.
|
|
108
|
+
- An endpoint **implies** `kind: "firefox"` in the returned `BackendSelection` — there is nothing
|
|
109
|
+
else `--connect`/`CDP_FIREFOX_ENDPOINT` could mean. `resolveBackend` throws
|
|
110
|
+
(`"--connect / CDP_FIREFOX_ENDPOINT is a Firefox attach option and is not valid with the
|
|
111
|
+
chrome backend"`) when an endpoint is present **and** the browser was explicitly set to chrome
|
|
112
|
+
(`--browser chrome` or `CDP_BROWSER=chrome`); an endpoint alongside no explicit `--browser` (or
|
|
113
|
+
an explicit `--browser firefox`) silently selects Firefox as always.
|
|
114
|
+
- `stripConnectFlag` mirrors `stripBrowserFlag`: `cli.ts` strips `--connect <value>` out of argv
|
|
115
|
+
before tool-arg parsing so it never reaches a tool's own args.
|
|
116
|
+
|
|
117
|
+
**The single-BiDi-session constraint.** Firefox allows exactly one active WebDriver BiDi session
|
|
118
|
+
per process; a second `session.new` against the same Firefox fails with `session.new: Maximum
|
|
119
|
+
number of active sessions` (verified against Firefox 153.0.3). `getConnection` in
|
|
120
|
+
`src/bidi/driver.ts` catches that specific message and rethrows via the local `driverError`
|
|
121
|
+
helper (code `"disconnected"`) naming the endpoint and explaining the fix (close the other
|
|
122
|
+
client, or restart Firefox), instead of letting the raw wire message reach the caller. Closing the BiDi socket alone does
|
|
123
|
+
**not** free the slot — only `session.end` does, which is why `BidiBrowserDriver.dispose()`
|
|
124
|
+
sends `session.end` (best-effort, swallowing any error — harmless if the socket is already gone)
|
|
125
|
+
**before** closing the connection. A client that dies without a clean dispose (e.g. `SIGKILL`)
|
|
126
|
+
leaves the slot occupied until Firefox restarts: this toolkit does not and cannot force-end a
|
|
127
|
+
session it does not own, since there is no id to end. This is why a colliding attach now fails
|
|
128
|
+
fast with an actionable error instead of hanging forever (the pre-1.9.4 behavior).
|
|
129
|
+
|
|
130
|
+
**Driver identity moved from port to endpoint.** `BidiBrowserDriver` is keyed by a BiDi ws
|
|
131
|
+
endpoint string (`this.endpoint`), not a `port: number`: a launched Firefox's endpoint is
|
|
132
|
+
`ws://127.0.0.1:${port}/session` (via `createFirefoxDriver(port)`), while an attach endpoint may
|
|
133
|
+
carry a different host, port, or path entirely (via `createFirefoxDriverForEndpoint(wsUrl)`).
|
|
134
|
+
Both funnel into the same `connections: Map<string, BidiConnection>` keyed by that string. Any
|
|
135
|
+
future change to connection identity must account for both call shapes.
|
|
136
|
+
|
|
80
137
|
## The element-reference scheme (READ if you touch snapshot or input)
|
|
81
138
|
|
|
82
139
|
A `Uid` **is** a CDP `backendDOMNodeId` (a number). This makes refs stateless:
|
|
@@ -114,10 +171,10 @@ A `Uid` **is** a CDP `backendDOMNodeId` (a number). This makes refs stateless:
|
|
|
114
171
|
| `src/tools/evaluate.ts` | `evaluate_script`→`evaluateScript` | `Runtime.enable` + `Runtime.evaluate` (`returnByValue`, `awaitPromise`). Support optional `args` via `Runtime.callFunctionOn` on a uid/selector. Surface `exceptionDetails` as thrown error. |
|
|
115
172
|
| `src/tools/snapshot.ts` | `take_snapshot`→`takeSnapshot` **+ export `resolveUid`** | a11y tree + ref scheme above. |
|
|
116
173
|
| `src/tools/input.ts` | `click`→`click`, `hover`→`hover`, `drag`→`drag`, `fill`→`fill`, `fill_form`→`fillForm`, `type_text`→`typeText`, `press_key`→`pressKey`, `upload_file`→`uploadFile` | imports `resolveUid` from `./snapshot.ts`. `hover`=mouseMoved. `drag`=press→move→release. `fill`/`type_text`=focus + `Input.insertText` (fast) or `dispatchKeyEvent`. `press_key`=`Input.dispatchKeyEvent` (support modifiers + named keys). `upload_file`=`DOM.setFileInputFiles` on resolved node. `fill_form`=array of {uid|selector,value}. |
|
|
117
|
-
| `src/tools/screenshot.ts` | `take_screenshot`→`takeScreenshot` | `Page.captureScreenshot`. Support `{ format:'png'|'jpeg', quality, fullPage, uid?/selector? (element clip via getBoxModel), savePath? }`. Write to ARTIFACT_DIR, return `{ path, bytes, format }` and optionally base64 when `{ returnBase64:true }`. |
|
|
174
|
+
| `src/tools/screenshot.ts` | `take_screenshot`→`takeScreenshot` | `Page.captureScreenshot`. Support `{ format:'png'|'jpeg', quality, fullPage, uid?/selector? (element clip via getBoxModel), savePath? }`. Write to ARTIFACT_DIR, return `{ path, bytes, format }` and optionally base64 when `{ returnBase64:true }`. **As of 1.9.2 also `scale` (Chrome-only capability `screenshot.scale`, rides `clip.scale`), `renderWidth`/`renderHeight` (BOTH backends, `screenshot.renderSize`, an `Emulation.setDeviceMetricsOverride` held for one capture and undone in a `finally` against the viewport record above), and `tile` (three-valued, `true` costs `screenshot.tile`).** Two rules the size arithmetic imposes on anyone touching this path. **(a) Guard the projection, not the knob:** output px are `ceil(css × scale × devicePixelRatio)`, Chrome cannot encode past 16384 device px per side, and past that `Page.captureScreenshot` NEVER ANSWERS while `captureBeyondViewport` (hardcoded true) leaves the renderer resized to the clip — a wedged tab a `finally` cannot undo, because the client-side timeout fires while Chrome's command is still running. So `Page.getLayoutMetrics` is fetched and the projection checked on EVERY capture, including the plain no-clip one; a guard conditioned on the feature that motivated it (`scale !== 1`) has already let two wedges through. **(b) Bands are lazy and vertical:** past the cap the driver hands the tool layer a `BandedCapture` whose Nth `Page.captureScreenshot` runs only when the Nth band is pulled (accumulating them into an array would typecheck, pass on a small page, and hold 1.4 GB on a large one), stitched by `stitchPngBandsToFile` in `src/png.ts` — a zero-dependency streaming PNG stitcher (`node:zlib`), not an MCP tool and deliberately absent from the `TOOLS` registry. Vertical only: PNG scanlines are the one axis concatenable without decoding the image, so an over-WIDE projection is refused, never split. |
|
|
118
175
|
| `src/tools/emulation.ts` | `emulate`→`emulate`, `resize_page`→`resizePage` | `Emulation.setDeviceMetricsOverride` (width,height,deviceScaleFactor,mobile), `Emulation.setUserAgentOverride`, `Emulation.setCPUThrottlingRate`, `Emulation.setEmulatedMedia`, `Network`-based throttling via `Network.emulateNetworkConditions`. `resize_page` = device metrics width/height. |
|
|
119
176
|
| `src/tools/dialogs.ts` | `handle_dialog`→`handleDialog` | `Page.enable`, subscribe `Page.javascriptDialogOpening`, respond with `Page.handleJavaScriptDialog({ accept, promptText })`. Support a "wait for next dialog then handle" mode and an "auto-handle for N ms" mode. |
|
|
120
|
-
| `src/tools/recorder.ts` + `src/tools/console.ts` + `src/tools/network.ts` | console: `list_console_messages`→`listConsoleMessages`, `get_console_message`→`getConsoleMessage`; network: `list_network_requests`→`listNetworkRequests`, `get_network_request`→`getNetworkRequest` | **Owned by one agent.** `recorder.ts` exports `startRecorder(target, {network,console}): Promise<{stop()}>` that opens a persistent `openPage` conn, enables `Network`+`Runtime`(+`Log`), and appends events to `rec-<targetId>.jsonl`. console/network `list_*` read that file (and support a one-shot `{ reload:true, durationMs }` mode that records fresh by reloading the page and capturing for a window, then returns). `get_*` filter by id/url. Document the recorder model at the top of `recorder.ts`. |
|
|
177
|
+
| `src/tools/recorder.ts` + `src/tools/console.ts` + `src/tools/network.ts` | console: `list_console_messages`→`listConsoleMessages`, `get_console_message`→`getConsoleMessage`; network: `list_network_requests`→`listNetworkRequests`, `get_network_request`→`getNetworkRequest` | **Owned by one agent.** `recorder.ts` exports `startRecorder(target, {network,console}): Promise<{stop()}>` that opens a persistent `openPage` conn, enables `Network`+`Runtime`(+`Log`), and appends events to `rec-<targetId>.jsonl`. console/network `list_*` read that file (and support a one-shot `{ reload:true, durationMs }` mode that records fresh by reloading the page and capturing for a window, then returns). `get_*` filter by id/url. Document the recorder model at the top of `recorder.ts`. **As of 1.9.1, the same three tools also accept a `worker:<substring>` target** (Chrome only, capability `worker.targets`; MV3 service/shared workers), resolved via `resolveRecorderTarget`/`resolveWorkerSelector` in `src/workers.ts` rather than `resolveTarget`. Because `Page.enable`/`Page.reload` do not exist on a worker session (measured: `-32601`), `captureWindow()` skips the reload for a worker-typed resolution and LISTENS on the held connection for `durationMs` instead — keyed off the resolved target's type, not the selector's shape, so a bare worker id takes the same branch as a `worker:` selector. Holding that session is a deliberate side effect, the same pattern as the beacon sessions below: it SUPPRESSES the worker's MV3 idle eviction for as long as the capture runs (measured: alive and still emitting events at 60s held, evicted ~30s after detach). `wake` (default `true` on a capture) restarts an evicted worker first, but is refused on a read-only call and on a bare target id, because a restart mints a NEW target id and the buffer is keyed by the old one — see `workerWakeMisuseMessage` in `src/workers.ts`. |
|
|
121
178
|
| `src/tools/performance.ts` | `performance_start_trace`→`performanceStartTrace`, `performance_stop_trace`→`performanceStopTrace`, `performance_analyze_insight`→`performanceAnalyzeInsight` | `Tracing.start`(categories incl. `devtools.timeline`,`blink.user_timing`,`loading`,`-*`), collect `Tracing.dataCollected`, `Tracing.end`→`Tracing.tracingComplete`, write trace JSON to ARTIFACT_DIR. start/stop need a persistent conn, so use a state file to hold the in-flight trace path/connection target. `performance_analyze_insight` parses a trace file for LCP, FCP/navigation timing, long tasks (>50ms), layout shifts (CLS), total blocking time; return structured metrics. **Mark in a top comment that this is a CDP-native approximation of the MCP's insight analyzer.** |
|
|
122
179
|
| `src/tools/screencast.ts` | `start_screen_recording`→`startScreenRecording`, `stop_screen_recording`→`stopScreenRecording` | **Toolkit addition (not MCP parity): a tab captured to an H.265 MP4.** Persistent `openPage` conn parked in a module-level `Map` keyed by targetId (recorder.ts's statefulness, performance.ts's registry), `Page.startScreencast` → `Page.screencastFrame` → **ack from the handler, before the disk write** (an unacked frame stalls the stream) → spool file + ledger row `{file,timestamp,width,height}`. Frames arrive on repaint only, so playback timing MUST come from per-frame ffconcat `duration`s off the ledger, never a fixed rate. Encoder ladder probed once from `ffmpeg -encoders`: `hevc_videotoolbox`→`h264_videotoolbox`→`libx265`→`libx264`, `-tag:v hvc1` on HEVC, always `+faststart`/`yuv420p`/even dims. ffmpeg is probed at START (fail before capturing, not after). Pure parts (`frameDurationsMs`/`buildConcatManifest`/`selectEncoder`/`buildFfmpegArgs`/`imageSize`) are unit-tested; live capture via `test/screencast-smoke.ts`. |
|
|
123
180
|
| `src/tools/heap.ts` | `take_heapsnapshot`→`takeHeapsnapshot` | `HeapProfiler.enable` → `HeapProfiler.takeHeapSnapshot`, assemble `HeapProfiler.addHeapSnapshotChunk` events into a `.heapsnapshot` file under ARTIFACT_DIR; return `{ path, bytes }`. |
|
package/README.md
CHANGED
|
@@ -119,6 +119,7 @@ bun run mcp:smoke # spawn the server + a real initialize/tools-list/tools-call
|
|
|
119
119
|
- **One target per call, never a broadcast.** Each call opens one WebSocket to the one target it named, with a bounded timeout on every CDP command, lazy domain enabling, and stateless element refs. Drive as many tabs as you like across calls; there is still no broadcast step that can stall on a wedged tab.
|
|
120
120
|
- **Network mocking: build the UI before the backend exists.** `mock_request` arms a persistent per-target fake backend: return canned responses, force errors, or inject latency/fault rates. Mocks survive reloads and navigations until `clear_mocks`.
|
|
121
121
|
- **Full chrome-devtools-mcp parity + extras.** All 29 upstream tools, plus `performance_trace` (a robust single-call trace), Lighthouse audits, heap snapshots, a cookie group that reads, writes, and deletes httpOnly cookies, real HTML5 drag-and-drop, raw scroll/mouse dispatch, download capture, permission grants, and tab-to-video screen recording. 45 single-purpose tools, no discovery overhead, and they coexist with `chrome-devtools-mcp` in a separate namespace.
|
|
122
|
+
- **A whole page is a file, not a wedged tab.** Chrome cannot encode a screenshot past 16384 device px on either side, and past that `Page.captureScreenshot` does not error — it never answers, and leaves the tab resized to the clip it was capturing. On a ratio-2 display that ceiling arrives at about 8192 CSS px: an ordinary long article. `take_screenshot` measures the projection before every capture and, past the cap, takes the page as vertical bands stitched losslessly into one PNG — a 140,982 CSS px page comes back as a 2780×281964 file in 18 bands, in 17 seconds, with the tab still healthy. Also per-capture `scale`, and `renderWidth`/`renderHeight` to shoot one capture at an emulated viewport and restore the tab afterwards.
|
|
122
123
|
- **Many agents, one browser, no stolen tabs.** `claim_page` hands out an opaque lease token for one tab; every other tool checks it at target resolution, so an unqualified call against a leased tab is refused by name rather than silently retargeted to whatever tab a different agent is driving.
|
|
123
124
|
- **Knows when a human is already using the tab you're driving.** An in-page activity beacon distinguishes a person's clicks/keys/scrolls from the toolkit's own dispatched input, so `claim_page` and `list_leases` can report `humanActiveMs` and warn on contention instead of silently fighting someone for the keyboard. See "Staleness: is a human already using this tab?" below.
|
|
124
125
|
- **Out-of-model secret handling.** A read that would return a credential, a JWT in `localStorage` via `evaluate_script` or an httpOnly session cookie via `list_cookies`, takes a `savePath` that writes the value to a file and keeps it out of the tool response entirely, so the secret never lands in the agent transcript. That same per-call, in-process design makes cdp-toolkit a clean substrate for credential-injection tools: a vaulted password can be typed straight into the DOM while only a status crosses back to the model, never the secret.
|
|
@@ -158,6 +159,8 @@ cdp clear_mocks --all true
|
|
|
158
159
|
# Run any tool by its MCP name; args come from --json and/or --key value flags.
|
|
159
160
|
cdp <tool> [--target <sel>] [--json '<obj>'] [--<key> <value> ...]
|
|
160
161
|
cdp --list # list every available tool name
|
|
162
|
+
cdp --help # top-level usage
|
|
163
|
+
cdp take_screenshot --help # that tool's arguments, from its schema — touches no browser
|
|
161
164
|
|
|
162
165
|
cdp list_pages
|
|
163
166
|
cdp navigate_page --target index:0 --url https://example.com
|
|
@@ -176,11 +179,21 @@ cdp set_cookie --json '{"name":"session","value":"abc","url":"https://example.co
|
|
|
176
179
|
# Remove it again. Either url or domain is required on both write tools.
|
|
177
180
|
cdp delete_cookies --json '{"name":"session","url":"https://example.com/"}'
|
|
178
181
|
cdp take_screenshot --target url:example --fullPage true
|
|
182
|
+
# A whole page, however long: past Chrome's 16384-device-px encode limit the capture is
|
|
183
|
+
# taken as vertical bands and stitched into one lossless PNG. No flag needed — this is the
|
|
184
|
+
# default — and the result says so: {"width":2780,"height":281964,"tiled":true,"bands":18}.
|
|
185
|
+
cdp take_screenshot --target url:example --fullPage true --savePath /tmp/whole-page.png
|
|
186
|
+
# 3x output pixels for one capture (Chrome only). The page is not told anything changed.
|
|
187
|
+
cdp take_screenshot --target url:example --scale 3
|
|
188
|
+
# Shoot a responsive page at desktop width from a tab that isn't that size, then restore it.
|
|
189
|
+
cdp take_screenshot --target url:example --renderWidth 1920 --renderHeight 1080
|
|
179
190
|
cdp lighthouse_audit --url https://example.com --json '{"categories":["performance"]}'
|
|
180
191
|
```
|
|
181
192
|
|
|
182
193
|
**Argument parsing:** the first positional token is the tool name. `--json '<obj>'` merges a JSON object into the args (applied first). `--target <sel>` sets `args.target`. Repeated `--key value` pairs become `args.key`, coerced (`true`/`false` → boolean, numeric strings → number, else string); a bare `--flag` is `true`. Explicit flags override keys from `--json`. Output is `JSON.stringify(result, null, 2)` on stdout (exit 0); on any throw, `{"error":"<message>"}` goes to stderr and the process exits 1.
|
|
183
194
|
|
|
195
|
+
**`--help`/`-h`** are recognized anywhere in argv, ahead of every other flag, and are never treated as a tool argument or a tool name — as of 1.9.3, `<tool> --help` used to run the tool instead. With no tool named, `cdp --help` prints the usage above. With a tool named, `cdp <tool> --help` prints that tool's arguments (name, type, required/optional, description) read from its schema, and exits 0 having made no CDP connection, taken no lease, and written no file.
|
|
196
|
+
|
|
184
197
|
### Programmatic use
|
|
185
198
|
|
|
186
199
|
```ts
|
|
@@ -214,11 +227,48 @@ cdp --capabilities --browser firefox # see what's available and
|
|
|
214
227
|
|
|
215
228
|
Backend selection precedence: `--browser chrome|firefox` flag, then `CDP_BROWSER`, then `chrome`. For the MCP server, set `CDP_BROWSER=firefox` (or pass `--browser firefox` in its launch args) in your MCP client config; the backend is fixed for the life of that server process.
|
|
216
229
|
|
|
217
|
-
|
|
230
|
+
Firefox runs in one of **two modes**, and the difference between them is process ownership.
|
|
231
|
+
|
|
232
|
+
**LAUNCH (default): a fresh throwaway-profile Firefox, launched and killed per session.** `--browser firefox` with no `--connect`/`CDP_FIREFOX_ENDPOINT` starts a brand-new Firefox process with an empty profile — a login wall for anything that needs a real, already-authenticated session:
|
|
218
233
|
|
|
219
234
|
- **CLI** (one process per invocation): each command launches Firefox, runs exactly one tool call, and disposes the session and kills the process before exiting, win or lose. State does not carry between separate CLI invocations: there is no running Firefox left afterward for a second command to find.
|
|
220
235
|
- **MCP server** (long-lived): the first Firefox tool call launches one Firefox process and memoizes its BiDi session for the life of the server; every later Firefox call reuses it. The session is torn down on `SIGINT`/`SIGTERM`/stdin close. Multi-step Firefox workflows (navigate, then snapshot, then click) need the MCP server, not the CLI, for exactly this reason.
|
|
221
236
|
|
|
237
|
+
**ATTACH (`--connect <port|host:port|ws-url>` / `CDP_FIREFOX_ENDPOINT`): connect to a Firefox YOU already started.** Launch a Firefox with its debug port open yourself, and cdp-toolkit connects to that process's BiDi endpoint instead of spawning a throwaway one — so tools see your real, logged-in profile, cookies and all. This process never launches or kills that Firefox: dispose only ends the BiDi session (`session.end`), never the browser.
|
|
238
|
+
|
|
239
|
+
```bash
|
|
240
|
+
# 1. Start YOUR Firefox with the debug port open (a separate, empty --no-remote profile
|
|
241
|
+
# is recommended so it doesn't collide with a Firefox you already have open; drop
|
|
242
|
+
# -profile/-no-remote to attach to your everyday profile instead, once nothing else
|
|
243
|
+
# is holding its BiDi session):
|
|
244
|
+
firefox --remote-debugging-port 9223 --no-remote -profile /tmp/ff-attach-profile &
|
|
245
|
+
|
|
246
|
+
# 2. CLI: --connect implies --browser firefox, so it doesn't need to be passed too
|
|
247
|
+
bun run src/cli.ts --connect 9223 take_snapshot
|
|
248
|
+
cdp --connect 127.0.0.1:9223 take_snapshot # host:port also works
|
|
249
|
+
cdp --connect ws://127.0.0.1:9223/session take_snapshot # or the full ws:// URL
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
3. MCP client config: set the env var instead of a flag (the endpoint doesn't belong in `args`):
|
|
253
|
+
|
|
254
|
+
```json
|
|
255
|
+
{
|
|
256
|
+
"mcpServers": {
|
|
257
|
+
"cdp-toolkit": {
|
|
258
|
+
"command": "npx",
|
|
259
|
+
"args": ["-y", "cdp-toolkit"],
|
|
260
|
+
"env": { "CDP_BROWSER": "firefox", "CDP_FIREFOX_ENDPOINT": "9223" }
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
`<endpoint>` accepts three spellings, all normalized to a ws URL: a bare port (`9223`), `host:port` (`127.0.0.1:9223`), or a full `ws://`/`wss://` URL. `--connect`/`CDP_FIREFOX_ENDPOINT` implies the Firefox backend on its own and errors against an explicit `--browser chrome`.
|
|
267
|
+
|
|
268
|
+
**Firefox allows only one active WebDriver BiDi session at a time.** A second `session.new` against the same endpoint fails outright while a first session is open. Disposing cleanly (the normal exit path, both CLI and MCP shutdown) sends `session.end` first, which frees the slot for the next attach. A client that dies **without** disposing — a `SIGKILL`, a crash — leaves the slot occupied: Firefox does not reap an abandoned session on its own. A colliding attach against an occupied slot now fails fast with an actionable error naming the endpoint, rather than hanging forever (fixed in 1.9.4); recover by closing the other client or restarting Firefox.
|
|
269
|
+
|
|
270
|
+
**Attaching is not relaunching.** The one thing that is genuinely impossible is handing a debug port to an *already-running* Firefox process after the fact: the `--remote-debugging-port` flag only takes effect on a process's original launch, so relaunching the `firefox` binary against a running instance hands off to it and exits silently, opening no port (verified against Firefox 153.0.3). That is a real, narrow limitation of the Firefox binary itself. It is not the same claim as "Firefox cannot be attached to" — a Firefox that was launched *with* the debug port open, whether by this toolkit or by your own hand, exposes a plain BiDi endpoint that any number of fresh clients can connect to later, which is exactly what `--connect`/`CDP_FIREFOX_ENDPOINT` does.
|
|
271
|
+
|
|
222
272
|
**Tool availability is filtered per backend**, not per call: `tools/list` (MCP) and `--list`/`--capabilities` (CLI) only ever advertise a tool the selected browser can actually run. A tool is never listed and then thrown from at call time. Under Firefox, four tool groups are absent because Firefox 153's BiDi implementation has no equivalent domain:
|
|
223
273
|
|
|
224
274
|
- `performance_start_trace`, `performance_stop_trace`, `performance_analyze_insight`, `performance_trace` (needs `trace.performance`)
|
|
@@ -237,9 +287,9 @@ Everything else, including `mock_request`/`list_mocks`/`clear_mocks` (Firefox's
|
|
|
237
287
|
- **Thin emulation.** Only viewport size/DPR and `userAgent` are applied. CPU throttling, media-feature emulation (e.g. `prefers-color-scheme`), and network-condition throttling are not available: Firefox 153 does not implement the underlying BiDi commands.
|
|
238
288
|
- **No tracing, heap snapshots, or Lighthouse.** See the capability list above; there is no BiDi equivalent for any of the three.
|
|
239
289
|
- **`locate.text` is not available** (Firefox 153's `browsingContext.locateNodes` rejects the `innerText` locator type as unsupported), unlike Chrome, which has it via `DOM.performSearch`.
|
|
240
|
-
- **No `--browser firefox` attach mode.** By design, per the launch model above: every invocation gets its own throwaway Firefox process and profile.
|
|
241
290
|
- **No modifier-key clicks.** `click`'s `modifiers` (Alt/Control/Meta/Shift) is a Chrome-only *parameter* on an otherwise-universal tool: a non-empty `modifiers` array throws under `--browser firefox` rather than being silently dropped. A plain click still works on both backends.
|
|
242
291
|
- **No real HTML5 drag-and-drop mode.** `drag`'s `mode:"html5"` requires capability `input.html5Drag` and is rejected with a clear error under Firefox; `mode:"mouse"` (the default) works on both.
|
|
292
|
+
- **No per-capture screenshot `scale`, and no band tiling** — two *parameters* on an otherwise-universal tool, for two different reasons. `scale` (`screenshot.scale`) is a genuine protocol gap: `browsingContext.captureScreenshot` has no scale parameter at all, and the refusal points at `emulate {deviceScaleFactor}` + a scale-1 capture instead. `tile:true` (`screenshot.tile`) is an **unmeasured** gap, not a missing primitive: BiDi does take a box clip, so bands are probably implementable, but every property tiling rests on (does a box clip render past the viewport, is `clip.y` CSS px, do fixed elements repeat per band, are seams pixel-exact) was measured on Chrome and nowhere else, so it is not advertised. Auto-tiling never fires without `screenshot.tile`, so a Firefox capture behaves exactly as it did before. **`renderWidth`/`renderHeight` (`screenshot.renderSize`) are available on both backends** — that one is not a gap.
|
|
243
293
|
|
|
244
294
|
## The tools (29 parity + 16 superset = 45)
|
|
245
295
|
|
|
@@ -253,7 +303,7 @@ The 29 parity tools are 1:1 with `chrome-devtools-mcp`; the 16 superset tools (`
|
|
|
253
303
|
| `select_page` | `Target.activateTarget` + selected-state file | Writes a flat-file selected target; `resolveTarget` does not read it, so `active` still means `index:0` unless a tool opts in. |
|
|
254
304
|
| `navigate_page` | `Page.navigate` / `Page.reload` + load events, or `history:'back'\|'forward'` (Chrome: `Page.getNavigationHistory` + `Page.navigateToHistoryEntry`; Firefox: `browsingContext.traverseHistory`) | Returns `{url,frameId,waitedFor}` (no auto-snapshot; a history move also returns `traversed:'back'\|'forward'`). `waitUntil` supports `load`/`domcontentloaded`. `reload:true` (+ `ignoreCache:true` for a hard reload). `url`/`reload`/`history` are mutually exclusive; going back from the first entry (or forward from the last) is an error naming the direction, never a silent no-op. Works on both backends. |
|
|
255
305
|
| `wait_for` | `Runtime.evaluate` (poll `innerText`) | Text-substring waiting only; throws on timeout rather than returning `{found:false}`. |
|
|
256
|
-
| `evaluate_script` | `Runtime.evaluate` / `callFunctionOn` | No live `page`/element handle; `args` are plain JSON. Main-world context only. Toolkit addition: optional `savePath` writes the value to a JSON file and keeps it out of the response entirely. If `expression` is missing/empty and the call instead carries `function`/`code`/`js`/`script`/`fn`/`body`, the error names the wrong key and points at `expression`.
|
|
306
|
+
| `evaluate_script` | `Runtime.evaluate` / `callFunctionOn` | No live `page`/element handle; `args` are plain JSON. Main-world context only. Toolkit addition: optional `savePath` writes the value to a JSON file and keeps it out of the response entirely. If `expression` is missing/empty and the call instead carries `function`/`code`/`js`/`script`/`fn`/`body`, the error names the wrong key and points at `expression`. One of four tools that also accept `target: "worker:<substring>"` (Chrome only, capability `worker.targets`) to reach an MV3 extension's background service worker — see "Driving MV3 extensions" below. |
|
|
257
307
|
| `list_cookies` *(superset)* | `Network.getCookies` | Reads the target page's cookie store, httpOnly cookies included, which `document.cookie` and therefore `evaluate_script` cannot see. Page-scoped on purpose, not the browser-wide jar (`Storage.getCookies`), so it answers for the tab you named. Optional `domain`/`name` filters; optional `savePath` writes the array to a JSON file and returns `{path,bytes,count,target}` with no cookie value in the response. |
|
|
258
308
|
| `set_cookie` *(superset)* | `Network.setCookie` | Writes one cookie, httpOnly and secure ones included, which `document.cookie` cannot create. Either `url` or `domain` is required and the call is refused with an error when neither is given. Chrome's `success:false` refusal is raised as an error rather than reported as a write. Answers `{set:true,target}` and never echoes the value back. `path` is passed through as given, never defaulted. |
|
|
259
309
|
| `delete_cookies` *(superset)* | `Network.deleteCookies` | Removes the named cookie, httpOnly ones included. Requires `name` plus `url` or `domain`, so a name-only call cannot sweep the store; optional `path` narrows further. Answers `{deleted:true,target}` with no count, because neither protocol reports one; read `list_cookies` before and after for a real count. |
|
|
@@ -268,16 +318,16 @@ The 29 parity tools are 1:1 with `chrome-devtools-mcp`; the 16 superset tools (`
|
|
|
268
318
|
| `type_text` | `Input.insertText` | Appends (does not clear first); insertText, not per-key. |
|
|
269
319
|
| `press_key` | `Input.dispatchKeyEvent` | Curated named-key table + single chars; not the full Puppeteer KeyInput enum. |
|
|
270
320
|
| `upload_file` | `DOM.setFileInputFiles` | Requires a resolvable `<input type=file>` (uid or selector). |
|
|
271
|
-
| `take_screenshot` | `Page.captureScreenshot` (+
|
|
321
|
+
| `take_screenshot` | `Page.captureScreenshot` (+ `Page.getLayoutMetrics` on **every** capture) | Full-page uses `captureBeyondViewport` + a layout-metrics clip. `scale` (>0, ≤8, **Chrome-only**) multiplies output pixels for one capture: output px = `ceil(css × scale × devicePixelRatio)`, and the page is never told (`devicePixelRatio`/`innerWidth` are unmoved). `renderWidth`+`renderHeight` (**both backends**, required together) emulate a viewport for one capture and restore it after — media queries flip, so this is how you shoot a responsive page at 1920×1080 from a tab that isn't. Chrome cannot encode past **16384 device px per side** and does not refuse politely there, so past the cap the capture is taken as vertical bands and stitched losslessly into one PNG (`tile`, auto by default; `tiled`/`bands` on the result): a 140,982 CSS px page returns 2780×281964 px in 18 bands instead of hanging and wedging the tab. Banding is vertical only (an over-wide projection is refused, not split), PNG-only, and never with `returnBase64`; content the page loads only on real scroll renders blank past the first viewport. `width`/`height` are decoded from the encoded bytes and omitted when undecodable. |
|
|
272
322
|
| `start_screen_recording` *(superset)* | `Page.startScreencast` / `Page.screencastFrame` / `Page.screencastFrameAck` | **Toolkit addition; chrome-devtools-mcp has no screen-recording tool.** Opens a persistent per-target connection and spools frames to a ledger on disk; pairs with `stop_screen_recording` in the SAME process, for the same cross-process reason as `performance_start_trace`. ffmpeg is probed here so a missing encoder fails before a recording is captured. Chrome only: absent from `tools/list` under `--browser firefox` (needs `capture.screencast`, which BiDi has no primitive for). |
|
|
273
323
|
| `stop_screen_recording` *(superset)* | `Page.stopScreencast` (+ ffmpeg encode) | Assembles the spooled frames into an H.265 (`hevc_videotoolbox`, falling back to `h264_videotoolbox` → `libx265` → `libx264`) MP4 using per-frame durations from the capture ledger, coalesced onto ffmpeg's 40ms concat-demuxer grid. Returns `{path,bytes,durationMs,frameCount,encodedFrames,codec,encoder,width,height,droppedFrames,target}` (`frameCount` captured vs. `encodedFrames` in the video). On an ffmpeg failure the spool is kept and the exact re-run command is named in the error. |
|
|
274
324
|
| `emulate` | `Emulation.*` / `Network.emulateNetworkConditions` | Stateless: UA/CPU/media/network overrides reset when the per-call connection closes. No named device presets. |
|
|
275
325
|
| `resize_page` | `Emulation.setDeviceMetricsOverride` | Verifies via `window.innerWidth/innerHeight`. Override persists on the target. |
|
|
276
326
|
| `handle_dialog` | `Page.javascriptDialogOpening` / `handleJavaScriptDialog` | Caller arms first and triggers out-of-band (or `handleDialogForExpression` to trigger-and-handle atomically). Supports wait-for-next and auto-handle-for-N-ms. |
|
|
277
|
-
| `list_console_messages` | `Runtime`/`Log` events (+ `Page.reload`) | `reload:true` records console+network into a unique per-capture file; default read returns the latest. Args flattened best-effort. |
|
|
278
|
-
| `get_console_message` | reads the shared "latest" buffer | Index into the latest capture; throws if out of range. |
|
|
279
|
-
| `list_network_requests` | `Network.*` events (+ `Page.reload`) | Correlated rows from the per-capture buffer; redirect chains collapse to the first row. No timing breakdown / POST data. |
|
|
280
|
-
| `get_network_request` | above + `Network.getResponseBody` | Bodies only via a fresh
|
|
327
|
+
| `list_console_messages` | `Runtime`/`Log` events (+ `Page.reload`, or LISTEN for `durationMs` on a worker target) | `reload:true` records console+network into a unique per-capture file; default read returns the latest. Args flattened best-effort. Accepts `target: "worker:<substring>"` (Chrome only) to capture an MV3 service worker's `console.log` — see "Driving MV3 extensions" below. |
|
|
328
|
+
| `get_console_message` | reads the shared "latest" buffer | Index into the latest capture; throws if out of range. Resolves a worker-keyed buffer too, but is deliberately not advertised as worker-capable (see CHANGELOG 1.9.1). |
|
|
329
|
+
| `list_network_requests` | `Network.*` events (+ `Page.reload`, or LISTEN for `durationMs` on a worker target) | Correlated rows from the per-capture buffer; redirect chains collapse to the first row. No timing breakdown / POST data. Accepts `target: "worker:<substring>"` (Chrome only) to capture the requests an MV3 service worker itself makes — see "Driving MV3 extensions" below. |
|
|
330
|
+
| `get_network_request` | above + `Network.getResponseBody` | Bodies only via a fresh capture (CDP serves bodies from the live session); `includeBody` matches by **url**. Worker-capable, same as `list_network_requests`. |
|
|
281
331
|
| `performance_start_trace` | `Tracing.start` / `dataCollected` | Works ONLY within one process; a live trace buffer is bound to its connection. Use `performance_trace` for robustness. |
|
|
282
332
|
| `performance_stop_trace` | `Tracing.end` / `tracingComplete` | Must run in the SAME process as `performance_start_trace`; throws a clear error otherwise. |
|
|
283
333
|
| `performance_analyze_insight` | parses a trace JSON file | A **CDP-native approximation** of the MCP insight analyzer (FCP/LCP/CLS/TBT/long-tasks); close but not byte-identical. Requires an explicit `tracePath`. |
|
|
@@ -295,7 +345,7 @@ The 29 parity tools are 1:1 with `chrome-devtools-mcp`; the 16 superset tools (`
|
|
|
295
345
|
|
|
296
346
|
## Driving MV3 extensions
|
|
297
347
|
|
|
298
|
-
An MV3 extension's logic lives in a background **service worker**, not a page, so `chrome.storage.local`, `chrome.runtime`, and everything else the extension owns is unreachable through a page-typed selector.
|
|
348
|
+
An MV3 extension's logic lives in a background **service worker**, not a page, so `chrome.storage.local`, `chrome.runtime`, and everything else the extension owns is unreachable through a page-typed selector. Four tools accept `target: "worker:<substring>"` (Chrome only, capability `worker.targets`) — `evaluate_script`, and, as of 1.9.1, `list_network_requests`, `get_network_request`, and `list_console_messages` — any substring of the worker's url, so `worker:<extension-id>` and `worker:background.js` both work:
|
|
299
349
|
|
|
300
350
|
```json
|
|
301
351
|
{"tool": "evaluate_script", "arguments": {"target": "worker:ekgaohljhieodkfggjkfgmmamfpngdhn", "expression": "chrome.storage.local.get()", "args": []}}
|
|
@@ -306,9 +356,23 @@ A few facts worth knowing before you reach for it, all measured against a real b
|
|
|
306
356
|
- **An idle MV3 worker is evicted within seconds of its last activity, and once evicted it is invisible to every target listing** — not "stopped and still visible," genuinely absent from `/json/list` and `Target.getTargets` under any filter. So a `worker:` miss almost always means "asleep," not "wrong id," and the error says so.
|
|
307
357
|
- **`wake` defaults to `true`:** a selector that matches nothing, or a known-but-stopped worker, is started for you first, then re-resolved. Pass `wake:false` to fail fast instead — the error then teaches the eviction fact rather than reading like a typo.
|
|
308
358
|
- **A wake is verified by re-reading the target list, never by the start command's own result** — Chrome's start command reports success even for a scope that doesn't exist, so nothing here trusts it.
|
|
309
|
-
- **Worker targets bypass the lease gate entirely.** `claim_page`/strict mode never mint a lease for a worker
|
|
359
|
+
- **Worker targets bypass the lease gate entirely.** `claim_page`/strict mode never mint a lease for a worker-targeted call, on any of the four tools; a worker id is Chrome's to create and destroy, not this toolkit's to fence.
|
|
310
360
|
- **If you're loading your own unpacked extension for testing, `--load-extension` does nothing on recent Chrome**, even with the documented unblock flag. Use `Extensions.loadUnpacked` over CDP instead (what this toolkit's own extension smoke does).
|
|
311
361
|
- **The extension id is a hash of the unpacked extension's absolute path**, so it's deterministic across loads from the same directory but differs from the id a packed/store install would get — don't hardcode an id copied from a different machine's path.
|
|
362
|
+
- **Before deep-debugging something that looks like "the toolkit can't see it," check you're driving the build you think you are:** `evaluate_script {target:"worker:<id>", expression:"chrome.runtime.getManifest().version"}`. Chrome keeps serving a stale unpacked build's worker until you explicitly reload the extension, and an outdated loaded build mimics every symptom below.
|
|
363
|
+
|
|
364
|
+
### Watching what a worker sends and logs (1.9.1)
|
|
365
|
+
|
|
366
|
+
`list_network_requests`, `get_network_request`, and `list_console_messages` record a worker the same way they record a page — over the worker's own CDP session, the same `Network`/`Runtime` domains, the same `get_*` follow-up for bodies — with one difference: a worker capture can't be driven by reload. Measured against Chrome 151.0.7922.109: `Page.enable`/`Page.reload` don't exist on a worker session (`-32601 'Page.enable' wasn't found`), so a worker capture LISTENS for `durationMs` instead of reloading; `Network.requestWillBeSent`/`responseReceived`/`loadingFinished` and `Runtime.consoleAPICalled` all arrive on that same session, and `Network.getResponseBody` serves the body, exactly like a page capture.
|
|
367
|
+
|
|
368
|
+
```json
|
|
369
|
+
{"tool": "list_network_requests", "arguments": {"target": "worker:ekgaohljhieodkfggjkfgmmamfpngdhn", "reload": true, "durationMs": 5000}}
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
- **Recording a worker keeps it alive for the length of the capture — a documented side effect, not a bug.** A held CDP session suppresses MV3 idle eviction (measured: alive and still emitting events at 60s held; evicted ~30s after the recorder detaches). Watch a worker and Chrome won't put it to sleep out from under you until you stop.
|
|
373
|
+
- **`wake` (default `true` on a capture) is refused on a read-only call and on a bare target id**, rather than accepted and quietly doing nothing useful. A worker that idle-evicts and restarts comes back under a brand-new target id (measured: re-minted on every restart), and the buffer is keyed by id, so waking on a *read* would hand back a live worker with an empty buffer — "0 requests" for a capture that really did record some, a zero shaped like an answer. Re-run with `reload:true` to record a fresh capture instead.
|
|
374
|
+
- **A worker woken by the capture has already run its top-level code by the time the recorder attaches**, so a fetch it makes at startup can be missed. Trigger the request you want to observe (a message, an alarm) after the capture starts rather than relying on the worker's own boot-time fetch.
|
|
375
|
+
- **A `self.fetch` monkeypatch via `evaluate_script` won't reliably intercept a worker's requests, and reading a module-scoped value from `evaluate_script` won't reliably work either — both for ordinary JavaScript reasons, not a service-worker-specific one.** A value bound at module top level is not visible from `evaluate_script`'s global scope (plain lexical scoping, identical in a page); assigning `self.fetch` cannot rebind a `fetch` reference the module already captured (closure semantics, also identical in a page). `list_network_requests`/`get_network_request` observe the `Network` domain on the worker's own session directly and need neither.
|
|
312
376
|
|
|
313
377
|
`worker:` is deliberately absent everywhere else — `close_page`/`select_page`/`release_page` and `claim_page`'s `target` stay page-only, refusing the arm by name rather than silently missing, and Firefox refuses it with a capability error (`worker.targets`), since WebDriver BiDi has no extension-service-worker target to map onto.
|
|
314
378
|
|