cdp-toolkit 2.6.0 → 3.0.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 +44 -0
- package/CONTRACT.md +1 -1
- package/README.md +81 -46
- package/dist/bidi/mux-daemon.js +47 -0
- package/dist/cli.js +78 -5
- package/dist/index.js +73 -4
- package/dist/mcp.js +236 -50
- package/package.json +5 -3
- package/src/bidi/driver.ts +8 -0
- package/src/cdp/driver.ts +69 -4
- package/src/client.ts +22 -0
- package/src/driver.ts +2 -1
- package/src/frames.ts +169 -0
- package/src/manifest.ts +1 -1
- package/src/mcp.ts +177 -55
- package/src/shared-tools.ts +13 -0
- package/src/toolDocs.ts +3 -3
- package/src/toolGroups.ts +41 -10
- package/src/types.ts +13 -0
- package/src/version.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,50 @@ 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
|
+
## [3.0.0] - 2026-09-29
|
|
9
|
+
|
|
10
|
+
**The default MCP tool listing went from 49 tools to 7 (37,346 bytes to 5,072, -86.4%; about 9,336 tokens to about 1,268).** With `CDP_TOOL_PROFILE` unset, `tools/list` now advertises `search_tools`, `call_tool` and five core tools (`navigate_page`, `evaluate_script`, `take_snapshot`, `click`, `fill`); every other tool is found with `search_tools` and run with `call_tool`. `CDP_TOOL_PROFILE=full` restores the 2.x listing (plus the two meta-tools). The 48 underlying tools and the `cdp` CLI are unchanged, and the listing is still static: computed once at startup, `listChanged:false`, same cache hints.
|
|
11
|
+
|
|
12
|
+
### Changed — BREAKING
|
|
13
|
+
|
|
14
|
+
- **The default profile is now `gateway`, not `full`.** Measured `tools/list` (raw stdio, compact JSON, tokens = bytes/4): 2.7.0 default 49 entries / 37,346 bytes / about 9,336 tokens; 3.0 default 7 entries / 5,072 bytes / about 1,268 tokens (identical on Chrome and Firefox). `CDP_TOOL_PROFILE=full` is now 50 entries / 37,911 bytes on Chrome (the 2.7.0 listing plus the two meta-tools).
|
|
15
|
+
- **`describe_tool` is no longer listed**, but is still answered with 2.x output for back-compat; `describe_tool {}` returns the same catalog as `search_tools {}`.
|
|
16
|
+
- **What is traded away.** A tool reached through `call_tool` appears to the host as `call_tool`, so name-keyed host allowlists and per-tool permission prompts (for example "allow click, deny evaluate_script") cannot distinguish the 43 non-gateway tools; set `CDP_TOOL_PROFILE=full` if you need per-tool host rules. Drop-in parity with chrome-devtools-mcp's listing is also gone by default: its 29 tool names still exist and are callable with the same names, but only 5 are listed unless you set `full`.
|
|
17
|
+
- Direct calls to unlisted tools by name still work over the wire (SDK and CLI consumers are unaffected). `call_tool` exists because a model can only invoke tools its host puts in front of it, and hosts forward `tools/list`.
|
|
18
|
+
- This does not repeat 2.0's `browser_tools` design, which mutated the listing mid-connection (a spec MUST-NOT); the 3.0 listing never changes. See `docs/design/2026-09-29-v3-gateway-default.md`.
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- **`search_tools {query?, limit?}`.** No query returns the grouped catalog; a query that is an exact tool name returns that one tool; anything else ranks available tools by name (strongest), group, then full docs, returning JSON `{query,total,matches:[{name,group,listed,description,inputSchema}],next}`. `limit` defaults to 8, max 20. (`src/mcp.ts`)
|
|
23
|
+
- **`call_tool {name, arguments?}`.** Unwraps and re-enters the same dispatch as a direct call, so availability errors, lease handling (the `lease` is read from the inner `arguments`) and error shapes are identical. Refuses to wrap a meta-tool. Verified live on headless Chrome 154 (`bun run gateway:smoke`): `new_page` via `call_tool {claim:true}` mints a lease, `navigate_page` without it is refused naming the lease holder, with it succeeds, and `close_page` releases it. (`src/mcp.ts`)
|
|
24
|
+
- **The `gateway` profile and `gateway,<groups>` lists.** `gateway` is the default; `gateway,network,console` adds only the named groups on top of the five gateway tools (11 entries, 7,899 bytes, about 1,975 tokens). A group list without `gateway` still always includes `core`, as in 2.x (`core`: 14 entries, 9,661 bytes; `core,network,console`: 18 entries, 12,488 bytes). Every profile lists `search_tools` and `call_tool` first. Unknown groups exit at startup with `CDP_TOOL_PROFILE: unknown tool group 'x'. Known: gateway, full, core, input, ...`. (`src/toolGroups.ts`)
|
|
25
|
+
- The server instructions (1,862 chars, under Claude Code's 2 KB cap) now tell the model that only 5 tools are listed, how to find and run the rest, and "No tab yet? call_tool {name:'new_page'}", since none of the five opens a tab.
|
|
26
|
+
|
|
27
|
+
- **Wedge benchmark (`bun run bench:wedge`).** A runnable, CI-able proof of the core claim: an isolated headless Chrome drives a throwaway page into an endpoint that accepts the connection and never responds, 8 times, and measures the whole stuck-page lifecycle — the wedged `navigate` rejects at the configured bound (15.03s observed against the default 15s bound), a witness tab keeps answering at healthy latency mid-brick (p95 3ms), and closing the bricked tab plus reopening evaluates at p95 89ms with zero `/mcp` restarts. Also reports, as an observation rather than a failure, that the stuck page itself stays unresponsive while its load is pending: the blast radius is the tab, not the server. (`scripts/wedge-bench.ts`)
|
|
28
|
+
|
|
29
|
+
### Migrating from 2.x
|
|
30
|
+
|
|
31
|
+
- **Do nothing** if your host and model can use `search_tools` plus `call_tool`.
|
|
32
|
+
- **Set `CDP_TOOL_PROFILE=full`** if you rely on per-tool host allowlists or permission rules, or simply want the old listing.
|
|
33
|
+
- **SDK and CLI callers** that call tools by name are unaffected.
|
|
34
|
+
|
|
35
|
+
## [2.7.0] - 2026-09-15
|
|
36
|
+
|
|
37
|
+
**Cross-origin iframes are now discoverable and addressable.** A visible out-of-process iframe used to be a silent hole: `take_snapshot` on the parent page stopped at a bare `Iframe` line with no hint that an entire interactable document lived behind it, `list_pages` hid the iframe's target by default, and the only way to drive it was to know that a raw target id from `list_pages {all:true}` happened to work. Now the snapshot marks the boundary, the docs say the target exists, and a `frame:` selector addresses it by URL. Verified live against Chrome 153 with a real cross-origin iframe: marker present in both snapshot modes, `evaluate_script`/`take_snapshot` drive the frame through both its target id and the new selector, and two iframes with the same URL produce an ambiguity error naming both ids instead of a silent first-match.
|
|
38
|
+
|
|
39
|
+
### Added
|
|
40
|
+
|
|
41
|
+
- **OOPIF markers in `take_snapshot`.** When an emitted `<iframe>` node owns an out-of-process (cross-origin) frame, its snapshot line now carries the frame's identity: `[4821] Iframe "Checkout" [frame="F405BF…" frameUrl="https://example.com/"]`. `frame` is the frame id, which IS the iframe's CDP target id — feed it straight back as `target` to any interaction tool. The marker appears in `interactiveOnly` mode too (an iframe at a process boundary is itself the interactable thing), while same-origin iframes are unchanged: their AX subtrees were already merged into the parent tree. Detection drives off the browser's target list correlated through `DOM.getFrameOwner` — deliberately not `Page.getFrameTree`, which omits out-of-process child frames on a page-level attachment (observed Chrome 153) — and is failure-soft end to end: any error in detection degrades to the pre-marker snapshot, never a failed call. (`src/cdp/driver.ts`, `test/iframe-markers.test.ts`)
|
|
42
|
+
- **`frame:<url-substring>` target selector (Chrome).** Resolves against the browser's out-of-process iframe targets by URL substring, so an iframe is addressable without first correlating raw target ids out of `list_pages {all:true}`. Follows the `worker:` arm's rules: an empty needle matches nothing, more than one match is an ambiguity error naming every id+url (never a silent first-match), and a miss lists the live iframes and teaches that only out-of-process iframes appear as targets. Accepted by every tool whose `target` flows through the shared resolver (evaluate_script, take_snapshot, click, screenshot, …); `close_page`/`select_page`/`release_page`/`claim_page` refuse it with a page-only message, and Firefox refuses it with `unsupported` naming the `frame.targets` capability, same as `worker:`. A bare iframe target id still resolves everywhere it did before. (`src/frames.ts`, `src/client.ts`, `src/shared-tools.ts`, `src/bidi/driver.ts`, `test/frame-selector.test.ts`)
|
|
43
|
+
|
|
44
|
+
### Documented
|
|
45
|
+
|
|
46
|
+
- `list_pages {all:true}` now says it lists out-of-process iframes (not just workers/background pages) and that a returned non-page targetId is accepted as a bare id by any tool's `target` param — in the tool's deep doc, the manifest, the MCP server instructions, the README tools table and target-grammar bullet, the CLI help grammar, and the CONTRACT grammar rule. The snapshot doc names the cross-origin boundary and the marker. (`src/toolDocs.ts`, `src/mcp.ts`, `src/manifest.ts`, `README.md`, `CONTRACT.md`, `skills/using-cdp-toolkit/SKILL.md`)
|
|
47
|
+
|
|
48
|
+
### Deferred
|
|
49
|
+
|
|
50
|
+
- Recursing child-frame accessibility nodes into the parent snapshot is NOT in this release. `backendDOMNodeId` uids are per-renderer-process, so child-frame uids would collide with parent uids under every interaction tool's `DOM.resolveNode` — recursion requires session-aware uid namespacing across all nine interaction tools, plus a BiDi-side answer where no AX domain exists. The marker + `frame:` pair ships the discoverability without that surgery.
|
|
51
|
+
|
|
8
52
|
## [2.6.0] - 2026-09-15
|
|
9
53
|
|
|
10
54
|
**Bun is now a first-class, CI-tested runtime, and the MCP server can serve streamable HTTP.** The package has always declared `engines.bun`, but nothing proved the server actually booted under Bun — or under Node, for that matter. This release pins both: the server and CLI run under Bun ≥ 1.1 or Node ≥ 22, importing `dist/mcp.js` / `dist/cli.js` as libraries is now silent (no server start, no CLI usage dump), and under Bun the same `mcp.js` entry can serve the full MCP surface over loopback HTTP with `--transport streamable-http` for hosts that launch it as an HTTP service instead of a stdio child.
|
package/CONTRACT.md
CHANGED
|
@@ -20,7 +20,7 @@ writing a module.
|
|
|
20
20
|
|
|
21
21
|
**A worker-typed hit inside `resolveTarget` is the other deliberate exception, added in 1.9.0.** A `worker:<substring>` selector (Chrome only, capability `worker.targets`; one tool in 1.9.0, `evaluate_script` — four as of 1.9.1, see `WORKER_CAPABLE_TOOLS` in `src/workers.ts`) resolves to a service/shared worker target, and `resolveTarget` returns it before reaching `assertLeaseOk` — see the comment at the top of that branch in `src/client.ts`. A lease answers "who is driving this tab"; a worker is not a tab, has no reap-meaningful close, and strict-mode auto-acquire on it would mint a lease file keyed on an id that vanishes the moment Chrome idles the worker out, garbage the page-only reap set (`ReapInput.livePageIds`) can never collect. This also cannot be used to bypass a lease on an actual tab: there is no tab a worker resolution could stand in for. **The three console/network reader tools take a parallel path, not this one** — `resolveRecorderTarget`/`resolveWorkerSelector` in `src/tools/recorder.ts`/`src/cdp/workers.ts` — but the same reasoning holds: neither touches `assertLeaseOk`, tested by asserting zero lease files on a worker read with a page control in the same strict-mode window.
|
|
22
22
|
|
|
23
|
-
**The `TargetSelector` grammar itself also has three independent copies, a separate fact from the lease choke points above** — `pickTarget` (`src/client.ts`, what `resolveTarget` calls, and what most tools reach through), `pickPage` (`src/shared-tools.ts`), and `pickContext` (`src/bidi/driver.ts`, Firefox). A prefix arm added to one does not exist for callers routed through another; `label:` (1.9.0) was first wired into only `pickPage` and passed every unit test while `evaluate_script` still rejected it, because `evaluate_script` calls `pickTarget`, not `pickPage`, and only the live smoke caught the gap. The shared lookup core for an arm that needs identical semantics everywhere (as `label:` does) belongs in its own module — see `resolveLiveLabel` in `src/origins.ts` — wired into all three call sites, each keeping its own error class (`CdpError` / `SharedToolError` / `DriverError`). `test/manifest-grammar-drift.test.ts` structurally asserts a completeness property (every `title:` mention also mentions `label:`) and, separately, an exclusion property for `worker:` (only a tool in the `WORKER_CAPABLE_TOOLS` allowlist — four as of 1.9.1 — may name it) — grep `startsWith("title:")` across `src/` before adding a new arm, and extend or scope that test for it.
|
|
23
|
+
**The `TargetSelector` grammar itself also has three independent copies, a separate fact from the lease choke points above** — `pickTarget` (`src/client.ts`, what `resolveTarget` calls, and what most tools reach through), `pickPage` (`src/shared-tools.ts`), and `pickContext` (`src/bidi/driver.ts`, Firefox). A prefix arm added to one does not exist for callers routed through another; `label:` (1.9.0) was first wired into only `pickPage` and passed every unit test while `evaluate_script` still rejected it, because `evaluate_script` calls `pickTarget`, not `pickPage`, and only the live smoke caught the gap. The shared lookup core for an arm that needs identical semantics everywhere (as `label:` does) belongs in its own module — see `resolveLiveLabel` in `src/origins.ts` — wired into all three call sites, each keeping its own error class (`CdpError` / `SharedToolError` / `DriverError`). `test/manifest-grammar-drift.test.ts` structurally asserts a completeness property (every `title:` mention also mentions `label:`) and, separately, an exclusion property for `worker:` (only a tool in the `WORKER_CAPABLE_TOOLS` allowlist — four as of 1.9.1 — may name it) — grep `startsWith("title:")` across `src/` before adding a new arm, and extend or scope that test for it. `frame:<substr>` (OOPIF iframes) is Chrome-only like `worker:` but resolves through all three parsers with one deliberate asymmetry: an iframe targetId from an unfiltered `list_pages{all:true}` resolves as a bare 32-hex id everywhere, while `worker:` never matches an iframe.
|
|
24
24
|
|
|
25
25
|
7. **Never dispatch `Input.*` outside `sendInput`.** As of 1.8.0 there is exactly one function that writes a synthesized input command to a Chrome connection: `sendInput(conn, backend, targetId, method, params)` in `src/activity.ts`. `CdpPageDriver.dispatchInput` (the per-driver choke point every interaction method already had to go through) delegates to it, and any tool built outside the Driver abstraction (`dispatch_mouse`, in `src/tools/dispatch-mouse.ts`, is the precedent) must call it directly rather than opening its own `conn.send("Input....", ...)`. This is what keeps the activity beacon's human/agent discrimination (`src/activity.ts`'s dispatch log) correct: a new input path that bypasses `sendInput` will typecheck, pass its own unit tests, and pass `bun test` in full, while silently making the toolkit's own input read as a human using the tab — exactly the 1.8.0 merge bug `test/input-dispatch-wiring.test.ts` now exists to catch (it walks every `.ts` under `src/`, strips comment-only lines, and fails on any line that both sends and names `Input.`). On the Firefox side, `BidiPageDriver.performActions` is the twin choke point and already calls `recordDispatch`; a new BiDi input path goes through it the same way `CdpPageDriver.dispatchInput` is used on Chrome. Mode-toggle commands that dispatch no page input themselves (`Input.setInterceptDrags`'s arm/disarm around HTML5 drag) are still sent through `sendInput` but are on a denylist that skips recording them — classify a new such command explicitly; the default for anything unclassified is to be logged, since over-warning a human is recoverable and silently misattributing their input to "agent" is not.
|
|
26
26
|
|
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 (and know when a human is using it too), plus a built-in network-mocking fake backend. **48 tools,
|
|
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. **48 tools, behind a 7-entry static, cacheable MCP listing** (5 everyday tools plus `search_tools` and `call_tool` to find and run the rest); `CDP_TOOL_PROFILE=full` lists all of them (see [Progressive disclosure](#progressive-disclosure)). Chrome is the flagship default; a second backend drives Firefox over WebDriver BiDi behind the same tool surface (see below).
|
|
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
|
|
|
@@ -40,6 +40,21 @@ If [`chrome-devtools-mcp`](https://github.com/ChromeDevTools/chrome-devtools-mcp
|
|
|
40
40
|
|
|
41
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 know which tabs you want driven, however many that is, and need them not to hang. They coexist: `cdp-toolkit`'s tools are namespaced `mcp__cdp-toolkit__*`, distinct from `mcp__chrome-devtools__*`.
|
|
42
42
|
|
|
43
|
+
## Measured, not marketed: the wedge benchmark
|
|
44
|
+
|
|
45
|
+
Every number above is runnable. `bun run bench:wedge` spawns an isolated headless Chrome, points a throwaway page at an endpoint that accepts the connection and never responds, and measures the full stuck-page lifecycle (re-run with `CDP_TIMEOUT_MS=3000` for a ~60s sample; defaults below use the 15s bound):
|
|
46
|
+
|
|
47
|
+
| Phase | Measured (2026-09-16, Chrome 153, isolated headless, 8 wedges) |
|
|
48
|
+
|---|---|
|
|
49
|
+
| Healthy `evaluate_script` (50×) | p50 **1ms**, p95 **2ms** |
|
|
50
|
+
| Wedged `navigate` → dead endpoint (8×) | **rejected at p50 15.03s, max 15.03s** — the configured bound, never a hang |
|
|
51
|
+
| Witness tab evaluated mid-brick (8×) | p95 **3ms** — other tabs never stall |
|
|
52
|
+
| Recovery: close bricked tab + reopen + evaluate (8×) | p95 **89ms** |
|
|
53
|
+
| `/mcp` restarts · server restarts | **0** · **0** |
|
|
54
|
+
|
|
55
|
+
One honest caveat the benchmark also reports: while a navigation to a dead endpoint is pending, *that page* stays unresponsive to new sessions — the blast radius is the tab, not the server. Closing it is a browser-level command (2ms) and a fresh page answers at healthy latency; your other tabs, and the MCP server, never noticed. Exit code is nonzero unless every wedge rejected in-bound and every recovery landed under 1s, so the benchmark is CI-able.
|
|
56
|
+
|
|
57
|
+
|
|
43
58
|
## Is this for you?
|
|
44
59
|
|
|
45
60
|
**Yes, if you:**
|
|
@@ -89,6 +104,8 @@ npx -y --package cdp-toolkit cdp navigate_page --target index:0 --url https://ex
|
|
|
89
104
|
|
|
90
105
|
**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, and the server + CLI are CI-tested under both runtimes). Chrome/Chromium with `--remote-debugging-port=9222`. Smoke-check the port: `curl -s http://127.0.0.1:9222/json/version`. Under Bun only, `--transport streamable-http` (with `--port`/`--host`, default `127.0.0.1:3000`) serves the same MCP surface over loopback HTTP instead of stdio.
|
|
91
106
|
|
|
107
|
+
**Upgrading from 2.x:** the MCP server's default `tools/list` went from 49 entries to 7 (the 5 gateway tools plus `search_tools` and `call_tool`); the other tools are still there, reached through `call_tool`. Set `CDP_TOOL_PROFILE=full` to get the old listing back. The CLI is unchanged. See [Progressive disclosure](#progressive-disclosure).
|
|
108
|
+
|
|
92
109
|
### MCP client setup
|
|
93
110
|
|
|
94
111
|
<details>
|
|
@@ -129,7 +146,7 @@ bun run mcp:smoke # spawn the server + a real initialize/tools-list/tools-call
|
|
|
129
146
|
|
|
130
147
|
- **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.
|
|
131
148
|
- **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`.
|
|
132
|
-
- **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. The MCP server
|
|
149
|
+
- **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. The MCP server lists 7 of them by default on one static, cacheable `tools/list` (≈1.3k tokens, −86% vs 2.7.0's default) and serves the rest through `search_tools` (find a tool, read its docs and `inputSchema`) and `call_tool` (run it); `CDP_TOOL_PROFILE=full` lists all of them. The CLI exposes all 48 regardless. They coexist with `chrome-devtools-mcp` in a separate namespace.
|
|
133
150
|
- **A whole page is a file, not a wedged tab.** Chrome cannot encode a screenshot past 16384 device px on either side, and past that `Page.captureScreenshot` does not error — it never answers, and leaves the tab resized to the clip it was capturing. On a ratio-2 display that ceiling arrives at about 8192 CSS px: an ordinary long article. `take_screenshot` measures the projection before every capture and, past the cap, takes the page as vertical bands stitched losslessly into one PNG — a 140,982 CSS px page comes back as a 2780×281964 file in 18 bands, in 17 seconds, with the tab still healthy. Also per-capture `scale`, and `renderWidth`/`renderHeight` to shoot one capture at an emulated viewport and restore the tab afterwards.
|
|
134
151
|
- **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.
|
|
135
152
|
- **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.
|
|
@@ -140,7 +157,7 @@ bun run mcp:smoke # spawn the server + a real initialize/tools-list/tools-call
|
|
|
140
157
|
|
|
141
158
|
Each point below leads with the symptom you've probably hit, then the cause, then the fix.
|
|
142
159
|
|
|
143
|
-
- **Your agent stalls on a call when a busy background tab is open** → that's the **all-target fan-out**: every operation broadcasts to all attached targets. every cdp-toolkit call resolves *one* target (`active | index:N | url:<substr> | title:<substr> | label:<name> | <targetId>`) and attaches a single WebSocket to just that page, so a busy tab you did not name is never touched.
|
|
160
|
+
- **Your agent stalls on a call when a busy background tab is open** → that's the **all-target fan-out**: every operation broadcasts to all attached targets. every cdp-toolkit call resolves *one* target (`active | index:N | url:<substr> | title:<substr> | label:<name> | <targetId>`, plus `frame:<substr>` for an out-of-process iframe, Chrome-only) and attaches a single WebSocket to just that page, so a busy tab you did not name is never touched.
|
|
144
161
|
- **A tool hangs forever and never returns** → the MCP's eager `Network.enable` on a busy or hung renderer is a known wedge. cdp-toolkit enables domains **lazily**, only where a tool needs them (the recorder enables `Network`/`Runtime`/`Log`; most tools touch only `Page`/`Runtime`/`DOM`).
|
|
145
162
|
- **No way to bound a slow call** → `CdpConnection.send()` enforces `CDP_TIMEOUT_MS` (15s default) on *every* command and **rejects rather than hangs**, so a stuck renderer can never block a caller indefinitely.
|
|
146
163
|
- **Element handles drift across calls** → a `uid` **is** a CDP `backendDOMNodeId`, resolved on demand via `DOM.resolveNode`. There is no server-side handle table to drift or expire.
|
|
@@ -230,7 +247,7 @@ await TOOLS.navigate_page({ target: "index:0", url: "https://example.com" });
|
|
|
230
247
|
| `CDP_EXTRACT_PROMPT` | `html` | `extract_page` only. Prompt shape: `html` sends the cleaned HTML as the only user message (the hosted Schematron API injects the schema server-side); `schematron` sends the open-weight Schematron model-card prompt with the schema inline. Per-call `prompt` overrides; any other value is a validation error. |
|
|
231
248
|
| `CDP_LEASE_TTL_MS` | `900000` | How long a tab lease survives without use before another agent can reclaim it. Refreshed on every checked call. |
|
|
232
249
|
| `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. |
|
|
233
|
-
| `CDP_TOOL_PROFILE` | `
|
|
250
|
+
| `CDP_TOOL_PROFILE` | `gateway` | MCP server only. Startup-only filter on what `tools/list` advertises, read once and fixed for the life of the process. `gateway` (the default, and what unset/empty means) lists 7 tools: `search_tools`, `call_tool`, and the 5 gateway tools (`navigate_page`, `evaluate_script`, `take_snapshot`, `click`, `fill`); `full` lists every group; `core` lists the 12 everyday tools plus `search_tools` and `call_tool`; or a comma-separated group list, e.g. `gateway,network,console` (adds only the named groups on top of the gateway tools) or `core,network,console` (a list without `gateway` always includes `core`). An unknown group name is a configuration error: the server prints `CDP_TOOL_PROFILE: unknown tool group 'x'. Known: gateway, full, core, input, …` and exits 1. Tools the profile leaves out stay callable through `call_tool` (or by name). See "Progressive disclosure" below. |
|
|
234
251
|
| `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. |
|
|
235
252
|
| `CDP_FIREFOX_MARIONETTE_PORT` | `2828` | Firefox backend only. The Marionette side-channel port used to force-clear Firefox's orphaned BiDi session during orphan-session recovery (a blind `WebDriver:DeleteSession`). Only effective when that Firefox was launched with `--marionette`. See "Firefox" below. |
|
|
236
253
|
| `CDP_FIREFOX_SESSION_WAIT_MS` | `10000` | Firefox backend only. How long a second process waits for a live holder to release Firefox's one BiDi session before returning the distinguishable "held by a live process" error. See "Firefox" below. |
|
|
@@ -240,58 +257,75 @@ await TOOLS.navigate_page({ target: "index:0", url: "https://example.com" });
|
|
|
240
257
|
|
|
241
258
|
## Progressive disclosure
|
|
242
259
|
|
|
243
|
-
|
|
260
|
+
As of 3.0 the MCP server's default `tools/list` has **7 entries**: the 5 gateway tools (`navigate_page`, `evaluate_script`, `take_snapshot`, `click`, `fill`) plus two meta-tools, `search_tools` and `call_tool`. The other 43 tools are still there; a model finds and runs them through the two meta-tools. Wire order is `search_tools`, `call_tool`, then the gateway tools in manifest order.
|
|
244
261
|
|
|
245
|
-
|
|
262
|
+
The listing is still **static**. It is computed once at startup and frozen: byte-identical on every call, on every connection, and unchangeable by anything a client does mid-session. On a 2026-era connection it carries cache hints (`ttlMs: 3600000`, `cacheScope: "public"`) so a client can hold it for an hour instead of re-fetching, and `capabilities.tools.listChanged` is `false`: nothing will ever notify, because nothing ever changes. What 3.0 changed is the default profile, not that design.
|
|
246
263
|
|
|
247
|
-
|
|
264
|
+
That shape is deliberate. The MCP **2026-07-28** revision puts lazy discovery on the *host* side, and a server cooperates by publishing a complete, deterministic, cacheable list rather than by mutating its own. The revision makes it a MUST that a server's tool set not change as a side effect of other requests on the connection. See [Build an MCP server](https://modelcontextprotocol.io/docs/2026-07-28/develop/build-server) and [Client best practices](https://modelcontextprotocol.io/docs/2026-07-28/develop/clients/client-best-practices); the latter also notes that adding or removing tool definitions mid-conversation invalidates the host's prompt cache, which a list that never changes never does. 2.0.0's `browser_tools` runtime activation toggle mutated the listing mid-connection, which is exactly that anti-pattern; it was **removed in 2.1** and calling it returns `unknown tool: browser_tools`. `search_tools` and `call_tool` do not touch the listing.
|
|
265
|
+
|
|
266
|
+
**How a model reaches a tool that is not listed.** A model can only invoke tools its host puts in front of it, and hosts forward `tools/list`, so an unlisted tool is unreachable for a model without help. That is what the two meta-tools are for:
|
|
248
267
|
|
|
249
268
|
```json
|
|
250
|
-
{"tool": "
|
|
251
|
-
{"tool": "
|
|
269
|
+
{"tool": "search_tools", "arguments": {"query": "new_page"}}
|
|
270
|
+
{"tool": "call_tool", "arguments": {"name": "new_page", "arguments": {"claim": true, "label": "demo"}}}
|
|
252
271
|
```
|
|
253
272
|
|
|
254
|
-
|
|
273
|
+
**`search_tools {query?, limit?}`** is the catalog and inspect layer:
|
|
274
|
+
|
|
275
|
+
- With no `query` it returns the grouped catalog text below.
|
|
276
|
+
- A `query` that is an exact tool name returns that one tool.
|
|
277
|
+
- Any other `query` ranks the available tools by name (strongest), group, and full docs, and returns JSON `{query, total, matches: [{name, group, listed, description, inputSchema}], next}`. `limit` defaults to 8, max 20.
|
|
278
|
+
|
|
279
|
+
The whole 48-tool surface, as `search_tools {}` prints it on the default profile under Chrome:
|
|
255
280
|
|
|
256
281
|
```
|
|
257
|
-
cdp-toolkit
|
|
258
|
-
[listed] core (12): list_pages, new_page, close_page, select_page, navigate_page, wait_for, take_snapshot, click, fill, type_text, evaluate_script, take_screenshot
|
|
259
|
-
[
|
|
260
|
-
[
|
|
261
|
-
[
|
|
262
|
-
[
|
|
263
|
-
[
|
|
264
|
-
[
|
|
265
|
-
[
|
|
266
|
-
[
|
|
267
|
-
[
|
|
268
|
-
[
|
|
269
|
-
[
|
|
270
|
-
[
|
|
271
|
-
[
|
|
272
|
-
|
|
282
|
+
cdp-toolkit 3.0.0 · browser=chrome · 48 tools available, 7 in tools/list (CDP_TOOL_PROFILE=gateway)
|
|
283
|
+
[partly listed] core (12): list_pages, new_page, close_page, select_page, navigate_page, wait_for, take_snapshot, click, fill, type_text, evaluate_script, take_screenshot
|
|
284
|
+
[hidden] input (9): hover, drag, scroll, dispatch_mouse, press_key, fill_form, upload_file, focus_emulation, click_focus_gated
|
|
285
|
+
[hidden] cookies (3): list_cookies, set_cookie, delete_cookies
|
|
286
|
+
[hidden] network (2): list_network_requests, get_network_request
|
|
287
|
+
[hidden] console (2): list_console_messages, get_console_message
|
|
288
|
+
[hidden] mocking (3): mock_request, list_mocks, clear_mocks
|
|
289
|
+
[hidden] emulation (2): emulate, resize_page
|
|
290
|
+
[hidden] performance (6): performance_start_trace, performance_stop_trace, performance_analyze_insight, performance_trace, take_heapsnapshot, lighthouse_audit
|
|
291
|
+
[hidden] recording (2): start_screen_recording, stop_screen_recording
|
|
292
|
+
[hidden] leases (3): claim_page, release_page, list_leases
|
|
293
|
+
[hidden] permissions (1): grant_permissions
|
|
294
|
+
[hidden] dialogs (1): handle_dialog
|
|
295
|
+
[hidden] downloads (1): wait_for_download
|
|
296
|
+
[hidden] extraction (1): extract_page
|
|
297
|
+
Run any tool with call_tool {name, arguments}; search_tools {query:<name>} returns its docs and inputSchema.
|
|
273
298
|
```
|
|
274
299
|
|
|
275
|
-
|
|
300
|
+
**`call_tool {name, arguments?}`** unwraps and re-enters the same dispatch as a direct call, so availability errors, lease handling, and error shapes are identical to calling the tool directly. The `lease` is read from the inner `arguments`. It refuses to wrap a meta-tool (`call_tool: 'call_tool' is a meta-tool; call it directly`), requires `name` (``call_tool: `name` is required (find one with search_tools)``), and requires `arguments`, when given, to be an object (``call_tool: `arguments` must be an object``). `bun run gateway:smoke` (`test/gateway-smoke.ts`) proves the lease half against a real browser: without the inner `lease` a claimed tab is refused, with it the call succeeds.
|
|
301
|
+
|
|
302
|
+
**`CDP_TOOL_PROFILE` is the only filter, and it is startup-only** — set once by whoever configures the server, then fixed for the life of the process. Every profile lists `search_tools` and `call_tool` first. Measured over raw stdio at 1e9f25f (bytes = compact JSON of the `tools` array, tokens ≈ bytes ÷ 4):
|
|
303
|
+
|
|
304
|
+
| `CDP_TOOL_PROFILE` | browser | entries in `tools/list` | bytes | ≈ tokens |
|
|
305
|
+
|---|---|---|---|---|
|
|
306
|
+
| unset / `gateway` — the 3.0 default | chrome | 7 | 5,072 | ≈1,268 |
|
|
307
|
+
| unset / `gateway` | firefox | 7 | 5,072 | ≈1,268 |
|
|
308
|
+
| `gateway,network,console` | chrome | 11 | 7,899 | ≈1,975 |
|
|
309
|
+
| `core` | chrome | 14 | 9,661 | ≈2,415 |
|
|
310
|
+
| `core,network,console` | chrome | 18 | 12,488 | ≈3,122 |
|
|
311
|
+
| `full` | chrome | 50 | 37,911 | ≈9,478 |
|
|
312
|
+
| `full` | firefox | 37 | 28,899 | ≈7,225 |
|
|
313
|
+
| 2.7.0 default (`full`, for comparison) | chrome | 49 | 37,346 | ≈9,336 |
|
|
276
314
|
|
|
277
|
-
|
|
315
|
+
The default listing is **−86.4% bytes** vs 2.7.0's default.
|
|
278
316
|
|
|
279
|
-
|
|
280
|
-
|---|---|---|---|
|
|
281
|
-
| unset / `full` — the default | 49 | 37,088 | ≈9,272 |
|
|
282
|
-
| `core` | 13 | 9,077 | ≈2,269 |
|
|
283
|
-
| `core,network,console` | 17 | 11,904 | ≈2,976 |
|
|
284
|
-
| `full` under `CDP_BROWSER=firefox` | 36 | 28,076 | ≈7,019 |
|
|
317
|
+
**What you trade away for that:**
|
|
285
318
|
|
|
286
|
-
|
|
319
|
+
- **Name-keyed host allowlists and per-tool permission prompts.** A tool reached through `call_tool` shows up to the host as `call_tool`, so a host rule like "allow `click`, deny `evaluate_script`" cannot tell apart the 43 non-gateway tools. If you need per-tool host rules, set `CDP_TOOL_PROFILE=full`.
|
|
320
|
+
- **Drop-in parity with `chrome-devtools-mcp`'s listing.** Its 29 tool names still exist and are still callable under the same names, but only 5 are *listed* by default. `CDP_TOOL_PROFILE=full` restores a listing with all of them.
|
|
287
321
|
|
|
288
|
-
|
|
322
|
+
A `CDP_TOOL_PROFILE` value is `gateway` (the default; also what unset or empty means), `full`, `core`, or a comma-separated group list. A list *without* `gateway` always includes `core` (the 2.x behaviour). A list *with* `gateway` adds only the named groups on top of the 5 gateway tools, e.g. `gateway,network,console`. An unknown group is a startup error: `CDP_TOOL_PROFILE: unknown tool group 'x'. Known: gateway, full, core, input, …`, exit 1.
|
|
289
323
|
|
|
290
|
-
|
|
324
|
+
Rules worth knowing:
|
|
291
325
|
|
|
292
|
-
- **Unlisted does not mean blocked.** Only discovery is filtered.
|
|
293
|
-
-
|
|
294
|
-
-
|
|
326
|
+
- **Unlisted does not mean blocked.** Only discovery is filtered. Calling an unlisted tool directly by name still works over the wire for SDK and CLI consumers, and `call_tool` reaches it for a model. Only a tool the selected browser cannot run at all is refused by name.
|
|
327
|
+
- **Profiles, `search_tools`, and `call_tool` are MCP-only.** `cdp search_tools` fails as an unknown tool, and the CLI keeps exposing all 48 tools no matter how `CDP_TOOL_PROFILE` is set. The 48 underlying tools are unchanged.
|
|
328
|
+
- **`describe_tool` is unlisted but still answered** for 2.x callers, with the same output as 2.x; `describe_tool {}` returns the same catalog as `search_tools {}`.
|
|
295
329
|
|
|
296
330
|
### MCP protocol eras
|
|
297
331
|
|
|
@@ -404,7 +438,7 @@ This coordinates and recovers **around** Firefox's one-session limit; it does no
|
|
|
404
438
|
|
|
405
439
|
**Attaching is not relaunching.** The one thing that is genuinely impossible is handing a debug port to an *already-running* Firefox process after the fact: the `--remote-debugging-port` flag only takes effect on a process's original launch, so relaunching the `firefox` binary against a running instance hands off to it and exits silently, opening no port (verified against Firefox 153.0.3). That is a real, narrow limitation of the Firefox binary itself. It is not the same claim as "Firefox cannot be attached to" — a Firefox that was launched *with* the debug port open, whether by this toolkit or by your own hand, exposes a plain BiDi endpoint that any number of fresh clients can connect to later, which is exactly what `--connect`/`CDP_FIREFOX_ENDPOINT` does.
|
|
406
440
|
|
|
407
|
-
**Tool availability is filtered per backend**, not per call: `tools/list` (MCP) and `--list`/`--capabilities` (CLI) only ever advertise a tool the selected browser can actually run. A tool is never listed and then thrown from at call time. On the MCP server a second filter composes on top: a tool is listed only when the backend can run it *and*
|
|
441
|
+
**Tool availability is filtered per backend**, not per call: `tools/list` (MCP) and `--list`/`--capabilities` (CLI) only ever advertise a tool the selected browser can actually run. A tool is never listed and then thrown from at call time. On the MCP server a second filter composes on top: a tool is listed only when the backend can run it *and* the startup profile includes it — backend availability first, `CDP_TOOL_PROFILE` second (see [Progressive disclosure](#progressive-disclosure)). Under Firefox, six capability areas are absent because Firefox 153's BiDi implementation has no equivalent domain:
|
|
408
442
|
|
|
409
443
|
- `performance_start_trace`, `performance_stop_trace`, `performance_analyze_insight`, `performance_trace` (needs `trace.performance`)
|
|
410
444
|
- `take_heapsnapshot` (needs `heap.snapshot`)
|
|
@@ -413,7 +447,7 @@ This coordinates and recovers **around** Firefox's one-session limit; it does no
|
|
|
413
447
|
- `dispatch_mouse` (needs `input.raw`): the raw move/down/up primitive is a direct `Input.dispatchMouseEvent` wrapper with no BiDi analogue.
|
|
414
448
|
- `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.
|
|
415
449
|
|
|
416
|
-
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, the `extract_page` extraction tool (its page work runs entirely through the driver's evaluate, no CDP-only capability), 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: 35 of the 48 tools. Under `CDP_BROWSER=firefox` the MCP server's
|
|
450
|
+
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, the `extract_page` extraction tool (its page work runs entirely through the driver's evaluate, no CDP-only capability), 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: 35 of the 48 tools. Under `CDP_BROWSER=firefox` the MCP server's `CDP_TOOL_PROFILE=full` listing is therefore 37 entries — those 35 plus `search_tools` and `call_tool` — while the 3.0 default (`gateway`) lists 7 under either browser; the profile filter applies after this backend filter. One asymmetry to know before expecting Chrome-style concurrency: the lease group fences *tabs* on both backends, but Firefox permits only **one BiDi session per browser instance**, so multiple agent PROCESSES under Firefox share that one session rather than opening independent ones the way Chrome's unlimited CDP connections allow — coordinated by the cross-process session lease and, by default, joined through the BiDi multiplexer above, so sharing no longer means waiting a turn — see "Parallel tabs" below.
|
|
417
451
|
|
|
418
452
|
**Honest capability gaps, not oversold parity:**
|
|
419
453
|
|
|
@@ -428,13 +462,13 @@ Everything else, including `mock_request`/`list_mocks`/`clear_mocks` (Firefox's
|
|
|
428
462
|
|
|
429
463
|
## The tools (29 parity + 19 superset = 48)
|
|
430
464
|
|
|
431
|
-
This table is the **full
|
|
465
|
+
This table is the **full view** — all 48 tools, which is what `cdp --list` and the MCP server's `CDP_TOOL_PROFILE=full` listing show (the MCP default lists only 7; see [Progressive disclosure](#progressive-disclosure)). They are partitioned into 14 static profile groups: `core` (12: list_pages, new_page, close_page, select_page, navigate_page, wait_for, take_snapshot, click, fill, type_text, evaluate_script, take_screenshot), `input` (9: hover, drag, scroll, dispatch_mouse, press_key, fill_form, upload_file, focus_emulation, click_focus_gated), `cookies` (3: list/set/delete_cookies), `network` (2: list/get_network_request), `console` (2: list/get_console_message), `mocking` (3: mock_request, list_mocks, clear_mocks), `emulation` (2: emulate, resize_page), `performance` (6: the four trace tools, take_heapsnapshot, lighthouse_audit), `recording` (2: start/stop_screen_recording), `leases` (3: claim_page, release_page, list_leases), `permissions` (1: grant_permissions), `dialogs` (1: handle_dialog), `downloads` (1: wait_for_download), `extraction` (1: extract_page). `CDP_TOOL_PROFILE=core` narrows the MCP listing to the first group; any group it leaves out stays callable by name. See [Progressive disclosure](#progressive-disclosure).
|
|
432
466
|
|
|
433
467
|
The 29 parity tools are 1:1 with `chrome-devtools-mcp`; the 19 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`, `grant_permissions`, the `focus_emulation`/`click_focus_gated` focus pair, and `extract_page`) are toolkit additions. Each row notes the underlying CDP method(s) and the precise parity gaps.
|
|
434
468
|
|
|
435
469
|
| MCP name | CDP method(s) | Parity notes / gaps |
|
|
436
470
|
|---|---|---|
|
|
437
|
-
| `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. |
|
|
471
|
+
| `list_pages` | `GET /json/list` | `all` flag also exposes worker/background and out-of-process (OOPIF) iframe targets; MCP lists only page tabs. A non-page targetId from this listing is accepted by any tool's `target` param as a bare id, and `take_snapshot` marks a cross-origin iframe node with `frame:"<targetId>"` (input inside such an iframe is invisible to the activity beacon — see "Staleness" below — but the frame itself is fully driveable as its own target). 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. |
|
|
438
472
|
| `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`. |
|
|
439
473
|
| `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. |
|
|
440
474
|
| `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. |
|
|
@@ -476,7 +510,7 @@ The 29 parity tools are 1:1 with `chrome-devtools-mcp`; the 19 superset tools (`
|
|
|
476
510
|
| `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`). |
|
|
477
511
|
| `list_mocks` *(superset)* | `Runtime.evaluate` (liveness probe) | Lists active mock sessions with rules + hit counts; prunes sessions whose tab closed. |
|
|
478
512
|
| `clear_mocks` *(superset)* | `Fetch.disable` | Tears down the resolved target's mock session (or all with `all:true`). |
|
|
479
|
-
| `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. The `
|
|
513
|
+
| `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. The `search_tools` and `call_tool` meta-tools are likewise MCP-only. |
|
|
480
514
|
| `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`. |
|
|
481
515
|
| `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. |
|
|
482
516
|
| `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`). |
|
|
@@ -639,7 +673,7 @@ src/
|
|
|
639
673
|
version.ts # the VERSION constant, single source of truth with package.json
|
|
640
674
|
manifest.ts # JSON Schemas advertised by the MCP server (one per tool)
|
|
641
675
|
toolGroups.ts # tool-to-group map + CDP_TOOL_PROFILE resolution (startup-only filter)
|
|
642
|
-
toolDocs.ts # full per-tool prose served on demand by describe_tool
|
|
676
|
+
toolDocs.ts # full per-tool prose served on demand by search_tools (and describe_tool, unlisted, for 2.x callers)
|
|
643
677
|
leases.ts # lease records, staleness, reclamation, assertLeaseOk, CDP_REQUIRE_LEASE
|
|
644
678
|
leases-tools.ts # claim_page / release_page / list_leases
|
|
645
679
|
origins.ts # tab creation ledger: who OPENED a tab, outliving its lease
|
|
@@ -654,6 +688,7 @@ test/
|
|
|
654
688
|
network_mock.test.ts # pure-logic unit tests (bun test)
|
|
655
689
|
mcp-smoke.ts # MCP handshake + live tools/call round-trip (bun run mcp:smoke)
|
|
656
690
|
lease-smoke.ts # two-process live lease harness (bun run lease:smoke)
|
|
691
|
+
gateway-smoke.ts # call_tool lease pass-through on a live browser (bun run gateway:smoke)
|
|
657
692
|
firefox-multi-agent-smoke.ts # N concurrent server PROCESSES on one shared Firefox (bun run firefox:multi:smoke)
|
|
658
693
|
```
|
|
659
694
|
|
package/dist/bidi/mux-daemon.js
CHANGED
|
@@ -689,6 +689,37 @@ var init_workers = __esm(() => {
|
|
|
689
689
|
WORKER_SELECTOR_PAGE_ONLY_MESSAGE = "target 'worker:<substring>' selects a service/shared worker, and this tool is page-only " + "(closing, selecting or releasing a worker is meaningless). The tools that accept a worker: target are " + `${WORKER_CAPABLE_TOOLS.join(", ")}.`;
|
|
690
690
|
});
|
|
691
691
|
|
|
692
|
+
// src/frames.ts
|
|
693
|
+
function isFrameSelector(selector) {
|
|
694
|
+
return typeof selector === "string" && selector.startsWith(FRAME_SELECTOR_PREFIX);
|
|
695
|
+
}
|
|
696
|
+
function frameNeedle(selector) {
|
|
697
|
+
return selector.slice(FRAME_SELECTOR_PREFIX.length);
|
|
698
|
+
}
|
|
699
|
+
function isFrameTargetType(type) {
|
|
700
|
+
return type === FRAME_TARGET_TYPE;
|
|
701
|
+
}
|
|
702
|
+
function resolveFrameTargets(targets, needle) {
|
|
703
|
+
const liveFrames = targets.filter((t) => isFrameTargetType(t.type));
|
|
704
|
+
return {
|
|
705
|
+
matches: needle === "" ? [] : liveFrames.filter((t) => t.url.includes(needle)),
|
|
706
|
+
liveFrames
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
function frameAmbiguityMessage(needle, matches) {
|
|
710
|
+
return `'frame:${needle}' matches more than one out-of-process iframe: ` + matches.map((m) => `${m.url} (${m.id})`).join(", ") + ". Narrow the substring, or pass the iframe's target id directly.";
|
|
711
|
+
}
|
|
712
|
+
function frameMissMessage(needle, liveFrames) {
|
|
713
|
+
const live = liveFrames.length ? ` (out-of-process iframes: ${liveFrames.map((f) => f.url).join(", ")})` : " (no out-of-process iframes are live)";
|
|
714
|
+
return `no out-of-process iframe whose url contains '${needle}'${live}. ` + "Only a CROSS-ORIGIN iframe gets its own process and therefore its own target; a same-origin iframe " + "runs inside its parent page and is NOT a target at all, so no substring can ever select it — reach a " + "same-origin iframe through its parent page instead (evaluate_script on the page, then the iframe " + "element's contentDocument). An out-of-process iframe whose document navigated away is likewise gone " + "from the listing: re-list with list_pages{all:true} and pass the new target id.";
|
|
715
|
+
}
|
|
716
|
+
var FRAME_SELECTOR_PREFIX = "frame:", FRAME_PAGE_ONLY_TOOLS, FRAME_TARGET_TYPE = "iframe", FRAME_EMPTY_NEEDLE_MESSAGE = "'frame:' needs a substring of the iframe's url, e.g. frame:ads.example.com or frame:player.html", FRAME_SELECTOR_UNSUPPORTED_MESSAGE, FRAME_SELECTOR_PAGE_ONLY_MESSAGE;
|
|
717
|
+
var init_frames = __esm(() => {
|
|
718
|
+
FRAME_PAGE_ONLY_TOOLS = ["close_page", "select_page", "release_page", "claim_page"];
|
|
719
|
+
FRAME_SELECTOR_UNSUPPORTED_MESSAGE = "target 'frame:<substring>' selects an out-of-process iframe, which this backend cannot address " + "(WebDriver BiDi has no iframe target — an iframe is a realm inside its tab's browsing context, not a " + "selectable target; Capability 'frame.targets' is Chrome-only). Use --browser chrome to act on a cross-origin iframe.";
|
|
720
|
+
FRAME_SELECTOR_PAGE_ONLY_MESSAGE = "target 'frame:<substring>' selects an out-of-process iframe, and this tool is page-only " + "(closing, selecting, releasing or claiming an iframe is meaningless; these act on the embedder tab). " + `The page-only tools are ${FRAME_PAGE_ONLY_TOOLS.join(", ")}; every other tool that takes a 'target' ` + "accepts a frame: selector.";
|
|
721
|
+
});
|
|
722
|
+
|
|
692
723
|
// src/activity.ts
|
|
693
724
|
function dispatchKey(backend, targetId) {
|
|
694
725
|
return `${backend}:${targetId}`;
|
|
@@ -2127,6 +2158,17 @@ async function pickTarget(targets, selector) {
|
|
|
2127
2158
|
return matches[0];
|
|
2128
2159
|
throw new CdpError(workerMissMessage(needle, { wakeAttempted: false, liveWorkers }));
|
|
2129
2160
|
}
|
|
2161
|
+
if (isFrameSelector(selector)) {
|
|
2162
|
+
const needle = frameNeedle(selector);
|
|
2163
|
+
if (needle === "")
|
|
2164
|
+
throw new CdpError(FRAME_EMPTY_NEEDLE_MESSAGE);
|
|
2165
|
+
const { matches, liveFrames } = resolveFrameTargets(targets, needle);
|
|
2166
|
+
if (matches.length > 1)
|
|
2167
|
+
throw new CdpError(frameAmbiguityMessage(needle, matches));
|
|
2168
|
+
if (matches.length === 1)
|
|
2169
|
+
return matches[0];
|
|
2170
|
+
throw new CdpError(frameMissMessage(needle, liveFrames));
|
|
2171
|
+
}
|
|
2130
2172
|
const byId = targets.find((t) => t.id === selector);
|
|
2131
2173
|
if (!byId)
|
|
2132
2174
|
throw new CdpError(`no target with id '${selector}'`);
|
|
@@ -2153,6 +2195,7 @@ var init_client2 = __esm(() => {
|
|
|
2153
2195
|
init_leases();
|
|
2154
2196
|
init_origins();
|
|
2155
2197
|
init_workers();
|
|
2198
|
+
init_frames();
|
|
2156
2199
|
BASE = process.env.CDP_BASE ?? "http://127.0.0.1:9222";
|
|
2157
2200
|
DEFAULT_TIMEOUT_MS3 = Number(process.env.CDP_TIMEOUT_MS ?? 15000);
|
|
2158
2201
|
CdpError = class CdpError extends Error {
|
|
@@ -2843,6 +2886,9 @@ async function pickContext(conn, contexts, selector) {
|
|
|
2843
2886
|
if (isWorkerSelector(selector)) {
|
|
2844
2887
|
throw driverError("unsupported", WORKER_SELECTOR_UNSUPPORTED_MESSAGE);
|
|
2845
2888
|
}
|
|
2889
|
+
if (isFrameSelector(selector)) {
|
|
2890
|
+
throw driverError("unsupported", FRAME_SELECTOR_UNSUPPORTED_MESSAGE);
|
|
2891
|
+
}
|
|
2846
2892
|
const exact = contexts.find((c) => c.context === selector);
|
|
2847
2893
|
if (exact)
|
|
2848
2894
|
return exact;
|
|
@@ -3054,6 +3100,7 @@ var init_driver2 = __esm(() => {
|
|
|
3054
3100
|
init_leases();
|
|
3055
3101
|
init_origins();
|
|
3056
3102
|
init_workers();
|
|
3103
|
+
init_frames();
|
|
3057
3104
|
init_activity();
|
|
3058
3105
|
init_client();
|
|
3059
3106
|
init_session_lease();
|