cdp-toolkit 1.1.0 → 1.2.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,21 +5,88 @@ All notable changes to cdp-toolkit are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [1.0.0] — 2026-06-24
8
+ ## [1.2.0] - 2026-08-05
9
+
10
+ Two things landed since 1.0: a second backend, Firefox over WebDriver BiDi,
11
+ alongside the existing Chrome over CDP; and lease based tab ownership, so
12
+ several agents can drive the same browser without stealing each other's tab.
13
+ Both are opt in. Pick Firefox explicitly or you get Chrome as before; omit
14
+ `lease` and every call behaves exactly as it did in 1.0.
15
+
16
+ ### Added
17
+
18
+ - **A second backend: Firefox over WebDriver BiDi**, behind the same tool
19
+ surface as Chrome over CDP. Chrome stays the default; select Firefox with
20
+ `--browser firefox` (CLI) or `CDP_BROWSER=firefox` (CLI or MCP server).
21
+ - **Per-backend tool filtering.** `tools/list` (MCP) and `--list`/
22
+ `--capabilities` (CLI) only ever advertise a tool the selected browser can
23
+ actually run; a tool a backend cannot run is absent from the listing rather
24
+ than throwing at call time. Firefox lacks the accessibility tree, atomic
25
+ text insert, full emulation, tracing, heap snapshots, and Lighthouse audits;
26
+ everything else, including network mocking and the lease group below, runs
27
+ on both backends.
28
+ - **A Firefox smoke job in CI**, gating the new backend the same way the
29
+ existing Chrome smoke gates the old one.
30
+ - **Three tools**: `claim_page` (take a tab, or open and take one in a single
31
+ call, and get back an opaque lease token), `release_page` (idempotent, and
32
+ does not close the tab), and `list_leases` (who holds what, with pid liveness
33
+ and reclaimability; needs no token and never returns the lease nonce).
34
+ - **An optional `lease` argument on the other tools.** Present it and a tab
35
+ you hold keeps answering you; omit it and an unleased tab behaves as before.
36
+ `release_page` is the one tool where the token is required.
37
+ - **`new_page` gained `claim`, `label`, and `ttlMs`.** With `claim:true` the new
38
+ tab is claimed as part of creating it and the response carries a `lease`
39
+ token, so no other agent can see the tab unclaimed and take it first.
40
+ - **`CDP_LEASE_TTL_MS`** (default `900000`, 15 minutes): how long a lease
41
+ survives without use before another agent can reclaim it. Every checked call
42
+ refreshes it, so a working agent never expires and needs no heartbeat.
43
+ - Lease files under `CDP_ARTIFACT_DIR`, one per leased tab, created with the
44
+ `wx` exclusive flag so two simultaneous claims cannot both win.
45
+
46
+ ### Changed
47
+
48
+ - **The one behavior change.** Any call that resolves to a leased tab without
49
+ presenting that tab's token is now refused, naming the tab, its url, and the
50
+ label of the holder. That is every selector form, not just one: `active` or no
51
+ target at all, `index:N`, `url:<substring>`, `title:<substring>`, and a bare
52
+ targetId all reach the same check. The case worth singling out is `active` (or
53
+ no target) against a leased tab index 0, because there you did not name the
54
+ tab and before 1.2 the call silently succeeded against whatever tab happened
55
+ to be first, but it is not the only exposure. What still holds: an unleased
56
+ tab is always allowed, a stale lease (dead owner or elapsed TTL) never blocks,
57
+ and no default, return shape, or previously legal call changed. For a user who
58
+ never claims a tab there are no lease files at all, so nothing changes.
59
+ A lease whose tab is no longer open is reported by `list_leases`
60
+ as `target-gone`, but that report is not what frees it: it is freed by the
61
+ same two rules, whichever lands first.
62
+ - `close_page` releases that tab's lease when the close actually succeeds, and
63
+ leaves the lease in place when it does not.
64
+ - Enforcement lives at the three target resolution choke points (`resolveTarget`
65
+ for Chrome, `resolveContext` for Firefox, `resolvePage` for `close_page` and
66
+ `select_page`), never in individual tools, so a tool added later is covered
67
+ with no action from whoever writes it.
68
+ - `claim_page` is refused from the CLI. A CLI invocation is one process per
69
+ call, so a lease it claimed would be reclaimable immediately under the dead
70
+ pid rule. It is still listed by `--list`, and CLI calls can still present a
71
+ token minted by the MCP server via `--lease`.
72
+ - Tool count is now 36 (29 parity + 7 superset); 30 under Firefox, which lacks
73
+ the six capability gated tools.
74
+
75
+ ## [1.0.0] - 2026-06-24
9
76
 
10
77
  First public release.
11
78
 
12
79
  ### Added
13
80
  - **33 tools** over raw Chrome DevTools Protocol on a single direct WebSocket:
