cdp-toolkit 1.7.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +284 -0
- package/CONTRACT.md +5 -1
- package/README.md +35 -13
- package/dist/cli.js +973 -56
- package/dist/index.js +834 -44
- package/dist/mcp.js +1213 -66
- package/package.json +5 -3
- package/src/activity.ts +533 -0
- package/src/bidi/driver.ts +231 -8
- package/src/capabilities.ts +1 -1
- package/src/cdp/driver.ts +472 -25
- package/src/driver.ts +161 -3
- package/src/index.ts +26 -4
- package/src/leases-tools.ts +125 -4
- package/src/leases.ts +1 -1
- package/src/manifest.ts +239 -10
- package/src/mcp.ts +6 -1
- package/src/reap.ts +55 -4
- package/src/shared-tools.ts +268 -27
- package/src/tools/browser-session.ts +246 -0
- package/src/tools/dispatch-mouse.ts +108 -0
- package/src/tools/downloads.ts +143 -0
- package/src/tools/permissions.ts +130 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,289 @@ 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.8.0] - 2026-08-09
|
|
9
|
+
|
|
10
|
+
Two tracks land together, merged onto one integration branch. Track S closes the
|
|
11
|
+
gap 1.7.0's takeover mode opened: an agent can now claim a human's open tab, but
|
|
12
|
+
nothing detected the human was still there, and reap could destroy an
|
|
13
|
+
agent-abandoned tab the moment it went idle. Track P is four groups of input
|
|
14
|
+
parity chrome-devtools-mcp already had and this toolkit didn't: a wheel/scroll
|
|
15
|
+
tool, a raw mouse primitive, real HTML5 drag-and-drop, and navigation
|
|
16
|
+
history/downloads/permissions. Tool count moves 41 → 45. Every new
|
|
17
|
+
input-dispatching path (Track P) was wired through Track S's dispatch log as
|
|
18
|
+
part of the merge, not left as a follow-up — see "Changed" below.
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- **An activity beacon: `claim_page` and `list_leases` now know when a human is
|
|
23
|
+
already using a tab (`src/activity.ts`).** A tiny in-page script records the
|
|
24
|
+
timestamp of the last `pointerdown`/`mousedown`/`keydown`/`wheel`/`touchstart`,
|
|
25
|
+
installed at claim time on both `claim_page` modes and
|
|
26
|
+
`new_page{claim:true}`. On Chrome, `Page.addScriptToEvaluateOnNewDocument`
|
|
27
|
+
alone does **not** survive navigation under this toolkit's per-call connection
|
|
28
|
+
lifetime — the registration dies with the connection that made it, which is
|
|
29
|
+
every tool call, a fact verified against a live browser before anything was
|
|
30
|
+
built on it (`addScriptToEvaluateOnNewDocument` → new connection → navigate →
|
|
31
|
+
`null`). So the CDP driver now holds one connection open per beaconed tab
|
|
32
|
+
purely to keep the registration alive across navigations, bounded at 32
|
|
33
|
+
tabs with oldest-first eviction; an evicted tab still answers for input on
|
|
34
|
+
its current document, only the next navigation's re-arm is lost. Firefox
|
|
35
|
+
needs none of this — its BiDi session lives for the whole process, so a
|
|
36
|
+
`script.addPreloadScript` registration survives navigation for free, and was
|
|
37
|
+
implemented and live-verified rather than left absent, since the spec's
|
|
38
|
+
Firefox carve-out only applied if the plumbing was intractable and it wasn't.
|
|
39
|
+
CDP-dispatched input is `isTrusted:true` too, so the beacon alone cannot tell
|
|
40
|
+
a human click from the toolkit's own: every input-dispatching call writes to
|
|
41
|
+
an in-process dispatch log, and a beacon timestamp counts as human only when
|
|
42
|
+
it postdates this server's last dispatch on that tab by more than 1500ms.
|
|
43
|
+
`claim_page`/`list_leases` surface it as `humanActiveMs` — milliseconds since
|
|
44
|
+
input this server did not dispatch. **`null`/absent means NO DATA, never "no
|
|
45
|
+
human"**: a fresh tab, a tab whose every input was this server's own, and a
|
|
46
|
+
backend that cannot answer at all are all indistinguishable from each other
|
|
47
|
+
and from silence. A `claim_page` takeover of a tab a human used within the
|
|
48
|
+
last 30 seconds additionally carries a `contention` warning string, and **the
|
|
49
|
+
claim is never refused for it** — taking over a person's tab is what
|
|
50
|
+
takeover mode is for, so the warning informs a caller who already holds the
|
|
51
|
+
lease, it does not gate anything. Known blind spots, stated rather than
|
|
52
|
+
designed away: input inside a cross-origin iframe is invisible to the
|
|
53
|
+
beacon, and a *second* MCP server process's dispatches read as human to this
|
|
54
|
+
one, since dispatch-log correlation is scoped to this process.
|
|
55
|
+
|
|
56
|
+
- **Split reap horizon: reclaimable and destroyed are different moments, now
|
|
57
|
+
separated by `CDP_REAP_GRACE_MS`.** Before this release, `list_pages` and
|
|
58
|
+
`list_leases` closed an agent's tab the instant its lease read `expired`,
|
|
59
|
+
which is also the instant another agent becomes *entitled* to take it —
|
|
60
|
+
fine for handing the lease to someone else, destructive when applied to the
|
|
61
|
+
tab itself. An agent 20 minutes into a build between tool calls does not
|
|
62
|
+
expect to come back to a destroyed tab just because it could, in principle,
|
|
63
|
+
have been reclaimed. `staleAgentTabs` (`src/reap.ts`) stays a pure function
|
|
64
|
+
but now takes `now`/`reapGraceMs`: an `expired` lease qualifies for the
|
|
65
|
+
destructive close only once `now - lastUsedAt > ttlMs + reapGraceMs`.
|
|
66
|
+
`CDP_REAP_GRACE_MS` defaults to `2700000` (45 minutes), for a 60-minute total
|
|
67
|
+
fuse after last use. Lease *reclaimability* — `list_leases`' `stale` field —
|
|
68
|
+
is byte-for-byte unchanged at `ttlMs`; only the destructive close moved.
|
|
69
|
+
`dead-pid` is unaffected by grace and reaps immediately, because that
|
|
70
|
+
process is never coming back and there is nothing to wait for.
|
|
71
|
+
|
|
72
|
+
- **Renderer ping and lease observability on `list_pages`/`list_leases`.**
|
|
73
|
+
`list_pages` gains `probe?: boolean` (default false, byte-identical shape
|
|
74
|
+
when omitted): one bounded (500ms) `Runtime.evaluate` per page-type target,
|
|
75
|
+
run concurrently so one wedged tab cannot stall the rest of the listing
|
|
76
|
+
beyond its own budget. Adds `responsive: boolean` (false on a timeout, a
|
|
77
|
+
page-side exception, or an unreachable target — never an error for the
|
|
78
|
+
whole call) and, where the same round trip finds human-attributed input,
|
|
79
|
+
`humanActiveMs`, reusing the beacon's own discrimination rule rather than a
|
|
80
|
+
second implementation of it. Separately and unconditional on `probe`,
|
|
81
|
+
`list_leases` rows gain computed `idleMs` (now−lastUsedAt) and `expiresAt`
|
|
82
|
+
(lastUsedAt+ttlMs, the same value `claim_page` already returns under that
|
|
83
|
+
name), and `list_pages` entries for a tab under an active, readable lease of
|
|
84
|
+
the driver's own backend gain `lease: {label,pid,idleMs,expiresAt,stale}`.
|
|
85
|
+
Both are computed fresh in the tool layer on every call, never stored.
|
|
86
|
+
|
|
87
|
+
- **`scroll`, a new wheel-dispatch tool, on both backends.** Anchor at
|
|
88
|
+
`uid`/`selector`/`x`+`y` (element anchors are scrolled into view first, same
|
|
89
|
+
as `click`), or the viewport center if all three are omitted; at least one
|
|
90
|
+
of `deltaX`/`deltaY` is required, positive `deltaY` scrolling down and
|
|
91
|
+
positive `deltaX` scrolling right (wheel convention). Chrome dispatches
|
|
92
|
+
`Input.dispatchMouseEvent{type:"mouseWheel"}`; Firefox dispatches WebDriver
|
|
93
|
+
BiDi's `wheel` input source — both live-verified against a real browser, not
|
|
94
|
+
left on "it typechecks." A real bug surfaced along the way: Chrome
|
|
95
|
+
smooth-scrolls a wheel dispatch, so the command's ack resolves once the
|
|
96
|
+
event is *queued*, not once the resulting scroll has *settled*, and a
|
|
97
|
+
caller reading `scrollTop` immediately after could see the pre-scroll
|
|
98
|
+
value. Fixed with a capture-phase `scroll` listener armed before dispatch
|
|
99
|
+
(window-level, since `scroll` doesn't bubble but a capture-phase listener on
|
|
100
|
+
`window` still sees it), debounced 60ms with a 500ms hard cap rather than a
|
|
101
|
+
blind sleep sized wrong for both large and small deltas.
|
|
102
|
+
|
|
103
|
+
- **`dispatch_mouse`, the raw move/down/up primitive, Chrome-only.** Dispatch
|
|
104
|
+
exactly one `move`/`down`/`up` mouse event at absolute viewport coordinates
|
|
105
|
+
(`x`/`y` required on every call — CDP has no notion of a current pointer
|
|
106
|
+
position to default from); compose your own move/down/move/up sequences for
|
|
107
|
+
anything a physical mouse can do that `click`/`drag`'s fixed sequences
|
|
108
|
+
can't — canvas drag-painting, marquee/rubber-band selection, a custom
|
|
109
|
+
hit-testing widget. Takes the same `button`/`clickCount`/`modifiers` as
|
|
110
|
+
`click`. Lives outside the Driver abstraction by design, alongside
|
|
111
|
+
`heap.ts`/`screencast.ts`'s other chrome-only-tool modules, but is
|
|
112
|
+
lease-gated through the exact same choke point every other interaction tool
|
|
113
|
+
uses. Chrome-only, capability `input.raw`: absent from `tools/list` under
|
|
114
|
+
`--browser firefox`, never present-and-throwing.
|
|
115
|
+
|
|
116
|
+
- **`click` gains `modifiers` and `clickCount:3`.** `modifiers` (Alt/Control/
|
|
117
|
+
Meta/Shift, CDP bits 1/2/4/8) is held for both press and release, e.g.
|
|
118
|
+
`["Shift"]` for a shift-click; `clickCount:3` triple-clicks, selecting a
|
|
119
|
+
paragraph/line in most editors. `modifiers` is a **Chrome-only parameter on
|
|
120
|
+
an otherwise-universal tool**: a non-empty array throws a clear error under
|
|
121
|
+
`--browser firefox` rather than being silently dropped or ignored — a
|
|
122
|
+
documented param-level gap under ADR-001, not a missing tool.
|
|
123
|
+
|
|
124
|
+
- **Real HTML5 drag-and-drop (`drag mode:"html5"`), plus `steps`, `to:{x,y}`,
|
|
125
|
+
and `by:{dx,dy}` on the existing mouse-mode drag.** The spec's premise —
|
|
126
|
+
that synthetic mouse events cannot drive HTML5 drag-and-drop at all — is
|
|
127
|
+
**false**, measured against Chrome 151: `mode:"mouse"` (the default) does
|
|
128
|
+
start a real drag and can fire `dragstart`/`dragover`/`drop`. The actual gap
|
|
129
|
+
is that *which* `dragover` events reach the page depends on where the
|
|
130
|
+
interpolated pointer path happens to land, because mouse mode's drag
|
|
131
|
+
events follow that path rather than being dispatched at fixed points. At
|
|
132
|
+
the default `steps:2`, a drop zone written the standard way (`preventDefault`
|
|
133
|
+
only inside `dragover`, the HTML spec's own pattern) sees **zero** `dragover`
|
|
134
|
+
events and refuses the drop outright — a coin flip decided by geometry, not
|
|
135
|
+
a reliable "it doesn't work." `mode:"html5"` closes that gap deterministically:
|
|
136
|
+
`Input.setInterceptDrags(true)` arms interception, the mouse press/move
|
|
137
|
+
sequence runs, `Input.dragIntercepted` hands back the page's own `dragstart`
|
|
138
|
+
DragData, and the toolkit replays it as `dragEnter`→`dragOver`→`drop` exactly
|
|
139
|
+
at the destination — path-independent, and interception is always disabled
|
|
140
|
+
in a `finally`. Missing the intercepted event within 5 seconds (e.g. the
|
|
141
|
+
source isn't `draggable="true"`) is an actionable error, never a silent
|
|
142
|
+
no-op. `mode:"html5"` is Chrome-only (capability `input.html5Drag`, absent
|
|
143
|
+
from `REQUIRED_CAPABILITIES` so `drag` itself stays available on both
|
|
144
|
+
backends) and is rejected with a clear error under Firefox rather than
|
|
145
|
+
silently downgrading to mouse mode, which would report a drop that never
|
|
146
|
+
happened. `steps` (default 2, matching the exact pre-1.8.0 dispatch) sets
|
|
147
|
+
the interpolated-move count for mouse mode; `to` now also accepts an
|
|
148
|
+
absolute `{x,y}` alongside `uid`/`selector`, and a new top-level `by:{dx,dy}`
|
|
149
|
+
offsets from the source point instead (sliders, map panning, resize
|
|
150
|
+
handles) — exactly one of `to`/`by` is required, refused rather than
|
|
151
|
+
resolved by precedence when both or neither are given.
|
|
152
|
+
|
|
153
|
+
- **`navigate_page` gains `history: "back"|"forward"`, on both backends.**
|
|
154
|
+
Traverses this tab's session history like the browser's own Back/Forward
|
|
155
|
+
buttons: Chrome via `Page.getNavigationHistory` + `Page.navigateToHistoryEntry`,
|
|
156
|
+
Firefox via BiDi's `browsingContext.traverseHistory{delta:±1}`. Mutually
|
|
157
|
+
exclusive with `url`/`reload` — passing two of the three is refused by name,
|
|
158
|
+
never resolved by precedence. Going back from the first entry, or forward
|
|
159
|
+
from the last, is an **error naming the direction**, never a silent
|
|
160
|
+
no-op that navigates nowhere; both backends were made to raise the same
|
|
161
|
+
sentence (Firefox's own error never names a direction, so the BiDi driver
|
|
162
|
+
rewraps it). Waits for the same load milestone as an ordinary navigation.
|
|
163
|
+
|
|
164
|
+
- **`wait_for_download`, capture a real file download as a file on disk,
|
|
165
|
+
Chrome-only and MCP-server-only.** Returns `{path,suggestedFilename,bytes,
|
|
166
|
+
url,target}`, renaming Chrome's internal guid to the page's own filename
|
|
167
|
+
(collision-suffixed: `report.csv`, `report-1.csv`, ...). Two premises in the
|
|
168
|
+
original design were probed against a real browser before anything was
|
|
169
|
+
built, and both were **false**: `Browser.setDownloadBehavior` does **not**
|
|
170
|
+
persist past the arming client's disconnect (measured: an armed-then-closed
|
|
171
|
+
connection followed by a click landed zero files, no events, anywhere — the
|
|
172
|
+
download is *denied*, not merely misdirected), and the toolkit's per-call
|
|
173
|
+
CDP lifetime means every tool call is exactly such a disconnect. The fix is
|
|
174
|
+
`src/tools/browser-session.ts`: one lazily-opened, module-scoped connection
|
|
175
|
+
to the browser endpoint, held open for the process's life, arming download
|
|
176
|
+
capture once and keeping a ring buffer of completions — opened only when
|
|
177
|
+
`wait_for_download` or `grant_permissions` is first called, so a session
|
|
178
|
+
that never downloads pays nothing. **Ordering rule, not optional:** call
|
|
179
|
+
`wait_for_download{arm:true}` *before* the click that starts the download
|
|
180
|
+
(it arms and returns immediately: `{armed:true,downloadPath,pending}`), then
|
|
181
|
+
click, then call `wait_for_download` again to collect the finished file —
|
|
182
|
+
arming late loses the download with no file anywhere to recover it from.
|
|
183
|
+
**Browser-global side effect:** while armed, *every* download in the
|
|
184
|
+
browser, all tabs and origins, is redirected into the toolkit's downloads
|
|
185
|
+
directory for as long as the server runs. **MCP-server only, not a
|
|
186
|
+
preference:** the arm lives on a connection this server process holds open;
|
|
187
|
+
under the one-shot CLI that connection dies with the process before a
|
|
188
|
+
download could ever complete, so the tool needs the long-lived server the
|
|
189
|
+
same way `performance_start_trace`/`start_screen_recording` already do.
|
|
190
|
+
Chrome-only, capability `browser.downloads`: WebDriver BiDi has no command
|
|
191
|
+
to redirect a download to a chosen directory.
|
|
192
|
+
|
|
193
|
+
- **`grant_permissions`, pre-answer permission prompts for an origin,
|
|
194
|
+
Chrome-only and MCP-server-only.** `permissions: string[]` takes CDP
|
|
195
|
+
PermissionType values (`geolocation`, `notifications`, `clipboardReadWrite`,
|
|
196
|
+
`camera`, `microphone`, `midi`, ...; an unknown name is refused by Chrome
|
|
197
|
+
with the bad value in the message), keyed by **origin**, not tab — every
|
|
198
|
+
tab on that origin is affected, including ones opened later. `reset:true`
|
|
199
|
+
clears this server's previous grants first, or instead when no permissions
|
|
200
|
+
are given; CDP's reset is not itself origin-scoped, so it clears every
|
|
201
|
+
origin at once. The same probe that falsified `wait_for_download`'s
|
|
202
|
+
persistence assumption was run against `Browser.grantPermissions` on
|
|
203
|
+
spec, since the same per-connection-state trap applies here too, and it
|
|
204
|
+
sprang: a grant also does **not** survive the granting client's disconnect.
|
|
205
|
+
Shares `browser-session.ts`'s standing connection with `wait_for_download`
|
|
206
|
+
for exactly that reason. Chrome-only, capability `browser.permissions`:
|
|
207
|
+
absent from `tools/list` under Firefox, never present-and-throwing.
|
|
208
|
+
|
|
209
|
+
### Changed (behavior, no flag)
|
|
210
|
+
|
|
211
|
+
- **Every input-dispatching path, old and new, now writes through one choke
|
|
212
|
+
point, closing a real bug the individual branches could not see.** Track P
|
|
213
|
+
was built against pre-1.8.0 main, before Track S's dispatch log existed, so
|
|
214
|
+
its `scroll` and html5-drag paths sent `Input.*` straight down the
|
|
215
|
+
connection. Merged as-is, this compiles, typechecks, and passes both
|
|
216
|
+
branches' full test suites while the activity beacon reports the toolkit's
|
|
217
|
+
own scroll as a human using the tab — an agent warned about contention with
|
|
218
|
+
itself, the exact failure the beacon exists to prevent, invisible to either
|
|
219
|
+
branch's own tests because neither branch could see the other's code.
|
|
220
|
+
`dispatch_mouse` lives outside the Driver abstraction and has no
|
|
221
|
+
`dispatchInput` to call, so the write moved to a new shared function,
|
|
222
|
+
`sendInput(conn, backend, targetId, method, params)` in `src/activity.ts`;
|
|
223
|
+
`CdpPageDriver.dispatchInput` now delegates to it and `dispatch-mouse.ts`
|
|
224
|
+
calls it directly. `Input.setInterceptDrags` (html5 drag's arm and its
|
|
225
|
+
`finally`) is sent but deliberately **not** recorded: it synthesizes no
|
|
226
|
+
page input, and logging it would suppress a real human input landing in the
|
|
227
|
+
same 1500ms window — classified by denylist, so an `Input.*` command nobody
|
|
228
|
+
thought to classify defaults to being logged, because over-warning is
|
|
229
|
+
recoverable and silent misattribution is the bug this feature exists to
|
|
230
|
+
avoid. Firefox needed no code change: Track P built its BiDi scroll and drag
|
|
231
|
+
on `performActions`, which already called `recordDispatch`, so it inherited
|
|
232
|
+
the log for free — the whole argument for having a choke point in the first
|
|
233
|
+
place. The invariant — nothing in `src/` sends `Input.*` except through
|
|
234
|
+
`sendInput` — is grep-verified post-merge and is now also a test
|
|
235
|
+
(`test/input-dispatch-wiring.test.ts`), mutation-checked by reverting
|
|
236
|
+
`scroll` to a raw send and confirming the test names the exact line and the
|
|
237
|
+
live smoke reproduces the bug (`humanActiveMs=202` on the toolkit's own
|
|
238
|
+
scroll, instead of `undefined`).
|
|
239
|
+
|
|
240
|
+
### Residuals
|
|
241
|
+
|
|
242
|
+
- **A stale claim in `drag`'s own manifest description was caught and fixed
|
|
243
|
+
during integration, not by a fresh reader later.** An earlier pass on the
|
|
244
|
+
top-level `drag` description already stated the measured mouse-vs-html5
|
|
245
|
+
finding, but the `mode` property's own enum description still read "does
|
|
246
|
+
nothing on HTML5 draggable elements" — the exact folklore the rest of this
|
|
247
|
+
release measured false. Corrected to match; no test pinned the stale
|
|
248
|
+
string.
|
|
249
|
+
- **Second-MCP-server blind spot, stated plainly.** Two MCP server processes
|
|
250
|
+
driving the same browser cannot see each other's dispatch logs, so one
|
|
251
|
+
server's tool calls read as human activity to the other. Accepted: the
|
|
252
|
+
discrimination rule is honest about what it can and cannot see, and the
|
|
253
|
+
alternative (a shared dispatch log across processes) is a much larger
|
|
254
|
+
feature for a scenario this toolkit does not otherwise support (see
|
|
255
|
+
"Parallel tabs" — one writer per flow).
|
|
256
|
+
- **Cross-origin iframe input is invisible to the beacon,** since the
|
|
257
|
+
listener is installed on the top document only. Under-warning here is the
|
|
258
|
+
same accepted direction as the denylist above: a missed human input reads
|
|
259
|
+
as agent-only, never the reverse.
|
|
260
|
+
- **`dispatch_mouse`'s `modifiers` and `click`'s `modifiers` are two separate
|
|
261
|
+
parameters that happen to share an enum,** not one modifier system: each
|
|
262
|
+
tool validates and documents its own Firefox behavior independently.
|
|
263
|
+
- **Firefox's BiDi `scroll` path shipped once and was verified twice, in two
|
|
264
|
+
different seats.** It typechecked and passed unit tests against the real
|
|
265
|
+
BiDi capability set when Track P landed, but no Firefox was available to
|
|
266
|
+
that seat to run it live; the integration seat had one, ran it, and it
|
|
267
|
+
passed unmodified — the implementation was correct the first time, only the
|
|
268
|
+
live evidence was missing. Recorded here so "typechecks" is never mistaken
|
|
269
|
+
for "verified" on the next capability that ships without a live check.
|
|
270
|
+
|
|
271
|
+
### Notes
|
|
272
|
+
|
|
273
|
+
- Tool count is now 45 (29 parity + 16 superset: `performance_trace`, the
|
|
274
|
+
cookie group, the network-mocking group, the lease group, the
|
|
275
|
+
screen-recording pair, `scroll`, `dispatch_mouse`, `wait_for_download`, and
|
|
276
|
+
`grant_permissions`); 34 under Firefox, which lacks the eleven
|
|
277
|
+
capability-gated tools — the pre-1.8.0 eight (tracing, heap-snapshot,
|
|
278
|
+
Lighthouse, screen-recording) plus `dispatch_mouse`, `wait_for_download`,
|
|
279
|
+
and `grant_permissions`. `scroll` and the extended `navigate_page`/`click`/
|
|
280
|
+
`drag` params ship on both backends; only `click`'s `modifiers` and `drag`'s
|
|
281
|
+
`mode:"html5"` stay Chrome-only, and only at the parameter level — the
|
|
282
|
+
tools themselves are universal.
|
|
283
|
+
- Two new live-smoke scripts, `bun run staleness:smoke` and `bun run
|
|
284
|
+
input:smoke`, are each a single process talking to a single browser:
|
|
285
|
+
`CDP_BASE` is a module-level constant captured at import time, so one
|
|
286
|
+
process cannot address two browsers on two ports. Each is therefore
|
|
287
|
+
env-driven — `staleness:smoke` honors `STALENESS_SMOKE_PORT` (default
|
|
288
|
+
9501), `input:smoke` honors `CDP_SMOKE_PORT` (default 9513) — so the two
|
|
289
|
+
can run against separate scratch Chromes without colliding.
|
|
290
|
+
|
|
8
291
|
## [1.7.0] - 2026-08-09
|
|
9
292
|
|
|
10
293
|
Four things land on top of 1.2's opt-in lease model, all under one new switch
|
|
@@ -469,6 +752,7 @@ First public release.
|
|
|
469
752
|
- Zero runtime dependencies in the CDP/CLI layer (Node's global `WebSocket` + `fetch`). The MCP server adds only `@modelcontextprotocol/sdk`; `lighthouse_audit` is the sole non-CDP tool and shells out to `npx lighthouse`.
|
|
470
753
|
- Runtime: Bun ≥ 1.1 (recommended) or Node ≥ 22 (for the global `WebSocket`). Requires Chrome/Chromium started with `--remote-debugging-port=9222`.
|
|
471
754
|
|
|
755
|
+
[1.8.0]: https://github.com/sblattj/cdp-toolkit/releases/tag/v1.8.0
|
|
472
756
|
[1.7.0]: https://github.com/sblattj/cdp-toolkit/releases/tag/v1.7.0
|
|
473
757
|
[1.6.0]: https://github.com/sblattj/cdp-toolkit/releases/tag/v1.6.0
|
|
474
758
|
[1.0.0]: https://github.com/sblattj/cdp-toolkit/releases/tag/v1.0.0
|
package/CONTRACT.md
CHANGED
|
@@ -18,6 +18,8 @@ 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
|
+
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.
|
|
22
|
+
|
|
21
23
|
## Core client API (from `src/client.ts`)
|
|
22
24
|
|
|
23
25
|
```ts
|
|
@@ -67,7 +69,9 @@ export async function click(args: ClickArgs): Promise<unknown>;
|
|
|
67
69
|
- Recorder buffers: `${ARTIFACT_DIR}/rec-<targetId>.jsonl`.
|
|
68
70
|
- 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`.
|
|
69
71
|
- 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.
|
|
70
|
-
- 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.
|
|
72
|
+
- 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.
|
|
73
|
+
- 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.
|
|
74
|
+
- 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.
|
|
71
75
|
|
|
72
76
|
## The element-reference scheme (READ if you touch snapshot or input)
|
|
73
77
|
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# cdp-toolkit
|
|
2
2
|
|
|
3
|
-
**A lightweight, drop-in alternative to [`chrome-devtools-mcp`](https://github.com/ChromeDevTools/chrome-devtools-mcp) that won't wedge your agent.** It drives the Chrome tabs you point it at over the raw DevTools Protocol: any number of tabs, one explicitly named target per call over one direct socket, with a bounded timeout on every call, so a stuck page returns a clean error instead of hanging your agent and forcing a `/mcp` restart. Same idea, no all-target fan-out, plus tab leases so several agents can work one browser, plus a built-in network-mocking fake backend. **
|
|
3
|
+
**A lightweight, drop-in alternative to [`chrome-devtools-mcp`](https://github.com/ChromeDevTools/chrome-devtools-mcp) that won't wedge your agent.** It drives the Chrome tabs you point it at over the raw DevTools Protocol: any number of tabs, one explicitly named target per call over one direct socket, with a bounded timeout on every call, so a stuck page returns a clean error instead of hanging your agent and forcing a `/mcp` restart. Same idea, no all-target fan-out, plus tab leases so several agents can work one browser (and know when a human is using it too), plus a built-in network-mocking fake backend. **45 tools.**
|
|
4
4
|
|
|
5
5
|
> For AI-agent developers and Claude Code / Cursor users who need **the tabs they name driven reliably**, not a Puppeteer-managed browser.
|
|
6
6
|
|
|
@@ -118,8 +118,9 @@ bun run mcp:smoke # spawn the server + a real initialize/tools-list/tools-call
|
|
|
118
118
|
|
|
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
|
-
- **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, and tab-to-video screen recording.
|
|
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
122
|
- **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
|
+
- **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.
|
|
123
124
|
- **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.
|
|
124
125
|
- **Provenance that outlives the lease.** `list_pages` reports `origin: "agent"` (with the creating `label`) for every tab this toolkit opened, and `"unknown"` otherwise. It never claims `"human"`: the absence of a creation record cannot prove a person opened the tab, so `"unknown"` is the honest word once an agent releases, expires, or dies and its tab is left behind.
|
|
125
126
|
|
|
@@ -199,10 +200,11 @@ await TOOLS.navigate_page({ target: "index:0", url: "https://example.com" });
|
|
|
199
200
|
| `CDP_STATE_DIR` | `/tmp/cdp-toolkit` | `select_page` selected-target file, in-flight trace state. |
|
|
200
201
|
| `CDP_LEASE_TTL_MS` | `900000` | How long a tab lease survives without use before another agent can reclaim it. Refreshed on every checked call. |
|
|
201
202
|
| `CDP_REQUIRE_LEASE` | off | Strict mode, MCP server only (inert under the CLI regardless of value). Turns leasing from optional into mandatory: a call against an unheld tab acquires a lease instead of driving it lease-free, and `list_pages`/`list_leases` close tabs an abandoned agent left behind. See "Parallel tabs" below. |
|
|
203
|
+
| `CDP_REAP_GRACE_MS` | `2700000` | Extra grace (on top of `CDP_LEASE_TTL_MS`) before an `expired` lease's tab is actually destroyed by reap; `dead-pid` tabs are reaped immediately regardless. See "Reap" below. |
|
|
202
204
|
|
|
203
205
|
## Firefox (WebDriver BiDi)
|
|
204
206
|
|
|
205
|
-
cdp-toolkit ships a second backend, Firefox over [WebDriver BiDi](https://w3c.github.io/webdriver-bidi/), behind the same
|
|
207
|
+
cdp-toolkit ships a second backend, Firefox over [WebDriver BiDi](https://w3c.github.io/webdriver-bidi/), behind the same 45-tool surface. Chrome stays the default and its behavior is unchanged, opt in explicitly to reach Firefox:
|
|
206
208
|
|
|
207
209
|
```bash
|
|
208
210
|
cdp --browser firefox take_snapshot # CLI: explicit flag
|
|
@@ -223,8 +225,10 @@ Backend selection precedence: `--browser chrome|firefox` flag, then `CDP_BROWSER
|
|
|
223
225
|
- `take_heapsnapshot` (needs `heap.snapshot`)
|
|
224
226
|
- `lighthouse_audit` (needs `audit.lighthouse`)
|
|
225
227
|
- `start_screen_recording`, `stop_screen_recording` (needs `capture.screencast`): BiDi has no streamed-frame primitive at all, only the one-shot `browsingContext.captureScreenshot`.
|
|
228
|
+
- `dispatch_mouse` (needs `input.raw`): the raw move/down/up primitive is a direct `Input.dispatchMouseEvent` wrapper with no BiDi analogue.
|
|
229
|
+
- `wait_for_download`, `grant_permissions` (need `browser.downloads` / `browser.permissions`): both drive Chrome's `Browser.*` domain; WebDriver BiDi has no command to redirect a download or pre-grant a permission.
|
|
226
230
|
|
|
227
|
-
Everything else, including `mock_request`/`list_mocks`/`clear_mocks` (Firefox's `network.addIntercept` covers the same fake-backend use case as Chrome's `Fetch` domain)
|
|
231
|
+
Everything else, including `mock_request`/`list_mocks`/`clear_mocks` (Firefox's `network.addIntercept` covers the same fake-backend use case as Chrome's `Fetch` domain), the `claim_page`/`release_page`/`list_leases` lease group, and the new `scroll` tool (Chrome dispatches `Input.dispatchMouseEvent{type:'mouseWheel'}`, Firefox uses BiDi's `wheel` input source — both live-verified), is available under both backends: 34 of the 45 tools.
|
|
228
232
|
|
|
229
233
|
**Honest capability gaps, not oversold parity:**
|
|
230
234
|
|
|
@@ -234,27 +238,31 @@ Everything else, including `mock_request`/`list_mocks`/`clear_mocks` (Firefox's
|
|
|
234
238
|
- **No tracing, heap snapshots, or Lighthouse.** See the capability list above; there is no BiDi equivalent for any of the three.
|
|
235
239
|
- **`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`.
|
|
236
240
|
- **No `--browser firefox` attach mode.** By design, per the launch model above: every invocation gets its own throwaway Firefox process and profile.
|
|
241
|
+
- **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
|
+
- **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.
|
|
237
243
|
|
|
238
|
-
## The tools (29 parity +
|
|
244
|
+
## The tools (29 parity + 16 superset = 45)
|
|
239
245
|
|
|
240
|
-
The 29 parity tools are 1:1 with `chrome-devtools-mcp`; the
|
|
246
|
+
The 29 parity tools are 1:1 with `chrome-devtools-mcp`; the 16 superset tools (`performance_trace`, the `list_cookies`/`set_cookie`/`delete_cookies` cookie group, the `mock_request`/`list_mocks`/`clear_mocks` group, the `claim_page`/`release_page`/`list_leases` lease group, the `start_screen_recording`/`stop_screen_recording` screen-recording pair, `scroll`, `dispatch_mouse`, `wait_for_download`, and `grant_permissions`) are toolkit additions. Each row notes the underlying CDP method(s) and the precise parity gaps.
|
|
241
247
|
|
|
242
248
|
| MCP name | CDP method(s) | Parity notes / gaps |
|
|
243
249
|
|---|---|---|
|
|
244
|
-
| `list_pages` | `GET /json/list` | `all` flag also exposes worker/background targets; MCP lists only page tabs. Each row additionally carries `origin` (`agent` or `unknown`, never `human`) plus `label`/`createdAt` for tabs this toolkit created. Under `CDP_REQUIRE_LEASE` also reaps abandoned agent tabs first, reporting closures in an additive `reaped` array. |
|
|
250
|
+
| `list_pages` | `GET /json/list` | `all` flag also exposes worker/background targets; MCP lists only page tabs. Each row additionally carries `origin` (`agent` or `unknown`, never `human`) plus `label`/`createdAt` for tabs this toolkit created, and, for a tab under an active lease of this backend, a `lease:{label,pid,idleMs,expiresAt,stale}` field (unconditional on `probe`). `probe:true` pings each page-type target's renderer (one bounded 500ms `Runtime.evaluate`, never more) and adds `responsive:boolean` plus `humanActiveMs` where that round trip found human-attributed input; a wedged/unreachable tab reports `responsive:false`, never an error for the whole call. Under `CDP_REQUIRE_LEASE` also reaps abandoned agent tabs first (destructively, only once a lease is `CDP_REAP_GRACE_MS` past its TTL — see "Staleness" below), reporting closures in an additive `reaped` array. |
|
|
245
251
|
| `new_page` | `Target.createTarget` (+ lease file) | Returns `{targetId,url}`; does not await navigation (use `navigate_page`). `claim:true` also claims the new tab and returns a `lease` token (`label`/`ttlMs` optional). Under `CDP_REQUIRE_LEASE` the tab is claimed and a `lease` returned even without `claim:true`. |
|
|
246
252
|
| `close_page` | `Target.closeTarget` (+ lease file) | Reports `success:true` on the empty result newer Chromium returns. A successful close also releases that tab's lease; a failed close leaves it in place. |
|
|
247
253
|
| `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. |
|
|
248
|
-
| `navigate_page` | `Page.navigate` / `Page.reload` + load events | Returns `{url,frameId,waitedFor}` (no auto-snapshot). `waitUntil` supports `load`/`domcontentloaded`. `reload:true` (+ `ignoreCache:true` for a hard reload). |
|
|
254
|
+
| `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. |
|
|
249
255
|
| `wait_for` | `Runtime.evaluate` (poll `innerText`) | Text-substring waiting only; throws on timeout rather than returning `{found:false}`. |
|
|
250
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. |
|
|
251
257
|
| `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. |
|
|
252
258
|
| `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. |
|
|
253
259
|
| `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. |
|
|
254
260
|
| `take_snapshot` | `Accessibility.getFullAXTree` | uid is the raw `backendDOMNodeId` (stateless, non-sequential). Full tree in one shot; frames flattened. `interactiveOnly` is a toolkit addition. |
|
|
255
|
-
| `click` | `Input.dispatchMouseEvent` | No implicit auto-wait/retry; resolves and acts once; re-snapshot between steps. |
|
|
261
|
+
| `click` | `Input.dispatchMouseEvent` | No implicit auto-wait/retry; resolves and acts once; re-snapshot between steps. `clickCount:3` triple-clicks (selects a paragraph/line in most editors). `modifiers` (Alt/Control/Meta/Shift) holds keys for the press and release, e.g. a shift-click — **Chrome-only param**: a non-empty array throws under `--browser firefox`. |
|
|
256
262
|
| `hover` | `Input.dispatchMouseEvent` (`mouseMoved`) | Same single-shot model as `click`. |
|
|
257
|
-
| `
|
|
263
|
+
| `scroll` *(superset)* | Chrome: `Input.dispatchMouseEvent{type:'mouseWheel'}`; Firefox: BiDi's `wheel` input source | Anchor at `uid`/`selector`/`x`+`y`, or the viewport center if all three are omitted; an element anchor is scrolled into view first. At least one of `deltaX`/`deltaY` is required (positive `deltaY` scrolls down, positive `deltaX` scrolls right — wheel convention). Returns `{x,y,deltaX,deltaY,target}`. Works on both backends. |
|
|
264
|
+
| `drag` | Chrome: `Input.dispatchMouseEvent` (press→move→release), or `mode:"html5"`: `Input.setInterceptDrags` + `Input.dispatchDragEvent` | `mode:"mouse"` (default) sends a synthetic mouse drag; Chrome does turn this into a real HTML5 drag too, but which `dragenter`/`dragover`/`drop` events reach the page depends on where the interpolated pointer path lands — at the default `steps:2`, a standard HTML5 drop zone (`preventDefault` inside `dragover`) sees zero `dragover` events and refuses the drop. `mode:"html5"` replays the page's own drag data as `dragEnter`/`dragOver`/`drop` exactly at the destination, so it works regardless of pointer path — **Chrome-only** (`input.html5Drag`), rejected with a clear error under Firefox. `steps` (default 2) sets the interpolated-move count for mouse mode. Destination is `to:{uid\|selector\|x,y}` or a new `by:{dx,dy}` offset (sliders, map panning); exactly one of `to`/`by` is required. |
|
|
265
|
+
| `dispatch_mouse` *(superset)* | `Input.dispatchMouseEvent` (one event per call) | The raw primitive: dispatch exactly one `move`/`down`/`up` at absolute viewport coordinates (`x`/`y` required on every call — CDP has no notion of a "current pointer position"). Compose your own move/down/move/up sequences for anything a physical mouse can do that `click`/`drag`'s fixed sequences can't: canvas drag-painting, marquee/rubber-band selection, a custom-hit-testing widget. Takes the same `button`/`clickCount`/`modifiers` as `click`. **Chrome-only** (`input.raw`): absent from `tools/list` under `--browser firefox`, never present-and-throwing. |
|
|
258
266
|
| `fill` | `Input.insertText` | Atomic paste-like commit, not per-character keystrokes. |
|
|
259
267
|
| `fill_form` | per field: `callFunctionOn` + `insertText` | Array of `{uid|selector,value}`; same insertText caveat. |
|
|
260
268
|
| `type_text` | `Input.insertText` | Appends (does not clear first); insertText, not per-key. |
|
|
@@ -279,9 +287,11 @@ The 29 parity tools are 1:1 with `chrome-devtools-mcp`; the 12 superset tools (`
|
|
|
279
287
|
| `mock_request` *(superset)* | `Fetch.*` (+ `Page.reload`) | **A fake backend.** Registers a rule on a target's persistent session: fulfill with a canned response, fail, or continue (with optional `delayMs`/`failRate`). Persists across reloads until `clear_mocks`. Request-stage only. Cached requests aren't intercepted (use `reload:true`). |
|
|
280
288
|
| `list_mocks` *(superset)* | `Runtime.evaluate` (liveness probe) | Lists active mock sessions with rules + hit counts; prunes sessions whose tab closed. |
|
|
281
289
|
| `clear_mocks` *(superset)* | `Fetch.disable` | Tears down the resolved target's mock session (or all with `all:true`). |
|
|
282
|
-
| `claim_page` *(superset)* | `Target.createTarget` + lease file | Opens a fresh tab and claims it (no `target`/`targetId`), or takes over a tab that is already open (`target`, any selector, or the exact-id `targetId`) and returns an opaque lease token plus `opened` (true only when this call created the tab). Never steals a tab another live process holds. MCP only; the CLI refuses it. |
|
|
290
|
+
| `claim_page` *(superset)* | `Target.createTarget` + lease file | Opens a fresh tab and claims it (no `target`/`targetId`), or takes over a tab that is already open (`target`, any selector, or the exact-id `targetId`) and returns an opaque lease token plus `opened` (true only when this call created the tab). Never steals a tab another live process holds. Also returns `humanActiveMs` (ms since input this server did not dispatch; `null` means no data, never "no human") and, on a takeover of a tab a human used within the last 30s, a `contention` warning — the claim is never refused for it. MCP only; the CLI refuses it. |
|
|
283
291
|
| `release_page` *(superset)* | lease file | Gives a lease back via `lease` (the token) or `target` (a selector, for a lease the gate auto-acquired and so never handed out a token for). Idempotent. Closes the tab by default when this toolkit's creation ledger says it opened it; leaves a merely-claimed tab open. Override either way with `close`. |
|
|
284
|
-
| `list_leases` *(superset)* | lease file | Who holds what, with pid liveness and reclaimability. Needs no token; never returns the nonce. A lease file that could not be read or parsed is reported as an `unreadable` row instead of being skipped. Under `CDP_REQUIRE_LEASE` also reaps abandoned agent tabs, reporting closures in an additive `reaped` array. |
|
|
292
|
+
| `list_leases` *(superset)* | lease file | Who holds what, with pid liveness and reclaimability, plus computed `idleMs`/`expiresAt` and, where the tab's beacon answers, `humanActiveMs` (absent, not null, when there's no answer). Needs no token; never returns the nonce. A lease file that could not be read or parsed is reported as an `unreadable` row instead of being skipped (with `idleMs`/`expiresAt` omitted). Under `CDP_REQUIRE_LEASE` also reaps abandoned agent tabs, reporting closures in an additive `reaped` array. |
|
|
293
|
+
| `wait_for_download` *(superset)* | `Browser.setDownloadBehavior` + `Browser.downloadWillBegin`/`downloadProgress` on a standing browser-endpoint connection | Waits for a download to finish and returns it as a real file on disk: `{path,suggestedFilename,bytes,url,target}`. **Ordering rule, not optional:** call `wait_for_download{arm:true}` *before* the click that starts the download, then click, then call `wait_for_download` again to collect it — arming this late is not a preference, it's because Chrome reverts the download-behavior override the instant the arming client disconnects, and denies an unarmed download outright. **Browser-global side effect:** while armed, *every* download in the browser is redirected into the toolkit's downloads directory. **MCP-server only:** the arm lives on a connection this server process holds open; the one-shot CLI's connection dies with the process, so nothing is captured there. Chrome-only (`browser.downloads`). |
|
|
294
|
+
| `grant_permissions` *(superset)* | `Browser.grantPermissions` / `Browser.resetPermissions` on the same standing connection | Grants permissions (`geolocation`, `notifications`, `clipboardReadWrite`, ...) for an origin up front, so a page doesn't show a prompt no agent can click. Keyed by **origin**, not tab — every tab on that origin is affected, including ones opened later. `reset:true` clears this server's previous grants first (or instead); note CDP's reset is not origin-scoped, so it clears every origin at once. Same MCP-server-only reasoning as `wait_for_download`: Chrome discards the grant the moment the granting connection closes. Chrome-only (`browser.permissions`). |
|
|
285
295
|
|
|
286
296
|
## Parallel tabs: many agents, one browser
|
|
287
297
|
|
|
@@ -347,10 +357,22 @@ Under `CDP_REQUIRE_LEASE`, `release_page{target}` is how you give back a lease t
|
|
|
347
357
|
|
|
348
358
|
`release_page` closes a tab an agent gives back; it does nothing for a tab whose agent crashed, was killed, or simply stopped calling; a timeout ending an agent is more common than a clean shutdown. Under `CDP_REQUIRE_LEASE`, `list_pages` and `list_leases` reap first: a tab is closed only when **all four** hold — its lease is for the browser being listed and is actually readable (an `unreadable` row is never touched), the lease is stale for `dead-pid` or `expired` (never `target-gone`, already closed, or a healthy lease), the target is still genuinely open, and **the toolkit both opened the tab and leased it**. That last condition is the one that matters most: an agent-created tab with no lease at all is never reaped, because that is exactly what `new_page` produces for a caller who never touches leases, and closing those would be data loss dressed as cleanup. Closed tabs are reported in an additive `reaped` array (`{targetId, label, reason}`), present only when something actually closed, so the common-case response shape is unchanged; a close that fails is silently left for the next read to retry rather than reported as done. Reap is strict-mode only, for the same reason auto-acquire is: outside a long-lived process, a dead-pid record proves nothing about abandonment.
|
|
349
359
|
|
|
360
|
+
**Reclaimable and destroyed are two different moments, on purpose (`CDP_REAP_GRACE_MS`).** A lease reads `stale`/reclaimable in `list_leases` the instant its `ttlMs` elapses — cheap and reversible, since another agent may simply take it over. Reap's *close*, the destructive half, waits longer: an `expired` lease only qualifies for reap once `now - lastUsedAt > ttlMs + CDP_REAP_GRACE_MS` (default `2700000`, 45 minutes, so 60 minutes total after last use). An agent 20 minutes into a build between tool calls does not come back to find its tab destroyed just because another agent could, in principle, have reclaimed the lease. `dead-pid` is unaffected by grace and reaps immediately — that process is never coming back, so there is nothing to wait for.
|
|
361
|
+
|
|
350
362
|
**Unreadable lease files.** A row in `list_leases` can carry `unreadable`, holding the errno (for example `EACCES`) or `"unparseable"`, when the lease file exists but could not be read or parsed. On such a row `label`, `pid`, `createdAt`, `lastUsedAt`, and `ttlMs` are zero placeholders, not real values, since they live inside the file that could not be read. `stale` is always `false` on that row, deliberately: `stale` is what a reader treats as free to take, and an unreadable lease must never read that way.
|
|
351
363
|
|
|
352
364
|
**Verifying it against a real browser.** `bun run lease:smoke` (`test/lease-smoke.ts`) is the end-to-end harness for this feature, and `bun test` cannot reach any of it: a lease is keyed on the claiming pid, so only a second genuinely separate OS process can collide with the first, and reap only ever fires from a long-lived process. It drives a REAL browser on `CDP_BASE` (default `http://127.0.0.1:9222`) and spawns real child OS processes to play a tokenless stranger, a doomed child that claims a tab and exits, and a corpse that exists only to donate a genuinely dead pid. Under the default (flag off): it asserts the owner can act, a tokenless stranger is refused with the `LeaseConflictError` type, `targetId` and `holder` intact and NOT coded `no-such-target`, the refusal actually prevented the side effect (proved by reading a marker back, not by the throw), a genuinely missing target still reports `no-such-target`, the stranger is admitted after `release_page`, and an orphaned lease from a real dead child is reclaimable with a fresh nonce. It then turns `CDP_REQUIRE_LEASE` on for the rest of the run and asserts: a second strict process with no token is refused the same way; an ordinary ungated call auto-acquires a lease, marked `auto` and owned by that pid; `release_page{target}` closes a tab this toolkit opened and leaves alone one with no creation record, even when explicitly claimed; and reap closes a tab with a genuinely dead-pid lease, names it in `reaped` with `reason:"dead-pid"`, and leaves every other tab the run opened untouched. A final scenario takes over a stand-in for a human's tab (open, unleased, no creation record) via `claim_page{target}`, drives it with the returned token, releases it, and confirms it is still open, then confirms a target that matches nothing is refused and opens no tab. It is safe to point at a browser with real tabs open: every tab it touches is one it opened itself, addressed only by its own target id, and it never calls `Browser.close`. It captures its own baseline tab listing at runtime and fails only if a tab that existed before the run went missing; tabs you open mid-run are ignored. Lease and origin files go to a private temp dir that is removed on exit, and reap — the single most destructive path exercised here — only ever considers a tab with both a lease record and an origin record in that private directory, which no pre-existing tab has.
|
|
353
365
|
|
|
366
|
+
### Staleness: is a human already using this tab?
|
|
367
|
+
|
|
368
|
+
`lastUsedAt` measures TOOLKIT calls only, so a tab a person has been typing in for ten minutes looks perfectly idle by that field — exactly the case that matters once `claim_page{target}` lets an agent take over a tab a human already has open. 1.8.0 adds an in-page activity beacon and surfaces what it sees, without ever refusing anything because of it.
|
|
369
|
+
|
|
370
|
+
**The beacon.** A capture-phase, passive listener on `pointerdown`/`mousedown`/`keydown`/`wheel`/`touchstart` records `window.__cdpToolkitLastInput = Date.now()`. It's injected at claim time on both `claim_page` modes and `new_page{claim:true}`. On Chrome, `Page.addScriptToEvaluateOnNewDocument` alone does not survive navigation under this toolkit's per-call connection lifetime: the registration dies the instant the installing connection closes, which is every tool call. So the driver holds one connection open per beaconed tab purely to keep the registration alive across navigations (bounded at 32 tabs, oldest-first eviction; an evicted tab still answers for input on its current document, only the next navigation's re-arm is lost). Firefox needs none of this — its BiDi session lives for the whole process, so a `script.addPreloadScript` registration survives navigation for free.
|
|
371
|
+
|
|
372
|
+
**Telling a person from the toolkit itself.** CDP-dispatched input is `isTrusted:true` too, so the beacon alone can't distinguish a human click from the toolkit's own — every input-dispatching call (`click`, `drag`, `scroll`, `fill`, `fill_form`, `type_text`, `press_key`, `dispatch_mouse`) writes through one dispatch-log choke point, and a beacon timestamp counts as human only when it postdates the toolkit's own last dispatch on that tab by more than 1500ms. Known blind spots: input inside a cross-origin iframe is invisible to it, and a *second* MCP server process's dispatches read as human to this one.
|
|
373
|
+
|
|
374
|
+
**Surfacing.** `claim_page` and `list_leases` both return `humanActiveMs` — milliseconds since input this server did not dispatch. **`null` (or an absent field) means NO DATA — no beacon on that tab yet, or every input on it was this server's own — it never means "no human."** On a `claim_page` takeover of a tab a human used within the last 30 seconds, the result also carries a `contention` warning string. **The claim is never refused for this**: taking over a person's tab is what takeover mode is for, so by the time you'd read the warning you already hold the lease and the tab is yours to drive — it just tells you that driving it now means fighting a live person for the keyboard and mouse, so you can open your own tab instead or ask first. `list_pages{probe:true}` adds a third read on top: one bounded (500ms) renderer ping per page-type target, annotating `responsive:boolean` (false on a timeout or a wedged tab, never an error for the whole call) and, where that same round trip found one, `humanActiveMs`.
|
|
375
|
+
|
|
354
376
|
### Which tab did an agent open? (`origin`)
|
|
355
377
|
|
|
356
378
|
A lease answers "who is *driving* this tab right now". It cannot answer "who *opened* it", and that second question outlives the first: the moment an agent releases, expires, or dies, its abandoned tab is indistinguishable from one you opened yourself. Diffing two `list_pages` snapshots does not settle it either, since a tab you opened a minute ago looks exactly like a stray agent tab.
|
|
@@ -386,7 +408,7 @@ So every tab this toolkit creates is written to a creation ledger, and `list_pag
|
|
|
386
408
|
src/
|
|
387
409
|
client.ts # CdpConnection, openPage/withPage/openBrowser, resolveTarget, timeouts
|
|
388
410
|
types.ts # Target, TargetSelector, Uid, CDP envelopes
|
|
389
|
-
index.ts # TOOLS registry (
|
|
411
|
+
index.ts # TOOLS registry (45) + re-exported client primitives
|
|
390
412
|
cli.ts # the Bun CLI
|
|
391
413
|
mcp.ts # stdio MCP server (exposes TOOLS via @modelcontextprotocol/sdk)
|
|
392
414
|
manifest.ts # JSON Schemas advertised by the MCP server (one per tool)
|