cdp-toolkit 2.0.0 → 2.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,6 +5,59 @@ 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
+ ## [2.2.0] - 2026-09-04
9
+
10
+ **Several agent PROCESSES can now drive tabs of ONE user-launched Firefox at the same time.** Firefox serves exactly one WebDriver BiDi session per browser (measured on 153.0.3: a second `session.new` gets "Maximum number of active sessions", a second WebSocket to `/session/<id>` is refused at the HTTP upgrade). Previously a second cdp-toolkit process attaching to the same Firefox was refused after the session-slot wait; now the process that wins the slot fronts the one real session with a loopback BiDi multiplexer, and every other process joins it instead of waiting. **Measured** (`bun run firefox:multi:smoke`, headless Firefox 153.0.3, 4 agents × 6 rounds): before, 4 server processes attached to one Firefox — 1 of 4 completed, the other 3 refused after the session wait; after, all 4 completed concurrently (40 calls, wall 437ms vs serial 1375ms, 4 distinct processes in flight), a killed server process left 3 survivors finishing with 0 retries, and a killed daemon was replaced within the recovery path with every survivor finishing on its original tab via the stale-id hint (`firefox:coord:smoke` 11/11, `bun test` 929 pass). See [`docs/design/2026-09-04-firefox-multi-agent-session-mux.md`](docs/design/2026-09-04-firefox-multi-agent-session-mux.md).
11
+
12
+ ### Added
13
+
14
+ - **`src/bidi/mux.ts`** — a zero-dependency loopback WebSocket server that speaks BiDi over one real upstream session: answers `session.new`/`end`/`subscribe`/`unsubscribe`/`status` locally, forwards every other command on the single upstream connection, fans events out per client.
15
+ - **`src/bidi/mux-daemon.ts`** (built to **`dist/bidi/mux-daemon.js`**, a fourth `scripts/build.ts` entrypoint) — a detached daemon that owns the real BiDi session and hosts the mux, so the session outlives every client process. The first process to attach an endpoint spawns it; every process, including the spawner, joins it. It exits on its own after `CDP_FIREFOX_MUX_IDLE_MS` (default 15000ms) with no clients, when Firefox goes away, or on `SIGTERM`/`SIGINT`/`SIGHUP`. Diagnostics: `<CDP_ARTIFACT_DIR or /tmp/cdp-toolkit>/ff-mux-<endpoint>.log`.
16
+ - **`CDP_FIREFOX_MUX`** (`off` | `host` | `daemon`, default `daemon` when attaching and `host` when launching), **`CDP_FIREFOX_MUX_IDLE_MS`** (default `15000`), **`CDP_FIREFOX_MUX_DAEMON`** (absolute path override of the daemon script; tests only).
17
+ - **A stale-target-id hint.** Firefox assigns new browsing-context ids whenever its BiDi session is re-created (measured: true even after a clean `session.end`), so if the daemon dies and a new one takes over, ids issued before the handover are stale. The driver's `no-such-target` error now names this and tells the agent to re-resolve the tab with `list_pages` (`url:`/`title:` selectors) and claim it again.
18
+ - **`src/bidi/session-lease.ts`**: `SessionSlotRecord` gains `muxEndpoint`; `acquireSessionSlot` returns `joinMux` immediately when a live holder advertises one, instead of polling out to `SessionSlotBusyError`; new `advertiseMux()` and `muxDaemonLogFile()`.
19
+ - **New live harness `test/firefox-multi-agent-smoke.ts`** (`bun run firefox:multi:smoke`): N agent PROCESSES each claim their own tab of one shared headless Firefox, navigate, write a marker, and run several rounds of isolated reads, so a call landing in another tab fails; every call is timestamped so concurrency is measured, not assumed. Also covers a SIGKILL of one server process (survivors finish with 0 retries) and a SIGKILL of the daemon itself (survivors hit the stale-target hint, re-resolve, and finish on the same tab).
20
+ - New unit tests `test/bidi-mux.test.ts`, `test/mux-daemon.test.ts`, plus additions to `test/session-lease.test.ts` and `test/session-coord.test.ts`.
21
+
22
+ ### Changed
23
+
24
+ - **`test/firefox-session-coord-smoke.ts`**: scenarios 1–4 are now pinned to `CDP_FIREFOX_MUX=off` (they test the no-mux refusal/orphan-recovery semantics specifically); a new scenario 5 covers the mux default, asserting a live holder is a detached daemon, a second process joins it instead of waiting, the daemon survives its spawner exiting, and a killed daemon's slot is recovered by the existing orphan-steal + Marionette force-clear path.
25
+
26
+ ### Fixed
27
+
28
+ - **`evaluate_script.args` now declares `items: {}`.** It was the only array property in `tools/list` without an `items` schema, and Gemini function declarations reject exactly that shape: a host that forwarded the full tool list to a Gemini model (opencode through an OpenRouter Gemini lane, 135 tools) got no response headers at all until the gateway timed the request out. Every other array in the listing already carried `items`; the live `bun src/mcp.ts` `tools/list` now shows none missing.
29
+
30
+ ## [2.1.0] - 2026-09-01
31
+
32
+ **Re-based on the MCP 2026-07-28 revision: `tools/list` is now one complete, deterministic, cacheable listing that never changes, and the runtime `browser_tools` activation toggle 2.0.0 shipped three days ago is removed.** The honest headline on cost: the **default** listing grows from ≈2,457 to **≈8,471 tokens** (46 entries, 33,886 bytes, measured over raw stdio against the real entry point), because a standards-conformant list must be complete and static — still **−58% against 1.x's ≈20,200**. `CDP_TOOL_PROFILE=core` keeps the lean ≈2,266-token surface (13 entries, 9,066 bytes, −89% vs 1.x) for hosts that eagerly load every schema. Tool names, parameters, and schema shapes are unchanged, now gated by a durable wire-vs-manifest deep-equal check rather than the one-off strip-descriptions comparison 1.10.0 and 2.0.0 used. No new runtime dependency.
33
+
34
+ ### Added
35
+
36
+ - **The 2026-07-28 protocol era, actually served.** `src/mcp.ts` connects through `serveStdio` (dual-era, default `legacy: 'serve'`) instead of `Server.connect(new StdioServerTransport())`. A modern client that opens with `server/discover` gets `supportedVersions: ["2026-07-28"]`, `resultType: "complete"`, and the `_meta['io.modelcontextprotocol/serverInfo']` envelope; a 2025-era client that opens with `initialize` still negotiates `2025-11-25` and sees no difference at all. The era is pinned per connection, and the server logs which one it pinned to stderr.
37
+ - **Cache hints on the listing.** `cacheHints` now declares `ttlMs: 3600000` + `cacheScope: "public"` for both `tools/list` and `server/discover` (emitted on modern-era connections), instead of the SDK's conservative `{ ttlMs: 0, cacheScope: 'private' }` default. The listing is identical for every caller — a stdio server has no per-caller auth — and a restart under a different `CDP_TOOL_PROFILE` or `--browser` is a different server configuration, not a mid-life change to this one.
38
+ - **`describe_tool` now doubles as the catalog.** Its `name` parameter became optional; called with no name it returns the grouped catalog of every tool the selected browser supports — a header line (`cdp-toolkit 2.1.0 · browser=chrome · 45 tools available, 46 in tools/list (CDP_TOOL_PROFILE=full)`), then one `[listed]`/`[hidden] <group> (n): names` line per group, then `Unlisted tools are callable by name; describe_tool {name} documents any of them.` The whole 45-tool catalog is 1,141 characters.
39
+ - **`CDP_TOOL_PROFILE` accepts a comma-separated group list** on top of `full` and `core` — e.g. `core,network,console` (17 entries, 11,893 bytes, ≈2,973 tokens). `core` is always included, so a list can never strand the basics, and the label is canonicalised to group order so equivalent spellings read the same. An unknown group name is now a configuration error rather than a silent fallback: the server prints `CDP_TOOL_PROFILE: unknown tool group 'x'. Known: full, core, input, …` and exits 1.
40
+ - **`src/version.ts`** — the `VERSION` constant moved out of `src/mcp.ts` into its own module, and a test asserts it equals `package.json`'s `version`. The two drifted apart between 1.11.0 and 2.0.0; that can no longer happen quietly.
41
+ - **`test/mcp-protocol.test.ts`** — raw-stdio protocol coverage: `server/discover` and `tools/list` field shapes, the cache hints, byte-identical deterministic ordering across repeated calls and connections, the **wire-vs-manifest deep-equal gate** (the durable replacement for the one-off strip-descriptions gate), the legacy `initialize` handshake, a `-32022` negative control, each profile, and proof that an unlisted tool is still callable. **`test/tool-profile.test.ts`** covers `resolveProfile` directly.
42
+
43
+ ### Changed
44
+
45
+ - **The tool listing is static.** It is computed once at startup — `describe_tool` first, then manifest order, filtered by backend availability and then by the profile — frozen, and returned as the same array to every `tools/list` on every connection. `capabilities.tools.listChanged` is now **`false`**; nothing will ever notify, because nothing ever changes. The 2026-07-28 revision makes it a MUST that a server's tool set not change as a side effect of other requests on the connection, and the client best-practices page notes that adding or removing tool definitions mid-conversation invalidates the host's prompt cache.
46
+ - **`CDP_TOOL_PROFILE` default flipped `core` → `full`.** Three reasons, in order of weight: (1) the standard puts lazy discovery on the **host** — the client runs its own catalog → inspect → execute funnel — and a host that defers schemas (Claude Code lists MCP tool *names* and loads schemas on demand) pays little for a complete list; (2) consumers that hold per-tool permission 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`; (3) a list that never changes never invalidates the prompt cache. `core` remains a first-class choice for hosts that eagerly load every schema — it is now a deployment decision, not a default imposed on everyone.
47
+ - **Host protocol eras, MEASURED 2026-09-01.** Two real hosts were pointed at the 2.1 server through a wrapper that tees its stderr: Claude Code 2.1.258 and Codex CLI 0.147.0 both connected and both logged `connection pinned to the legacy protocol era` — today they still open with `initialize` even though their binaries carry the 2026-07-28 client strings (capability present, not exercised by default). The SDK 2.0.0 `Client` does the same unless `versionNegotiation` opts in. So the dual-era default is load-bearing, not courtesy: a modern-only server would be unreachable from every host measured. The modern era is currently exercised by the raw-stdio test suite and by `mcp:smoke`'s pinned client. The static listing pays off on either era; the `ttlMs`/`cacheScope` hints are emitted only on modern connections.
48
+ - **The server `instructions` block** trades the activation walkthrough for how to read a fixed, cacheable listing (`tools/list` is complete and fixed for this process's life — cache it; `describe_tool {name}` / `describe_tool {}`; profile semantics; unlisted tools stay callable). 2,019 → 1,976 characters, still under the 2 KB cap.
49
+ - **SDK usage, deliberately unchanged in shape.** The server stays on the low-level `Server` with string-method `setRequestHandler('tools/list', …)`, which SDK 2.0 still supports for advanced use cases (and which `McpServer` itself is built on; `McpServerFactory` explicitly accepts a `Server`). `McpServer.registerTool` was evaluated and rejected on two grounds: it wants a Standard-Schema (zod) object for `inputSchema`, and `CONTRACT.md` rule 1 forbids any runtime dependency beyond the SDK — our manifest is plain JSON Schema — and it cannot express "unlisted tools remain callable by name", which is the whole point of `CDP_TOOL_PROFILE`.
50
+ - **`test/mcp-smoke.ts`** additionally connects with the SDK client pinned to `2026-07-28` and asserts the modern era end to end, alongside the existing legacy handshake and live `tools/call` round trip.
51
+ - Under `CDP_BROWSER=firefox` the default listing is 35 entries (26,533 bytes, ≈6,633 tokens): the 34 tools Firefox's BiDi surface supports, plus `describe_tool`.
52
+
53
+ ### Removed
54
+
55
+ - **`browser_tools`** — the `list_groups`/`activate`/`deactivate`/`set_profile` meta-tool, removed three days after 2.0.0 shipped it. Mutating the advertised tool set at runtime and firing `notifications/tools/list_changed` is precisely what the 2026-07-28 revision forbids (the tool set MUST NOT change as a side effect of other requests) and what the client best-practices page warns invalidates the host's prompt cache. Its open question — "does any given client actually re-fetch on `listChanged`?" — becomes moot once the list is static. Calling it now returns `unknown tool: browser_tools`; nothing else needs it, because every tool it could have activated is either already listed under the new `full` default or reachable by name under a narrower profile, and `describe_tool {}` covers what `list_groups` reported.
56
+
57
+ ### Fixed
58
+
59
+ - **v2.0.0 was not on the current protocol revision, despite running on the 2.0 SDK.** Probed raw over stdio, it answered `server/discover` with `-32601 Method not found` and served a `tools/list` carrying no `resultType`, `ttlMs`, or `cacheScope`: a 2025-11-25 (legacy-era) server, because `Server.connect(new StdioServerTransport())` **is** the legacy path. The `"2026-07-28"` literal that appears inside the SDK packages is `SUPPORTED_MODERN_PROTOCOL_VERSIONS` in `@modelcontextprotocol/server/stdio` — the modern era is reachable only through `serveStdio` (or the HTTP `createMcpHandler`), never through `connect()`. Both eras are now served.
60
+
8
61
  ## [2.0.0] - 2026-08-29
9
62
 
10
63
  **Progressive disclosure: the default `tools/list` an agent loads shrinks ~88% (≈20,200 → ≈2,500 tokens) to 12 core tools plus two meta-tools, with the full 45-tool surface, and the full per-tool documentation, one `browser_tools` call away.** This is the first npm publication since 1.11.0, so it also **carries the [1.11.1] changes below** (the two Firefox BiDi navigation fixes) that landed on main but were never released. Tool names, parameters, and schema shapes are unchanged — a strip-descriptions deep-equal of the manifest gates the change, the same check 1.10.0 used.
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. **45 tools — 12 of them on the MCP server's default listing**, the rest revealed by one `browser_tools` call (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. **45 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).
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
 
@@ -129,7 +129,7 @@ bun run mcp:smoke # spawn the server + a real initialize/tools-list/tools-call
129
129
 
130
130
  - **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
131
  - **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 lists a lean 12-tool core by default and activates the rest per group via `browser_tools`, so the discovery payload stays small; the CLI exposes all 45. They coexist with `chrome-devtools-mcp` in a separate namespace.
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` (≈8.5k tokens, −58% 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 45 regardless. They coexist with `chrome-devtools-mcp` in a separate namespace.
133
133
  - **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
134
  - **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
135
  - **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.
@@ -224,32 +224,76 @@ await TOOLS.navigate_page({ target: "index:0", url: "https://example.com" });
224
224
  | `CDP_STATE_DIR` | `/tmp/cdp-toolkit` | `select_page` selected-target file, in-flight trace state. |
225
225
  | `CDP_LEASE_TTL_MS` | `900000` | How long a tab lease survives without use before another agent can reclaim it. Refreshed on every checked call. |
226
226
  | `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. |
227
- | `CDP_TOOL_PROFILE` | `core` | MCP server only. Startup tool profile: `core` lists the 12 everyday tools plus the `browser_tools`/`describe_tool` meta-tools; `full` lists everything at startup (exact 1.x behavior). Groups toggle at runtime via `browser_tools` either way. See "Progressive disclosure" below. |
227
+ | `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. |
228
228
  | `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. |
229
229
  | `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. |
230
230
  | `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. |
231
+ | `CDP_FIREFOX_MUX` | `daemon` when attaching, `host` when launching | Firefox backend only. Which process hosts the loopback BiDi multiplexer that lets several cdp-toolkit processes drive one Firefox: `daemon` (attach-mode default) spawns/joins a detached process that owns the real session for as long as anyone needs it; `host` makes THIS process the holder (launch mode's default, since a launched Firefox and its holder already share a lifetime); `off` restores pre-mux behavior — one live session per Firefox, a second process refused after the wait. See "Firefox" below and "Parallel tabs" below. |
232
+ | `CDP_FIREFOX_MUX_IDLE_MS` | `15000` | Firefox backend only, daemon mode. How long the mux daemon stays up with zero connected clients before it exits on its own (also counted from its own startup, so a spawner that never connects can't strand one). |
233
+ | `CDP_FIREFOX_MUX_DAEMON` | resolved from the running module's location | Firefox backend only, tests only. Absolute path override for the daemon script the driver spawns, in place of the built-in resolution (`./mux-daemon.ts` in source, `dist/bidi/mux-daemon.js` in the published package). |
231
234
 
232
235
  ## Progressive disclosure
233
236
 
234
- By default the MCP server starts on the `core` profile: 12 everyday tools (`list_pages`, `new_page`, `close_page`, `select_page`, `navigate_page`, `wait_for`, `take_snapshot`, `click`, `fill`, `type_text`, `evaluate_script`, `take_screenshot`) plus two always-listed meta-tools, `browser_tools` and `describe_tool`. The other 33 tools stay hidden inside 12 activatable groups (`input`, `cookies`, `network`, `console`, `mocking`, `emulation`, `performance`, `recording`, `leases`, `permissions`, `dialogs`, `downloads`) until one `browser_tools` call reveals them. `CDP_TOOL_PROFILE=full` in the server's env restores 1.x behavior: everything advertised at startup.
237
+ 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.
235
238
 
236
- The default listing costs under 2.5k tokens and the full profile about 8.5k, down from about 20.2k in 1.x (−88% and −58%). Tool names, parameters, and schema shapes are unchanged; only what a client sees at discovery time moves.
239
+ 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`.
237
240
 
238
- `browser_tools` drives the disclosure; `describe_tool` serves the full docs:
241
+ **`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:
239
242
 
240
243
  ```json
241
- {"tool": "browser_tools", "arguments": {"action": "list_groups"}}
242
- {"tool": "browser_tools", "arguments": {"action": "activate", "group": "mocking"}}
243
244
  {"tool": "describe_tool", "arguments": {"name": "wait_for_download"}}
245
+ {"tool": "describe_tool", "arguments": {}}
244
246
  ```
245
247
 
246
- Every `activate`/`deactivate`/`set_profile` that changes the listing fires `notifications/tools/list_changed` (the server declares `listChanged: true`), so a compliant client re-fetches `tools/list` and sees the new surface. Clients that cache their tool list should honor that notification rather than keep serving the stale one.
248
+ With no `name` it returns the grouped catalog of everything this server can run. The whole 45-tool surface costs 1,141 characters:
249
+
250
+ ```
251
+ cdp-toolkit 2.2.0 · browser=chrome · 45 tools available, 46 in tools/list (CDP_TOOL_PROFILE=full)
252
+ [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
253
+ [listed] input (7): hover, drag, scroll, dispatch_mouse, press_key, fill_form, upload_file
254
+ [listed] cookies (3): list_cookies, set_cookie, delete_cookies
255
+ [listed] network (2): list_network_requests, get_network_request
256
+ [listed] console (2): list_console_messages, get_console_message
257
+ [listed] mocking (3): mock_request, list_mocks, clear_mocks
258
+ [listed] emulation (2): emulate, resize_page
259
+ [listed] performance (6): performance_start_trace, performance_stop_trace, performance_analyze_insight, performance_trace, take_heapsnapshot, lighthouse_audit
260
+ [listed] recording (2): start_screen_recording, stop_screen_recording
261
+ [listed] leases (3): claim_page, release_page, list_leases
262
+ [listed] permissions (1): grant_permissions
263
+ [listed] dialogs (1): handle_dialog
264
+ [listed] downloads (1): wait_for_download
265
+ Unlisted tools are callable by name; describe_tool {name} documents any of them.
266
+ ```
267
+
268
+ Under a narrower profile the groups it leaves out read `[hidden]` instead of `[listed]`, and the header's second count drops accordingly.
269
+
270
+ **`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):
271
+
272
+ | `CDP_TOOL_PROFILE` | entries in `tools/list` | bytes | ≈ tokens |
273
+ |---|---|---|---|
274
+ | unset / `full` — the default | 46 | 33,886 | ≈8,471 |
275
+ | `core` | 13 | 9,066 | ≈2,266 |
276
+ | `core,network,console` | 17 | 11,893 | ≈2,973 |
277
+ | `full` under `CDP_BROWSER=firefox` | 35 | 26,533 | ≈6,633 |
278
+
279
+ For comparison, 1.x advertised 45 tools with full prose at roughly 20,200 tokens. So the default listing is **−58% vs 1.x**, and `core` is **−89%**.
280
+
281
+ 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.
247
282
 
248
283
  Three rules worth knowing:
249
284
 
250
- - **Hidden does not mean blocked.** Only discovery is gated. `tools/call` checks backend availability, not group membership, so an agent that names a hidden-but-available tool still executes it; only a tool the selected browser cannot run at all is refused by name.
251
- - **`describe_tool` works for any tool by name, hidden ones included** — the full description and per-parameter docs behind the terse one-liner `tools/list` now carries.
252
- - **Meta-tools are MCP-only.** Profiles are an MCP-server concept: `cdp browser_tools` fails with `unknown tool`, and the CLI keeps exposing all 45 tools no matter how `CDP_TOOL_PROFILE` is set.
285
+ - **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.
286
+ - **`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.
287
+ - **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 45 tools no matter how `CDP_TOOL_PROFILE` is set.
288
+
289
+ ### MCP protocol eras
290
+
291
+ The server serves **both** eras off the same stdio transport, and the era is pinned per connection by how the client opens:
292
+
293
+ - A modern client opens with **`server/discover`** and gets `protocolVersion` `2026-07-28`, `resultType: "complete"`, the `_meta['io.modelcontextprotocol/serverInfo']` envelope, and the `ttlMs`/`cacheScope` cache hints on both `server/discover` and `tools/list`.
294
+ - A 2025-era client opens with **`initialize`** and gets `protocolVersion` `2025-11-25`, exactly as before — this upgrade is invisible to it.
295
+
296
+ Measured 2026-09-01: Claude Code 2.1.258 and Codex CLI 0.147.0 both connect and both pin the **legacy** era — their binaries carry the 2026-07-28 client strings, but neither opens with `server/discover` by default, and the MCP SDK's own `Client` behaves the same unless it opts in. So serving both eras is load-bearing, not courtesy, and today the `ttlMs`/`cacheScope` hints reach only clients that ask for the modern era. The static listing pays off on either one — no mid-session tool churn, and no prompt-cache invalidation.
253
297
 
254
298
  ## Firefox (WebDriver BiDi)
255
299
 
@@ -287,6 +331,8 @@ cdp --connect 127.0.0.1:9223 take_snapshot # host:port also works
287
331
  cdp --connect ws://127.0.0.1:9223/session take_snapshot # or the full ws:// URL
288
332
  ```
289
333
 
334
+ **A side effect of the daemon above: CLI target ids now survive across separate invocations, for as long as the daemon does.** Before it, every CLI call opened and closed its own throwaway BiDi session against the shared Firefox, and Firefox hands out fresh browsing-context ids on every new session — so a `cdp list_pages` id was already stale by the time a following `cdp <tool> --target <id>` call used it; only `url:`/`title:`/`label:` selectors survived across invocations. Measured 2026-09-04 against headless Firefox 153.0.3: three consecutive `cdp list_pages` calls on one attached Firefox returned the SAME tab id all three times in the default (daemon) mode (the 2nd and 3rd calls took ~31ms, joining the already-running daemon instead of standing up a session), and a DIFFERENT id on each of the three calls under `CDP_FIREFOX_MUX=off`. So a `list_pages` → `<tool> --target <id>` CLI pipeline now works by id — not just by `url:`/`title:`/`label:` — for as long as the daemon stays up: the idle window (`CDP_FIREFOX_MUX_IDLE_MS`, default `15000`ms after the last call) or indefinitely while any MCP server keeps a client joined to it.
335
+
290
336
  3. MCP client config: set the env var instead of a flag (the endpoint doesn't belong in `args`):
291
337
 
292
338
  ```json
@@ -323,11 +369,19 @@ claude mcp add cdp-toolkit -e CDP_FIREFOX_ENDPOINT=9223 -- npx -y cdp-toolkit
323
369
  - **Two genuinely live processes** are serialized rather than both wedging: the second waits up to `CDP_FIREFOX_SESSION_WAIT_MS` (default `10000`ms) for the slot to free, then returns a distinguishable, fast error naming the live holder — "Firefox's single WebDriver BiDi session on `<endpoint>` is held by a LIVE process `<label>` (pid N) … wait & retry, or point this server at a different endpoint/browser" — instead of hanging.
324
370
  - **An orphaned session from a dead holder** — the classic wedge, a client `SIGKILL`ed or crashed without `session.end`, which Firefox does not reap on its own — is now **auto-recovered**: cdp-toolkit force-clears the orphaned session over the **Marionette side channel** (a blind `WebDriver:DeleteSession` on port `2828`, `CDP_FIREFOX_MARIONETTE_PORT`) **without** killing or restarting Firefox, then retries. This works **only** if that Firefox was launched with `--marionette` (verified against FF153: `--remote-debugging-port` alone does **not** start Marionette). When Marionette is absent it degrades to a clear, actionable error ("orphaned … could not be auto-cleared. Marionette recovery needs Firefox launched with `--marionette` …; otherwise restart Firefox") rather than the old dead-end.
325
371
 
326
- This coordinates and recovers **around** Firefox's one-session limit; it does not remove it. Two live holders still cannot share the slot at once — one is served, the other waits or is told, by name, who holds it.
372
+ **Several agent PROCESSES driving one Firefox at once: a live holder is now something you JOIN, not just wait on.** The slot's holder fronts the one real session with a loopback WebSocket server that itself speaks BiDi — a multiplexer (`src/bidi/mux.ts`) answering `session.new`/`end`/`subscribe`/`unsubscribe`/`status` locally and forwarding everything else on the single upstream connection, fanning events out per client. A second (third, fourth, …) cdp-toolkit process attaching to the same endpoint finds the holder's mux advertised in the slot record and dials it instead of polling the slot; from that joiner's side a joined connection is indistinguishable from a real one, and it drives its own tab exactly as if it held the session outright — measured with 4 server processes each claiming and driving its own tab concurrently on one Firefox, 0 wedges (`test/firefox-multi-agent-smoke.ts`, `bun run firefox:multi:smoke`). Three modes, `CDP_FIREFOX_MUX`:
373
+
374
+ - **`daemon`** (the default when attaching): the session is owned by a small **detached daemon** process, not by any client. The first process to attach spawns it (`dist/bidi/mux-daemon.js` in the published package); every process, including the spawner, joins it the same way. This is deliberate, not incidental: with the session living inside a client process, that client exiting — even cleanly, e.g. the first agent session finishing while a second is still mid-task — would re-create the session and invalidate every other client's tab ids, leases and origin records. A client process dying is invisible to the others (measured: 3 survivors finish all rounds with 0 retries after a 4th server process is `SIGKILL`ed). The daemon exits on its own once nobody is joined to it (`CDP_FIREFOX_MUX_IDLE_MS`, default `15000`ms with zero clients, also counted from its own startup), when Firefox goes away, or on `SIGTERM`/`SIGINT`/`SIGHUP` — it never outlives the browser it fronts. It runs with no console (spawned detached, stdio ignored); its diagnostics go to `<CDP_ARTIFACT_DIR or /tmp/cdp-toolkit>/ff-mux-<endpoint>.log`, which a driver error names if a daemon it spawned never advertises within the wait window.
375
+ - **`host`**: the mux runs inside the first process itself rather than a detached daemon. This is launch mode's default (a launched Firefox and its holder already share a lifetime, so there is nothing extra to leak) and an opt-in for attach mode if you'd rather not have a stray daemon process at all, at the cost of the invalidation risk above.
376
+ - **`off`**: restores the pre-mux behavior exactly — no mux hosted, none joined, two live processes serialize on the slot as described above.
377
+
378
+ **One caveat, and it is narrow.** Firefox regenerates every top-level browsing-context id whenever its one BiDi session is re-created — measured on 153.0.3: true even after a clean `session.end` + `session.new`, the tab survives at the same URL but `browsingContext.getTree` reports a new id. A client process dying never triggers this (that is the entire point of the daemon), but if the **daemon itself** dies and a new one takes over, ids issued before the handover go stale. The next call against a stale id fails with `no-such-target` plus a hint that the session was re-established and to re-resolve the tab with `list_pages` (`url:`/`title:` selectors) and claim it again — the tab itself, its state, and its lease are untouched, only the id changed.
379
+
380
+ This coordinates and recovers **around** Firefox's one-session limit; it does not remove it. There is still exactly one real session, and `CDP_FIREFOX_MUX=off` still serializes on it exactly as before — the mux's default just means most agents never have to.
327
381
 
328
382
  **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.
329
383
 
330
- **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 active — backend availability first, profile second (see [Progressive disclosure](#progressive-disclosure)). Under Firefox, six capability areas are absent because Firefox 153's BiDi implementation has no equivalent domain:
384
+ **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:
331
385
 
332
386
  - `performance_start_trace`, `performance_stop_trace`, `performance_analyze_insight`, `performance_trace` (needs `trace.performance`)
333
387
  - `take_heapsnapshot` (needs `heap.snapshot`)
@@ -336,7 +390,7 @@ This coordinates and recovers **around** Firefox's one-session limit; it does no
336
390
  - `dispatch_mouse` (needs `input.raw`): the raw move/down/up primitive is a direct `Input.dispatchMouseEvent` wrapper with no BiDi analogue.
337
391
  - `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.
338
392
 
339
- Everything else, including `mock_request`/`list_mocks`/`clear_mocks` (Firefox's `network.addIntercept` covers the same fake-backend use case as Chrome's `Fetch` domain), the `claim_page`/`release_page`/`list_leases` lease group, and the new `scroll` tool (Chrome dispatches `Input.dispatchMouseEvent{type:'mouseWheel'}`, Firefox uses BiDi's `wheel` input source — both live-verified), is available under both backends: 34 of the 45 tools. Under the MCP server's default profile only the core subset of those is listed until their groups are activated — 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 agents under Firefox share and serialize on that single session (coordinated by the cross-process session lease above), not the independent concurrent sessions Chrome's unlimited CDP connections allow — see "Parallel tabs" below.
393
+ Everything else, including `mock_request`/`list_mocks`/`clear_mocks` (Firefox's `network.addIntercept` covers the same fake-backend use case as Chrome's `Fetch` domain), the `claim_page`/`release_page`/`list_leases` lease group, and the new `scroll` tool (Chrome dispatches `Input.dispatchMouseEvent{type:'mouseWheel'}`, Firefox uses BiDi's `wheel` input source — both live-verified), is available under both backends: 34 of the 45 tools. Under `CDP_BROWSER=firefox` the MCP server's default (`full`) listing is therefore 35 entries — those 34 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.
340
394
 
341
395
  **Honest capability gaps, not oversold parity:**
342
396
 
@@ -351,7 +405,7 @@ Everything else, including `mock_request`/`list_mocks`/`clear_mocks` (Firefox's
351
405
 
352
406
  ## The tools (29 parity + 16 superset = 45)
353
407
 
354
- This table is the **full-profile view** — all 45 tools, which is what `cdp --list` and `CDP_TOOL_PROFILE=full` show. On the MCP server's default `core` profile only 12 of them are listed at startup; the rest sit in 12 activatable groups: `input` (7: hover, drag, scroll, dispatch_mouse, press_key, fill_form, upload_file), `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). See [Progressive disclosure](#progressive-disclosure).
408
+ This table is the **full-profile view** — all 45 tools, which is what `cdp --list` and the MCP server's default listing (`CDP_TOOL_PROFILE=full`) both show. They are partitioned into 13 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` (7: hover, drag, scroll, dispatch_mouse, press_key, fill_form, upload_file), `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). `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).
355
409
 
356
410
  The 29 parity tools are 1:1 with `chrome-devtools-mcp`; the 16 superset tools (`performance_trace`, the `list_cookies`/`set_cookie`/`delete_cookies` cookie group, the `mock_request`/`list_mocks`/`clear_mocks` group, the `claim_page`/`release_page`/`list_leases` lease group, the `start_screen_recording`/`stop_screen_recording` screen-recording pair, `scroll`, `dispatch_mouse`, `wait_for_download`, and `grant_permissions`) are toolkit additions. Each row notes the underlying CDP method(s) and the precise parity gaps.
357
411
 
@@ -397,7 +451,7 @@ The 29 parity tools are 1:1 with `chrome-devtools-mcp`; the 16 superset tools (`
397
451
  | `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`). |
398
452
  | `list_mocks` *(superset)* | `Runtime.evaluate` (liveness probe) | Lists active mock sessions with rules + hit counts; prunes sessions whose tab closed. |
399
453
  | `clear_mocks` *(superset)* | `Fetch.disable` | Tears down the resolved target's mock session (or all with `all:true`). |
400
- | `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 `browser_tools`/`describe_tool` meta-tools are likewise MCP-only. |
454
+ | `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. |
401
455
  | `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`. |
402
456
  | `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. |
403
457
  | `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`). |
@@ -452,7 +506,7 @@ Two agents pointed at the same Chrome both resolve `target: active` to whatever
452
506
 
453
507
  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 and, by default, closes the tab if this toolkit opened it (see "Close on release" below). `list_leases` shows who holds what.
454
508
 
455
- **Firefox: the ceiling is one BiDi session per browser instance.** The `claim_page`/`release_page`/`list_leases` tab-lease group works under both backends, but a real asymmetry sits underneath it. Chrome accepts unlimited concurrent CDP connections, so "many agents, one browser" there means many genuinely independent sessions. Firefox permits only **one** WebDriver BiDi session per browser instance (see [Firefox](#firefox-webdriver-bidi)), so under Firefox "many agents, one browser" means many agents *sharing and serialized on that single session* — coordinated by the cross-process session lease, not running as independent sessions. Expect Chrome-style multi-session concurrency on Chrome only; on Firefox the tabs are still leasable, but the session beneath them is a single shared slot.
509
+ **Firefox: the ceiling is one BiDi session per browser instance, but several PROCESSES can now drive it at once.** The `claim_page`/`release_page`/`list_leases` tab-lease group works under both backends, but a real asymmetry sits underneath it. Chrome accepts unlimited concurrent CDP connections, so "many agents, one browser" there means many genuinely independent sessions. Firefox permits only **one** WebDriver BiDi session per browser instance (see [Firefox](#firefox-webdriver-bidi)) — that ceiling is real and does not move — but as of the BiDi multiplexer described there, several cdp-toolkit *processes* attached to the same Firefox now share that one session by joining a mux fronting it rather than waiting their turn for it: each still claims and drives its own tab concurrently, measured at 4 distinct server processes with a call in flight at once, not the historical one-at-a-time wedge. Expect Chrome-style *within-process* concurrency (many subagents, one server, unlimited connections) on Chrome; on Firefox the tabs are still leasable per process exactly the same way, and the session underneath is one shared real session multiplexed across processes rather than independent ones — `CDP_FIREFOX_MUX=off` is the one setting that goes back to strict serialization.
456
510
 
457
511
  **Opt in, and only ever a refusal — this is the default, with `CDP_REQUIRE_LEASE` unset.** 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 was 1.2 and not 2.0.
458
512
 
@@ -555,9 +609,10 @@ src/
555
609
  types.ts # Target, TargetSelector, Uid, CDP envelopes
556
610
  index.ts # TOOLS registry (45) + re-exported client primitives
557
611
  cli.ts # the Bun CLI
558
- mcp.ts # stdio MCP server (exposes TOOLS via @modelcontextprotocol/server)
612
+ mcp.ts # stdio MCP server, dual-era (serveStdio): static cacheable tools/list
613
+ version.ts # the VERSION constant, single source of truth with package.json
559
614
  manifest.ts # JSON Schemas advertised by the MCP server (one per tool)
560
- toolGroups.ts # tool-to-group map + core/full profiles (progressive disclosure)
615
+ toolGroups.ts # tool-to-group map + CDP_TOOL_PROFILE resolution (startup-only filter)
561
616
  toolDocs.ts # full per-tool prose served on demand by describe_tool
562
617
  leases.ts # lease records, staleness, reclamation, assertLeaseOk, CDP_REQUIRE_LEASE
563
618
  leases-tools.ts # claim_page / release_page / list_leases
@@ -573,6 +628,7 @@ test/
573
628
  network_mock.test.ts # pure-logic unit tests (bun test)
574
629
  mcp-smoke.ts # MCP handshake + live tools/call round-trip (bun run mcp:smoke)
575
630
  lease-smoke.ts # two-process live lease harness (bun run lease:smoke)
631
+ firefox-multi-agent-smoke.ts # N concurrent server PROCESSES on one shared Firefox (bun run firefox:multi:smoke)
576
632
  ```
577
633
 
578
634
  Each module ends with a footer comment listing the exact CDP methods it uses and its parity gaps. The full design contract lives in [`CONTRACT.md`](./CONTRACT.md).