14
- - **29 `chrome-devtools-mcp` parity tools** — `list_pages`, `new_page`, `close_page`, `select_page`, `navigate_page`, `wait_for`, `evaluate_script`, `take_snapshot`, `click`, `hover`, `drag`, `fill`, `fill_form`, `type_text`, `press_key`, `upload_file`, `take_screenshot`, `emulate`, `resize_page`, `handle_dialog`, `list_console_messages`, `get_console_message`, `list_network_requests`, `get_network_request`, `performance_start_trace`, `performance_stop_trace`, `performance_analyze_insight`, `take_heapsnapshot`, `lighthouse_audit`.
15
- - **4 superset tools** beyond parity — `performance_trace` (a robust single-call trace that survives the cross-process limitation of the start/stop pair), and the network-mocking group `mock_request` / `list_mocks` / `clear_mocks` (a persistent per-target fake backend).
16
- - **Single direct WebSocket per call** — every tool resolves exactly one target (`active | index:N | url:<substr> | title:<substr> | <targetId>`); no all-target fan-out.
17
- - **Per-command timeout** (`CDP_TIMEOUT_MS`, default 15s) — `CdpConnection.send()` rejects rather than hangs, so a stuck renderer can't block a caller indefinitely.
18
- - **Lazy domain enabling** — domains are enabled only where a tool needs them; no eager `Network.enable` on connect.
19
- - **Stateless element refs** — a `uid` IS the CDP `backendDOMNodeId`, resolved on demand; no server-side handle table to drift or expire.
81
+ - **29 `chrome-devtools-mcp` parity tools**: `list_pages`, `new_page`, `close_page`, `select_page`, `navigate_page`, `wait_for`, `evaluate_script`, `take_snapshot`, `click`, `hover`, `drag`, `fill`, `fill_form`, `type_text`, `press_key`, `upload_file`, `take_screenshot`, `emulate`, `resize_page`, `handle_dialog`, `list_console_messages`, `get_console_message`, `list_network_requests`, `get_network_request`, `performance_start_trace`, `performance_stop_trace`, `performance_analyze_insight`, `take_heapsnapshot`, `lighthouse_audit`.
82
+ - **4 superset tools** beyond parity: `performance_trace` (a robust single-call trace that survives the cross-process limitation of the start/stop pair), and the network-mocking group `mock_request` / `list_mocks` / `clear_mocks` (a persistent per-target fake backend).
83
+ - **Single direct WebSocket per call**: every tool resolves exactly one target (`active | index:N | url:<substr> | title:<substr> | <targetId>`); no all-target fan-out.
84
+ - **Per-command timeout** (`CDP_TIMEOUT_MS`, default 15s): `CdpConnection.send()` rejects rather than hangs, so a stuck renderer can't block a caller indefinitely.
85
+ - **Lazy domain enabling**: domains are enabled only where a tool needs them; no eager `Network.enable` on connect.
86
+ - **Stateless element refs**: a `uid` IS the CDP `backendDOMNodeId`, resolved on demand; no server-side handle table to drift or expire.
20
87
  - **stdio MCP server** (`src/mcp.ts`) exposing every tool via `@modelcontextprotocol/sdk`, with JSON Schemas in `src/manifest.ts`. Connects to Chrome lazily per call, so it loads cleanly even when Chrome isn't running.
21
- - **Bun CLI** (`src/cli.ts`) — `bun run src/cli.ts <tool> [--target <sel>] [--json '<obj>'] [--<key> <value> ...]`.
22
- - Hermetic test fixtures — `mock-smoke.ts` mocks `https://mock.invalid/*` (CDP `Fetch.requestPaused` intercepts before DNS, so the smoke needs no real network).
88
+ - **Bun CLI** (`src/cli.ts`): `bun run src/cli.ts <tool> [--target <sel>] [--json '<obj>'] [--<key> <value> ...]`.
89
+ - Hermetic test fixtures: `mock-smoke.ts` mocks `https://mock.invalid/*` (CDP `Fetch.requestPaused` intercepts before DNS, so the smoke needs no real network).
23
90
 
24
91
  ### Notes
25
92
  - License: MIT.
package/CONTRACT.md CHANGED
@@ -1,4 +1,4 @@
1
- # cdp-toolkit — implementation contract
1
+ # cdp-toolkit implementation contract
2
2
 
3
3
  **Single source of truth for every tool module.** The goal: replicate each of the
4
4
  29 `chrome-devtools-mcp` tools over raw CDP on a direct WebSocket
@@ -7,11 +7,12 @@ writing a module.
7
7
 
8
8
  ## Hard rules
9
9
 
10
- 1. **Zero runtime dependencies.** Use Node's global `WebSocket` (Node ≥ 22; we run 25.9) and `fetch`. The only allowed devDeps are `typescript` + `@types/node` (already installed). `lighthouse.ts` is the sole exception and shells out to a subprocess — see its section. `tsconfig.json` deliberately does NOT pull in `bun-types`, so any test or script that touches `Bun.serve` or another `Bun` global fails `tsc --noEmit` with `Cannot find name 'Bun'`. The convention is to hand-write a minimal ambient declaration in that file, scoped to just the API it uses (e.g. `declare const Bun: { serve(opts: { port: number; fetch(req: Request): Response | Promise<Response> }): { port: number; stop(): void } };`), never to add the devDependency.
10
+ 1. **Zero runtime dependencies.** Use Node's global `WebSocket` (Node ≥ 22; we run 25.9) and `fetch`. The only allowed devDeps are `typescript` + `@types/node` (already installed). `lighthouse.ts` is the sole exception and shells out to a subprocess, see its section. `tsconfig.json` deliberately does NOT pull in `bun-types`, so any test or script that touches `Bun.serve` or another `Bun` global fails `tsc --noEmit` with `Cannot find name 'Bun'`. The convention is to hand-write a minimal ambient declaration in that file, scoped to just the API it uses (e.g. `declare const Bun: { serve(opts: { port: number; fetch(req: Request): Response | Promise<Response> }): { port: number; stop(): void } };`), never to add the devDependency.
11
11
  2. **Build only on `src/client.ts` and `src/types.ts`.** Do not open raw `new WebSocket` yourself. Use `openPage`, `withPage`, `openBrowser`, `resolveTarget`, `CdpConnection`. This is what gives us the per-command timeout that prevents the wedged-tab hang.
12
12
  3. **TypeScript strict.** `tsc --noEmit` must pass with `strict`, `noUncheckedIndexedAccess`, `verbatimModuleSyntax`. Import types with `import type`. Use `.ts` extensions in imports (e.g. `import { withPage } from "../client.ts"`).
13
13
  4. **One module per assigned bundle.** Write only the files you are assigned. Never edit `client.ts`, `types.ts`, or another agent's file.
