cdp-toolkit
v2.7.0
Published
A lightweight chrome-devtools-mcp alternative: an MCP server + CLI that drives the tabs you name, one target per call over one direct WebSocket, with a bounded timeout on every call, so a stuck page can't wedge your agent or force a /mcp restart. Chrome o
Downloads
1,471
Maintainers
Keywords
Readme
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, all of them on one static, cacheable MCP listing — or a lean 12-tool core listing when your client loads every schema eagerly (see Progressive disclosure). 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
| | 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__*.
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 installIt asks for a harness (claude | codex | opencode), a debug port, and a browser (arc | chrome | firefox), 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_pagesThat'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.
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-tripKey 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 publishes all of them on one static, cacheabletools/list(≈9.3k tokens, −54% vs 1.x) and serves the full per-tool prose on demand throughdescribe_tool;CDP_TOOL_PROFILE=coretrims that listing to a 12-tool everyday set (≈2.3k tokens) for clients that eagerly load every schema, and the trimmed-away tools stay callable by name. The CLI exposes all 48 regardless. They coexist 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 trueCross-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, 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
| 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 | full | MCP server only. Startup-only filter on what tools/list advertises, read once and fixed for the life of the process. full (the default, and what unset/empty means) lists every group; core lists just the 12 everyday tools plus describe_tool; or a comma-separated group list, e.g. core,network,console (core is always included). An unknown group name is a configuration error: the server prints CDP_TOOL_PROFILE: unknown tool group 'x'. Known: full, core, input, … and exits 1. Tools the profile leaves out stay callable by name. See "Progressive disclosure" below. |
| 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
The MCP server publishes one complete, deterministic, cacheable tools/list: describe_tool first, then every tool the selected browser can actually run whose group the startup profile advertises, in manifest order. It is computed once at startup and frozen — byte-identical on every call, on every connection, and unchangeable by anything a client does mid-session — and on a 2026-era connection it carries cache hints (ttlMs: 3600000, cacheScope: "public") so a client can hold it for an hour instead of re-fetching. capabilities.tools.listChanged is false: nothing will ever notify, because nothing ever changes.
That shape is deliberate. The MCP 2026-07-28 revision puts lazy discovery on the host side — the client runs its own catalog → inspect → execute funnel across the servers it has connected — and a server cooperates by publishing a complete, deterministic, cacheable list rather than by mutating its own. The revision makes it a MUST that a server's tool set not change as a side effect of other requests on the connection. See Build an MCP server 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 was exactly that anti-pattern and is removed in 2.1: calling it now returns unknown tool: browser_tools.
describe_tool is the inspect layer. The listing carries terse one-liners; the full description and per-parameter docs load on demand, for any tool — listed or not:
{"tool": "describe_tool", "arguments": {"name": "wait_for_download"}}
{"tool": "describe_tool", "arguments": {}}With no name it returns the grouped catalog of everything this server can run. The whole 48-tool surface:
cdp-toolkit 2.5.1 · browser=chrome · 48 tools available, 49 in tools/list (CDP_TOOL_PROFILE=full)
[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
[listed] input (9): hover, drag, scroll, dispatch_mouse, press_key, fill_form, upload_file, focus_emulation, click_focus_gated
[listed] cookies (3): list_cookies, set_cookie, delete_cookies
[listed] network (2): list_network_requests, get_network_request
[listed] console (2): list_console_messages, get_console_message
[listed] mocking (3): mock_request, list_mocks, clear_mocks
[listed] emulation (2): emulate, resize_page
[listed] performance (6): performance_start_trace, performance_stop_trace, performance_analyze_insight, performance_trace, take_heapsnapshot, lighthouse_audit
[listed] recording (2): start_screen_recording, stop_screen_recording
[listed] leases (3): claim_page, release_page, list_leases
[listed] permissions (1): grant_permissions
[listed] dialogs (1): handle_dialog
[listed] downloads (1): wait_for_download
[listed] extraction (1): extract_page
Unlisted tools are callable by name; describe_tool {name} documents any of them.Under a narrower profile the groups it leaves out read [hidden] instead of [listed], and the header's second count drops accordingly.
CDP_TOOL_PROFILE is the only filter, and it is startup-only — set once by whoever configures the server, then fixed for the life of the process. Measured over raw stdio (bytes = compact JSON of the tools array, tokens ≈ bytes ÷ 4):
| CDP_TOOL_PROFILE | entries in tools/list | bytes | ≈ tokens |
|---|---|---|---|
| unset / full — the default | 49 | 37,088 | ≈9,272 |
| core | 13 | 9,077 | ≈2,269 |
| core,network,console | 17 | 11,904 | ≈2,976 |
| full under CDP_BROWSER=firefox | 36 | 28,076 | ≈7,019 |
For comparison, 1.x advertised 45 tools with full prose at roughly 20,200 tokens. So the default listing is −54% vs 1.x, and core is −89%.
The default flipped from core (2.0.0) to full in 2.1 for three reasons: the standard puts discovery on the host, and a host that defers schemas — Claude Code lists MCP tool names and loads schemas on demand — pays little for a complete list; consumers that hold per-tool allowlists or per-tool interception keyed on the tool name for non-core tools (the console and network readers, performance_analyze_insight) were silently broken by a core default, because those tools were simply absent from tools/list; and a list that never changes never invalidates the host's prompt cache. Keep CDP_TOOL_PROFILE=core if your client eagerly loads every schema — that is where the ≈2.3k-token surface is worth the round trips.
Three rules worth knowing:
- Unlisted does not mean blocked. Only discovery is filtered.
tools/callchecks backend availability, not group membership, so an agent that names an unlisted-but-available tool still executes it; only a tool the selected browser cannot run at all is refused by name. describe_toolworks for any tool by name, unlisted ones included — the full description and per-parameter docs behind the terse one-linertools/listcarries.- Profiles and
describe_toolare MCP-only. They are MCP-server concepts:cdp describe_toolfails withunknown tool 'describe_tool', and the CLI keeps exposing all 48 tools no matter howCDP_TOOL_PROFILEis set.
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'tBackend 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:// URLA 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 localFor 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-toolkitFirefox 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 its group is in the startup profile — 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 default (full) listing is therefore 36 entries — those 35 plus describe_tool — and a narrower CDP_TOOL_PROFILE trims it further, the profile filter applying after this backend filter. One asymmetry to know before expecting Chrome-style concurrency: the lease group fences tabs on both backends, but Firefox permits only one BiDi session per browser instance, so multiple agent PROCESSES under Firefox share that one session rather than opening independent ones the way Chrome's unlimited CDP connections allow — coordinated by the cross-process session lease and, by default, joined through the BiDi multiplexer above, so sharing no longer means waiting a turn — see "Parallel tabs" below.
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-profile view — all 48 tools, which is what cdp --list and the MCP server's default listing (CDP_TOOL_PROFILE=full) both show. They are partitioned into 14 static profile groups: core (12: list_pages, new_page, close_page, select_page, navigate_page, wait_for, take_snapshot, click, fill, type_text, evaluate_script, take_screenshot), input (9: hover, drag, scroll, dispatch_mouse, press_key, fill_form, upload_file, focus_emulation, click_focus_gated), cookies (3: list/set/delete_cookies), network (2: list/get_network_request), console (2: list/get_console_message), mocking (3: mock_request, list_mocks, clear_mocks), emulation (2: emulate, resize_page), performance (6: the four trace tools, take_heapsnapshot, lighthouse_audit), recording (2: start/stop_screen_recording), leases (3: claim_page, release_page, list_leases), permissions (1: grant_permissions), dialogs (1: handle_dialog), downloads (1: wait_for_download), extraction (1: extract_page). CDP_TOOL_PROFILE=core narrows the MCP listing to the first group; any group it leaves out stays callable by name. See Progressive disclosure.
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.
| MCP name | CDP method(s) | Parity notes / gaps |
|---|---|---|
| list_pages | GET /json/list | all flag also exposes worker/background and out-of-process (OOPIF) iframe targets; MCP lists only page tabs. A non-page targetId from this listing is accepted by any tool's target param as a bare id, and take_snapshot marks a cross-origin iframe node with frame:"<targetId>" (input inside such an iframe is invisible to the activity beacon — see "Staleness" below — but the frame itself is fully driveable as its own target). Each row additionally carries origin (agent or unknown, never human) plus label/createdAt for tabs this toolkit created, and, for a tab under an active lease of this backend, a lease:{label,pid,idleMs,expiresAt,stale} field (unconditional on probe). probe:true pings each page-type target's renderer (one bounded 500ms Runtime.evaluate, never more) and adds responsive:boolean plus humanActiveMs where that round trip found human-attributed input; a wedged/unreachable tab reports responsive:false, never an error for the whole call. Under CDP_REQUIRE_LEASE also reaps abandoned agent tabs first (destructively, only once a lease is CDP_REAP_GRACE_MS past its TTL — see "Staleness" below), reporting closures in an additive reaped array. |
| new_page | Target.createTarget (+ lease file) | Returns {targetId,url}; does not await navigation (use navigate_page). claim:true also claims the new tab and returns a lease token (label/ttlMs optional). Under CDP_REQUIRE_LEASE the tab is claimed and a lease returned even without claim:true. |
| close_page | Target.closeTarget (+ lease file) | Reports success:true on the empty result newer Chromium returns. A successful close also releases that tab's lease; a failed close leaves it in place. |
| select_page | Target.activateTarget + selected-state file | Writes a flat-file selected target; resolveTarget does not read it, so active still means index:0 unless a tool opts in. |
| navigate_page | Page.navigate / Page.reload + load events, or history:'back'\|'forward' (Chrome: Page.getNavigationHistory + Page.navigateToHistoryEntry; Firefox: browsingContext.traverseHistory) | Returns {url,frameId,waitedFor} (no auto-snapshot; a history move also returns traversed:'back'\|'forward'). waitUntil supports load/domcontentloaded. reload:true (+ ignoreCache:true for a hard reload). url/reload/history are mutually exclusive; going back from the first entry (or forward from the last) is an error naming the direction, never a silent no-op. Works on both backends. |
| wait_for | Runtime.evaluate (poll innerText) | Text-substring waiting only; throws on timeout rather than returning {found:false}. |
| evaluate_script | Runtime.evaluate / callFunctionOn | No live page/element handle; args are plain JSON. Main-world context only. Toolkit addition: optional savePath writes the value to a JSON file and keeps it out of the response entirely. If expression is missing/empty and the call instead carries function/code/js/script/fn/body, the error names the wrong key and points at expression. One of four tools that also accept target: "worker:<substring>" (Chrome only, capability worker.targets) to reach an MV3 extension's background service worker — see "Driving MV3 extensions" below. |
| list_cookies (superset) | Network.getCookies | Reads the target page's cookie store, httpOnly cookies included, which document.cookie and therefore evaluate_script cannot see. Page-scoped on purpose, not the browser-wide jar (Storage.getCookies), so it answers for the tab you named. Optional domain/name filters; optional savePath writes the array to a JSON file and returns {path,bytes,count,target} with no cookie value in the response. |
| set_cookie (superset) | Network.setCookie | Writes one cookie, httpOnly and secure ones included, which document.cookie cannot create. Either url or domain is required and the call is refused with an error when neither is given. Chrome's success:false refusal is raised as an error rather than reported as a write. Answers {set:true,target} and never echoes the value back. path is passed through as given, never defaulted. |
| delete_cookies (superset) | Network.deleteCookies | Removes the named cookie, httpOnly ones included. Requires name plus url or domain, so a name-only call cannot sweep the store; optional path narrows further. Answers {deleted:true,target} with no count, because neither protocol reports one; read list_cookies before and after for a real count. |
| take_snapshot | Accessibility.getFullAXTree | uid is the raw backendDOMNodeId (stateless, non-sequential). Full tree in one shot; frames flattened. interactiveOnly is a toolkit addition. |
| click | Input.dispatchMouseEvent | No implicit auto-wait/retry; resolves and acts once; re-snapshot between steps. clickCount:3 triple-clicks (selects a paragraph/line in most editors). modifiers (Alt/Control/Meta/Shift) holds keys for the press and release, e.g. a shift-click — Chrome-only param: a non-empty array throws under --browser firefox. |
| hover | Input.dispatchMouseEvent (mouseMoved) | Same single-shot model as click. |
| scroll (superset) | Chrome: Input.dispatchMouseEvent{type:'mouseWheel'}; Firefox: BiDi's wheel input source | Anchor at uid/selector/x+y, or the viewport center if all three are omitted; an element anchor is scrolled into view first. At least one of deltaX/deltaY is required (positive deltaY scrolls down, positive deltaX scrolls right — wheel convention). Returns {x,y,deltaX,deltaY,target}. Works on both backends. |
| drag | Chrome: Input.dispatchMouseEvent (press→move→release), or mode:"html5": Input.setInterceptDrags + Input.dispatchDragEvent | mode:"mouse" (default) sends a synthetic mouse drag; Chrome does turn this into a real HTML5 drag too, but which dragenter/dragover/drop events reach the page depends on where the interpolated pointer path lands — at the default steps:2, a standard HTML5 drop zone (preventDefault inside dragover) sees zero dragover events and refuses the drop. mode:"html5" replays the page's own drag data as dragEnter/dragOver/drop exactly at the destination, so it works regardless of pointer path — Chrome-only (input.html5Drag), rejected with a clear error under Firefox. steps (default 2) sets the interpolated-move count for mouse mode. Destination is to:{uid\|selector\|x,y} or a new by:{dx,dy} offset (sliders, map panning); exactly one of to/by is required. |
| dispatch_mouse (superset) | Input.dispatchMouseEvent (one event per call) | The raw primitive: dispatch exactly one move/down/up at absolute viewport coordinates (x/y required on every call — CDP has no notion of a "current pointer position"). Compose your own move/down/move/up sequences for anything a physical mouse can do that click/drag's fixed sequences can't: canvas drag-painting, marquee/rubber-band selection, a custom-hit-testing widget. Takes the same button/clickCount/modifiers as click. Chrome-only (input.raw): absent from tools/list under --browser firefox, never present-and-throwing. |
| focus_emulation (superset) | Emulation.setFocusEmulationEnabled | Makes the tab believe it is focused (document.hasFocus()→true, visibility stays visible, focus events fire) while the real OS window stays put — no focus theft. The primitive that unlocks focus-gated UI (a button a site disables unless its tab is focused, e.g. the Claude Code OAuth Authorize button) on a background tab. Call with enabled:false to restore. Chrome-only (emulate.focus): BiDi's emulation module has no focus primitive, so absent from tools/list under --browser firefox. |
| click_focus_gated (superset) | Emulation.setFocusEmulationEnabled + Input.dispatchMouseEvent | The one-call compose: enable focus emulation, poll until the button (CSS selector or exact visible-text match, e.g. "Authorize") exists and is enabled, deliver a trusted click (isTrusted:true — a content script's synthesized events can never satisfy a focus gate), then restore emulation (always, unless keepFocus, even on throw). Timeout error names the failure mode ("never located" vs "found but stayed disabled"). Same Chrome-only gap as focus_emulation. |
| fill | Input.insertText | Atomic paste-like commit, not per-character keystrokes. |
| fill_form | per field: callFunctionOn + insertText | Array of {uid|selector,value}; same insertText caveat. |
| type_text | Input.insertText | Appends (does not clear first); insertText, not per-key. |
| press_key | Input.dispatchKeyEvent | Curated named-key table + single chars; not the full Puppeteer KeyInput enum. |
| upload_file | DOM.setFileInputFiles | Requires a resolvable <input type=file> (uid or selector). |
| take_screenshot | Page.captureScreenshot (+ Page.getLayoutMetrics on every capture) | Full-page uses captureBeyondViewport + a layout-metrics clip. scale (>0, ≤8, Chrome-only) multiplies output pixels for one capture: output px = ceil(css × scale × devicePixelRatio), and the page is never told (devicePixelRatio/innerWidth are unmoved). renderWidth+renderHeight (both backends, required together) emulate a viewport for one capture and restore it after — media queries flip, so this is how you shoot a responsive page at 1920×1080 from a tab that isn't. Chrome cannot encode past 16384 device px per side and does not refuse politely there, so past the cap the capture is taken as vertical bands and stitched losslessly into one PNG (tile, auto by default; tiled/bands on the result): a 140,982 CSS px page returns 2780×281964 px in 18 bands instead of hanging and wedging the tab. Banding is vertical only (an over-wide projection is refused, not split), PNG-only, and never with returnBase64; content the page loads only on real scroll renders blank past the first viewport. width/height are decoded from the encoded bytes and omitted when undecodable. |
| start_screen_recording (superset) | Page.startScreencast / Page.screencastFrame / Page.screencastFrameAck | Toolkit addition; chrome-devtools-mcp has no screen-recording tool. Opens a persistent per-target connection and spools frames to a ledger on disk; pairs with stop_screen_recording in the SAME process, for the same cross-process reason as performance_start_trace. ffmpeg is probed here so a missing encoder fails before a recording is captured. Chrome only: absent from tools/list under --browser firefox (needs capture.screencast, which BiDi has no primitive for). |
| stop_screen_recording (superset) | Page.stopScreencast (+ ffmpeg encode) | Assembles the spooled frames into an H.265 (hevc_videotoolbox, falling back to h264_videotoolbox → libx265 → libx264) MP4 using per-frame durations from the capture ledger, coalesced onto ffmpeg's 40ms concat-demuxer grid. Returns `{path,bytes,durationMs,frameCount,encodedFr
