# cdp-toolkit **A lightweight, drop-in alternative to [`chrome-devtools-mcp`](https://github.com/ChromeDevTools/chrome-devtools-mcp) that won't wedge your agent.** It drives the Chrome tabs you point it at over the raw DevTools Protocol: any number of tabs, one explicitly named target per call over one direct socket, with a bounded timeout on every call, so a stuck page returns a clean error instead of hanging your agent and forcing a `/mcp` restart. Same idea, no all-target fan-out, plus tab leases so several agents can work one browser (and know when a human is using it too), plus a built-in network-mocking fake backend. **48 tools, behind a 7-entry static, cacheable MCP listing** (5 everyday tools plus `search_tools` and `call_tool` to find and run the rest); `CDP_TOOL_PROFILE=full` lists all of them (see [Progressive disclosure](#progressive-disclosure)). Chrome is the flagship default; a second backend drives Firefox over WebDriver BiDi behind the same tool surface (see below). > For AI-agent developers and Claude Code / Cursor users who need **the tabs they name driven reliably**, not a Puppeteer-managed browser. [![CI](https://github.com/sblattj/cdp-toolkit/actions/workflows/ci.yml/badge.svg)](https://github.com/sblattj/cdp-toolkit/actions/workflows/ci.yml) [![npm](https://img.shields.io/npm/v/cdp-toolkit?color=cb3837&logo=npm)](https://www.npmjs.com/package/cdp-toolkit) [![license](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE) [![Model Context Protocol](https://img.shields.io/badge/MCP-compatible-1f6feb)](https://modelcontextprotocol.io) ![cdp-toolkit demo](docs/demo.gif) ## In plain terms 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. `cdp-toolkit` keeps it simple: **one connection to the one tab each call names, and a time limit on every action.** Drive as many tabs as you like, one named target at a time, and point several agents at the same Chrome without them stealing each other's tabs. When something stalls, you get an error back, not a frozen agent. Same things you could do before, minus the wedging and the restarts. That's the whole pitch. The technical *why* (fan-out, lazy domain enabling, the `Network.enable` hang) is below. ## Why it exists 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. `cdp-toolkit` makes the opposite bet. Every call attaches **one** direct WebSocket to the **one** target it resolved, enables only the CDP domains it needs, and enforces a **per-command timeout** so a stuck renderer can never hang the caller. The connection lives for that call and closes after it, so nothing bleeds between tabs. For driving tabs you already have in hand, the common automation and evidence-gathering case, it's materially more robust. ## cdp-toolkit vs chrome-devtools-mcp | | **cdp-toolkit** | **chrome-devtools-mcp** | |---|---|---| | **Target scope** | one resolved tab per call (`active` / `index:N` / `url:` / `title:`), any number of tabs across calls | all attached targets; fan-out can stall on an unrelated tab | | **`Network.enable`** | lazy, only when a tool needs it | eager on connect, a known hang on busy renderers | | **Per-command timeout** | ✅ bounded (`CDP_TIMEOUT_MS`, 15s default), rejects, never hangs | ❌ none; a stuck renderer blocks indefinitely | | **Element refs** | stateless `backendDOMNodeId` (resolved on demand) | server-side handle table (can drift / expire) | | **Network mocking** | ✅ persistent per-target fake backend | ❌ not available | | **Runtime deps** | CDP/CLI layer: native `WebSocket` + `fetch` (the MCP server adds only the MCP SDK) | Puppeteer stack | | **Auto-wait / retry** | ❌ single-shot; re-snapshot between steps | ✅ Puppeteer's auto-wait envelope | **Use `chrome-devtools-mcp`** if you need multi-target autonomy or Puppeteer's auto-wait/retry on an *unknown* page. **Use `cdp-toolkit`** when you know which tabs you want driven, however many that is, and need them not to hang. They coexist: `cdp-toolkit`'s tools are namespaced `mcp__cdp-toolkit__*`, distinct from `mcp__chrome-devtools__*`. ## Measured, not marketed: the wedge benchmark Every number above is runnable. `bun run bench:wedge` spawns an isolated headless Chrome, points a throwaway page at an endpoint that accepts the connection and never responds, and measures the full stuck-page lifecycle (re-run with `CDP_TIMEOUT_MS=3000` for a ~60s sample; defaults below use the 15s bound): | Phase | Measured (2026-09-29, HeadlessChrome 154.0.8037.59, isolated headless, 8 wedges) | |---|---| | Healthy `evaluate_script` (50×) | p50 **1ms**, p95 **3ms** | | Wedged `navigate` → dead endpoint (8×) | **rejected at p50 15.01s, max 15.02s** — the configured bound, never a hang | | Witness tab evaluated mid-brick (8×) | p95 **3ms** — other tabs never stall | | Recovery: close bricked tab + reopen + evaluate (8×) | p95 **33ms** | | `/mcp` restarts · server restarts | **0** · **0** | One honest caveat the benchmark also reports: while a navigation to a dead endpoint is pending, *that page* stays unresponsive to new sessions — the blast radius is the tab, not the server. Closing it is a browser-level command (2ms) and a fresh page answers at healthy latency; your other tabs, and the MCP server, never noticed. Exit code is nonzero unless every wedge rejected in-bound and every recovery landed under 1s, so the benchmark is CI-able. ## Is this for you? **Yes, if you:** - drive tabs you name from Claude Code / Cursor / any MCP host and want them to never wedge; - need to **mock a backend** to build or test a UI before the real API exists; - have been bitten by the eager-`Network.enable` hang on a busy renderer; - run **multiple agents against one Chrome** and need them to stop stealing each other's tabs. **Probably not, if you:** - need **WebKit / Safari**, or a browser that is neither Chromium nor Firefox — use [`playwright-mcp`](https://github.com/microsoft/playwright-mcp). Firefox *is* supported here, as a first-class backend (see [Firefox (WebDriver BiDi)](#firefox-webdriver-bidi)); - need Puppeteer's auto-wait/retry envelope for an unknown, changing page; - want one server to fan out across *all* your open tabs at once. ## Quickstart (about 30 seconds) **Fastest path — the interactive installer.** `cdp install` registers the MCP server into a harness you pick and appends a shell alias that launches your browser with its debug port open: ```bash npx -y --package cdp-toolkit cdp install # or `cdp install` from a clone / after a global install ``` It asks for a harness (`claude` | `codex` | `opencode`), a debug port, and a browser (`arc` | `chrome` | `firefox` | `vivaldi`), then writes an idempotent marker block into your `~/.zshrc`/`~/.bashrc`. The same flags drive it non-interactively: `--harness --browser --port --name --no-alias --yes`. The Firefox alias it writes includes `--marionette` (required for cdp-toolkit's orphaned-session auto-recovery — see [Firefox](#firefox-webdriver-bidi)). It is a CLI subcommand, **not** a 49th tool: `cdp --list` still shows 48. Prefer to wire it by hand? The manual path is three steps: ```bash # 1. Start Chrome/Chromium with the DevTools port open open -a "Google Chrome" --args --remote-debugging-port=9222 # (Linux: google-chrome --remote-debugging-port=9222) # 2. Add cdp-toolkit to Claude Code at user scope (every project, no install step) claude mcp add cdp-toolkit --scope user -- npx -y cdp-toolkit # or: bunx -y cdp-toolkit # 3. In Claude Code, call a tool to prove it works: # mcp__cdp-toolkit__list_pages ``` That's it. Tools appear namespaced as `mcp__cdp-toolkit__`. The server connects to Chrome **lazily per call**, so it loads cleanly even when Chrome isn't running. Prefer the **CLI**? Every tool is runnable directly: ```bash npx -y --package cdp-toolkit cdp list_pages # or swap npx → bunx npx -y --package cdp-toolkit cdp navigate_page --target index:0 --url https://example.com # …or from a clone: `bun run src/cli.ts …` ``` **Requirements:** Node ≥ 22 **or** Bun ≥ 1.1: `npx -y cdp-toolkit` and `bunx -y cdp-toolkit` both work (the published bins are plain Node ESM, and the server + CLI are CI-tested under both runtimes). Chrome/Chromium with `--remote-debugging-port=9222`. Smoke-check the port: `curl -s http://127.0.0.1:9222/json/version`. Under Bun only, `--transport streamable-http` (with `--port`/`--host`, default `127.0.0.1:3000`) serves the same MCP surface over loopback HTTP instead of stdio. **Upgrading from 2.x:** the MCP server's default `tools/list` went from 49 entries to 7 (the 5 gateway tools plus `search_tools` and `call_tool`); the other tools are still there, reached through `call_tool`. Set `CDP_TOOL_PROFILE=full` to get the old listing back. The CLI is unchanged. See [Progressive disclosure](#progressive-disclosure). ### MCP client setup
Claude Code Either let `cdp install` register it for you (pick `claude` at the harness prompt), or wire it by hand: ```bash claude mcp add cdp-toolkit --scope user -- npx -y cdp-toolkit # or: bunx -y cdp-toolkit claude mcp get cdp-toolkit # status (should show ✓ Connected) ``` A newly-registered server loads on the next Claude Code start; in an existing session, reconnect via `/mcp`.
Cursor / Windsurf / any MCP host Add to your MCP config (e.g. `~/.cursor/mcp.json`): ```json { "mcpServers": { "cdp-toolkit": { "command": "npx", "args": ["-y", "cdp-toolkit"] } } } ```
From a local checkout ```bash git clone https://github.com/sblattj/cdp-toolkit && cd cdp-toolkit && bun install claude mcp add cdp-toolkit --scope user -- bun run "$(pwd)/src/mcp.ts" bun run mcp:smoke # spawn the server + a real initialize/tools-list/tools-call round-trip ```
## Key capabilities - **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. - **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`. - **Full chrome-devtools-mcp parity + extras.** All 29 upstream tools, plus `performance_trace` (a robust single-call trace), Lighthouse audits, heap snapshots, a cookie group that reads, writes, and deletes httpOnly cookies, real HTML5 drag-and-drop, raw scroll/mouse dispatch, download capture, permission grants, and tab-to-video screen recording. The MCP server lists 7 of them by default on one static, cacheable `tools/list` (≈1.3k tokens, −86% vs 2.7.0's default) and serves the rest through `search_tools` (find a tool, read its docs and `inputSchema`) and `call_tool` (run it); `CDP_TOOL_PROFILE=full` lists all of them. The CLI exposes all 48 regardless. They coexist with `chrome-devtools-mcp` in a separate namespace. - **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. - **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. - **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. - **Out-of-model secret handling.** A read that would return a credential, a JWT in `localStorage` via `evaluate_script` or an httpOnly session cookie via `list_cookies`, takes a `savePath` that writes the value to a file and keeps it out of the tool response entirely, so the secret never lands in the agent transcript. That same per-call, in-process design makes cdp-toolkit a clean substrate for credential-injection tools: a vaulted password can be typed straight into the DOM while only a status crosses back to the model, never the secret. - **Provenance that outlives the lease.** `list_pages` reports `origin: "agent"` (with the creating `label`) for every tab this toolkit opened, and `"unknown"` otherwise. It never claims `"human"`: the absence of a creation record cannot prove a person opened the tab, so `"unknown"` is the honest word once an agent releases, expires, or dies and its tab is left behind. ## Why raw CDP beats the MCP for known targets Each point below leads with the symptom you've probably hit, then the cause, then the fix. - **Your agent stalls on a call when a busy background tab is open** → that's the **all-target fan-out**: every operation broadcasts to all attached targets. every cdp-toolkit call resolves *one* target (`active | index:N | url: | title: | label: | `, plus `frame:` for an out-of-process iframe, Chrome-only) and attaches a single WebSocket to just that page, so a busy tab you did not name is never touched. - **A tool hangs forever and never returns** → the MCP's eager `Network.enable` on a busy or hung renderer is a known wedge. cdp-toolkit enables domains **lazily**, only where a tool needs them (the recorder enables `Network`/`Runtime`/`Log`; most tools touch only `Page`/`Runtime`/`DOM`). - **No way to bound a slow call** → `CdpConnection.send()` enforces `CDP_TIMEOUT_MS` (15s default) on *every* command and **rejects rather than hangs**, so a stuck renderer can never block a caller indefinitely. - **Element handles drift across calls** → a `uid` **is** a CDP `backendDOMNodeId`, resolved on demand via `DOM.resolveNode`. There is no server-side handle table to drift or expire. The trade-off is generality: this toolkit acts on one named page per call and does **not** replicate Puppeteer's auto-wait/retry envelope. Re-`take_snapshot` between steps rather than expecting an implicit wait. ## Network mocking: a fake backend for building/testing UIs 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`. ```bash # Return empty search results and reload to see how the UI renders the zero state cdp mock_request --urlPattern '*/api/search*' --body '{"results":[],"total":0}' --reload true # Force the endpoint to error (does the UI show a clean error or hang?) cdp mock_request --urlPattern '*/api/search*' --action fail --errorReason Failed --reload true # Resilience: fail 30% of calls + add 800ms latency cdp mock_request --urlPattern '*/api/*' --failRate 0.3 --delayMs 800 cdp list_mocks cdp clear_mocks --all true ``` > Cross-origin fetches (e.g. from a `data:` page) need an `Access-Control-Allow-Origin` header on the mock: `--json '{"urlPattern":"*api*","body":"{}","headers":{"Access-Control-Allow-Origin":"*"}}'`. Persistent mock sessions live in the long-lived MCP-server process; under the one-shot CLI each `mock_request` is its own process, so use `--reload true` to apply-and-observe within the single invocation. ## CLI usage ```bash # Run any tool by its MCP name; args come from --json and/or --key value flags. cdp [--target ] [--json ''] [-- ...] cdp --list # list every available tool name cdp --help # top-level usage cdp take_screenshot --help # that tool's arguments, from its schema — touches no browser cdp list_pages cdp navigate_page --target index:0 --url https://example.com cdp take_snapshot --target url:example --interactiveOnly true cdp click --target index:0 --uid 42 cdp evaluate_script --json '{"expression":"document.title"}' # Read a value WITHOUT it landing in the response (or an agent transcript): # savePath writes the value to a JSON file and returns {path,bytes,type,target} only. cdp evaluate_script --json '{"expression":"localStorage.getItem(\"auth\")","savePath":"auth.json"}' # Cookies, httpOnly ones included (document.cookie cannot see those): cdp list_cookies --target index:0 --json '{"domain":"example.com"}' # Same read, with the values kept out of the response: {path,bytes,count,target} only. cdp list_cookies --json '{"name":"session","savePath":"cookies.json"}' # Write one, httpOnly included (document.cookie cannot create those either): cdp set_cookie --json '{"name":"session","value":"abc","url":"https://example.com/","httpOnly":true}' # Remove it again. Either url or domain is required on both write tools. cdp delete_cookies --json '{"name":"session","url":"https://example.com/"}' cdp take_screenshot --target url:example --fullPage true # A whole page, however long: past Chrome's 16384-device-px encode limit the capture is # taken as vertical bands and stitched into one lossless PNG. No flag needed — this is the # default — and the result says so: {"width":2780,"height":281964,"tiled":true,"bands":18}. cdp take_screenshot --target url:example --fullPage true --savePath /tmp/whole-page.png # 3x output pixels for one capture (Chrome only). The page is not told anything changed. cdp take_screenshot --target url:example --scale 3 # Shoot a responsive page at desktop width from a tab that isn't that size, then restore it. cdp take_screenshot --target url:example --renderWidth 1920 --renderHeight 1080 cdp lighthouse_audit --url https://example.com --json '{"categories":["performance"]}' ``` **Argument parsing:** the first positional token is the tool name. `--json ''` merges a JSON object into the args (applied first). `--target ` sets `args.target`. Repeated `--key value` pairs become `args.key`. A key the tool's schema types as a string keeps its raw text (so `--requestId 91775.10` stays `"91775.10"`); any other key is coerced (`true`/`false` → boolean, numeric strings → number, else string). A bare `--flag` is `true`. Explicit flags override keys from `--json`. Output is `JSON.stringify(result, null, 2)` on stdout (exit 0); on any throw, `{"error":""}` goes to stderr and the process exits 1. **`--help`/`-h`** are recognized anywhere in argv, ahead of every other flag, and are never treated as a tool argument or a tool name — as of 1.9.3, ` --help` used to run the tool instead. With no tool named, `cdp --help` prints the usage above. With a tool named, `cdp --help` prints that tool's arguments (name, type, required/optional, description) read from its schema, and exits 0 having made no CDP connection, taken no lease, and written no file. ### Programmatic use ```ts import { TOOLS, withPage, resolveTarget, CdpError } from "cdp-toolkit"; const pages = await TOOLS.list_pages({}); await TOOLS.navigate_page({ target: "index:0", url: "https://example.com" }); ``` ### Environment knobs | Env var | Default | Purpose | |---|---|---| | `CDP_BASE` | `http://127.0.0.1:9222` | DevTools HTTP origin (drives discovery + the lighthouse `--port`). | | `CDP_TIMEOUT_MS` | `15000` | Per-command timeout. | | `CDP_ARTIFACT_DIR` | `/tmp/cdp-toolkit` | Screenshots, traces, heap snapshots, lighthouse reports, recorder buffers. | | `CDP_STATE_DIR` | `/tmp/cdp-toolkit` | `select_page` selected-target file, in-flight trace state. | | `CDP_EXTRACT_BASE_URL` | `http://127.0.0.1:8090/v1` | `extract_page` only. Base URL of the OpenAI-compatible extraction endpoint (the server appends `/chat/completions`). The default is loopback llm-ferry, so page content never leaves the machine; overriding it sends page content wherever the operator points. | | `CDP_EXTRACT_MODEL` | `schematron` | `extract_page` only. Model name for the extraction endpoint call. | | `CDP_EXTRACT_API_KEY` | unset | `extract_page` only. Bearer token for the endpoint. Never logged and redacted from every error; the response never contains it. | | `CDP_EXTRACT_TIMEOUT_MS` | `90000` | `extract_page` only. Default budget (ms) for the endpoint call; per-call `timeoutMs` overrides, hard max 300000. | | `CDP_EXTRACT_MAX_CHARS` | `300000` | `extract_page` only. Default character cap on the cleaned HTML payload; per-call `maxChars` overrides. | | `CDP_EXTRACT_PROMPT` | `html` | `extract_page` only. Prompt shape: `html` sends the cleaned HTML as the only user message (the hosted Schematron API injects the schema server-side); `schematron` sends the open-weight Schematron model-card prompt with the schema inline. Per-call `prompt` overrides; any other value is a validation error. | | `CDP_LEASE_TTL_MS` | `900000` | How long a tab lease survives without use before another agent can reclaim it. Refreshed on every checked call. | | `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. | | `CDP_TOOL_PROFILE` | `gateway` | MCP server only. Startup-only filter on what `tools/list` advertises, read once and fixed for the life of the process. `gateway` (the default, and what unset/empty means) lists 7 tools: `search_tools`, `call_tool`, and the 5 gateway tools (`navigate_page`, `evaluate_script`, `take_snapshot`, `click`, `fill`); `full` lists every group; `core` lists the 12 everyday tools plus `search_tools` and `call_tool`; or a comma-separated group list, e.g. `gateway,network,console` (adds only the named groups on top of the gateway tools) or `core,network,console` (a list without `gateway` always includes `core`). An unknown group name is a configuration error: the server prints `CDP_TOOL_PROFILE: unknown tool group 'x'. Known: gateway, full, core, input, …` and exits 1. Tools the profile leaves out stay callable through `call_tool` (or by name). See "Progressive disclosure" below. | | `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. | | `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. | | `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. | | `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. | | `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). | | `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). | ## Progressive disclosure As of 3.0 the MCP server's default `tools/list` has **7 entries**: the 5 gateway tools (`navigate_page`, `evaluate_script`, `take_snapshot`, `click`, `fill`) plus two meta-tools, `search_tools` and `call_tool`. The other 43 tools are still there; a model finds and runs them through the two meta-tools. Wire order is `search_tools`, `call_tool`, then the gateway tools in manifest order. The listing is still **static**. It is computed once at startup and frozen: byte-identical on every call, on every connection, and unchangeable by anything a client does mid-session. On a 2026-era connection it carries cache hints (`ttlMs: 3600000`, `cacheScope: "public"`) so a client can hold it for an hour instead of re-fetching, and `capabilities.tools.listChanged` is `false`: nothing will ever notify, because nothing ever changes. What 3.0 changed is the default profile, not that design. That shape is deliberate. The MCP **2026-07-28** revision puts lazy discovery on the *host* side, and a server cooperates by publishing a complete, deterministic, cacheable list rather than by mutating its own. The revision makes it a MUST that a server's tool set not change as a side effect of other requests on the connection. See [Build an MCP server](https://modelcontextprotocol.io/docs/2026-07-28/develop/build-server) and [Client best practices](https://modelcontextprotocol.io/docs/2026-07-28/develop/clients/client-best-practices); the latter also notes that adding or removing tool definitions mid-conversation invalidates the host's prompt cache, which a list that never changes never does. 2.0.0's `browser_tools` runtime activation toggle mutated the listing mid-connection, which is exactly that anti-pattern; it was **removed in 2.1** and calling it returns `unknown tool: browser_tools`. `search_tools` and `call_tool` do not touch the listing. **How a model reaches a tool that is not listed.** A model can only invoke tools its host puts in front of it, and hosts forward `tools/list`, so an unlisted tool is unreachable for a model without help. That is what the two meta-tools are for: ```json {"tool": "search_tools", "arguments": {"query": "new_page"}} {"tool": "call_tool", "arguments": {"name": "new_page", "arguments": {"claim": true, "label": "demo"}}} ``` **`search_tools {query?, limit?}`** is the catalog and inspect layer: - With no `query` it returns the grouped catalog text below. - A `query` that is an exact tool name returns that one tool. - Any other `query` ranks the available tools by name (strongest), group, and full docs, and returns JSON `{query, total, matches: [{name, group, listed, description, inputSchema}], next}`. `limit` defaults to 8, max 20. The whole 48-tool surface, as `search_tools {}` prints it on the default profile under Chrome: ``` cdp-toolkit 3.0.0 · browser=chrome · 48 tools available, 7 in tools/list (CDP_TOOL_PROFILE=gateway) [partly listed] core (12): list_pages, new_page, close_page, select_page, navigate_page, wait_for, take_snapshot, click, fill, type_text, evaluate_script, take_screenshot [hidden] input (9): hover, drag, scroll, dispatch_mouse, press_key, fill_form, upload_file, focus_emulation, click_focus_gated [hidden] cookies (3): list_cookies, set_cookie, delete_cookies [hidden] network (2): list_network_requests, get_network_request [hidden] console (2): list_console_messages, get_console_message [hidden] mocking (3): mock_request, list_mocks, clear_mocks [hidden] emulation (2): emulate, resize_page [hidden] performance (6): performance_start_trace, performance_stop_trace, performance_analyze_insight, performance_trace, take_heapsnapshot, lighthouse_audit [hidden] recording (2): start_screen_recording, stop_screen_recording [hidden] leases (3): claim_page, release_page, list_leases [hidden] permissions (1): grant_permissions [hidden] dialogs (1): handle_dialog [hidden] downloads (1): wait_for_download [hidden] extraction (1): extract_page Run any tool with call_tool {name, arguments}; search_tools {query:} returns its docs and inputSchema. ``` **`call_tool {name, arguments?}`** unwraps and re-enters the same dispatch as a direct call, so availability errors, lease handling, and error shapes are identical to calling the tool directly. The `lease` is read from the inner `arguments`. It refuses to wrap a meta-tool (`call_tool: 'call_tool' is a meta-tool; call it directly`), requires `name` (``call_tool: `name` is required (find one with search_tools)``), and requires `arguments`, when given, to be an object (``call_tool: `arguments` must be an object``). `bun run gateway:smoke` (`test/gateway-smoke.ts`) proves the lease half against a real browser: without the inner `lease` a claimed tab is refused, with it the call succeeds. **`CDP_TOOL_PROFILE` is the only filter, and it is startup-only** — set once by whoever configures the server, then fixed for the life of the process. Every profile lists `search_tools` and `call_tool` first. Measured over raw stdio at 1e9f25f (bytes = compact JSON of the `tools` array, tokens ≈ bytes ÷ 4): | `CDP_TOOL_PROFILE` | browser | entries in `tools/list` | bytes | ≈ tokens | |---|---|---|---|---| | unset / `gateway` — the 3.0 default | chrome | 7 | 5,072 | ≈1,268 | | unset / `gateway` | firefox | 7 | 5,072 | ≈1,268 | | `gateway,network,console` | chrome | 11 | 7,899 | ≈1,975 | | `core` | chrome | 14 | 9,661 | ≈2,415 | | `core,network,console` | chrome | 18 | 12,488 | ≈3,122 | | `full` | chrome | 50 | 37,911 | ≈9,478 | | `full` | firefox | 37 | 28,899 | ≈7,225 | | 2.7.0 default (`full`, for comparison) | chrome | 49 | 37,346 | ≈9,336 | The default listing is **−86.4% bytes** vs 2.7.0's default. **What you trade away for that:** - **Name-keyed host allowlists and per-tool permission prompts.** A tool reached through `call_tool` shows up to the host as `call_tool`, so a host rule like "allow `click`, deny `evaluate_script`" cannot tell apart the 43 non-gateway tools. If you need per-tool host rules, set `CDP_TOOL_PROFILE=full`. - **Drop-in parity with `chrome-devtools-mcp`'s listing.** Its 29 tool names still exist and are still callable under the same names, but only 5 are *listed* by default. `CDP_TOOL_PROFILE=full` restores a listing with all of them. A `CDP_TOOL_PROFILE` value is `gateway` (the default; also what unset or empty means), `full`, `core`, or a comma-separated group list. A list *without* `gateway` always includes `core` (the 2.x behaviour). A list *with* `gateway` adds only the named groups on top of the 5 gateway tools, e.g. `gateway,network,console`. An unknown group is a startup error: `CDP_TOOL_PROFILE: unknown tool group 'x'. Known: gateway, full, core, input, …`, exit 1. Rules worth knowing: - **Unlisted does not mean blocked.** Only discovery is filtered. Calling an unlisted tool directly by name still works over the wire for SDK and CLI consumers, and `call_tool` reaches it for a model. Only a tool the selected browser cannot run at all is refused by name. - **Profiles, `search_tools`, and `call_tool` are MCP-only.** `cdp search_tools` fails as an unknown tool, and the CLI keeps exposing all 48 tools no matter how `CDP_TOOL_PROFILE` is set. The 48 underlying tools are unchanged. - **`describe_tool` is unlisted but still answered** for 2.x callers, with the same output as 2.x; `describe_tool {}` returns the same catalog as `search_tools {}`. ### MCP protocol eras The server serves **both** eras off the same stdio transport, and the era is pinned per connection by how the client opens: - 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`. - A 2025-era client opens with **`initialize`** and gets `protocolVersion` `2025-11-25`, exactly as before — this upgrade is invisible to it. 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. ## Structured page extraction (`extract_page`) `extract_page` turns a page into **schema-conformant JSON** instead of prose: it grabs the target page's HTML, cleans it (scripts, styles, hidden elements and other non-content noise stripped), and sends it to an OpenAI-compatible `/chat/completions` endpoint along with your JSON Schema, returning JSON that matches the schema — fields you name, not a paragraph you have to parse. A `selector` scopes extraction to one subtree (piercing open shadow roots), so you can extract the article or the table rather than the whole page. **Privacy posture: loopback by default.** The default endpoint is llm-ferry at `http://127.0.0.1:8090/v1` with model `schematron`, so **page content stays on this machine** unless the operator explicitly points `CDP_EXTRACT_BASE_URL` (or the per-call `baseUrl`) somewhere else — that override is an operator decision about where page content may travel, and the tool treats it as one. `CDP_EXTRACT_API_KEY` is never logged and is redacted from every error; `usage` token counts (and cost, when the endpoint reports one) come back inline on every call, so a bloated extraction is visible in the answer, not discovered on a bill. **The schema is the prompt.** Extraction instructions live in the schema's property `description`s — write them like prompts ("the job title, exactly as printed", "ISO date, not 'yesterday'"). A schema whose properties carry no descriptions is rejected up front with an error saying exactly what to fix, because a description-less schema extracts garbage with no hint why. Cleaned HTML over `maxChars` (default 300000) fails with `html_too_large` naming the actual size rather than silently truncating — a half page extracted as though it were whole is a wrong answer that looks right; shrink with `selector` or `clean:"aggressive"`, or raise the cap if you truly need it all. ```json {"tool": "extract_page", "arguments": {"target": "index:0", "schema": {"type": "object", "properties": {"title": {"type": "string", "description": "the page title, exactly as printed"}}}}} ``` **Prompt shape (`prompt`, `CDP_EXTRACT_PROMPT`).** By default (`prompt:"html"`) the cleaned HTML is the *only* user message and your schema rides in `response_format` alone — which is what the **hosted** Schematron API expects, since it injects the schema into the prompt server-side. Point `baseUrl` at a **locally served open-weight Schematron** (e.g. an MLX `schematron8B`) and that breaks: the open weights were fine-tuned on a prompt with the schema *inside* the user message, and a bare-HTML prompt returns garbage even under constrained decoding. `prompt:"schematron"` sends the model card's own messages instead — a `You are a helpful assistant` system message plus a user message carrying the compact `JSON.stringify`'d schema, then the HTML, then `MAKE SURE ITS VALID JSON.` That adds roughly the stringified schema's length to the prompt (`maxChars` caps the **HTML** only; the true count still comes back in `usage.promptTokens`). `schematron` mode sends **no `response_format`** at all — the schema already lives in the prompt, and the client validates only that the response parses as JSON. (Measured 2026-09-12 against a locally served open-weight Schematron-8B under llguidance-constrained decoding: identical messages returned `{"stories": []}` in 6 completion tokens with `response_format` present, versus a valid, schema-conformant 5-story result in 285 tokens with it omitted — constrained decoding on top of an already-inlined schema is redundant and, on real markup, harmful.) The per-call arg wins over `CDP_EXTRACT_PROMPT`, and anything but `html`/`schematron` is rejected before any page or network work. Works on both backends (Chrome and Firefox); see the `extract_page` row in [The tools](#the-tools-29-parity--19-superset--48) for the full parameter set. ## Firefox (WebDriver BiDi) cdp-toolkit ships a second backend, Firefox over [WebDriver BiDi](https://w3c.github.io/webdriver-bidi/), behind the same tool surface. Chrome stays the default and its behavior is unchanged, opt in explicitly to reach Firefox: ```bash cdp --browser firefox take_snapshot # CLI: explicit flag CDP_BROWSER=firefox cdp take_snapshot # CLI: env var (same precedence, lower priority) cdp --capabilities --browser firefox # see what's available and why the rest isn't ``` Backend selection precedence: `--browser chrome|firefox` flag, then `CDP_BROWSER`, then `chrome`. For the MCP server, set `CDP_BROWSER=firefox` (or pass `--browser firefox` in its launch args) in your MCP client config; the backend is fixed for the life of that server process. Firefox runs in one of **two modes**, and the difference between them is process ownership. **LAUNCH (default): a fresh throwaway-profile Firefox, launched and killed per session.** `--browser firefox` with no `--connect`/`CDP_FIREFOX_ENDPOINT` starts a brand-new Firefox process with an empty profile — a login wall for anything that needs a real, already-authenticated session: - **CLI** (one process per invocation): each command launches Firefox, runs exactly one tool call, and disposes the session and kills the process before exiting, win or lose. State does not carry between separate CLI invocations: there is no running Firefox left afterward for a second command to find. - **MCP server** (long-lived): the first Firefox tool call launches one Firefox process and memoizes its BiDi session for the life of the server; every later Firefox call reuses it. The session is torn down on `SIGINT`/`SIGTERM`/stdin close. Multi-step Firefox workflows (navigate, then snapshot, then click) need the MCP server, not the CLI, for exactly this reason. **ATTACH (`--connect ` / `CDP_FIREFOX_ENDPOINT`): connect to a Firefox YOU already started.** Launch a Firefox with its debug port open yourself, and cdp-toolkit connects to that process's BiDi endpoint instead of spawning a throwaway one — so tools see your real, logged-in profile, cookies and all. This process never launches or kills that Firefox: dispose only ends the BiDi session (`session.end`), never the browser. ```bash # 1. Start YOUR Firefox with the debug port open (a separate, empty --no-remote profile # is recommended so it doesn't collide with a Firefox you already have open; drop # -profile/-no-remote to attach to your everyday profile instead, once nothing else # is holding its BiDi session). --marionette enables cdp-toolkit's orphaned-session # auto-recovery over the Marionette side channel; without it, a killed client's # wedged session can only be cleared by restarting Firefox (see "one session" below): firefox --remote-debugging-port 9223 --marionette --no-remote -profile /tmp/ff-attach-profile & # 2. CLI: --connect implies --browser firefox, so it doesn't need to be passed too bun run src/cli.ts --connect 9223 take_snapshot cdp --connect 127.0.0.1:9223 take_snapshot # host:port also works cdp --connect ws://127.0.0.1:9223/session take_snapshot # or the full ws:// URL ``` **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 --target ` 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` → ` --target ` 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. 3. MCP client config: set the env var instead of a flag (the endpoint doesn't belong in `args`): ```json { "mcpServers": { "cdp-toolkit": { "command": "npx", "args": ["-y", "cdp-toolkit"], "env": { "CDP_BROWSER": "firefox", "CDP_FIREFOX_ENDPOINT": "9223" } } } } ``` `` accepts three spellings, all normalized to a ws URL: a bare port (`9223`), `host:port` (`127.0.0.1:9223`), or a full `ws://`/`wss://` URL. `--connect`/`CDP_FIREFOX_ENDPOINT` implies the Firefox backend on its own and errors against an explicit `--browser chrome`. **On Linux, and over SSH to a remote host.** Attach is a plain loopback WebSocket, so it is byte-identical on every OS; only the *launch* half is platform-specific. On Linux the binary is `firefox` on `PATH` (or `/usr/bin/firefox`), and the same `--remote-debugging-port 9223 --marionette` opens the endpoint (keep `--marionette` for the orphan-recovery reason above). One catch worth stating plainly: if your everyday Firefox is already running, relaunching it with the flag just focuses the open window and opens no port (see "Attaching is not relaunching" below). To attach to your real logged-in profile, quit Firefox first and reopen it with the flag; to run beside your daily browser instead, use the separate `--no-remote -profile` instance shown above. When Firefox runs on a different host than cdp-toolkit (a remote box, a container), its debug port binds to loopback *there*, so forward the port and attach to localhost: ```bash ssh -L 9223:127.0.0.1:9223 you@the-box # forward the remote debug port cdp --connect 9223 take_snapshot # then attach as if it were local ``` For Claude Code, register the MCP server with the endpoint in one line (the env var implies the Firefox backend): ```bash claude mcp add cdp-toolkit -e CDP_FIREFOX_ENDPOINT=9223 -- npx -y cdp-toolkit ``` **Firefox allows only one active WebDriver BiDi session at a time.** A second `session.new` against the same endpoint fails outright while a first session is open. Disposing cleanly (the normal exit path — normal exit, `SIGINT`/`SIGTERM`, stdin close) sends `session.end` first, which frees the slot for the next process cross-process. When several cdp-toolkit processes attach to the **same** user Firefox endpoint, they now coordinate that single session slot through a file-based lease (a sibling of the tab-lease mechanism), so a collision no longer hard-wedges both sides: - **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 `` is held by a LIVE process `