cdp-toolkit 1.9.1 → 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 CHANGED
@@ -5,6 +5,395 @@ All notable changes to cdp-toolkit are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.9.4] - 2026-08-11
9
+
10
+ Firefox gains a second mode: **attach**. Until now `--browser firefox` always spawned a
11
+ throwaway-profile Firefox — a login wall for anything needing a real, already-authenticated
12
+ session. `--connect <port|host:port|ws-url>` (or env `CDP_FIREFOX_ENDPOINT`) instead connects to
13
+ a Firefox **you** already started with `--remote-debugging-port`, so tools see your real logged-in
14
+ profile. This process never launches or kills that browser: dispose only ends the BiDi session
15
+ (`session.end`), never the process. Also fixes a doc-nit (README/CONTRACT overclaimed "Firefox
16
+ cannot be attached to at all" — the real, narrower limitation is that you cannot relaunch the
17
+ `firefox` binary with the debug flag against an already-running instance) and a real hang (a
18
+ second attach against a Firefox that already has an active BiDi session used to hang forever;
19
+ it now fails fast with an actionable error).
20
+
21
+ ### Added
22
+
23
+ - **`--connect <endpoint>` / `CDP_FIREFOX_ENDPOINT`** — Firefox attach mode. `<endpoint>` accepts
24
+ three spellings, all normalized to a ws URL: a bare port (`9223`), `host:port`
25
+ (`127.0.0.1:9223`), or a full `ws://`/`wss://` URL. Flag beats env, mirroring
26
+ `--browser`/`CDP_BROWSER` precedence. An endpoint **implies** the Firefox backend on its own —
27
+ there is nothing else it could mean — and errors against an explicit `--browser chrome` /
28
+ `CDP_BROWSER=chrome` rather than silently picking one side.
29
+ - `src/backend.ts`: `normalizeBidiEndpoint`, `resolveFirefoxEndpoint`, `stripConnectFlag`,
30
+ `resolveBackend` (the single backend-selection entry point `cli.ts`/`mcp.ts` now both call —
31
+ returns `{kind, endpoint?}`), and `startFirefoxSession`/`getOrCreateFirefoxSession` now accept
32
+ an `{ endpoint }` to attach instead of launch.
33
+ - `src/bidi/client.ts`: `connectBidiSessionUrl(wsUrl, opts?)` — connects directly to a ws URL,
34
+ no port-to-URL construction. `connectBidiSession(port, opts)` is now a thin wrapper over it
35
+ (kept exported, unchanged behavior).
36
+ - `src/bidi/driver.ts`: `createFirefoxDriverForEndpoint(wsUrl, opts?)` — the attach-mode driver
37
+ constructor. The driver's connection identity moved from a bare `port: number` to a ws
38
+ `endpoint: string`, so an attach endpoint can carry a different host/port/path than a launched
39
+ Firefox's `ws://127.0.0.1:<port>/session`.
40
+ - `firefox:attach:smoke` script (`test/firefox-attach-smoke.ts`) — drives the real attach path
41
+ against an out-of-band-spawned Firefox: attach, read a page, dispose (Firefox stays alive), a
42
+ second attach cycle to the same Firefox (proves `session.end` freed the slot), and confirms
43
+ `resolveBackend` auto-implies `firefox` from `--connect` alone.
44
+ - `test/backend-attach.test.ts` — pure-unit coverage of `normalizeBidiEndpoint`,
45
+ `resolveFirefoxEndpoint`, `stripConnectFlag`, and `resolveBackend`, including the
46
+ `--browser chrome` + `--connect` conflict.
47
+
48
+ ### Fixed
49
+
50
+ - **A second attach against a Firefox with an already-active BiDi session used to hang forever.**
51
+ Firefox allows exactly one active WebDriver BiDi session at a time; a colliding `session.new`
52
+ fails with the raw wire message `Maximum number of active sessions`, which reached no caller
53
+ as an error — it hung. `getConnection` (`src/bidi/driver.ts`) now catches that specific message
54
+ and raises a clear, actionable error naming the endpoint and the fix (close the other client,
55
+ or restart Firefox; a client killed without a clean dispose, e.g. `SIGKILL`, can leave a
56
+ session Firefox does not reap on its own).
57
+ - **Attach-mode dispose was undefined behavior before this release** (the mode did not exist):
58
+ `BidiBrowserDriver.dispose()` now sends `session.end` before closing the socket, which is what
59
+ actually frees Firefox's single session slot for the next attach — closing the socket alone
60
+ does not. Launch-mode dispose is unaffected: the spawned process is still killed after the
61
+ driver disposes, same as before.
62
+ - **`src/bidi/launch.ts` doc-nit** (the issue's explicit ask, code unchanged): the header/footer
63
+ said Firefox "cannot be attached to at all." That overstated a real, narrower fact — you cannot
64
+ *relaunch* the `firefox` binary with `--remote-debugging-port` against an already-running
65
+ instance and have it take effect (the relaunch hands off and exits, opening no port; still true,
66
+ still verified against Firefox 153.0.3). Attaching to a port a Firefox process was *originally*
67
+ launched with — by this toolkit or by hand — always worked and is now this release's first-class
68
+ path. README.md and CONTRACT.md carried the same overclaim and are corrected too.
69
+
70
+ ### Notes
71
+
72
+ - Tool count unchanged at **45**; no tool added, renamed, or removed. Both Firefox modes expose
73
+ the same tool set as before (see README's "Tool availability is filtered per backend").
74
+ - The CLI is one process per invocation either way: an attach-mode CLI call still launches no
75
+ process and kills none, it just skips `launchFirefox` entirely. Multi-step attach workflows
76
+ still want the MCP server, same reasoning as launch mode.
77
+
78
+ ## [1.9.3] - 2026-08-10
79
+
80
+ `cli.ts` had **no `--help` handling at all.** `--help` fell through the generic
81
+ `--key value` branch into `args.help = true`, and the named tool ran with that
82
+ as an inert extra property; `-h` doesn't even start with `--`, so it fell all
83
+ the way to the positional-token branch and was silently dropped once a tool
84
+ name was already set. Either way, **the tool ran.** Measured live this
85
+ session: `cdp take_screenshot --help` executed a real capture of whatever tab
86
+ happened to be active and wrote a 1.2 MB PNG to `/tmp/cdp-toolkit` — a "tell
87
+ me, don't do it" discovery gesture that silently photographed what the user
88
+ was looking at. Reproduced again here with the safe, non-capturing
89
+ `list_pages --help` against a `CDP_BASE` pointed at a closed port: pre-fix it
90
+ printed `{"error":"Unable to connect. Is the computer able to access the
91
+ url?"}` and exited 1 — proof the tool dispatched and tried to reach the
92
+ browser instead of printing help. Post-fix, the identical invocation against
93
+ the same dead port prints usage and exits 0, and a directory listing of the
94
+ artifact dir taken before and after is byte-identical: no file, no browser
95
+ touch.
96
+
97
+ ### Fixed
98
+
99
+ - **`--help`/`-h` now short-circuit before any tool dispatch, recognised
100
+ anywhere in argv** (not just as the first token), and are never treated as
101
+ a tool argument or a tool name. With no tool named, `cdp --help`/`-h` prints
102
+ the existing top-level usage banner (now also documenting the flag itself).
103
+ With a tool named, `cdp <tool> --help` prints that tool's argument list —
104
+ name, type, required/optional, description — read directly from
105
+ `MANIFEST`'s `inputSchema` (`src/manifest.ts`), the exact JSON Schema the
106
+ MCP server advertises via `tools/list`, so there is no second
107
+ hand-maintained argument list to drift from it.
108
+ - An unknown tool name alongside `--help` (`cdp nope --help`) is reported as
109
+ `{"error":"unknown tool 'nope'. ..."}` and exits 1 — the same message the
110
+ CLI already gives for `cdp nope` without `--help` — rather than silently
111
+ falling back to top-level usage as though the name had been accepted.
112
+ - **Decision, stated explicitly:** an unknown `--flag` passed to a *known*
113
+ tool still passes through as a tool argument, unchanged from pre-1.9.3
114
+ behavior. Every `inputSchema` already declares `additionalProperties:
115
+ false`, but neither `cli.ts` nor `mcp.ts` has ever enforced that — each tool
116
+ validates its own args at runtime, extra keys are typically just ignored,
117
+ and an existing caller may already depend on that leniency for something
118
+ harmless. Rejecting unknown flags here would diverge the CLI from the MCP
119
+ server (whose validation this release does not touch) to fix a problem this
120
+ bug did not create. `--help`/`-h` are instead recognised by name,
121
+ specifically, ahead of generic flag parsing — narrow enough that it needs
122
+ none of that broader, riskier call.
123
+
124
+ ### Added
125
+
126
+ - `test/cli-help.test.ts` (6 tests): spawns the real CLI as a subprocess with
127
+ `CDP_BASE` pointed at a closed port, so any invocation that still reaches
128
+ tool dispatch fails loudly with a connection error instead of silently
129
+ succeeding. Watched fail against the pre-fix code first — 5 of 6 red on
130
+ exit code 1 instead of 0 — before confirming green post-fix.
131
+
132
+ ### Notes
133
+
134
+ - Tool count unchanged at **45**; no tool added, renamed, or removed.
135
+ `take_screenshot` is not among the files this release touches.
136
+ - 748 pass / 0 fail across 22 files, up from 742/21. `bun run typecheck` clean.
137
+
138
+ ## [1.9.2] - 2026-08-10
139
+
140
+ `take_screenshot` grows four arguments — `scale`, `renderWidth`/`renderHeight`,
141
+ `tile` — and stops being able to hang the tab it is capturing. The constraint
142
+ worth learning from this release: **Chrome cannot encode a screenshot larger
143
+ than 16384 device px on either side, and past that it does not refuse politely
144
+ — `Page.captureScreenshot` never answers, and because `captureBeyondViewport`
145
+ resizes the renderer to the clip for the length of the capture, a call that
146
+ never returns never puts it back.** Output px are `ceil(css × scale ×
147
+ devicePixelRatio)`, so on an ordinary ratio-2 display that ceiling arrives at
148
+ about 8192 CSS px — a long article, a chat log, a docs page. A plain no-flag
149
+ `take_screenshot` of a 9000 CSS px page timed out (probed with an 8 s command
150
+ timeout) and left the tab wedged at 1390×9000, with every later capture on it
151
+ timing out too. It now returns a 2780×18000 px file in 1 second, as two
152
+ stitched bands. Tool count
153
+ stays 45 — no new tool, `take_screenshot` grew arguments. Five commits landed
154
+ it in sequence, on top of two measurement passes that drove a live
155
+ Chrome 151.0.7922.76 and wrote no code — the encode cap and the band-artifact
156
+ hunt. Every number below comes off a measurement, and where a measurement
157
+ contradicted the brief that ordered the work, the measurement is what is
158
+ written down.
159
+
160
+ ### Added
161
+
162
+ - **`scale` — a per-capture output multiplier**, a finite number greater than
163
+ 0 and at most 8 (default 1), Chrome only (capability `screenshot.scale`).
164
+ Measured exact on a live tab: a 2780×2128 default capture at `scale 3` is
165
+ 8340×6384, a `--fullPage` at `scale 2` is 5560×13000, a 400×250 CSS element
166
+ at `scale 4` is 3200×2000 — `sips` agrees with the reported size on 9 of 9
167
+ artifacts, PNG and JPEG alike. It re-renders, it does not upsample, and it
168
+ changes nothing the page can see: `devicePixelRatio` read 2 and
169
+ `innerWidth`/`innerHeight` read 1390×1064 both before and after a `scale 3`
170
+ capture. Firefox refuses it rather than silently returning a 1x image the
171
+ caller could not tell apart from the one they wanted —
172
+ `browsingContext.captureScreenshot` has no scale parameter, and the refusal
173
+ points at `emulate {deviceScaleFactor}` instead.
174
+
175
+ - **`renderWidth`/`renderHeight` — emulate a viewport for exactly one capture,
176
+ then put the previous one back.** Required together, integers in
177
+ `[1, 16384]` CSS px, and declared by **both** backends (capability
178
+ `screenshot.renderSize`) — unlike `scale`, this is not a Chrome-only
179
+ argument. A 1390×1064 tab captured at 1920×1080 yields 3840×2160 device px
180
+ and reads back at 1390×1064 afterwards; captured at 420×900 it is
181
+ **byte-identical** (sha256 `93ffca74…`) to the same page genuinely emulated
182
+ at 420×900, media query flipped and all. That is the difference from `scale`
183
+ in one sentence: `scale` re-renders the same layout at more pixels,
184
+ `renderWidth`/`renderHeight` change what the page *believes* for one
185
+ capture. The restore holds on the success path, on the throwing path, and
186
+ over a caller's prior `emulate` (a tab emulated at 777×555 is 777×555 again
187
+ afterwards, not the real device). Two honest limits ride in the result
188
+ rather than in a comment: the restore can only put back an override **this
189
+ toolkit** applied — one set by the DevTools UI, another client, or an older
190
+ build leaves no record and is reset to the real device — and a capture that
191
+ TIMES OUT cannot be restored at all, because the `finally` runs while
192
+ Chrome's own command is still executing and re-resizes the page behind it.
193
+ `renderRestored: false` (plus `renderRestoreError`) is what the caller gets
194
+ when the tab is still emulated and they must fix it themselves.
195
+
196
+ - **`tile` — vertical band capture, stitched losslessly, and AUTO by
197
+ default.** Three-valued: omitted means one shot when the projection fits and
198
+ bands when it does not; `false` never bands and refuses instead (the exact
199
+ pre-1.9.2 behaviour); `true` always bands, even when one shot would have
200
+ fitted. Only `true` costs a capability (`screenshot.tile`, Chrome only).
201
+ The headline, a plain call with no flags on a 140,982 CSS px page:
202
+ **2780×281964 px, 18 bands, 10,818,058 bytes, 17–18 s**, tab healthy
203
+ afterwards (`[innerWidth, innerHeight, devicePixelRatio]` = `[1390, 1064,
204
+ 2]`, `scrollY` 0) and two runs byte-identical. 281964 is 140982 × 2 exactly:
205
+ the whole document, nothing dropped. A banded capture is PNG-only and cannot
206
+ also `returnBase64`; both combinations are refused in 21–31 ms measured,
207
+ before a single band is captured, with a message naming the band count and
208
+ the region that forced the decision. Tiling is **vertical only** — a projected WIDTH
209
+ past the cap is refused (naming the largest scale, or renderWidth, that
210
+ would fit), not split into a grid, because PNG scanlines are the unit that
211
+ can be concatenated without decoding the image whole.
212
+
213
+ - **New result fields.** `scale` is always present. `width`/`height` are
214
+ **decoded from the encoded bytes** (PNG IHDR, JPEG SOF marker walk) rather
215
+ than projected, and are **omitted entirely** when the bytes cannot be
216
+ decoded — absent beats invented. `renderSize`, `renderRestored` and
217
+ `renderRestoreError` accompany a render-size capture; `tiled: true` and
218
+ `bands: <n>` are present only on a banded one, so a caller always knows when
219
+ an image was assembled rather than captured.
220
+
221
+ - **`src/png.ts`, a streaming lossless PNG band stitcher**, zero runtime
222
+ dependencies (`node:zlib` is a builtin). Peak RSS is **135.3 MB while
223
+ writing a 1.42 GB image** (368,640 rows) — 9× the raw pixels of a 157 MB one
224
+ for 11.7% more memory, because peak tracks band size, not image size (Bun's
225
+ own floor is 23.6 MB of that).
226
+ Its output is byte-identical under ffmpeg's raw decode to the source bands
227
+ concatenated, at every one of PNG's five row filters, and a 2-band stitch of
228
+ a page that fitted decodes to the identical sha256 as the single shot of the
229
+ same page. It carries `gAMA`/`cHRM`/`sRGB`/`iCCP`/`pHYs` from the first band
230
+ and throws on a mismatch: every Chrome capture measured carries an embedded
231
+ ICC profile, so a stitcher that dropped ancillary chunks — the obvious
232
+ default — would silently recolour every image it touched.
233
+
234
+ ### Changed
235
+
236
+ - **Every capture now costs one `Page.getLayoutMetrics` round trip, including
237
+ the plain default one.** That is the change that closed the hazard. The
238
+ plain path was the one path that built no clip, so the encode guard never
239
+ ran on it, while `captureBeyondViewport: true` is hardcoded for every
240
+ capture — meaning an ordinary long page, hit with an ordinary flagless
241
+ screenshot, was already capturing beyond the viewport with nothing standing
242
+ between it and the hang. Nothing else about that path moved: it still sends
243
+ **no `clip` key at all**, and every step of this release A/B'd the common
244
+ path against its own parent commit's binaries on the same live tab —
245
+ default, `fullPage`, `scale 2`, `jpeg`, element-clip and `tile:false` all
246
+ came back byte-identical.
247
+
248
+ - **`savePath` outside the artifact dir now works.** The destination directory
249
+ is created from the output path's own dirname rather than always
250
+ `CDP_ARTIFACT_DIR`; previously a `savePath` in a non-existent directory
251
+ failed `ENOENT` while `CDP_ARTIFACT_DIR` was created for nobody.
252
+
253
+ - **The default filename's stamp is the capture's START**, not its end: a
254
+ banded capture writes as bands arrive and has no finished image to name a
255
+ file after, and one naming path is easier to trust than two.
256
+
257
+ - **A new state-file type**, `${CDP_ARTIFACT_DIR}/viewport-<scheme>-<targetId>.json`,
258
+ a sibling of the lease and origin files. A render-size capture must restore
259
+ the caller's *previous* override, and a `PageDriver` lives for exactly one
260
+ call (under the CLI, one process), so the record has to outlive the
261
+ connection the same way a lease does — measured: an `emulate` override set
262
+ in one process is still in force when read from another.
263
+
264
+ ### Fixed
265
+
266
+ - **An element-clipped `take_screenshot` returned the wrong region of the page
267
+ whenever the element was not already on screen.** `--selector` and `--uid`
268
+ both, at every page height tried. The mechanism is a frame mismatch worth
269
+ learning on its own: `DOM.getBoxModel` answers in the **viewport** frame (its
270
+ quad matched `getBoundingClientRect()` to the pixel every time), but
271
+ `captureBeyondViewport: true` — hardcoded on every capture here — makes
272
+ Chrome read `clip.x`/`clip.y` as **document** coordinates. The driver scrolled
273
+ the element into view with `DOM.scrollIntoViewIfNeeded` and then handed
274
+ Chrome the element's now-small *on-screen* y as if it were its position in
275
+ the document, so the capture landed within one viewport height of the top of
276
+ the page no matter how far down the element really was. Measured on
277
+ Chrome 151.0.7922.76, a 10,000 CSS px page with the target at y=5000: the
278
+ scroll settled at `scrollY` 4593 with `rectTop` 407, and the returned
279
+ 2780×500 PNG was `document[407..657]` — a flat slab of page background with
280
+ no part of the element in it. Identical failure at the two sizes this bug was
281
+ first reported at, 16,578 px (`scrollY` 14593) and 140,982 px (`scrollY`
282
+ 69593). Starting the same call from `scrollY` 4950 instead — element already
283
+ visible at `rectTop` 50, so the driver's own scroll is a no-op — returned
284
+ `document[50..300]`: the document's top banner for its first 100 CSS px, then
285
+ background, boundary on the predicted pixel. **The fix is the frame
286
+ conversion**: the page's scroll offset is added to the box-model quad, in CSS
287
+ px (`cssVisualViewport.pageX`/`pageY`), read **after** the scroll settles and
288
+ off the same single `Page.getLayoutMetrics` every capture already pays for —
289
+ reading it before the scroll would correct by 0 on a freshly navigated tab
290
+ and change nothing. Those same three page heights now return the element
291
+ itself, and the three starting scroll positions 0 / 4950 / 9000 return one
292
+ identical sha256 `54b96705…` instead of two different wrong images. `scale`
293
+ (5560×1000 at `scale 2`), `renderWidth`/`renderHeight` (1600×500 at 800×600,
294
+ `renderRestored: true`), `tile` and the `uid` path all ride the corrected
295
+ rect unchanged, and the short-page control — element inside the initial
296
+ viewport, no scroll needed, offset 0 — is byte-for-byte what it was.
297
+ **Two honest notes.** First, the units are a live trap: on a scrolled page
298
+ `visualViewport.pageX`/`pageY` come back in **CSS** px (700/4593) while
299
+ `clientWidth`/`clientHeight` on that very same rect are **device** px
300
+ (2780×2128), and only `layoutViewport` reports the offset in device px
301
+ (1400/9186) — so "no `css` prefix means device px" is false for exactly the
302
+ fields this fix reads, and a plausible implementation that divided them by
303
+ the pixel ratio would have halved the correction. Second, this release's own
304
+ regression check could not have caught it: the element-clip A/B recorded
305
+ under *Changed* above compared each commit against its parent's binaries and
306
+ came back byte-identical **because both sides were wrong the same way** — a
307
+ same-vs-same check is blind to a bug older than both of its arms.
308
+ `DOM.scrollIntoViewIfNeeded` was kept rather than dropped, and that was
309
+ measured too, not assumed: with `captureBeyondViewport` nothing has to be on
310
+ screen to be captured, but on a fixture whose target fills itself from an
311
+ `IntersectionObserver`, the capture with the scroll reads "LAZY CONTENT
312
+ LOADED" and the same capture without it returns the unloaded placeholder in
313
+ the correct region — the same caveat the tiling notes below record, from the
314
+ other side. WebDriver BiDi never had this defect: it passes
315
+ `clip: {type: "element"}` and does no coordinate arithmetic of its own.
316
+
317
+ - **`emulate {clearOverrides: true}` reported `"cleared": true` while clearing
318
+ nothing.** Reproduced across three separate CLI processes: process A set
319
+ 802×601, process B cleared and reported success, process C read back
320
+ `[802, 601]`. `Emulation.clearDeviceMetricsOverride` only reverts to the real
321
+ device when the clearing connection is also the connection that *set* the
322
+ override — and under this toolkit's per-call connection lifetime it
323
+ essentially never is, so this was the common case, not an edge case. The
324
+ driver now reads the tab's currently effective size, re-asserts that exact
325
+ size on its own connection (a value the caller cannot observe changing,
326
+ whose only job is to take ownership), and then clears; the same trace now
327
+ reads back `[1390, 1064]`, the real device. If any step of that sequence
328
+ throws, the call throws too rather than returning a false success, and the
329
+ viewport record is deliberately left in place so a later capture does not
330
+ read "no record" as "nothing to restore".
331
+
332
+ ### Measured, not assumed — the numbers this release ships on
333
+
334
+ - **16384 device px per side, both axes, clipped and unclipped alike.**
335
+ 16384×1600 encodes in 266 ms; 16400×1600 — sixteen pixels more — never
336
+ answers (60 s probe cap). With no `clip` key at all: 2560×16384 succeeds in
337
+ 217 ms, 2560×24000 hangs. **It is not an area limit:** 8192×8192 (67,108,864
338
+ px) succeeds in 1.3 s while 16400×1600 (26,240,000 px, 2.6× less) hangs. The
339
+ cap is also in DEVICE px, so a CSS clip of 8200×800 at `deviceScaleFactor: 2`
340
+ hangs identically to asking for 16400×1600 directly.
341
+ - **Band tiling is visually clean, and this was falsification-tested rather
342
+ than assumed.** A `position: fixed` header present in the top band appears
343
+ **zero** times in a band 8000 px down; a `position: sticky` header appears
344
+ only in the band containing its true document position. Seams are row-exact
345
+ — band A ends on ROW 039, band B begins on ROW 040, no duplicate, no gap.
346
+ Clipped bands are byte-identical across three different real scroll
347
+ positions, so a stitcher never has to manage scroll. `clip.y` is always CSS
348
+ px and `scale` only multiplies resolution, so the two compose without
349
+ rescaling offsets. Content-verified end to end on the 140,982 px page: all
350
+ **71** label bands land at exactly their expected rows across 9 bands and 8
351
+ seams.
352
+ - **The honest caveat.** A page that loads content only on real scroll —
353
+ lazy images, virtualized lists, infinite scroll — renders **blank** in bands
354
+ the real viewport never reached. Measured directly: a page whose
355
+ `IntersectionObserver` had loaded 9 of 300 rows still reported 9 after a
356
+ deep-band capture, and the deep band was uniform `#222`.
357
+ `captureBeyondViewport` does not trigger an `IntersectionObserver`. This is
358
+ a property of the source page, not a bug in the stitch, and it is not
359
+ fixable here — which is exactly why an auto-tiled capture reports `tiled`,
360
+ so a caller knows to reason about it (pre-scroll the page, or disable
361
+ virtualization, before capturing).
362
+ - **Two corrections to earlier internal notes, kept rather than quietly
363
+ dropped.** (1) At 140,982 CSS px the *old* code did not hang: it failed in
364
+ about a second with `Page.captureScreenshot: Page is too large.` and left
365
+ the tab healthy. The hang-and-wedge lives at *modest* overshoot — the 9000
366
+ CSS px page above. Both are failures, but "it hangs at 140,982" is not what
367
+ this Chrome build does. (2) A wedged tab is **not** recovered by
368
+ `emulate --clearOverrides`: measured in order, clearing reports success and
369
+ the tab stays 1390×9000, an explicit `emulate --width/--height` recovers it,
370
+ and clearing again puts it *back*. Only a reload actually clears it. The
371
+ renderer's real viewport is what the abandoned capture resized, so
372
+ "clear to the real device" lands on the wrong size.
373
+
374
+ ### Notes
375
+
376
+ - Tool count is unchanged at **45**. `take_screenshot` grew four arguments;
377
+ no tool was added, renamed, or removed.
378
+ - **Firefox/BiDi does not declare `screenshot.scale` or `screenshot.tile`**,
379
+ and for two different reasons stated plainly rather than dressed up as
380
+ design: `browsingContext.captureScreenshot` genuinely has no scale
381
+ parameter, while band tiling is *probably* implementable there
382
+ (`clip: {type: "box"}` exists) but every property it rests on — does a box
383
+ clip render past the viewport, is `clip.y` CSS px, do fixed elements repeat
384
+ per band, are seams pixel-exact — was measured on Chrome and nowhere else,
385
+ because no Firefox was driven for it. `screenshot.renderSize` **is**
386
+ declared by both. Auto-tiling never fires on a backend without
387
+ `screenshot.tile`, so Firefox behaviour is unchanged.
388
+ - New tests: **117**, in `test/screenshot-scale.test.ts` (81) and
389
+ `test/png.test.ts` (36) — 736 pass / 0 fail across 21 files, up from 619.
390
+ Both new suites were mutation-checked rather than merely green: 13 deliberate
391
+ mutants of `src/png.ts` and 6 of the tiling arithmetic were each killed,
392
+ and the three mutants that survived are documented in the source as provably
393
+ equivalent rather than silently "fixed" later.
394
+ - `docs/social-preview.html` and `docs/demo.tape` carry no version string and
395
+ needed no re-render for this release.
396
+
8
397
  ## [1.9.1] - 2026-08-10
9
398
 
10
399
  `list_network_requests`, `get_network_request`, and `list_console_messages` now
package/CONTRACT.md CHANGED
@@ -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,7 +171,7 @@ 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
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`. |
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
- **Firefox is launched, never attached to.** Unlike Chrome, there is no way to add a debug port to an already-running Firefox: the flag only takes effect on the process's original launch, so relaunching the binary against a running instance hands off and exits silently, opening no port (verified against Firefox 153.0.3). `--browser firefox` therefore always starts a fresh Firefox process with a throwaway profile:
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
 
@@ -268,7 +318,7 @@ 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` (+ layout metrics) | Clip scale fixed at 1. Full-page uses `captureBeyondViewport` + layout-metrics clip. |
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. |