14
14
  5. **Tool fn naming = camelCase of the MCP tool name.** `take_snapshot` → `export async function takeSnapshot(...)`. `list_network_requests` → `listNetworkRequests`. The integration step maps mechanically, so the names must be exact.
15
+ 6. **Never re-resolve a target outside a choke point.** There are exactly three functions that turn a `TargetSelector` into a concrete tab, and all three call `assertLeaseOk`: `resolveTarget` (`src/client.ts`, Chrome), `resolveContext` (`src/bidi/driver.ts`, Firefox), and `resolvePage` (`src/shared-tools.ts`, used by `close_page` and `select_page`). A new tool must go through one of them. If you add a fourth resolution path, it must call `assertLeaseOk` too, or every tool on that path silently loses lease protection. Do NOT add a lease check to an individual tool: that puts the burden on every future contributor to remember it, and one forgotten tool defeats the feature for that tool. A lease conflict throws `LeaseConflictError` rather than being modelled as a capability gap under ADR-001. That departure is deliberate and reviewed: a runtime ownership collision between two agents is not a missing backend capability, so it cannot be discovered at `tools/list` time and must surface at call time.
15
16
 
16
17
  ## Core client API (from `src/client.ts`)
17
18
 
@@ -57,9 +58,10 @@ export async function click(args: ClickArgs): Promise<unknown>;
57
58
 
58
59
  ## Artifact + state locations
59
60
 
60
- - Artifacts (PNG, trace, heapsnapshot, lighthouse report): write under `ARTIFACT_DIR` = `process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit"`. `mkdir -p` it. Filenames: `<tool>-<targetIdShort>-<isoish>.<ext>` where you derive a stamp from `Date.now()` at call time (allowed in normal runtime; only the Workflow *script* sandbox forbids it — modules run normally).
61
+ - Artifacts (PNG, trace, heapsnapshot, lighthouse report): write under `ARTIFACT_DIR` = `process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit"`. `mkdir -p` it. Filenames: `<tool>-<targetIdShort>-<isoish>.<ext>` where you derive a stamp from `Date.now()` at call time (allowed in normal runtime; only the Workflow *script* sandbox forbids it, modules run normally).
61
62
  - Selected-target state file (for `select_page`): `process.env.CDP_STATE_DIR ?? "/tmp/cdp-toolkit"` + `/selected`. Contains a bare targetId. `resolveTarget` does NOT read it; tools may read it as a fallback default if you choose, but the simplest correct behavior is fine.
62
63
  - Recorder buffers: `${ARTIFACT_DIR}/rec-<targetId>.jsonl`.
64
+ - Lease files: `${ARTIFACT_DIR}/lease-<backend>-<targetId>.json`, one per leased tab, backend is `chrome` or `firefox`. Written with the `wx` exclusive-create flag so two simultaneous claims cannot both win. Keyed by backend plus id because a CDP targetId and a BiDi context id are not disjoint by construction.
63
65
 
64
66
  ## The element-reference scheme (READ if you touch snapshot or input)
65
67
 
@@ -102,10 +104,10 @@ A `Uid` **is** a CDP `backendDOMNodeId` (a number). This makes refs stateless:
102
104
  | `src/tools/emulation.ts` | `emulate`→`emulate`, `resize_page`→`resizePage` | `Emulation.setDeviceMetricsOverride` (width,height,deviceScaleFactor,mobile), `Emulation.setUserAgentOverride`, `Emulation.setCPUThrottlingRate`, `Emulation.setEmulatedMedia`, `Network`-based throttling via `Network.emulateNetworkConditions`. `resize_page` = device metrics width/height. |
103
105
  | `src/tools/dialogs.ts` | `handle_dialog`→`handleDialog` | `Page.enable`, subscribe `Page.javascriptDialogOpening`, respond with `Page.handleJavaScriptDialog({ accept, promptText })`. Support a "wait for next dialog then handle" mode and an "auto-handle for N ms" mode. |
104
106
  | `src/tools/recorder.ts` + `src/tools/console.ts` + `src/tools/network.ts` | console: `list_console_messages`→`listConsoleMessages`, `get_console_message`→`getConsoleMessage`; network: `list_network_requests`→`listNetworkRequests`, `get_network_request`→`getNetworkRequest` | **Owned by one agent.** `recorder.ts` exports `startRecorder(target, {network,console}): Promise<{stop()}>` that opens a persistent `openPage` conn, enables `Network`+`Runtime`(+`Log`), and appends events to `rec-<targetId>.jsonl`. console/network `list_*` read that file (and support a one-shot `{ reload:true, durationMs }` mode that records fresh by reloading the page and capturing for a window, then returns). `get_*` filter by id/url. Document the recorder model at the top of `recorder.ts`. |
105
- | `src/tools/performance.ts` | `performance_start_trace`→`performanceStartTrace`, `performance_stop_trace`→`performanceStopTrace`, `performance_analyze_insight`→`performanceAnalyzeInsight` | `Tracing.start`(categories incl. `devtools.timeline`,`blink.user_timing`,`loading`,`-*`), collect `Tracing.dataCollected`, `Tracing.end`→`Tracing.tracingComplete`, write trace JSON to ARTIFACT_DIR. start/stop need a persistent conn — use a state file to hold the in-flight trace path/connection target. `performance_analyze_insight` parses a trace file for LCP, FCP/navigation timing, long tasks (>50ms), layout shifts (CLS), total blocking time; return structured metrics. **Mark in a top comment that this is a CDP-native approximation of the MCP's insight analyzer.** |
107
+ | `src/tools/performance.ts` | `performance_start_trace`→`performanceStartTrace`, `performance_stop_trace`→`performanceStopTrace`, `performance_analyze_insight`→`performanceAnalyzeInsight` | `Tracing.start`(categories incl. `devtools.timeline`,`blink.user_timing`,`loading`,`-*`), collect `Tracing.dataCollected`, `Tracing.end`→`Tracing.tracingComplete`, write trace JSON to ARTIFACT_DIR. start/stop need a persistent conn, so use a state file to hold the in-flight trace path/connection target. `performance_analyze_insight` parses a trace file for LCP, FCP/navigation timing, long tasks (>50ms), layout shifts (CLS), total blocking time; return structured metrics. **Mark in a top comment that this is a CDP-native approximation of the MCP's insight analyzer.** |
106
108
  | `src/tools/heap.ts` | `take_heapsnapshot`→`takeHeapsnapshot` | `HeapProfiler.enable` → `HeapProfiler.takeHeapSnapshot`, assemble `HeapProfiler.addHeapSnapshotChunk` events into a `.heapsnapshot` file under ARTIFACT_DIR; return `{ path, bytes }`. |
