cdp-toolkit
A lightweight, drop-in alternative to 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). 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.

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 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 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
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):
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.enablehang 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. Firefox is supported here, as a first-class backend (see 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:
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). 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:
# 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__<tool>. 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:
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 <tool> …`
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.
MCP client setup
Either let cdp install register it for you (pick claude at the harness prompt), or wire it by hand:
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.
Add to your MCP config (e.g. ~/.cursor/mcp.json):
{
"mcpServers": {
"cdp-toolkit": { "command": "npx", "args": ["-y", "cdp-toolkit"] }
}
}
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_requestarms a persistent per-target fake backend: return canned responses, force errors, or inject latency/fault rates. Mocks survive reloads and navigations untilclear_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, cacheabletools/list(≈1.3k tokens, −86% vs 2.7.0's default) and serves the rest throughsearch_tools(find a tool, read its docs andinputSchema) andcall_tool(run it);CDP_TOOL_PROFILE=fulllists all of them. The CLI exposes all 48 regardless. They coexist withchrome-devtools-mcpin 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.captureScreenshotdoes 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_screenshotmeasures 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-capturescale, andrenderWidth/renderHeightto shoot one capture at an emulated viewport and restore the tab afterwards. - Many agents, one browser, no stolen tabs.
claim_pagehands 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_pageandlist_leasescan reporthumanActiveMsand 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
localStorageviaevaluate_scriptor an httpOnly session cookie vialist_cookies, takes asavePaththat 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_pagesreportsorigin: "agent"(with the creatinglabel) 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:<substr> | title:<substr> | label:<name> | <targetId>, plusframe:<substr>for an out-of-process iframe, Chrome-only) and attaches a single WebSocket to just that page, so a busy tab you did not name is never touched. - A tool hangs forever and never returns → the MCP's eager
Network.enableon a busy or hung renderer is a known wedge. cdp-toolkit enables domains lazily, only where a tool needs them (the recorder enablesNetwork/Runtime/Log; most tools touch onlyPage/Runtime/DOM). - No way to bound a slow call →
CdpConnection.send()enforcesCDP_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
uidis a CDPbackendDOMNodeId, resolved on demand viaDOM.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.
# 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 anAccess-Control-Allow-Originheader 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 eachmock_requestis its own process, so use--reload trueto apply-and-observe within the single invocation.
CLI usage
# Run any tool by its MCP name; args come from --json and/or --key value flags.
cdp <tool> [--target <sel>] [--json '<obj>'] [--<key> <value> ...]
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 '<obj>' merges a JSON object into the args (applied first). --target <sel> 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":"<message>"} 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, <tool> --help used to run the tool instead. With no tool named, cdp --help prints the usage above. With a tool named, cdp <tool> --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
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
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 and 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:
{"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
queryit returns the grouped catalog text below. - A
querythat is an exact tool name returns that one tool. - Any other
queryranks the available tools by name (strongest), group, and full docs, and returns JSON{query, total, matches: [{name, group, listed, description, inputSchema}], next}.limitdefaults 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:<name>} 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):
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_toolshows up to the host ascall_tool, so a host rule like "allowclick, denyevaluate_script" cannot tell apart the 43 non-gateway tools. If you need per-tool host rules, setCDP_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=fullrestores 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_toolreaches it for a model. Only a tool the selected browser cannot run at all is refused by name. - Profiles,
search_tools, andcall_toolare MCP-only.cdp search_toolsfails as an unknown tool, and the CLI keeps exposing all 48 tools no matter howCDP_TOOL_PROFILEis set. The 48 underlying tools are unchanged. describe_toolis unlisted but still answered for 2.x callers, with the same output as 2.x;describe_tool {}returns the same catalog assearch_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/discoverand getsprotocolVersion2026-07-28,resultType: "complete", the_meta['io.modelcontextprotocol/serverInfo']envelope, and thettlMs/cacheScopecache hints on bothserver/discoverandtools/list. - A 2025-era client opens with
initializeand getsprotocolVersion2025-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 descriptions — 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.
{"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 for the full parameter set.
Firefox (WebDriver BiDi)
cdp-toolkit ships a second backend, Firefox over WebDriver BiDi, behind the same tool surface. Chrome stays the default and its behavior is unchanged, opt in explicitly to reach Firefox:
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 <port|host:port|ws-url> / 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.
# 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 <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 15000ms after the last call) or indefinitely while any MCP server keeps a client joined to it.
- MCP client config: set the env var instead of a flag (the endpoint doesn't belong in
args):
{
"mcpServers": {
"cdp-toolkit": {
"command": "npx",
"args": ["-y", "cdp-toolkit"],
"env": { "CDP_BROWSER": "firefox", "CDP_FIREFOX_ENDPOINT": "9223" }
}
}
}
<endpoint> 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:
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):
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(default10000ms) 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. - An orphaned session from a dead holder — the classic wedge, a client
SIGKILLed or crashed withoutsession.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 blindWebDriver:DeleteSessionon port2828,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-portalone 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.
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:
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.jsin 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 isSIGKILLed). The daemon exits on its own once nobody is joined to it (CDP_FIREFOX_MUX_IDLE_MS, default15000ms with zero clients, also counted from its own startup), when Firefox goes away, or onSIGTERM/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.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.off: restores the pre-mux behavior exactly — no mux hosted, none joined, two live processes serialize on the slot as described above.
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.
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.
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.
Tool availability is filtered per backend, not per call: tools/list (MCP) and --list/--capabilities (CLI) only ever advertise a tool the selected browser can actually run. A tool is never listed and then thrown from at call time. On the MCP server a second filter composes on top: a tool is listed only when the backend can run it and the startup profile includes it — backend availability first, CDP_TOOL_PROFILE second (see Progressive disclosure). Under Firefox, six capability areas are absent because Firefox 153's BiDi implementation has no equivalent domain:
performance_start_trace,performance_stop_trace,performance_analyze_insight,performance_trace(needstrace.performance)take_heapsnapshot(needsheap.snapshot)lighthouse_audit(needsaudit.lighthouse)start_screen_recording,stop_screen_recording(needscapture.screencast): BiDi has no streamed-frame primitive at all, only the one-shotbrowsingContext.captureScreenshot.dispatch_mouse(needsinput.raw): the raw move/down/up primitive is a directInput.dispatchMouseEventwrapper with no BiDi analogue.wait_for_download,grant_permissions(needbrowser.downloads/browser.permissions): both drive Chrome'sBrowser.*domain; WebDriver BiDi has no command to redirect a download or pre-grant a permission.
Everything else, including mock_request/list_mocks/clear_mocks (Firefox's network.addIntercept covers the same fake-backend use case as Chrome's Fetch domain), the claim_page/release_page/list_leases lease group, the extract_page extraction tool (its page work runs entirely through the driver's evaluate, no CDP-only capability), and the new scroll tool (Chrome dispatches Input.dispatchMouseEvent{type:'mouseWheel'}, Firefox uses BiDi's wheel input source — both live-verified), is available under both backends: 35 of the 48 tools. Under CDP_BROWSER=firefox the MCP server's CDP_TOOL_PROFILE=full listing is therefore 37 entries — those 35 plus search_tools and call_tool — while the 3.0 default (gateway) lists 7 under either browser; the profile filter applies after this backend filter. One asymmetry to know before expecting Chrome-style concurrency: the lease group fences tabs on both backends, but Firefox permits only one BiDi session per browser instance, so multiple agent PROCESSES under Firefox share that one session rather than opening independent ones the way Chrome's unlimited CDP connections allow — coordinated by the cross-process session lease and, by default, joined through the BiDi multiplexer above, so sharing no longer means waiting a turn — see "Parallel tabs" below.
Honest capability gaps, not oversold parity:
- No accessibility tree.
take_snapshotunder Chrome reads a native a11y tree (Accessibility.getFullAXTree). Firefox 153's BiDi has no equivalent domain, so the Firefox snapshot is a DOM-heuristic walk that infers roles from tag/attribute conventions. It is good enough to find and click things; it is not a substitute for a real accessibility audit. - No atomic text insert. Chrome's
fill/type_textcommit a value in oneInput.insertTextcall. BiDi has no equivalent primitive, so Firefox always synthesizes real per-character keystrokes viainput.performActions(one<select>exception: an exact-match value/index assignment, since typeahead-by-first-letter cannot reliably commit an arbitrary option). - Thin emulation. Only viewport size/DPR and
userAgentare applied. CPU throttling, media-feature emulation (e.g.prefers-color-scheme), and network-condition throttling are not available: Firefox 153 does not implement the underlying BiDi commands. - No tracing, heap snapshots, or Lighthouse. See the capability list above; there is no BiDi equivalent for any of the three.
locate.textis not available (Firefox 153'sbrowsingContext.locateNodesrejects theinnerTextlocator type as unsupported), unlike Chrome, which has it viaDOM.performSearch.- No modifier-key clicks.
click'smodifiers(Alt/Control/Meta/Shift) is a Chrome-only parameter on an otherwise-universal tool: a non-emptymodifiersarray throws under--browser firefoxrather than being silently dropped. A plain click still works on both backends. - No real HTML5 drag-and-drop mode.
drag'smode:"html5"requires capabilityinput.html5Dragand is rejected with a clear error under Firefox;mode:"mouse"(the default) works on both. - No per-capture screenshot
scale; band tiling is a Chrome-only workaround Firefox has no use for. Two parameters on an otherwise-universal tool, for two different reasons.scale(screenshot.scale) is a genuine protocol gap:browsingContext.captureScreenshothas no scale parameter at all, and the refusal points atemulate {deviceScaleFactor}+ a scale-1 capture instead — Firefox captures are always 1x.tile:true(screenshot.tile) is not a Firefox gap: measured against Firefox 153.0.3,take_screenshot --fullPage trueon a 20,000px-tall page returned one 1366×20000 PNG at scale 1 from a single BiDicaptureScreenshot(origin:"document")call — verified complete top-to-bottom (a marker at y=0, a marker at y=20000, an unbroken ruler through the middle, no truncation). Chrome's banding exists solely to route around its 16384-device-px encode cap; Firefox has no such cap, so there is nothing for tiling to work around, and auto-tiling correctly never fires there — a FirefoxfullPagecapture is already the whole page in one shot, however long.renderWidth/renderHeight(screenshot.renderSize) are available on both backends — that one is not a gap.
The tools (29 parity + 19 superset = 48)
This table is the full view — all 48 tools, which is what cdp --list and the MCP server's CDP_TOOL_PROFILE=full listing show (the MCP default lists only 7; see Progressive disclosure). They are partitioned into 14 static profile groups: core (12: list_pages, new_page, close_page, select_page, navigate_page, wait_for, take_snapshot, click, fill, type_text, evaluate_script, take_screenshot), input (9: hover, drag, scroll, dispatch_mouse, press_key, fill_form, upload_file, focus_emulation, click_focus_gated), cookies (3: list/set/delete_cookies), network (2: list/get_network_request), console (2: list/get_console_message), mocking (3: mock_request, list_mocks, clear_mocks), emulation (2: emulate, resize_page), performance (6: the four trace tools, take_heapsnapshot, lighthouse_audit), recording (2: start/stop_screen_recording), leases (3: claim_page, release_page, list_leases), permissions (1: grant_permissions), dialogs (1: handle_dialog), downloads (1: wait_for_download), extraction (1: extract_page). CDP_TOOL_PROFILE=core narrows the MCP listing to the first group; any group it leaves out stays callable by name. See Progressive disclosure.
The 29 parity tools are 1:1 with chrome-devtools-mcp; the 19 superset tools (performance_trace, the list_cookies/set_cookie/delete_cookies cookie group, the mock_request/list_mocks/clear_mocks group, the claim_page/release_page/list_leases lease group, the start_screen_recording/stop_screen_recording screen-recording pair, scroll, dispatch_mouse, wait_for_download, grant_permissions, the focus_emulation/click_focus_gated focus pair, and extract_page) are toolkit additions. Each row notes the underlying CDP method(s) and the precise parity gaps.
Driving MV3 extensions
An MV3 extension's logic lives in a background service worker, not a page, so chrome.storage.local, chrome.runtime, and everything else the extension owns is unreachable through a page-typed selector. Four tools accept target: "worker:<substring>" (Chrome only, capability worker.targets) — evaluate_script, and, as of 1.9.1, list_network_requests, get_network_request, and list_console_messages — any substring of the worker's url, so worker:<extension-id> and worker:background.js both work:
{"tool": "evaluate_script", "arguments": {"target": "worker:ekgaohljhieodkfggjkfgmmamfpngdhn", "expression": "chrome.storage.local.get()", "args": []}}
A few facts worth knowing before you reach for it, all measured against a real browser rather than assumed:
- An idle MV3 worker is evicted within seconds of its last activity, and once evicted it is invisible to every target listing — not "stopped and still visible," genuinely absent from
/json/listandTarget.getTargetsunder any filter. So aworker:miss almost always means "asleep," not "wrong id," and the error says so. wakedefaults totrue: a selector that matches nothing, or a known-but-stopped worker, is started for you first, then re-resolved. Passwake:falseto fail fast instead — the error then teaches the eviction fact rather than reading like a typo.- A wake is verified by re-reading the target list, never by the start command's own result — Chrome's start command reports success even for a scope that doesn't exist, so nothing here trusts it.
- Worker targets bypass the lease gate entirely.
claim_page/strict mode never mint a lease for a worker-targeted call, on any of the four tools; a worker id is Chrome's to create and destroy, not this toolkit's to fence. - If you're loading your own unpacked extension for testing,
--load-extensiondoes nothing on recent Chrome, even with the documented unblock flag. UseExtensions.loadUnpackedover CDP