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 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, all of them on one static, cacheable MCP listing** — or a lean 12-tool `core` listing when your client loads every schema eagerly (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).
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 publishes all of them on one static, cacheable `tools/list` (≈9.3k tokens, −54% vs 1.x) and serves the full per-tool prose on demand through `describe_tool`; `CDP_TOOL_PROFILE=core` trims that listing to a 12-tool everyday set (≈2.3k tokens) for clients that eagerly load every schema, and the trimmed-away tools stay callable by name. The CLI exposes all 48 regardless. They coexist with `chrome-devtools-mcp` in a separate namespace.
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` | `full` | MCP server only. Startup-only filter on what `tools/list` advertises, read once and fixed for the life of the process. `full` (the default, and what unset/empty means) lists every group; `core` lists just the 12 everyday tools plus `describe_tool`; or a comma-separated group list, e.g. `core,network,console` (`core` is always included). An unknown group name is a configuration error: the server prints `CDP_TOOL_PROFILE: unknown tool group 'x'. Known: full, core, input, …` and exits 1. Tools the profile leaves out stay callable by name. See "Progressive disclosure" below. |
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
- The MCP server publishes **one complete, deterministic, cacheable `tools/list`**: `describe_tool` first, then every tool the selected browser can actually run whose group the startup profile advertises, in manifest order. 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 — and 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. `capabilities.tools.listChanged` is `false`: nothing will ever notify, because nothing ever changes.
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
- That shape is deliberate. The MCP **2026-07-28** revision puts lazy discovery on the *host* side — the client runs its own catalog → inspect → execute funnel across the servers it has connected — 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 was exactly that anti-pattern and is **removed in 2.1**: calling it now returns `unknown tool: browser_tools`.
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
- **`describe_tool` is the inspect layer.** The listing carries terse one-liners; the full description and per-parameter docs load on demand, for *any* tool — listed or not:
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": "describe_tool", "arguments": {"name": "wait_for_download"}}
251
- {"tool": "describe_tool", "arguments": {}}
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
- With no `name` it returns the grouped catalog of everything this server can run. The whole 48-tool surface:
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 2.5.1 · browser=chrome · 48 tools available, 49 in tools/list (CDP_TOOL_PROFILE=full)
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
- [listed] input (9): hover, drag, scroll, dispatch_mouse, press_key, fill_form, upload_file, focus_emulation, click_focus_gated
260
- [listed] cookies (3): list_cookies, set_cookie, delete_cookies
261
- [listed] network (2): list_network_requests, get_network_request
262
- [listed] console (2): list_console_messages, get_console_message
263
- [listed] mocking (3): mock_request, list_mocks, clear_mocks
264
- [listed] emulation (2): emulate, resize_page
265
- [listed] performance (6): performance_start_trace, performance_stop_trace, performance_analyze_insight, performance_trace, take_heapsnapshot, lighthouse_audit
266
- [listed] recording (2): start_screen_recording, stop_screen_recording
267
- [listed] leases (3): claim_page, release_page, list_leases
268
- [listed] permissions (1): grant_permissions
269
- [listed] dialogs (1): handle_dialog
270
- [listed] downloads (1): wait_for_download
271
- [listed] extraction (1): extract_page
272
- Unlisted tools are callable by name; describe_tool {name} documents any of them.
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
- Under a narrower profile the groups it leaves out read `[hidden]` instead of `[listed]`, and the header's second count drops accordingly.
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
- **`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. Measured over raw stdio (bytes = compact JSON of the `tools` array, tokens ≈ bytes ÷ 4):
315
+ The default listing is **−86.4% bytes** vs 2.7.0's default.
278
316
 
279
- | `CDP_TOOL_PROFILE` | entries in `tools/list` | bytes | ≈ tokens |
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
- For comparison, 1.x advertised 45 tools with full prose at roughly 20,200 tokens. So the default listing is **−54% vs 1.x**, and `core` is **−89%**.
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
- The default flipped from `core` (2.0.0) to `full` in 2.1 for three reasons: the standard puts discovery on the host, and a host that defers schemas — Claude Code lists MCP tool *names* and loads schemas on demand — pays little for a complete list; consumers that hold per-tool allowlists or per-tool interception keyed on the tool *name* for non-core tools (the console and network readers, `performance_analyze_insight`) were silently broken by a `core` default, because those tools were simply absent from `tools/list`; and a list that never changes never invalidates the host's prompt cache. Keep `CDP_TOOL_PROFILE=core` if your client eagerly loads every schema — that is where the ≈2.3k-token surface is worth the round trips.
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
- Three rules worth knowing:
324
+ Rules worth knowing:
291
325
 
292
- - **Unlisted does not mean blocked.** Only discovery is filtered. `tools/call` checks backend availability, not group membership, so an agent that names an unlisted-but-available tool still executes it; only a tool the selected browser cannot run at all is refused by name.
293
- - **`describe_tool` works for any tool by name, unlisted ones included** — the full description and per-parameter docs behind the terse one-liner `tools/list` carries.
294
- - **Profiles and `describe_tool` are MCP-only.** They are MCP-server concepts: `cdp describe_tool` fails with `unknown tool 'describe_tool'`, and the CLI keeps exposing all 48 tools no matter how `CDP_TOOL_PROFILE` is set.
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* its group is in the startup profile — 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:
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 default (`full`) listing is therefore 36 entries — those 35 plus `describe_tool` — and a narrower `CDP_TOOL_PROFILE` trims it further, the profile filter applying 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.
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-profile view** — all 48 tools, which is what `cdp --list` and the MCP server's default listing (`CDP_TOOL_PROFILE=full`) both show. 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).
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 `describe_tool` meta-tool is likewise MCP-only. |
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
 
@@ -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();