107
109
  | `src/tools/lighthouse.ts` | `lighthouse_audit`→`lighthouseAudit` | **Not pure CDP.** Spawn `npx --yes lighthouse <url> --port=9222 --output=json --output-path=<artifact> --chrome-flags=...` via `node:child_process`. If lighthouse/npx unavailable, throw a clear `CdpError` explaining the dependency. Return `{ path, categories: {performance, accessibility, ...scores} }` parsed from the JSON. Top comment must state this is the one non-CDP tool. |
108
- | `src/tools/network_mock.ts` | `mock_request`→`mockRequest`, `list_mocks`→`listMocks`, `clear_mocks`→`clearMocks` | **Toolkit addition (not MCP parity) — a persistent fake backend.** Holds a per-target session (module-level `Map`, like `recorder.ts`) with a persistent `openPage` conn and `Fetch.enable` (patterns). `Fetch.requestPaused` → `selectRule` → `Fetch.fulfillRequest`/`failRequest`/`continueRequest` (default-continue unmatched, or page hangs). Pure logic (`urlMatches`/`selectRule`/`buildFulfillParams`/`effectiveAction`) is unit-tested; I/O via `test/mock-smoke.ts`. Persistence is MCP-server-process-scoped (CLI is one-shot). Request-stage only. |
110
+ | `src/tools/network_mock.ts` | `mock_request`→`mockRequest`, `list_mocks`→`listMocks`, `clear_mocks`→`clearMocks` | **Toolkit addition (not MCP parity): a persistent fake backend.** Holds a per-target session (module-level `Map`, like `recorder.ts`) with a persistent `openPage` conn and `Fetch.enable` (patterns). `Fetch.requestPaused` → `selectRule` → `Fetch.fulfillRequest`/`failRequest`/`continueRequest` (default-continue unmatched, or page hangs). Pure logic (`urlMatches`/`selectRule`/`buildFulfillParams`/`effectiveAction`) is unit-tested; I/O via `test/mock-smoke.ts`. Persistence is MCP-server-process-scoped (CLI is one-shot). Request-stage only. |
109
111
 
110
112
  ## Each module's footer
111
113
 
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # cdp-toolkit
2
2
 
