cdp-toolkit 1.1.0 → 1.4.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 +203 -9
- package/CONTRACT.md +7 -5
- package/README.md +113 -33
- package/dist/cli.js +764 -64
- package/dist/index.js +650 -42
- package/dist/mcp.js +1091 -77
- package/package.json +4 -3
- package/src/bidi/driver.ts +167 -4
- package/src/capabilities.ts +1 -1
- package/src/cdp/driver.ts +145 -1
- package/src/cli.ts +47 -22
- package/src/client.ts +25 -7
- package/src/driver.ts +107 -1
- package/src/firefox-tools.ts +6 -3
- package/src/index.ts +14 -2
- package/src/leases-tools.ts +107 -0
- package/src/leases.ts +541 -0
- package/src/manifest.ts +339 -15
- package/src/mcp.ts +27 -19
- package/src/origins.ts +266 -0
- package/src/shared-tools.ts +365 -14
- package/src/tools/console.ts +3 -3
- package/src/tools/evaluate.ts +2 -2
- package/src/tools/heap.ts +3 -3
- package/src/tools/lighthouse.ts +4 -4
- package/src/tools/navigation.ts +1 -1
- package/src/tools/network.ts +7 -7
- package/src/tools/network_mock.ts +8 -8
- package/src/tools/performance.ts +11 -11
- package/src/tools/recorder.ts +5 -5
- package/src/tools/snapshot.ts +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -5,21 +5,215 @@ 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
|
+
## [1.4.0] - 2026-08-06
|
|
9
|
+
|
|
10
|
+
This is the first npm release since 1.2.0. Both `v1.2.1` and `v1.3.0` were
|
|
11
|
+
tagged and pushed to GitHub but never reached npm, because the release runs
|
|
12
|
+
through GitHub Actions with OIDC trusted publishing and Actions was down. Their
|
|
13
|
+
changes are folded in here rather than published separately, so 1.4.0 on npm
|
|
14
|
+
carries the lease-conflict fix (1.2.1), the tab-origin ledger (1.3.0), and the
|
|
15
|
+
cookie group plus the `evaluate_script` file sink below. See the [1.3.0] and
|
|
16
|
+
[1.2.1] sections for the folded-in details.
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- **`set_cookie` and `delete_cookies`, the WRITE half of the cookie group.**
|
|
21
|
+
`list_cookies` made the store readable; these make it writable, including the
|
|
22
|
+
httpOnly and secure cookies `document.cookie` can neither create nor remove,
|
|
23
|
+
so seeding an authenticated session or clearing one is a tool call rather
|
|
24
|
+
than a hand-rolled raw CDP detour. `set_cookie` takes `{name, value}` plus
|
|
25
|
+
the usual cookie attributes (`url`, `domain`, `path`, `expires` in Unix
|
|
26
|
+
seconds, `httpOnly`, `secure`, `sameSite`) and answers `{set:true, target}`;
|
|
27
|
+
`delete_cookies` takes `{name}` plus `url` or `domain` and an optional
|
|
28
|
+
`path`, and answers `{deleted:true, target}`. Chrome rides `Network.setCookie`
|
|
29
|
+
and `Network.deleteCookies`, Firefox rides `storage.setCookie` and
|
|
30
|
+
`storage.deleteCookies` partitioned by the resolved browsing context.
|
|
31
|
+
Three deliberate refusals, all of which exist because the alternative is a
|
|
32
|
+
call that looks like it worked. First, either `url` or `domain` is required
|
|
33
|
+
on both tools: a cookie has to be attributed to a site, and guessing the
|
|
34
|
+
current page's origin would write a real cookie somewhere the caller never
|
|
35
|
+
named, while a name-only delete would sweep the whole partition. Second,
|
|
36
|
+
Chrome answers `Network.setCookie` with `success:false` rather than an error
|
|
37
|
+
when it declines a cookie (a domain the url does not cover, a secure cookie
|
|
38
|
+
on an insecure origin, an oversized value), so that false is raised as an
|
|
39
|
+
error instead of being reported as `set:true`. Third, BiDi has no `url`
|
|
40
|
+
parameter and requires a `domain`, so the Firefox driver derives one from the
|
|
41
|
+
url's host and throws on a url with no host (`about:blank`, a `data:` URL)
|
|
42
|
+
rather than sending a call Firefox answers cleanly while writing nothing.
|
|
43
|
+
Two things are deliberately absent: the response never echoes the value back,
|
|
44
|
+
since the value is usually the credential the caller just supplied, and
|
|
45
|
+
`delete_cookies` reports no count, because neither protocol says how many
|
|
46
|
+
cookies it removed and a number here would be invented. Read `list_cookies`
|
|
47
|
+
before and after when a real count is needed. `path` is passed through
|
|
48
|
+
exactly as given and never defaulted to `/`, since Chrome derives the path
|
|
49
|
+
from `url` and an invented default would widen a deliberately narrow cookie.
|
|
50
|
+
|
|
51
|
+
- **`list_cookies`, a new tool that reads the target page's cookie store,
|
|
52
|
+
httpOnly cookies included.** There was no cookie tool at all before this, so
|
|
53
|
+
reading an httpOnly session cookie, exactly the set `document.cookie` cannot
|
|
54
|
+
see and `evaluate_script` therefore cannot reach, meant dropping to raw CDP
|
|
55
|
+
by hand: a fallback that fights the very contention this toolkit exists to
|
|
56
|
+
avoid, since a page target accepts a second websocket and then never answers
|
|
57
|
+
on it while another client is attached. Each cookie carries `name`, `value`,
|
|
58
|
+
`domain`, `path`, `expires` (Unix seconds, `-1` for a session cookie),
|
|
59
|
+
`size`, `httpOnly`, `secure`, `sameSite` and `session`. The read is
|
|
60
|
+
page-scoped, not browser-wide: Chrome uses `Network.getCookies` rather than
|
|
61
|
+
the whole-profile `Storage.getCookies`, and Firefox uses `storage.getCookies`
|
|
62
|
+
partitioned by that browsing context, so a call naming one tab never returns
|
|
63
|
+
every other site's credentials. Both backends are supported, with the same
|
|
64
|
+
shape: the BiDi differences (a bytes-value that may be base64, an absent
|
|
65
|
+
`expiry` meaning a session cookie, a lowercase `sameSite`) are normalized in
|
|
66
|
+
the driver. Optional `domain` and `name` filters narrow the result: `name` is
|
|
67
|
+
an exact match, `domain` ignores a leading dot on either side and also
|
|
68
|
+
matches subdomains, and that is the entire matching rule. Optional `savePath`
|
|
69
|
+
behaves exactly as `evaluate_script`'s does: the cookie array is written to
|
|
70
|
+
that file as JSON and the response carries only `{path, bytes, count,
|
|
71
|
+
target}`, with no cookie value in any form, no preview and no truncation,
|
|
72
|
+
which is how to capture a session cookie without putting the credential in
|
|
73
|
+
the calling agent's transcript. Tab leases are respected the same way every
|
|
74
|
+
sibling tool respects them, at target resolution.
|
|
75
|
+
|
|
76
|
+
- **`evaluate_script` can write its value to a file instead of returning it,
|
|
77
|
+
via a new optional `savePath`.** Reading a JWT, a session token, or any other
|
|
78
|
+
credential out of the page previously forced the secret through the tool
|
|
79
|
+
result, which is to say straight into the calling agent's transcript, with no
|
|
80
|
+
way to opt out. With `savePath` set, the evaluated value is serialized as
|
|
81
|
+
JSON to that file and the response carries only `{path, bytes, type, target}`:
|
|
82
|
+
no copy of the value, no preview, no truncated form, no leading characters.
|
|
83
|
+
`type` is the JS `typeof` of the value (`"null"` for null), which describes
|
|
84
|
+
the value without disclosing any of it. Path handling matches
|
|
85
|
+
`take_heapsnapshot`: an absolute path is used as-is, a relative one resolves
|
|
86
|
+
under the artifact dir (`/tmp/cdp-toolkit`, or `CDP_ARTIFACT_DIR`), and
|
|
87
|
+
missing parent directories are created. Omitting `savePath` leaves
|
|
88
|
+
`evaluate_script` behaving exactly as it did before, value returned inline,
|
|
89
|
+
so this is purely additive for every existing caller. Thrown page-side
|
|
90
|
+
exceptions still surface as errors and are never written into the file.
|
|
91
|
+
|
|
92
|
+
## [1.3.0] - 2026-08-06
|
|
93
|
+
|
|
94
|
+
`v1.2.1` was tagged and pushed to GitHub but never published to npm: the
|
|
95
|
+
release runs through GitHub Actions with OIDC trusted publishing, and Actions
|
|
96
|
+
was down for a full day. Rather than publish that fix on its own, it ships
|
|
97
|
+
here alongside tab origin, so 1.3.0 is the first npm release carrying both
|
|
98
|
+
the lease feature (`claim_page`/`release_page`/`list_leases`) and tab origin.
|
|
99
|
+
|
|
100
|
+
### Added
|
|
101
|
+
|
|
102
|
+
- **`list_pages` now reports where each tab came from.** Every tab cdp-toolkit
|
|
103
|
+
creates (`new_page`, and `claim_page` with no `targetId`) is written to a
|
|
104
|
+
creation ledger stored alongside the lease files, and each `list_pages` row
|
|
105
|
+
gains `origin`: `"agent"` when the toolkit created the tab, carrying the
|
|
106
|
+
creating `label` and `createdAt`, otherwise `"unknown"`. The record outlives
|
|
107
|
+
the lease on purpose. Once an agent releases, expires, or dies, its abandoned
|
|
108
|
+
tab was previously indistinguishable from one the human opened, which is
|
|
109
|
+
exactly when provenance is worth having. `origin` is never `"human"`: the
|
|
110
|
+
toolkit cannot prove a person opened a tab, so `"unknown"` is the honest word
|
|
111
|
+
for anything it did not create itself. A tab whose record exists but could
|
|
112
|
+
not be read reports `"unknown"` plus `originUnreadable`, so a broken record is
|
|
113
|
+
never mistaken for no record, matching how `list_leases` reports an unreadable
|
|
114
|
+
lease. The ledger is reaped when it is read, dropping records for targets the
|
|
115
|
+
browser no longer has, so nothing has to be scheduled to clean it up. Purely
|
|
116
|
+
additive: `id`, `url`, `title`, and `type` are unchanged, and a missing or
|
|
117
|
+
unreadable ledger degrades to every page reporting `"unknown"` rather than
|
|
118
|
+
failing the call.
|
|
119
|
+
|
|
120
|
+
## [1.2.1] - 2026-08-05
|
|
121
|
+
|
|
122
|
+
### Fixed
|
|
123
|
+
|
|
124
|
+
- **On Chrome, a refusal caused by another agent's lease is now reported as a
|
|
125
|
+
lease conflict.** It was reported with the code meaning the target did not
|
|
126
|
+
exist, and the holder details were dropped, so a caller could not tell "this
|
|
127
|
+
tab is spoken for, go get the token or retry" apart from "that tab is not
|
|
128
|
+
there, stop looking". The refusal message itself always read correctly, so
|
|
129
|
+
anyone reading the text saw the real reason; only the machine readable code
|
|
130
|
+
and the holder fields were wrong. A genuinely missing target is still
|
|
131
|
+
reported as `no-such-target`. Enforcement was never affected: a leased tab
|
|
132
|
+
was refused before this fix and is refused after it. Firefox was never
|
|
133
|
+
affected, and now reports an identical conflict to Chrome's.
|
|
134
|
+
|
|
135
|
+
## [1.2.0] - 2026-08-05
|
|
136
|
+
|
|
137
|
+
Two things landed since 1.0: a second backend, Firefox over WebDriver BiDi,
|
|
138
|
+
alongside the existing Chrome over CDP; and lease based tab ownership, so
|
|
139
|
+
several agents can drive the same browser without stealing each other's tab.
|
|
140
|
+
Both are opt in. Pick Firefox explicitly or you get Chrome as before; omit
|
|
141
|
+
`lease` and every call behaves exactly as it did in 1.0.
|
|
142
|
+
|
|
143
|
+
### Added
|
|
144
|
+
|
|
145
|
+
- **A second backend: Firefox over WebDriver BiDi**, behind the same tool
|
|
146
|
+
surface as Chrome over CDP. Chrome stays the default; select Firefox with
|
|
147
|
+
`--browser firefox` (CLI) or `CDP_BROWSER=firefox` (CLI or MCP server).
|
|
148
|
+
- **Per-backend tool filtering.** `tools/list` (MCP) and `--list`/
|
|
149
|
+
`--capabilities` (CLI) only ever advertise a tool the selected browser can
|
|
150
|
+
actually run; a tool a backend cannot run is absent from the listing rather
|
|
151
|
+
than throwing at call time. Firefox lacks the accessibility tree, atomic
|
|
152
|
+
text insert, full emulation, tracing, heap snapshots, and Lighthouse audits;
|
|
153
|
+
everything else, including network mocking and the lease group below, runs
|
|
154
|
+
on both backends.
|
|
155
|
+
- **A Firefox smoke job in CI**, gating the new backend the same way the
|
|
156
|
+
existing Chrome smoke gates the old one.
|
|
157
|
+
- **Three tools**: `claim_page` (take a tab, or open and take one in a single
|
|
158
|
+
call, and get back an opaque lease token), `release_page` (idempotent, and
|
|
159
|
+
does not close the tab), and `list_leases` (who holds what, with pid liveness
|
|
160
|
+
and reclaimability; needs no token and never returns the lease nonce).
|
|
161
|
+
- **An optional `lease` argument on the other tools.** Present it and a tab
|
|
162
|
+
you hold keeps answering you; omit it and an unleased tab behaves as before.
|
|
163
|
+
`release_page` is the one tool where the token is required.
|
|
164
|
+
- **`new_page` gained `claim`, `label`, and `ttlMs`.** With `claim:true` the new
|
|
165
|
+
tab is claimed as part of creating it and the response carries a `lease`
|
|
166
|
+
token, so no other agent can see the tab unclaimed and take it first.
|
|
167
|
+
- **`CDP_LEASE_TTL_MS`** (default `900000`, 15 minutes): how long a lease
|
|
168
|
+
survives without use before another agent can reclaim it. Every checked call
|
|
169
|
+
refreshes it, so a working agent never expires and needs no heartbeat.
|
|
170
|
+
- Lease files under `CDP_ARTIFACT_DIR`, one per leased tab, created with the
|
|
171
|
+
`wx` exclusive flag so two simultaneous claims cannot both win.
|
|
172
|
+
|
|
173
|
+
### Changed
|
|
174
|
+
|
|
175
|
+
- **The one behavior change.** Any call that resolves to a leased tab without
|
|
176
|
+
presenting that tab's token is now refused, naming the tab, its url, and the
|
|
177
|
+
label of the holder. That is every selector form, not just one: `active` or no
|
|
178
|
+
target at all, `index:N`, `url:<substring>`, `title:<substring>`, and a bare
|
|
179
|
+
targetId all reach the same check. The case worth singling out is `active` (or
|
|
180
|
+
no target) against a leased tab index 0, because there you did not name the
|
|
181
|
+
tab and before 1.2 the call silently succeeded against whatever tab happened
|
|
182
|
+
to be first, but it is not the only exposure. What still holds: an unleased
|
|
183
|
+
tab is always allowed, a stale lease (dead owner or elapsed TTL) never blocks,
|
|
184
|
+
and no default, return shape, or previously legal call changed. For a user who
|
|
185
|
+
never claims a tab there are no lease files at all, so nothing changes.
|
|
186
|
+
A lease whose tab is no longer open is reported by `list_leases`
|
|
187
|
+
as `target-gone`, but that report is not what frees it: it is freed by the
|
|
188
|
+
same two rules, whichever lands first.
|
|
189
|
+
- `close_page` releases that tab's lease when the close actually succeeds, and
|
|
190
|
+
leaves the lease in place when it does not.
|
|
191
|
+
- Enforcement lives at the three target resolution choke points (`resolveTarget`
|
|
192
|
+
for Chrome, `resolveContext` for Firefox, `resolvePage` for `close_page` and
|
|
193
|
+
`select_page`), never in individual tools, so a tool added later is covered
|
|
194
|
+
with no action from whoever writes it.
|
|
195
|
+
- `claim_page` is refused from the CLI. A CLI invocation is one process per
|
|
196
|
+
call, so a lease it claimed would be reclaimable immediately under the dead
|
|
197
|
+
pid rule. It is still listed by `--list`, and CLI calls can still present a
|
|
198
|
+
token minted by the MCP server via `--lease`.
|
|
199
|
+
- Tool count is now 36 (29 parity + 7 superset); 30 under Firefox, which lacks
|
|
200
|
+
the six capability gated tools.
|
|
201
|
+
|
|
202
|
+
## [1.0.0] - 2026-06-24
|
|
9
203
|
|
|
10
204
|
First public release.
|
|
11
205
|
|
|
12
206
|
### Added
|
|
13
207
|
- **33 tools** over raw Chrome DevTools Protocol on a single direct WebSocket:
|
|
14
|
-
- **29 `chrome-devtools-mcp` parity tools
|
|
15
|
-
- **4 superset tools** beyond parity
|
|
16
|
-
- **Single direct WebSocket per call
|
|
17
|
-
- **Per-command timeout** (`CDP_TIMEOUT_MS`, default 15s)
|
|
18
|
-
- **Lazy domain enabling
|
|
19
|
-
- **Stateless element refs
|
|
208
|
+
- **29 `chrome-devtools-mcp` parity tools**: `list_pages`, `new_page`, `close_page`, `select_page`, `navigate_page`, `wait_for`, `evaluate_script`, `take_snapshot`, `click`, `hover`, `drag`, `fill`, `fill_form`, `type_text`, `press_key`, `upload_file`, `take_screenshot`, `emulate`, `resize_page`, `handle_dialog`, `list_console_messages`, `get_console_message`, `list_network_requests`, `get_network_request`, `performance_start_trace`, `performance_stop_trace`, `performance_analyze_insight`, `take_heapsnapshot`, `lighthouse_audit`.
|
|
209
|
+
- **4 superset tools** beyond parity: `performance_trace` (a robust single-call trace that survives the cross-process limitation of the start/stop pair), and the network-mocking group `mock_request` / `list_mocks` / `clear_mocks` (a persistent per-target fake backend).
|
|
210
|
+
- **Single direct WebSocket per call**: every tool resolves exactly one target (`active | index:N | url:<substr> | title:<substr> | <targetId>`); no all-target fan-out.
|
|
211
|
+
- **Per-command timeout** (`CDP_TIMEOUT_MS`, default 15s): `CdpConnection.send()` rejects rather than hangs, so a stuck renderer can't block a caller indefinitely.
|
|
212
|
+
- **Lazy domain enabling**: domains are enabled only where a tool needs them; no eager `Network.enable` on connect.
|
|
213
|
+
- **Stateless element refs**: a `uid` IS the CDP `backendDOMNodeId`, resolved on demand; no server-side handle table to drift or expire.
|
|
20
214
|
- **stdio MCP server** (`src/mcp.ts`) exposing every tool via `@modelcontextprotocol/sdk`, with JSON Schemas in `src/manifest.ts`. Connects to Chrome lazily per call, so it loads cleanly even when Chrome isn't running.
|
|
21
|
-
- **Bun CLI** (`src/cli.ts`)
|
|
22
|
-
- Hermetic test fixtures
|
|
215
|
+
- **Bun CLI** (`src/cli.ts`): `bun run src/cli.ts <tool> [--target <sel>] [--json '<obj>'] [--<key> <value> ...]`.
|
|
216
|
+
- Hermetic test fixtures: `mock-smoke.ts` mocks `https://mock.invalid/*` (CDP `Fetch.requestPaused` intercepts before DNS, so the smoke needs no real network).
|
|
23
217
|
|
|
24
218
|
### Notes
|
|
25
219
|
- License: MIT.
|
package/CONTRACT.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# cdp-toolkit
|
|
1
|
+
# cdp-toolkit implementation contract
|
|
2
2
|
|
|
3
3
|
**Single source of truth for every tool module.** The goal: replicate each of the
|
|
4
4
|
29 `chrome-devtools-mcp` tools over raw CDP on a direct WebSocket
|
|
@@ -7,11 +7,12 @@ writing a module.
|
|
|
7
7
|
|
|
8
8
|
## Hard rules
|
|
9
9
|
|
|
10
|
-
1. **Zero runtime dependencies.** Use Node's global `WebSocket` (Node ≥ 22; we run 25.9) and `fetch`. The only allowed devDeps are `typescript` + `@types/node` (already installed). `lighthouse.ts` is the sole exception and shells out to a subprocess
|
|
10
|
+
1. **Zero runtime dependencies.** Use Node's global `WebSocket` (Node ≥ 22; we run 25.9) and `fetch`. The only allowed devDeps are `typescript` + `@types/node` (already installed). `lighthouse.ts` is the sole exception and shells out to a subprocess, see its section. `tsconfig.json` deliberately does NOT pull in `bun-types`, so any test or script that touches `Bun.serve` or another `Bun` global fails `tsc --noEmit` with `Cannot find name 'Bun'`. The convention is to hand-write a minimal ambient declaration in that file, scoped to just the API it uses (e.g. `declare const Bun: { serve(opts: { port: number; fetch(req: Request): Response | Promise<Response> }): { port: number; stop(): void } };`), never to add the devDependency.
|
|
11
11
|
2. **Build only on `src/client.ts` and `src/types.ts`.** Do not open raw `new WebSocket` yourself. Use `openPage`, `withPage`, `openBrowser`, `resolveTarget`, `CdpConnection`. This is what gives us the per-command timeout that prevents the wedged-tab hang.
|
|
12
12
|
3. **TypeScript strict.** `tsc --noEmit` must pass with `strict`, `noUncheckedIndexedAccess`, `verbatimModuleSyntax`. Import types with `import type`. Use `.ts` extensions in imports (e.g. `import { withPage } from "../client.ts"`).
|
|
13
13
|
4. **One module per assigned bundle.** Write only the files you are assigned. Never edit `client.ts`, `types.ts`, or another agent's file.
|
|
14
14
|
5. **Tool fn naming = camelCase of the MCP tool name.** `take_snapshot` → `export async function takeSnapshot(...)`. `list_network_requests` → `listNetworkRequests`. The integration step maps mechanically, so the names must be exact.
|
|
15
|
+
6. **Never re-resolve a target outside a choke point.** There are exactly three functions that turn a `TargetSelector` into a concrete tab, and all three call `assertLeaseOk`: `resolveTarget` (`src/client.ts`, Chrome), `resolveContext` (`src/bidi/driver.ts`, Firefox), and `resolvePage` (`src/shared-tools.ts`, used by `close_page` and `select_page`). A new tool must go through one of them. If you add a fourth resolution path, it must call `assertLeaseOk` too, or every tool on that path silently loses lease protection. Do NOT add a lease check to an individual tool: that puts the burden on every future contributor to remember it, and one forgotten tool defeats the feature for that tool. A lease conflict throws `LeaseConflictError` rather than being modelled as a capability gap under ADR-001. That departure is deliberate and reviewed: a runtime ownership collision between two agents is not a missing backend capability, so it cannot be discovered at `tools/list` time and must surface at call time. If you touch any of those three choke points, or the error handling above them, re-run `bun run lease:smoke` (`test/lease-smoke.ts`): `bun test` cannot reach this, because a lease is keyed on the claiming pid and only a second live OS process can collide with the first. That harness spawns one, drives a real browser, and asserts the refusal keeps its type, its `targetId` and its `holder` and actually blocks the side effect. It is safe against a browser with real tabs open: one throwaway tab, addressed and closed by its own id, with a runtime-captured baseline diffed at the end.
|
|
15
16
|
|
|
16
17
|
## Core client API (from `src/client.ts`)
|
|
17
18
|
|
|
@@ -57,9 +58,10 @@ export async function click(args: ClickArgs): Promise<unknown>;
|
|
|
57
58
|
|
|
58
59
|
## Artifact + state locations
|
|
59
60
|
|
|
60
|
-
- 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
|
|
61
|
+
- 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).
|
|
61
62
|
- 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.
|
|
62
63
|
- Recorder buffers: `${ARTIFACT_DIR}/rec-<targetId>.jsonl`.
|
|
64
|
+
- 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.
|
|
63
65
|
|
|
64
66
|
## The element-reference scheme (READ if you touch snapshot or input)
|
|
65
67
|
|
|
@@ -102,10 +104,10 @@ A `Uid` **is** a CDP `backendDOMNodeId` (a number). This makes refs stateless:
|
|
|
102
104
|
| `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. |
|
|
103
105
|
| `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. |
|
|
104
106
|
| `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`. |
|
|
105
|
-
| `src/tools/performance.ts` | `performance_start_trace`→`performanceStartTrace`, `performance_stop_trace`→`performanceStopTrace`, `performance_analyze_insight`→`performanceAnalyzeInsight` | `Tracing.start`(categories incl. `devtools.timeline`,`blink.user_timing`,`loading`,`-*`), collect `Tracing.dataCollected`, `Tracing.end`→`Tracing.tracingComplete`, write trace JSON to ARTIFACT_DIR. start/stop need a persistent conn
|
|
107
|
+
| `src/tools/performance.ts` | `performance_start_trace`→`performanceStartTrace`, `performance_stop_trace`→`performanceStopTrace`, `performance_analyze_insight`→`performanceAnalyzeInsight` | `Tracing.start`(categories incl. `devtools.timeline`,`blink.user_timing`,`loading`,`-*`), collect `Tracing.dataCollected`, `Tracing.end`→`Tracing.tracingComplete`, write trace JSON to ARTIFACT_DIR. start/stop need a persistent conn, so use a state file to hold the in-flight trace path/connection target. `performance_analyze_insight` parses a trace file for LCP, FCP/navigation timing, long tasks (>50ms), layout shifts (CLS), total blocking time; return structured metrics. **Mark in a top comment that this is a CDP-native approximation of the MCP's insight analyzer.** |
|
|
106
108
|
| `src/tools/heap.ts` | `take_heapsnapshot`→`takeHeapsnapshot` | `HeapProfiler.enable` → `HeapProfiler.takeHeapSnapshot`, assemble `HeapProfiler.addHeapSnapshotChunk` events into a `.heapsnapshot` file under ARTIFACT_DIR; return `{ path, bytes }`. |
|
|
107
109
|
| `src/tools/lighthouse.ts` | `lighthouse_audit`→`lighthouseAudit` | **Not pure CDP.** Spawn `npx --yes lighthouse <url> --port=9222 --output=json --output-path=<artifact> --chrome-flags=...` via `node:child_process`. If lighthouse/npx unavailable, throw a clear `CdpError` explaining the dependency. Return `{ path, categories: {performance, accessibility, ...scores} }` parsed from the JSON. Top comment must state this is the one non-CDP tool. |
|
|
108
|
-
| `src/tools/network_mock.ts` | `mock_request`→`mockRequest`, `list_mocks`→`listMocks`, `clear_mocks`→`clearMocks` | **Toolkit addition (not MCP parity)
|
|
110
|
+
| `src/tools/network_mock.ts` | `mock_request`→`mockRequest`, `list_mocks`→`listMocks`, `clear_mocks`→`clearMocks` | **Toolkit addition (not MCP parity): a persistent fake backend.** Holds a per-target session (module-level `Map`, like `recorder.ts`) with a persistent `openPage` conn and `Fetch.enable` (patterns). `Fetch.requestPaused` → `selectRule` → `Fetch.fulfillRequest`/`failRequest`/`continueRequest` (default-continue unmatched, or page hangs). Pure logic (`urlMatches`/`selectRule`/`buildFulfillParams`/`effectiveAction`) is unit-tested; I/O via `test/mock-smoke.ts`. Persistence is MCP-server-process-scoped (CLI is one-shot). Request-stage only. |
|
|
109
111
|
|
|
110
112
|
## Each module's footer
|
|
111
113
|
|
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
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 *one* Chrome tab you point it at over the raw DevTools Protocol, with a bounded timeout on every call
|
|
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 *one* Chrome tab you point it at over the raw DevTools Protocol, 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, one tab, no fan-out, plus a built-in network-mocking fake backend. **39 tools.**
|
|
4
4
|
|
|
5
|
-
> For AI-agent developers and Claude Code / Cursor users who need **one known tab driven reliably
|
|
5
|
+
> For AI-agent developers and Claude Code / Cursor users who need **one known tab driven reliably**, not a Puppeteer-managed browser.
|
|
6
6
|
|
|
7
7
|
[](https://github.com/sblattj/cdp-toolkit/actions/workflows/ci.yml)
|
|
8
8
|
[](https://www.npmjs.com/package/cdp-toolkit)
|
|
@@ -14,41 +14,42 @@
|
|
|
14
14
|
|
|
15
15
|
## In plain terms
|
|
16
16
|
|
|
17
|
-
You let an AI agent
|
|
17
|
+
You let an AI agent (Claude Code, Cursor, any MCP host) control a Chrome tab: click, type, read the page, take screenshots, even fake API responses to test a UI before its backend exists. [`chrome-devtools-mcp`](https://github.com/ChromeDevTools/chrome-devtools-mcp) does this too, but it runs a whole Puppeteer browser and talks to *all* your open tabs at once, which is why a single busy tab can freeze it and leave you typing `/mcp` to restart the server mid-task.
|
|
18
18
|
|
|
19
|
-
`cdp-toolkit` keeps it simple: **one connection to the one tab you point it at, and a time limit on every action.** When something stalls, you get an error back
|
|
19
|
+
`cdp-toolkit` keeps it simple: **one connection to the one tab you point it at, and a time limit on every action.** When something stalls, you get an error back, not a frozen agent. Same things you could do before, minus the wedging and the restarts.
|
|
20
20
|
|
|
21
|
-
That's the whole pitch. The technical *why*
|
|
21
|
+
That's the whole pitch. The technical *why* (fan-out, lazy domain enabling, the `Network.enable` hang) is below.
|
|
22
22
|
|
|
23
23
|
## Why it exists
|
|
24
24
|
|
|
25
25
|
If [`chrome-devtools-mcp`](https://github.com/ChromeDevTools/chrome-devtools-mcp) ever **wedged your agent on a busy tab**, you've met its design: it manages a Puppeteer browser, fans every operation out across *all* attached targets, and enables the `Network` domain on connect so it can passively buffer everything. That generality is exactly what makes it fragile once you already know which tab you want to drive.
|
|
26
26
|
|
|
27
|
-
`cdp-toolkit` makes the opposite bet. Every tool attaches **one** direct WebSocket to **one** resolved target, enables only the CDP domains it needs, and enforces a **per-command timeout** so a stuck renderer can never hang the caller. For driving a tab you already have in hand
|
|
27
|
+
`cdp-toolkit` makes the opposite bet. Every tool attaches **one** direct WebSocket to **one** resolved target, enables only the CDP domains it needs, and enforces a **per-command timeout** so a stuck renderer can never hang the caller. For driving a tab you already have in hand, the common automation and evidence-gathering case, it's materially more robust.
|
|
28
28
|
|
|
29
29
|
## cdp-toolkit vs chrome-devtools-mcp
|
|
30
30
|
|
|
31
31
|
| | **cdp-toolkit** | **chrome-devtools-mcp** |
|
|
32
32
|
|---|---|---|
|
|
33
|
-
| **Target scope** | one resolved tab (`active` / `index:N` / `url:` / `title:`) | all attached targets
|
|
34
|
-
| **`Network.enable`** | lazy
|
|
35
|
-
| **Per-command timeout** | ✅ bounded (`CDP_TIMEOUT_MS`, 15s default)
|
|
33
|
+
| **Target scope** | one resolved tab (`active` / `index:N` / `url:` / `title:`) | all attached targets; fan-out can stall on an unrelated tab |
|
|
34
|
+
| **`Network.enable`** | lazy, only when a tool needs it | eager on connect, a known hang on busy renderers |
|
|
35
|
+
| **Per-command timeout** | ✅ bounded (`CDP_TIMEOUT_MS`, 15s default), rejects, never hangs | ❌ none; a stuck renderer blocks indefinitely |
|
|
36
36
|
| **Element refs** | stateless `backendDOMNodeId` (resolved on demand) | server-side handle table (can drift / expire) |
|
|
37
37
|
| **Network mocking** | ✅ persistent per-target fake backend | ❌ not available |
|
|
38
38
|
| **Runtime deps** | CDP/CLI layer: native `WebSocket` + `fetch` (the MCP server adds only the MCP SDK) | Puppeteer stack |
|
|
39
|
-
| **Auto-wait / retry** | ❌ single-shot
|
|
39
|
+
| **Auto-wait / retry** | ❌ single-shot; re-snapshot between steps | ✅ Puppeteer's auto-wait envelope |
|
|
40
40
|
|
|
41
|
-
**Use `chrome-devtools-mcp`** if you need multi-target autonomy or Puppeteer's auto-wait/retry on an *unknown* page. **Use `cdp-toolkit`** when you have one tab in hand and need it not to hang. They coexist
|
|
41
|
+
**Use `chrome-devtools-mcp`** if you need multi-target autonomy or Puppeteer's auto-wait/retry on an *unknown* page. **Use `cdp-toolkit`** when you have one tab in hand and need it not to hang. They coexist: `cdp-toolkit`'s tools are namespaced `mcp__cdp-toolkit__*`, distinct from `mcp__chrome-devtools__*`.
|
|
42
42
|
|
|
43
43
|
## Is this for you?
|
|
44
44
|
|
|
45
|
-
**Yes, if you
|
|
45
|
+
**Yes, if you:**
|
|
46
46
|
- drive one known tab from Claude Code / Cursor / any MCP host and want it to never wedge;
|
|
47
47
|
- need to **mock a backend** to build or test a UI before the real API exists;
|
|
48
|
-
- have been bitten by the eager-`Network.enable` hang on a busy renderer
|
|
48
|
+
- have been bitten by the eager-`Network.enable` hang on a busy renderer;
|
|
49
|
+
- run **multiple agents against one Chrome** and need them to stop stealing each other's tabs.
|
|
49
50
|
|
|
50
|
-
**Probably not, if you
|
|
51
|
-
- need cross-browser (Firefox / WebKit)
|
|
51
|
+
**Probably not, if you:**
|
|
52
|
+
- need cross-browser (Firefox / WebKit), use [`playwright-mcp`](https://github.com/microsoft/playwright-mcp);
|
|
52
53
|
- need Puppeteer's auto-wait/retry envelope for an unknown, changing page;
|
|
53
54
|
- want one server to fan out across *all* your open tabs at once.
|
|
54
55
|
|
|
@@ -76,7 +77,7 @@ npx -y --package cdp-toolkit cdp navigate_page --target index:0 --url https://ex
|
|
|
76
77
|
# …or from a clone: `bun run src/cli.ts <tool> …`
|
|
77
78
|
```
|
|
78
79
|
|
|
79
|
-
**Requirements:** Node ≥ 22 **or** Bun ≥ 1.1
|
|
80
|
+
**Requirements:** Node ≥ 22 **or** Bun ≥ 1.1: `npx -y cdp-toolkit` and `bunx -y cdp-toolkit` both work (the published bins are plain Node ESM). Chrome/Chromium with `--remote-debugging-port=9222`. Smoke-check the port: `curl -s http://127.0.0.1:9222/json/version`.
|
|
80
81
|
|
|
81
82
|
### MCP client setup
|
|
82
83
|
|
|
@@ -116,8 +117,10 @@ bun run mcp:smoke # spawn the server + a real initialize/tools-list/tools-call
|
|
|
116
117
|
## Key capabilities
|
|
117
118
|
|
|
118
119
|
- **Single-target reliability.** One WebSocket to one resolved target, a bounded timeout on every CDP command, lazy domain enabling, and stateless element refs. There is no broadcast step that can stall on a wedged tab.
|
|
119
|
-
- **Network mocking
|
|
120
|
-
- **Full chrome-devtools-mcp parity + extras.** All 29 upstream tools, plus `performance_trace` (a robust single-call trace), Lighthouse audits, and
|
|
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, and a cookie group that reads, writes, and deletes httpOnly cookies. 39 single-purpose tools, no discovery overhead, and they coexist with `chrome-devtools-mcp` in a separate namespace.
|
|
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
|
+
- **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.
|
|
121
124
|
|
|
122
125
|
## Why raw CDP beats the MCP for known targets
|
|
123
126
|
|
|
@@ -130,9 +133,9 @@ Each point below leads with the symptom you've probably hit, then the cause, the
|
|
|
130
133
|
|
|
131
134
|
The trade-off is generality: this toolkit targets one known page at a time and does **not** replicate Puppeteer's auto-wait/retry envelope. Re-`take_snapshot` between steps rather than expecting an implicit wait.
|
|
132
135
|
|
|
133
|
-
## Network mocking
|
|
136
|
+
## Network mocking: a fake backend for building/testing UIs
|
|
134
137
|
|
|
135
|
-
Build and test a UI before its backend (or its data) exists. `mock_request` arms a persistent per-target interception session; mock several endpoints by calling it repeatedly, then iterate on the page
|
|
138
|
+
Build and test a UI before its backend (or its data) exists. `mock_request` arms a persistent per-target interception session; mock several endpoints by calling it repeatedly, then iterate on the page; the mocks survive reloads and navigations until `clear_mocks`.
|
|
136
139
|
|
|
137
140
|
```bash
|
|
138
141
|
# Return empty search results and reload to see how the UI renders the zero state
|
|
@@ -159,6 +162,17 @@ cdp navigate_page --target index:0 --url https://example.com
|
|
|
159
162
|
cdp take_snapshot --target url:example --interactiveOnly true
|
|
160
163
|
cdp click --target index:0 --uid 42
|
|
161
164
|
cdp evaluate_script --json '{"expression":"document.title"}'
|
|
165
|
+
# Read a value WITHOUT it landing in the response (or an agent transcript):
|
|
166
|
+
# savePath writes the value to a JSON file and returns {path,bytes,type,target} only.
|
|
167
|
+
cdp evaluate_script --json '{"expression":"localStorage.getItem(\"auth\")","savePath":"auth.json"}'
|
|
168
|
+
# Cookies, httpOnly ones included (document.cookie cannot see those):
|
|
169
|
+
cdp list_cookies --target index:0 --json '{"domain":"example.com"}'
|
|
170
|
+
# Same read, with the values kept out of the response: {path,bytes,count,target} only.
|
|
171
|
+
cdp list_cookies --json '{"name":"session","savePath":"cookies.json"}'
|
|
172
|
+
# Write one, httpOnly included (document.cookie cannot create those either):
|
|
173
|
+
cdp set_cookie --json '{"name":"session","value":"abc","url":"https://example.com/","httpOnly":true}'
|
|
174
|
+
# Remove it again. Either url or domain is required on both write tools.
|
|
175
|
+
cdp delete_cookies --json '{"name":"session","url":"https://example.com/"}'
|
|
162
176
|
cdp take_screenshot --target url:example --fullPage true
|
|
163
177
|
cdp lighthouse_audit --url https://example.com --json '{"categories":["performance"]}'
|
|
164
178
|
```
|
|
@@ -182,10 +196,11 @@ await TOOLS.navigate_page({ target: "index:0", url: "https://example.com" });
|
|
|
182
196
|
| `CDP_TIMEOUT_MS` | `15000` | Per-command timeout. |
|
|
183
197
|
| `CDP_ARTIFACT_DIR` | `/tmp/cdp-toolkit` | Screenshots, traces, heap snapshots, lighthouse reports, recorder buffers. |
|
|
184
198
|
| `CDP_STATE_DIR` | `/tmp/cdp-toolkit` | `select_page` selected-target file, in-flight trace state. |
|
|
199
|
+
| `CDP_LEASE_TTL_MS` | `900000` | How long a tab lease survives without use before another agent can reclaim it. Refreshed on every checked call. |
|
|
185
200
|
|
|
186
201
|
## Firefox (WebDriver BiDi)
|
|
187
202
|
|
|
188
|
-
cdp-toolkit ships a second backend, Firefox over [WebDriver BiDi](https://w3c.github.io/webdriver-bidi/), behind the same
|
|
203
|
+
cdp-toolkit ships a second backend, Firefox over [WebDriver BiDi](https://w3c.github.io/webdriver-bidi/), behind the same 39-tool surface. Chrome stays the default and its behavior is unchanged, opt in explicitly to reach Firefox:
|
|
189
204
|
|
|
190
205
|
```bash
|
|
191
206
|
cdp --browser firefox take_snapshot # CLI: explicit flag
|
|
@@ -206,7 +221,7 @@ Backend selection precedence: `--browser chrome|firefox` flag, then `CDP_BROWSER
|
|
|
206
221
|
- `take_heapsnapshot` (needs `heap.snapshot`)
|
|
207
222
|
- `lighthouse_audit` (needs `audit.lighthouse`)
|
|
208
223
|
|
|
209
|
-
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), is available under both backends.
|
|
224
|
+
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) and the `claim_page`/`release_page`/`list_leases` lease group, is available under both backends: 33 of the 39 tools.
|
|
210
225
|
|
|
211
226
|
**Honest capability gaps, not oversold parity:**
|
|
212
227
|
|
|
@@ -217,21 +232,24 @@ Everything else, including `mock_request`/`list_mocks`/`clear_mocks` (Firefox's
|
|
|
217
232
|
- **`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`.
|
|
218
233
|
- **No `--browser firefox` attach mode.** By design, per the launch model above: every invocation gets its own throwaway Firefox process and profile.
|
|
219
234
|
|
|
220
|
-
## The tools (29 parity +
|
|
235
|
+
## The tools (29 parity + 10 superset = 39)
|
|
221
236
|
|
|
222
|
-
The 29 parity tools are 1:1 with `chrome-devtools-mcp`; the
|
|
237
|
+
The 29 parity tools are 1:1 with `chrome-devtools-mcp`; the 10 superset tools (`performance_trace`, the `list_cookies`/`set_cookie`/`delete_cookies` cookie group, the `mock_request`/`list_mocks`/`clear_mocks` group, and the `claim_page`/`release_page`/`list_leases` lease group) are toolkit additions. Each row notes the underlying CDP method(s) and the precise parity gaps.
|
|
223
238
|
|
|
224
239
|
| MCP name | CDP method(s) | Parity notes / gaps |
|
|
225
240
|
|---|---|---|
|
|
226
|
-
| `list_pages` | `GET /json/list` | `all` flag also exposes worker/background targets; MCP lists only page tabs. |
|
|
227
|
-
| `new_page` | `Target.createTarget` | Returns `{targetId,url}`; does not await navigation (use `navigate_page`). |
|
|
228
|
-
| `close_page` | `Target.closeTarget` | Reports `success:true` on the empty result newer Chromium returns. |
|
|
241
|
+
| `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. |
|
|
242
|
+
| `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). |
|
|
243
|
+
| `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. |
|
|
229
244
|
| `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. |
|
|
230
245
|
| `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). |
|
|
231
246
|
| `wait_for` | `Runtime.evaluate` (poll `innerText`) | Text-substring waiting only; throws on timeout rather than returning `{found:false}`. |
|
|
232
|
-
| `evaluate_script` | `Runtime.evaluate` / `callFunctionOn` | No live `page`/element handle; `args` are plain JSON. Main-world context only. |
|
|
247
|
+
| `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. |
|
|
248
|
+
| `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. |
|
|
249
|
+
| `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. |
|
|
250
|
+
| `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. |
|
|
233
251
|
| `take_snapshot` | `Accessibility.getFullAXTree` | uid is the raw `backendDOMNodeId` (stateless, non-sequential). Full tree in one shot; frames flattened. `interactiveOnly` is a toolkit addition. |
|
|
234
|
-
| `click` | `Input.dispatchMouseEvent` | No implicit auto-wait/retry
|
|
252
|
+
| `click` | `Input.dispatchMouseEvent` | No implicit auto-wait/retry; resolves and acts once; re-snapshot between steps. |
|
|
235
253
|
| `hover` | `Input.dispatchMouseEvent` (`mouseMoved`) | Same single-shot model as `click`. |
|
|
236
254
|
| `drag` | `Input.dispatchMouseEvent` (press→move→release) | Synthetic mouse drag; native HTML5 DnD is approximated. |
|
|
237
255
|
| `fill` | `Input.insertText` | Atomic paste-like commit, not per-character keystrokes. |
|
|
@@ -247,15 +265,73 @@ The 29 parity tools are 1:1 with `chrome-devtools-mcp`; the 4 superset tools (`p
|
|
|
247
265
|
| `get_console_message` | reads the shared "latest" buffer | Index into the latest capture; throws if out of range. |
|
|
248
266
|
| `list_network_requests` | `Network.*` events (+ `Page.reload`) | Correlated rows from the per-capture buffer; redirect chains collapse to the first row. No timing breakdown / POST data. |
|
|
249
267
|
| `get_network_request` | above + `Network.getResponseBody` | Bodies only via a fresh reload capture (CDP serves bodies from the live session); `includeBody` matches by **url**. |
|
|
250
|
-
| `performance_start_trace` | `Tracing.start` / `dataCollected` | Works ONLY within one process
|
|
268
|
+
| `performance_start_trace` | `Tracing.start` / `dataCollected` | Works ONLY within one process; a live trace buffer is bound to its connection. Use `performance_trace` for robustness. |
|
|
251
269
|
| `performance_stop_trace` | `Tracing.end` / `tracingComplete` | Must run in the SAME process as `performance_start_trace`; throws a clear error otherwise. |
|
|
252
270
|
| `performance_analyze_insight` | parses a trace JSON file | A **CDP-native approximation** of the MCP insight analyzer (FCP/LCP/CLS/TBT/long-tasks); close but not byte-identical. Requires an explicit `tracePath`. |
|
|
253
271
|
| `take_heapsnapshot` | `HeapProfiler.takeHeapSnapshot` | Returns `{path,bytes,chunks,target}`; does not parse the snapshot (load the `.heapsnapshot` in the DevTools Memory panel). |
|
|
254
|
-
| `lighthouse_audit` | **none (non-CDP)
|
|
272
|
+
| `lighthouse_audit` | **none (non-CDP)**, spawns `npx --yes lighthouse …` | The toolkit's sole non-CDP tool. Defaults to the desktop preset. Returns numeric category scores (full report on disk). |
|
|
255
273
|
| `performance_trace` *(superset)* | `Tracing.*` (+ `Page.reload`) | **Toolkit convenience.** A robust single-call trace: start → optional reload → capture for `durationMs` → end → write the trace JSON → return `{path,bytes,events,metrics}`. Preferred over the `start`/`stop` pair (CDP tracing is browser-global and bound to one connection). |
|
|
256
274
|
| `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`). |
|
|
257
275
|
| `list_mocks` *(superset)* | `Runtime.evaluate` (liveness probe) | Lists active mock sessions with rules + hit counts; prunes sessions whose tab closed. |
|
|
258
276
|
| `clear_mocks` *(superset)* | `Fetch.disable` | Tears down the resolved target's mock session (or all with `all:true`). |
|
|
277
|
+
| `claim_page` *(superset)* | `Target.createTarget` + lease file | Opens or claims a tab and returns an opaque lease token. MCP only; the CLI refuses it. |
|
|
278
|
+
| `release_page` *(superset)* | lease file | Gives a lease back. Idempotent, and does not close the tab. |
|
|
279
|
+
| `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. |
|
|
280
|
+
|
|
281
|
+
## Parallel tabs: many agents, one browser
|
|
282
|
+
|
|
283
|
+
Two agents pointed at the same Chrome both resolve `target: active` to whatever tab the human last focused. Nothing errors. Each one gets plausible, well-formed output from the wrong page: a snapshot of someone else's form, a fill that lands in someone else's field. A crash gets caught; a silent wrong-tab success does not.
|
|
284
|
+
|
|
285
|
+
`claim_page` fixes that by handing you an opaque lease token for one tab:
|
|
286
|
+
|
|
287
|
+
```json
|
|
288
|
+
{"tool": "claim_page", "arguments": {"url": "https://example.com", "label": "checkout-agent"}}
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
```json
|
|
292
|
+
{"lease": "chrome:1A2B...:0f3c...", "targetId": "1A2B...", "url": "https://example.com", "label": "checkout-agent", "ttlMs": 900000, "expiresAt": 1754400000000}
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
Pass that token as `lease` on every later call against the tab. `new_page` with `claim: true` does the open-and-claim in one call. `release_page` gives it back, `list_leases` shows who holds what.
|
|
296
|
+
|
|
297
|
+
**Opt in, and only ever a refusal.** A tab nobody claimed behaves exactly as it did before. Omitting `lease` is identical to pre-1.2 behavior in every respect: no default changed, no return shape changed, no previously legal call now needs a new argument. This is why it is 1.2 and not 2.0.
|
|
298
|
+
|
|
299
|
+
**What can newly fail.** Any call that resolves to a leased tab without presenting that tab's token is refused, naming the tab, its url, and the label of whoever holds it. That is every selector form, not one of them: `active` or no target at all, `index:N`, `url:<substring>`, `title:<substring>`, and a bare targetId all reach the same check. The case worth singling out is `active` (or no target) against a leased tab index 0, because there you did not name the tab and before 1.2 the call silently succeeded against whatever tab happened to be first. That refusal is the point of the feature, and it is called out here rather than left to be discovered.
|
|
300
|
+
|
|
301
|
+
**Reclamation.** A lease is reclaimable when its owning process is gone, or when `lastUsedAt` is older than `ttlMs` (default 15 minutes, `CDP_LEASE_TTL_MS`). Those two are the whole list. A lease whose tab is no longer open is *reported* by `list_leases` as `target-gone`, which is useful to see, but that report is not what frees it: such a record is freed by the same two rules as any other, whichever lands first. Every checked call refreshes `lastUsedAt`, so an agent that is working never expires and no heartbeat is needed. Reclaiming mints a fresh nonce, which invalidates the previous owner's token: a stalled agent that comes back cannot keep driving a tab someone else now owns.
|
|
302
|
+
|
|
303
|
+
**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.
|
|
304
|
+
|
|
305
|
+
**Verifying it against a real browser.** `bun run lease:smoke` (`test/lease-smoke.ts`) is the end-to-end harness for this feature. It drives a REAL browser on `CDP_BASE` (default `http://127.0.0.1:9222`) and spawns a genuine second OS process for the stranger role, because a lease records the claiming pid and a sequence of CLI calls can never collide: the first process is dead before the second starts. It asserts that the owner can act, that a tokenless stranger is refused with the `LeaseConflictError` type, `targetId` and `holder` intact and NOT coded `no-such-target`, that the refusal actually prevented the side effect (proved by reading the marker back, not by the throw), that a genuinely missing target still reports `no-such-target`, that the stranger is admitted after `release_page`, and that a lease orphaned by a real child process which exited is reclaimable with a fresh nonce. It is safe to point at a browser with real tabs open: it opens exactly one throwaway `about:blank` tab, addresses it only by its own target id, closes exactly that tab, and never closes the browser. 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 files go to a private temp dir that is removed on exit.
|
|
306
|
+
|
|
307
|
+
### Which tab did an agent open? (`origin`)
|
|
308
|
+
|
|
309
|
+
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.
|
|
310
|
+
|
|
311
|
+
So every tab this toolkit creates is written to a creation ledger, and `list_pages` reports it:
|
|
312
|
+
|
|
313
|
+
```json
|
|
314
|
+
{"id": "1A2B...", "url": "https://example.com", "title": "Example", "type": "page",
|
|
315
|
+
"origin": "agent", "label": "checkout-agent", "createdAt": 1754400000000}
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
- `origin: "agent"` means cdp-toolkit created the tab, via `new_page` or via `claim_page` with no `targetId`. The row then also carries the creating `label` (the same value a lease would show, defaulting to `pid-<pid>`) and `createdAt`.
|
|
319
|
+
- `origin: "unknown"` means there is no creation record. **It never says `"human"`.** The toolkit cannot prove a person opened a tab: no record is equally consistent with a tab opened before the server started, a tab restored from a previous session, another tool driving the same Chrome, or a record that could not be written. `unknown` is the honest word, and reading it as "safe, a human opened this" is the one mistake this field exists to prevent.
|
|
320
|
+
|
|
321
|
+
**It outlives the lease, deliberately.** `release_page`, expiry, and reclamation all leave the record alone. Only the tab going away removes it: the ledger is reaped when it is read, dropping records for targets the browser no longer has, so it stays self-cleaning with nothing scheduled.
|
|
322
|
+
|
|
323
|
+
**Claiming a tab you did not open records nothing.** `claim_page` with an explicit `targetId` takes ownership of an existing tab; that tab keeps reporting `origin: "unknown"`, because provenance is not ownership.
|
|
324
|
+
|
|
325
|
+
**Unreadable records.** If a target has a creation record that could not be read or parsed, its row reports `origin: "unknown"` plus `originUnreadable` (the errno, for example `EACCES`, or `"unparseable"`). Same principle as `list_leases`' `unreadable` rows: a broken record must never be indistinguishable from no record at all. A missing or unreadable ledger never fails `list_pages`; every page simply reports `unknown`.
|
|
326
|
+
|
|
327
|
+
**Backward compatible.** `id`, `url`, `title`, and `type` are unchanged. The provenance fields are purely additive.
|
|
328
|
+
|
|
329
|
+
**What this does not do.**
|
|
330
|
+
|
|
331
|
+
- No fan-out. The shape is many agents with one tab each. One agent driving several tabs at once is a different feature and is not addressed.
|
|
332
|
+
- No isolation. Two leased tabs on one Chrome still share cookies, `localStorage`, and every other origin-scoped store. A lease is ownership of a tab, not isolation of the browser under it.
|
|
333
|
+
- No protection from humans. The lease is enforced against cdp-toolkit's own tool calls. It cannot stop someone clicking into the tab.
|
|
334
|
+
- Not for the CLI. A CLI invocation is one process per call, so a lease it claimed would be reclaimable immediately by the dead-pid rule. `claim_page` is refused there. CLI calls can still present a token minted by the MCP server with `--lease`.
|
|
259
335
|
|
|
260
336
|
## How it's built
|
|
261
337
|
|
|
@@ -263,10 +339,13 @@ The 29 parity tools are 1:1 with `chrome-devtools-mcp`; the 4 superset tools (`p
|
|
|
263
339
|
src/
|
|
264
340
|
client.ts # CdpConnection, openPage/withPage/openBrowser, resolveTarget, timeouts
|
|
265
341
|
types.ts # Target, TargetSelector, Uid, CDP envelopes
|
|
266
|
-
index.ts # TOOLS registry (
|
|
342
|
+
index.ts # TOOLS registry (39) + re-exported client primitives
|
|
267
343
|
cli.ts # the Bun CLI
|
|
268
344
|
mcp.ts # stdio MCP server (exposes TOOLS via @modelcontextprotocol/sdk)
|
|
269
345
|
manifest.ts # JSON Schemas advertised by the MCP server (one per tool)
|
|
346
|
+
leases.ts # lease records, staleness, reclamation, assertLeaseOk
|
|
347
|
+
leases-tools.ts # claim_page / release_page / list_leases
|
|
348
|
+
origins.ts # tab creation ledger: who OPENED a tab, outliving its lease
|
|
270
349
|
tools/
|
|
271
350
|
pages.ts navigation.ts evaluate.ts snapshot.ts input.ts
|
|
272
351
|
screenshot.ts emulation.ts dialogs.ts recorder.ts console.ts
|
|
@@ -276,13 +355,14 @@ test/
|
|
|
276
355
|
mock-smoke.ts # network-mocking end-to-end smoke (bun run mock:smoke)
|
|
277
356
|
network_mock.test.ts # pure-logic unit tests (bun test)
|
|
278
357
|
mcp-smoke.ts # MCP handshake + live tools/call round-trip (bun run mcp:smoke)
|
|
358
|
+
lease-smoke.ts # two-process live lease harness (bun run lease:smoke)
|
|
279
359
|
```
|
|
280
360
|
|
|
281
361
|
Each module ends with a footer comment listing the exact CDP methods it uses and its parity gaps. The full design contract lives in [`CONTRACT.md`](./CONTRACT.md).
|
|
282
362
|
|
|
283
363
|
## Contributing
|
|
284
364
|
|
|
285
|
-
Issues and PRs welcome
|
|
365
|
+
Issues and PRs welcome, see [`CONTRIBUTING.md`](./CONTRIBUTING.md) and the [`good first issue`](https://github.com/sblattj/cdp-toolkit/labels/good%20first%20issue) label. By participating you agree to the [Code of Conduct](./CODE_OF_CONDUCT.md).
|
|
286
366
|
|
|
287
367
|
## License
|
|
288
368
|
|