3
- **A lightweight, drop-in alternative to [`chrome-devtools-mcp`](https://github.com/ChromeDevTools/chrome-devtools-mcp) that won't wedge your agent.** It drives the *one* Chrome tab you point it at over the raw DevTools Protocol, with a bounded timeout on every call — so a stuck page returns a clean error instead of hanging your agent and forcing a `/mcp` restart. Same idea, one tab, no fan-out — plus a built-in network-mocking fake backend. **33 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 *one* Chrome tab you point it at over the raw DevTools Protocol, with a bounded timeout on every call, so a stuck page returns a clean error instead of hanging your agent and forcing a `/mcp` restart. Same idea, one tab, no fan-out, plus a built-in network-mocking fake backend. **36 tools.**
4
4
 
5
- > For AI-agent developers and Claude Code / Cursor users who need **one known tab driven reliably** — not a Puppeteer-managed browser.
5
+ > For AI-agent developers and Claude Code / Cursor users who need **one known tab driven reliably**, not a Puppeteer-managed browser.
6
6
 
7
7
  [![CI](https://github.com/sblattj/cdp-toolkit/actions/workflows/ci.yml/badge.svg)](https://github.com/sblattj/cdp-toolkit/actions/workflows/ci.yml)
8
8
  [![npm](https://img.shields.io/npm/v/cdp-toolkit?color=cb3837&logo=npm)](https://www.npmjs.com/package/cdp-toolkit)
@@ -14,41 +14,41 @@
14
14
 
15
15
  ## In plain terms
16
16
 
17
- You let an AI agent — Claude Code, Cursor, any MCP host — control a Chrome tab: click, type, read the page, take screenshots, even fake API responses to test a UI before its backend exists. [`chrome-devtools-mcp`](https://github.com/ChromeDevTools/chrome-devtools-mcp) does this too, but it runs a whole Puppeteer browser and talks to *all* your open tabs at once — which is why a single busy tab can freeze it and leave you typing `/mcp` to restart the server mid-task.
17
+ You let an AI agent (Claude Code, Cursor, any MCP host) control a Chrome tab: click, type, read the page, take screenshots, even fake API responses to test a UI before its backend exists. [`chrome-devtools-mcp`](https://github.com/ChromeDevTools/chrome-devtools-mcp) does this too, but it runs a whole Puppeteer browser and talks to *all* your open tabs at once, which is why a single busy tab can freeze it and leave you typing `/mcp` to restart the server mid-task.
18
18
 
19
- `cdp-toolkit` keeps it simple: **one connection to the one tab you point it at, and a time limit on every action.** When something stalls, you get an error back — not a frozen agent. Same things you could do before, minus the wedging and the restarts.
19
+ `cdp-toolkit` keeps it simple: **one connection to the one tab you point it at, and a time limit on every action.** When something stalls, you get an error back, not a frozen agent. Same things you could do before, minus the wedging and the restarts.
20
20
 
21
- That's the whole pitch. The technical *why* — fan-out, lazy domain enabling, the `Network.enable` hang — is below.
21
+ That's the whole pitch. The technical *why* (fan-out, lazy domain enabling, the `Network.enable` hang) is below.
22
22
 
23
23
  ## Why it exists
24
24
 
25
25
  If [`chrome-devtools-mcp`](https://github.com/ChromeDevTools/chrome-devtools-mcp) ever **wedged your agent on a busy tab**, you've met its design: it manages a Puppeteer browser, fans every operation out across *all* attached targets, and enables the `Network` domain on connect so it can passively buffer everything. That generality is exactly what makes it fragile once you already know which tab you want to drive.
26
26
 
27
- `cdp-toolkit` makes the opposite bet. Every tool attaches **one** direct WebSocket to **one** resolved target, enables only the CDP domains it needs, and enforces a **per-command timeout** so a stuck renderer can never hang the caller. For driving a tab you already have in hand — the common automation and evidence-gathering case — it's materially more robust.
27
+ `cdp-toolkit` makes the opposite bet. Every tool attaches **one** direct WebSocket to **one** resolved target, enables only the CDP domains it needs, and enforces a **per-command timeout** so a stuck renderer can never hang the caller. For driving a tab you already have in hand, the common automation and evidence-gathering case, it's materially more robust.
28
28
 
29
29
  ## cdp-toolkit vs chrome-devtools-mcp
30
30
 
31
31
  | | **cdp-toolkit** | **chrome-devtools-mcp** |
32
32
  |---|---|---|
33
- | **Target scope** | one resolved tab (`active` / `index:N` / `url:` / `title:`) | all attached targets — fan-out can stall on an unrelated tab |
34
- | **`Network.enable`** | lazy — only when a tool needs it | eager on connect — a known hang on busy renderers |
35
- | **Per-command timeout** | ✅ bounded (`CDP_TIMEOUT_MS`, 15s default) — rejects, never hangs | ❌ none — a stuck renderer blocks indefinitely |
33
+ | **Target scope** | one resolved tab (`active` / `index:N` / `url:` / `title:`) | all attached targets; fan-out can stall on an unrelated tab |
34
+ | **`Network.enable`** | lazy, only when a tool needs it | eager on connect, a known hang on busy renderers |
35
+ | **Per-command timeout** | ✅ bounded (`CDP_TIMEOUT_MS`, 15s default), rejects, never hangs | ❌ none; a stuck renderer blocks indefinitely |
36
36
  | **Element refs** | stateless `backendDOMNodeId` (resolved on demand) | server-side handle table (can drift / expire) |
37
37
  | **Network mocking** | ✅ persistent per-target fake backend | ❌ not available |
38
38
  | **Runtime deps** | CDP/CLI layer: native `WebSocket` + `fetch` (the MCP server adds only the MCP SDK) | Puppeteer stack |
39
- | **Auto-wait / retry** | ❌ single-shot — re-snapshot between steps | ✅ Puppeteer's auto-wait envelope |
39
+ | **Auto-wait / retry** | ❌ single-shot; re-snapshot between steps | ✅ Puppeteer's auto-wait envelope |
40
40
 
41
- **Use `chrome-devtools-mcp`** if you need multi-target autonomy or Puppeteer's auto-wait/retry on an *unknown* page. **Use `cdp-toolkit`** when you have one tab in hand and need it not to hang. They coexist — `cdp-toolkit`'s tools are namespaced `mcp__cdp-toolkit__*`, distinct from `mcp__chrome-devtools__*`.
41
+ **Use `chrome-devtools-mcp`** if you need multi-target autonomy or Puppeteer's auto-wait/retry on an *unknown* page. **Use `cdp-toolkit`** when you have one tab in hand and need it not to hang. They coexist: `cdp-toolkit`'s tools are namespaced `mcp__cdp-toolkit__*`, distinct from `mcp__chrome-devtools__*`.
42
42
 
43
43
  ## Is this for you?
44
44
 
45
- **Yes, if you —**
45
+ **Yes, if you:**
46
46
  - drive one known tab from Claude Code / Cursor / any MCP host and want it to never wedge;
47
47
  - need to **mock a backend** to build or test a UI before the real API exists;
48
48
  - have been bitten by the eager-`Network.enable` hang on a busy renderer.
49
49
 
50
- **Probably not, if you —**
51
- - need cross-browser (Firefox / WebKit) — use [`playwright-mcp`](https://github.com/microsoft/playwright-mcp);
50
+ **Probably not, if you:**
51
+ - need cross-browser (Firefox / WebKit), use [`playwright-mcp`](https://github.com/microsoft/playwright-mcp);
52
52
  - need Puppeteer's auto-wait/retry envelope for an unknown, changing page;
53
53
  - want one server to fan out across *all* your open tabs at once.
54
54
 
@@ -76,7 +76,7 @@ npx -y --package cdp-toolkit cdp navigate_page --target index:0 --url https://ex
76
76
  # …or from a clone: `bun run src/cli.ts <tool> …`
77
77
  ```
78
78
 
79
- **Requirements:** Node ≥ 22 **or** Bun ≥ 1.1 — `npx -y cdp-toolkit` and `bunx -y cdp-toolkit` both work (the published bins are plain Node ESM). Chrome/Chromium with `--remote-debugging-port=9222`. Smoke-check the port: `curl -s http://127.0.0.1:9222/json/version`.
79
+ **Requirements:** Node ≥ 22 **or** Bun ≥ 1.1: `npx -y cdp-toolkit` and `bunx -y cdp-toolkit` both work (the published bins are plain Node ESM). Chrome/Chromium with `--remote-debugging-port=9222`. Smoke-check the port: `curl -s http://127.0.0.1:9222/json/version`.
80
80
 
81
81
  ### MCP client setup
82
82
 
@@ -116,8 +116,8 @@ bun run mcp:smoke # spawn the server + a real initialize/tools-list/tools-call
116
116
  ## Key capabilities
117
117
 
118
118
  - **Single-target reliability.** One WebSocket to one resolved target, a bounded timeout on every CDP command, lazy domain enabling, and stateless element refs. There is no broadcast step that can stall on a wedged tab.
119
- - **Network mocking — 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`.
120
- - **Full chrome-devtools-mcp parity + extras.** All 29 upstream tools, plus `performance_trace` (a robust single-call trace), Lighthouse audits, and heap snapshots. 33 single-purpose tools — no discovery overhead, and they coexist with `chrome-devtools-mcp` in a separate namespace.
119
+ - **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`.
120
+ - **Full chrome-devtools-mcp parity + extras.** All 29 upstream tools, plus `performance_trace` (a robust single-call trace), Lighthouse audits, and heap snapshots. 36 single-purpose tools, no discovery overhead, and they coexist with `chrome-devtools-mcp` in a separate namespace.
121
121
 
122
122
  ## Why raw CDP beats the MCP for known targets
123
123
 
@@ -130,9 +130,9 @@ Each point below leads with the symptom you've probably hit, then the cause, the
130
130
 
131
131
  The trade-off is generality: this toolkit targets one known page at a time and does **not** replicate Puppeteer's auto-wait/retry envelope. Re-`take_snapshot` between steps rather than expecting an implicit wait.
132
132
 
133
- ## Network mocking — a fake backend for building/testing UIs
133
+ ## Network mocking: a fake backend for building/testing UIs
134
134
 
135
- Build and test a UI before its backend (or its data) exists. `mock_request` arms a persistent per-target interception session; mock several endpoints by calling it repeatedly, then iterate on the page — the mocks survive reloads and navigations until `clear_mocks`.
135
+ Build and test a UI before its backend (or its data) exists. `mock_request` arms a persistent per-target interception session; mock several endpoints by calling it repeatedly, then iterate on the page; the mocks survive reloads and navigations until `clear_mocks`.
136
136
 
137
137
  ```bash
138
138
  # Return empty search results and reload to see how the UI renders the zero state
@@ -182,10 +182,11 @@ await TOOLS.navigate_page({ target: "index:0", url: "https://example.com" });
182
182
  | `CDP_TIMEOUT_MS` | `15000` | Per-command timeout. |
183
183
  | `CDP_ARTIFACT_DIR` | `/tmp/cdp-toolkit` | Screenshots, traces, heap snapshots, lighthouse reports, recorder buffers. |
184
184
  | `CDP_STATE_DIR` | `/tmp/cdp-toolkit` | `select_page` selected-target file, in-flight trace state. |
185
+ | `CDP_LEASE_TTL_MS` | `900000` | How long a tab lease survives without use before another agent can reclaim it. Refreshed on every checked call. |
185
186
 
186
187
  ## Firefox (WebDriver BiDi)
187
188
 
188
- cdp-toolkit ships a second backend, Firefox over [WebDriver BiDi](https://w3c.github.io/webdriver-bidi/), behind the same 33-tool surface. Chrome stays the default and its behavior is unchanged, opt in explicitly to reach Firefox:
189
+ cdp-toolkit ships a second backend, Firefox over [WebDriver BiDi](https://w3c.github.io/webdriver-bidi/), behind the same 36-tool surface. Chrome stays the default and its behavior is unchanged, opt in explicitly to reach Firefox:
189
190
 
190
191
  ```bash
191
192
  cdp --browser firefox take_snapshot # CLI: explicit flag
@@ -206,7 +207,7 @@ Backend selection precedence: `--browser chrome|firefox` flag, then `CDP_BROWSER
206
207
  - `take_heapsnapshot` (needs `heap.snapshot`)
207
208
  - `lighthouse_audit` (needs `audit.lighthouse`)
208
209
 
209
- Everything else, including `mock_request`/`list_mocks`/`clear_mocks` (Firefox's `network.addIntercept` covers the same fake-backend use case as Chrome's `Fetch` domain), is available under both backends.
210
+ Everything else, including `mock_request`/`list_mocks`/`clear_mocks` (Firefox's `network.addIntercept` covers the same fake-backend use case as Chrome's `Fetch` domain) and the `claim_page`/`release_page`/`list_leases` lease group, is available under both backends: 30 of the 36 tools.
210
211
 
211
212
  **Honest capability gaps, not oversold parity:**
212
213
 
@@ -217,21 +218,21 @@ Everything else, including `mock_request`/`list_mocks`/`clear_mocks` (Firefox's
217
218
  - **`locate.text` is not available** (Firefox 153's `browsingContext.locateNodes` rejects the `innerText` locator type as unsupported), unlike Chrome, which has it via `DOM.performSearch`.
218
219
  - **No `--browser firefox` attach mode.** By design, per the launch model above: every invocation gets its own throwaway Firefox process and profile.
219
220
 
220
- ## The tools (29 parity + 4 superset = 33)
221
+ ## The tools (29 parity + 7 superset = 36)
221
222
 
222
- The 29 parity tools are 1:1 with `chrome-devtools-mcp`; the 4 superset tools (`performance_trace` + the `mock_request`/`list_mocks`/`clear_mocks` group) are toolkit additions. Each row notes the underlying CDP method(s) and the precise parity gaps.
223
+ The 29 parity tools are 1:1 with `chrome-devtools-mcp`; the 7 superset tools (`performance_trace`, the `mock_request`/`list_mocks`/`clear_mocks` group, and the `claim_page`/`release_page`/`list_leases` lease group) are toolkit additions. Each row notes the underlying CDP method(s) and the precise parity gaps.
223
224
 
224
225
  | MCP name | CDP method(s) | Parity notes / gaps |
225
226
  |---|---|---|
226
227
  | `list_pages` | `GET /json/list` | `all` flag also exposes worker/background targets; MCP lists only page tabs. |
227
- | `new_page` | `Target.createTarget` | Returns `{targetId,url}`; does not await navigation (use `navigate_page`). |
228
- | `close_page` | `Target.closeTarget` | Reports `success:true` on the empty result newer Chromium returns. |
228
+ | `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). |
229
+ | `close_page` | `Target.closeTarget` (+ lease file) | Reports `success:true` on the empty result newer Chromium returns. A successful close also releases that tab's lease; a failed close leaves it in place. |
229
230
  | `select_page` | `Target.activateTarget` + selected-state file | Writes a flat-file selected target; `resolveTarget` does not read it, so `active` still means `index:0` unless a tool opts in. |
230
231
  | `navigate_page` | `Page.navigate` / `Page.reload` + load events | Returns `{url,frameId,waitedFor}` (no auto-snapshot). `waitUntil` supports `load`/`domcontentloaded`. `reload:true` (+ `ignoreCache:true` for a hard reload). |
231
232
  | `wait_for` | `Runtime.evaluate` (poll `innerText`) | Text-substring waiting only; throws on timeout rather than returning `{found:false}`. |
232
233
  | `evaluate_script` | `Runtime.evaluate` / `callFunctionOn` | No live `page`/element handle; `args` are plain JSON. Main-world context only. |
233
234
  | `take_snapshot` | `Accessibility.getFullAXTree` | uid is the raw `backendDOMNodeId` (stateless, non-sequential). Full tree in one shot; frames flattened. `interactiveOnly` is a toolkit addition. |
234
- | `click` | `Input.dispatchMouseEvent` | No implicit auto-wait/retry — resolves and acts once; re-snapshot between steps. |
235
+ | `click` | `Input.dispatchMouseEvent` | No implicit auto-wait/retry; resolves and acts once; re-snapshot between steps. |
235
236
  | `hover` | `Input.dispatchMouseEvent` (`mouseMoved`) | Same single-shot model as `click`. |
236
237
  | `drag` | `Input.dispatchMouseEvent` (press→move→release) | Synthetic mouse drag; native HTML5 DnD is approximated. |
237
238
  | `fill` | `Input.insertText` | Atomic paste-like commit, not per-character keystrokes. |
@@ -247,15 +248,49 @@ The 29 parity tools are 1:1 with `chrome-devtools-mcp`; the 4 superset tools (`p
247
248
  | `get_console_message` | reads the shared "latest" buffer | Index into the latest capture; throws if out of range. |
248
249
  | `list_network_requests` | `Network.*` events (+ `Page.reload`) | Correlated rows from the per-capture buffer; redirect chains collapse to the first row. No timing breakdown / POST data. |
249
250
  | `get_network_request` | above + `Network.getResponseBody` | Bodies only via a fresh reload capture (CDP serves bodies from the live session); `includeBody` matches by **url**. |
250
- | `performance_start_trace` | `Tracing.start` / `dataCollected` | Works ONLY within one process — a live trace buffer is bound to its connection. Use `performance_trace` for robustness. |
251
+ | `performance_start_trace` | `Tracing.start` / `dataCollected` | Works ONLY within one process; a live trace buffer is bound to its connection. Use `performance_trace` for robustness. |
251
252
  | `performance_stop_trace` | `Tracing.end` / `tracingComplete` | Must run in the SAME process as `performance_start_trace`; throws a clear error otherwise. |
252
253
  | `performance_analyze_insight` | parses a trace JSON file | A **CDP-native approximation** of the MCP insight analyzer (FCP/LCP/CLS/TBT/long-tasks); close but not byte-identical. Requires an explicit `tracePath`. |
253
254
  | `take_heapsnapshot` | `HeapProfiler.takeHeapSnapshot` | Returns `{path,bytes,chunks,target}`; does not parse the snapshot (load the `.heapsnapshot` in the DevTools Memory panel). |
254
- | `lighthouse_audit` | **none (non-CDP)** — spawns `npx --yes lighthouse …` | The toolkit's sole non-CDP tool. Defaults to the desktop preset. Returns numeric category scores (full report on disk). |
255
+ | `lighthouse_audit` | **none (non-CDP)**, spawns `npx --yes lighthouse …` | The toolkit's sole non-CDP tool. Defaults to the desktop preset. Returns numeric category scores (full report on disk). |
255
256
  | `performance_trace` *(superset)* | `Tracing.*` (+ `Page.reload`) | **Toolkit convenience.** A robust single-call trace: start → optional reload → capture for `durationMs` → end → write the trace JSON → return `{path,bytes,events,metrics}`. Preferred over the `start`/`stop` pair (CDP tracing is browser-global and bound to one connection). |
256
257
  | `mock_request` *(superset)* | `Fetch.*` (+ `Page.reload`) | **A fake backend.** Registers a rule on a target's persistent session: fulfill with a canned response, fail, or continue (with optional `delayMs`/`failRate`). Persists across reloads until `clear_mocks`. Request-stage only. Cached requests aren't intercepted (use `reload:true`). |
257
258
  | `list_mocks` *(superset)* | `Runtime.evaluate` (liveness probe) | Lists active mock sessions with rules + hit counts; prunes sessions whose tab closed. |
258
259
  | `clear_mocks` *(superset)* | `Fetch.disable` | Tears down the resolved target's mock session (or all with `all:true`). |
260
+ | `claim_page` *(superset)* | `Target.createTarget` + lease file | Opens or claims a tab and returns an opaque lease token. MCP only; the CLI refuses it. |
261
+ | `release_page` *(superset)* | lease file | Gives a lease back. Idempotent, and does not close the tab. |
262
+ | `list_leases` *(superset)* | lease file | Who holds what, with pid liveness and reclaimability. Needs no token; never returns the nonce. A lease file that could not be read or parsed is reported as an `unreadable` row instead of being skipped. |
263
+
264
+ ## Parallel tabs: many agents, one browser
265
+
266
+ Two agents pointed at the same Chrome both resolve `target: active` to whatever tab the human last focused. Nothing errors. Each one gets plausible, well-formed output from the wrong page: a snapshot of someone else's form, a fill that lands in someone else's field. A crash gets caught; a silent wrong-tab success does not.
267
+
268
+ `claim_page` fixes that by handing you an opaque lease token for one tab:
269
+
270
+ ```json
271
+ {"tool": "claim_page", "arguments": {"url": "https://example.com", "label": "checkout-agent"}}
272
+ ```
273
+
274
+ ```json
275
+ {"lease": "chrome:1A2B...:0f3c...", "targetId": "1A2B...", "url": "https://example.com", "label": "checkout-agent", "ttlMs": 900000, "expiresAt": 1754400000000}
276
+ ```
277
+
278
+ Pass that token as `lease` on every later call against the tab. `new_page` with `claim: true` does the open-and-claim in one call. `release_page` gives it back, `list_leases` shows who holds what.
279
+
280
+ **Opt in, and only ever a refusal.** A tab nobody claimed behaves exactly as it did before. Omitting `lease` is identical to pre-1.2 behavior in every respect: no default changed, no return shape changed, no previously legal call now needs a new argument. This is why it is 1.2 and not 2.0.
281
+
282
+ **What can newly fail.** Any call that resolves to a leased tab without presenting that tab's token is refused, naming the tab, its url, and the label of whoever holds it. That is every selector form, not one of them: `active` or no target at all, `index:N`, `url:<substring>`, `title:<substring>`, and a bare targetId all reach the same check. The case worth singling out is `active` (or no target) against a leased tab index 0, because there you did not name the tab and before 1.2 the call silently succeeded against whatever tab happened to be first. That refusal is the point of the feature, and it is called out here rather than left to be discovered.
283
+
284
+ **Reclamation.** A lease is reclaimable when its owning process is gone, or when `lastUsedAt` is older than `ttlMs` (default 15 minutes, `CDP_LEASE_TTL_MS`). Those two are the whole list. A lease whose tab is no longer open is *reported* by `list_leases` as `target-gone`, which is useful to see, but that report is not what frees it: such a record is freed by the same two rules as any other, whichever lands first. Every checked call refreshes `lastUsedAt`, so an agent that is working never expires and no heartbeat is needed. Reclaiming mints a fresh nonce, which invalidates the previous owner's token: a stalled agent that comes back cannot keep driving a tab someone else now owns.
285
+
286
+ **Unreadable lease files.** A row in `list_leases` can carry `unreadable`, holding the errno (for example `EACCES`) or `"unparseable"`, when the lease file exists but could not be read or parsed. On such a row `label`, `pid`, `createdAt`, `lastUsedAt`, and `ttlMs` are zero placeholders, not real values, since they live inside the file that could not be read. `stale` is always `false` on that row, deliberately: `stale` is what a reader treats as free to take, and an unreadable lease must never read that way.
287
+
288
+ **What this does not do.**
289
+
290
+ - No fan-out. The shape is many agents with one tab each. One agent driving several tabs at once is a different feature and is not addressed.
291
+ - No isolation. Two leased tabs on one Chrome still share cookies, `localStorage`, and every other origin-scoped store. A lease is ownership of a tab, not isolation of the browser under it.
292
+ - No protection from humans. The lease is enforced against cdp-toolkit's own tool calls. It cannot stop someone clicking into the tab.
293
+ - Not for the CLI. A CLI invocation is one process per call, so a lease it claimed would be reclaimable immediately by the dead-pid rule. `claim_page` is refused there. CLI calls can still present a token minted by the MCP server with `--lease`.
259
294
 
260
295
  ## How it's built
261
296
 
@@ -263,10 +298,12 @@ The 29 parity tools are 1:1 with `chrome-devtools-mcp`; the 4 superset tools (`p
263
298
  src/
264
299
  client.ts # CdpConnection, openPage/withPage/openBrowser, resolveTarget, timeouts
265
300
  types.ts # Target, TargetSelector, Uid, CDP envelopes
266
- index.ts # TOOLS registry (33) + re-exported client primitives
301
+ index.ts # TOOLS registry (36) + re-exported client primitives
267
302
  cli.ts # the Bun CLI
268
303
  mcp.ts # stdio MCP server (exposes TOOLS via @modelcontextprotocol/sdk)
269
304
  manifest.ts # JSON Schemas advertised by the MCP server (one per tool)
305
+ leases.ts # lease records, staleness, reclamation, assertLeaseOk
306
+ leases-tools.ts # claim_page / release_page / list_leases
270
307
  tools/
271
308
  pages.ts navigation.ts evaluate.ts snapshot.ts input.ts
272
309
  screenshot.ts emulation.ts dialogs.ts recorder.ts console.ts
@@ -282,7 +319,7 @@ Each module ends with a footer comment listing the exact CDP methods it uses and
282
319
 
283
320
  ## Contributing
284
321
 
285
- Issues and PRs welcome — see [`CONTRIBUTING.md`](./CONTRIBUTING.md) and the [`good first issue`](https://github.com/sblattj/cdp-toolkit/labels/good%20first%20issue) label. By participating you agree to the [Code of Conduct](./CODE_OF_CONDUCT.md).
322
+ Issues and PRs welcome, see [`CONTRIBUTING.md`](./CONTRIBUTING.md) and the [`good first issue`](https://github.com/sblattj/cdp-toolkit/labels/good%20first%20issue) label. By participating you agree to the [Code of Conduct](./CODE_OF_CONDUCT.md).
286
323
 
287
324
  ## License
288
325