Skip to content

Releases: sblattj/cdp-toolkit

v3.0.2

Choose a tag to compare

@github-actions github-actions released this 03 Oct 19:24

Fixed

  • CLI: get_network_request --requestId <id> failed with "no network request matched" for every Fetch/XHR request. CDP mints those ids as <pid>.<n> (e.g. 91775.34), and the CLI coerced any numeric-looking flag value to a number, so the lookup compared a number against the stored string, and 91775.10 was mangled to 91775.1. Flag coercion now follows the tool's schema: a key typed as a string keeps its raw text, and every other key is coerced as before. Verified live on headless Chrome 154: all 6 captured requests resolve by --requestId from both the source and the built CLI; on 3.0.1 the 5 Fetch requests failed and only the hex-id Document request resolved. MCP callers were not affected, since their arguments arrive already typed. (src/cli.ts, test/cli-coerce.test.ts)

v3.0.1

Choose a tag to compare

@github-actions github-actions released this 30 Sep 01:51

Fixed

  • bench:wedge now drives the headless Chrome it spawns (free port, HeadlessChrome check); it previously drove CDP_BASE/9222, so the 2026-09-16 README numbers were not from an isolated instance. Re-measured.

v3.0.0

Choose a tag to compare

@github-actions github-actions released this 30 Sep 00:51

The default MCP tool listing went from 49 tools to 7 (37,346 bytes to 5,072, -86.4%; about 9,336 tokens to about 1,268). With CDP_TOOL_PROFILE unset, tools/list now advertises search_tools, call_tool and five core tools (navigate_page, evaluate_script, take_snapshot, click, fill); every other tool is found with search_tools and run with call_tool. CDP_TOOL_PROFILE=full restores the 2.x listing (plus the two meta-tools). The 48 underlying tools and the cdp CLI are unchanged, and the listing is still static: computed once at startup, listChanged:false, same cache hints.

Changed — BREAKING

  • The default profile is now gateway, not full. Measured tools/list (raw stdio, compact JSON, tokens = bytes/4): 2.7.0 default 49 entries / 37,346 bytes / about 9,336 tokens; 3.0 default 7 entries / 5,072 bytes / about 1,268 tokens (identical on Chrome and Firefox). CDP_TOOL_PROFILE=full is now 50 entries / 37,911 bytes on Chrome (the 2.7.0 listing plus the two meta-tools).
  • describe_tool is no longer listed, but is still answered with 2.x output for back-compat; describe_tool {} returns the same catalog as search_tools {}.
  • What is traded away. A tool reached through call_tool appears to the host as call_tool, so name-keyed host allowlists and per-tool permission prompts (for example "allow click, deny evaluate_script") cannot distinguish the 43 non-gateway tools; set CDP_TOOL_PROFILE=full if you need per-tool host rules. Drop-in parity with chrome-devtools-mcp's listing is also gone by default: its 29 tool names still exist and are callable with the same names, but only 5 are listed unless you set full.
  • Direct calls to unlisted tools by name still work over the wire (SDK and CLI consumers are unaffected). call_tool exists because a model can only invoke tools its host puts in front of it, and hosts forward tools/list.
  • This does not repeat 2.0's browser_tools design, which mutated the listing mid-connection (a spec MUST-NOT); the 3.0 listing never changes. See docs/design/2026-09-29-v3-gateway-default.md.

Added

  • search_tools {query?, limit?}. No query returns the grouped catalog; a query that is an exact tool name returns that one tool; anything else ranks available tools by name (strongest), group, then full docs, returning JSON {query,total,matches:[{name,group,listed,description,inputSchema}],next}. limit defaults to 8, max 20. (src/mcp.ts)

  • call_tool {name, arguments?}. Unwraps and re-enters the same dispatch as a direct call, so availability errors, lease handling (the lease is read from the inner arguments) and error shapes are identical. Refuses to wrap a meta-tool. Verified live on headless Chrome 154 (bun run gateway:smoke): new_page via call_tool {claim:true} mints a lease, navigate_page without it is refused naming the lease holder, with it succeeds, and close_page releases it. (src/mcp.ts)

  • The gateway profile and gateway,<groups> lists. gateway is the default; gateway,network,console adds only the named groups on top of the five gateway tools (11 entries, 7,899 bytes, about 1,975 tokens). A group list without gateway still always includes core, as in 2.x (core: 14 entries, 9,661 bytes; core,network,console: 18 entries, 12,488 bytes). Every profile lists search_tools and call_tool first. Unknown groups exit at startup with CDP_TOOL_PROFILE: unknown tool group 'x'. Known: gateway, full, core, input, .... (src/toolGroups.ts)

  • The server instructions (1,862 chars, under Claude Code's 2 KB cap) now tell the model that only 5 tools are listed, how to find and run the rest, and "No tab yet? call_tool {name:'new_page'}", since none of the five opens a tab.

  • Wedge benchmark (bun run bench:wedge). A runnable, CI-able proof of the core claim: an isolated headless Chrome drives a throwaway page into an endpoint that accepts the connection and never responds, 8 times, and measures the whole stuck-page lifecycle — the wedged navigate rejects at the configured bound (15.03s observed against the default 15s bound), a witness tab keeps answering at healthy latency mid-brick (p95 3ms), and closing the bricked tab plus reopening evaluates at p95 89ms with zero /mcp restarts. Also reports, as an observation rather than a failure, that the stuck page itself stays unresponsive while its load is pending: the blast radius is the tab, not the server. (scripts/wedge-bench.ts)

Migrating from 2.x

  • Do nothing if your host and model can use search_tools plus call_tool.
  • Set CDP_TOOL_PROFILE=full if you rely on per-tool host allowlists or permission rules, or simply want the old listing.
  • SDK and CLI callers that call tools by name are unaffected.

v2.7.0

Choose a tag to compare

@github-actions github-actions released this 16 Sep 00:23

Cross-origin iframes are now discoverable and addressable. A visible out-of-process iframe used to be a silent hole: take_snapshot on the parent page stopped at a bare Iframe line with no hint that an entire interactable document lived behind it, list_pages hid the iframe's target by default, and the only way to drive it was to know that a raw target id from list_pages {all:true} happened to work. Now the snapshot marks the boundary, the docs say the target exists, and a frame: selector addresses it by URL. Verified live against Chrome 153 with a real cross-origin iframe: marker present in both snapshot modes, evaluate_script/take_snapshot drive the frame through both its target id and the new selector, and two iframes with the same URL produce an ambiguity error naming both ids instead of a silent first-match.

Added

  • OOPIF markers in take_snapshot. When an emitted <iframe> node owns an out-of-process (cross-origin) frame, its snapshot line now carries the frame's identity: [4821] Iframe "Checkout" [frame="F405BF…" frameUrl="https://example.com/"]. frame is the frame id, which IS the iframe's CDP target id — feed it straight back as target to any interaction tool. The marker appears in interactiveOnly mode too (an iframe at a process boundary is itself the interactable thing), while same-origin iframes are unchanged: their AX subtrees were already merged into the parent tree. Detection drives off the browser's target list correlated through DOM.getFrameOwner — deliberately not Page.getFrameTree, which omits out-of-process child frames on a page-level attachment (observed Chrome 153) — and is failure-soft end to end: any error in detection degrades to the pre-marker snapshot, never a failed call. (src/cdp/driver.ts, test/iframe-markers.test.ts)
  • frame:<url-substring> target selector (Chrome). Resolves against the browser's out-of-process iframe targets by URL substring, so an iframe is addressable without first correlating raw target ids out of list_pages {all:true}. Follows the worker: arm's rules: an empty needle matches nothing, more than one match is an ambiguity error naming every id+url (never a silent first-match), and a miss lists the live iframes and teaches that only out-of-process iframes appear as targets. Accepted by every tool whose target flows through the shared resolver (evaluate_script, take_snapshot, click, screenshot, …); close_page/select_page/release_page/claim_page refuse it with a page-only message, and Firefox refuses it with unsupported naming the frame.targets capability, same as worker:. A bare iframe target id still resolves everywhere it did before. (src/frames.ts, src/client.ts, src/shared-tools.ts, src/bidi/driver.ts, test/frame-selector.test.ts)

Documented

  • list_pages {all:true} now says it lists out-of-process iframes (not just workers/background pages) and that a returned non-page targetId is accepted as a bare id by any tool's target param — in the tool's deep doc, the manifest, the MCP server instructions, the README tools table and target-grammar bullet, the CLI help grammar, and the CONTRACT grammar rule. The snapshot doc names the cross-origin boundary and the marker. (src/toolDocs.ts, src/mcp.ts, src/manifest.ts, README.md, CONTRACT.md, skills/using-cdp-toolkit/SKILL.md)

Deferred

  • Recursing child-frame accessibility nodes into the parent snapshot is NOT in this release. backendDOMNodeId uids are per-renderer-process, so child-frame uids would collide with parent uids under every interaction tool's DOM.resolveNode — recursion requires session-aware uid namespacing across all nine interaction tools, plus a BiDi-side answer where no AX domain exists. The marker + frame: pair ships the discoverability without that surgery.

v2.6.0

Choose a tag to compare

@github-actions github-actions released this 15 Sep 23:10

Bun is now a first-class, CI-tested runtime, and the MCP server can serve streamable HTTP. The package has always declared engines.bun, but nothing proved the server actually booted under Bun — or under Node, for that matter. This release pins both: the server and CLI run under Bun ≥ 1.1 or Node ≥ 22, importing dist/mcp.js / dist/cli.js as libraries is now silent (no server start, no CLI usage dump), and under Bun the same mcp.js entry can serve the full MCP surface over loopback HTTP with --transport streamable-http for hosts that launch it as an HTTP service instead of a stdio child.

Added

  • --transport streamable-http (Bun only). mcp.js accepts --transport <stdio|streamable-http> (default stdio, byte-identical behavior to previous releases), plus --port (default 3000; 0 picks an OS port, announced on the stderr listening line) and --host (default 127.0.0.1). The HTTP path is built on the MCP SDK 2.0's Web-standard createMcpHandler behind the same buildServer() factory stdio uses, so both transports can never drift apart; loopback host-header and origin validation are enforced in front of it. Launched under Node with --transport streamable-http, the server fails fast with a clear stderr message rather than half-serving. The listening URL goes to stderr only — stdout remains the stdio JSON-RPC channel. (src/mcp.ts)
  • CI runtime job that gates the badge. Boots the BUILT dist/mcp.js under both node (22, matching the engines.node floor) and bun on every push/PR: silent-import checks for dist/mcp.js and dist/cli.js under both runtimes, a hand-rolled stdio JSON-RPC initialize + tools/list handshake under both runtimes (no SDK client, so the wire is what's asserted), and a streamable-http initialize round-trip under Bun. No browser involved, so every failure is a real regression. New bun run test:runtime runs the same suite locally (test/runtime-entry.test.ts, 7 tests). (.github/workflows/ci.yml)

Fixed

  • Importing the entries is now silent under both runtimes. dist/mcp.js and dist/cli.js used to start serving / print usage the moment any bundler or require.resolve-style host imported them. Both files now gate on a direct-run check (realpathSync(process.argv[1]) against import.meta.url, which handles bin symlinks under npm/bunx and works identically under Node and Bun, ESM-only): only direct/bin invocation runs the server or CLI; library import is side-effect free. buildServer is exported from src/mcp.ts for embedders. (src/mcp.ts, src/cli.ts)

v2.5.1

Choose a tag to compare

@github-actions github-actions released this 13 Sep 02:44

Fixed

  • extract_page { prompt: "schematron" } no longer sends response_format. Measured against a locally served open-weight Schematron-8B on mlx_vlm.server (llguidance-constrained decoding): with response_format present, a real CNN zone came back as {"stories": []} in 6 completion tokens; the IDENTICAL messages with response_format omitted returned valid, schema-conformant JSON with 5 stories in 285 tokens. A 3-link toy page worked either way, so the collapse only shows on real markup. The schema already rides inline in the schematron prompt, so constrained decoding on top of it was redundant and, on real pages, actively harmful; response_format is now omitted entirely (never sent as null) in schematron mode, unchanged in html mode. Since nothing constrains the shape server-side anymore in schematron mode, extractPage now also rejects a syntactically valid but non-object JSON response (array/null/primitive) with the same invalid-JSON error shape. One new unit test pins html mode still sending response_format; the existing schematron wire-shape test now asserts its absence (test/extract.test.ts).

v2.5.0

Choose a tag to compare

@github-actions github-actions released this 13 Sep 02:22

Added

  • extract_page { prompt } — a second prompt shape, for locally served open-weight Schematron. The tool used to send the cleaned HTML as the ONLY user message and let response_format: json_schema strict carry the schema; that is right for the HOSTED Schematron API, which injects the schema into the prompt server-side, and wrong for open weights served locally (e.g. an MLX pchamart/schematron8B-mlx-8bit lane), which were fine-tuned on a prompt with the schema INSIDE the user message and return garbage from a bare-HTML prompt even under constrained decoding. prompt:"schematron" sends the inference-net/Schematron-8B model card's own messages verbatim — a You are a helpful assistant system message plus a user message with the compact JSON.stringify'd schema, the HTML, and the MAKE SURE ITS VALID JSON. tail. prompt:"html" (the default) is byte-identical to the previous wire shape; CDP_EXTRACT_PROMPT sets the default and the per-call arg wins over it; anything else is rejected before any page or network work. response_format is still sent in BOTH modes (llguidance-constrained decoding on mlx_vlm honours it, and it is harmless elsewhere). The schema text is charged to the prompt on top of the HTML — maxChars caps the HTML only, and the real number comes back in usage.promptTokens. Five new unit tests pin both wire shapes, the env/arg precedence and the rejection (test/extract.test.ts).

Fixed

  • extract_page names an output-cap truncation for what it is. A completion the endpoint cut off at max_tokens (8192) used to surface as endpoint returned invalid JSON (Unterminated string), which reads as a model or quoting defect and sends the operator debugging the wrong layer. The tool now reads choices[0].finish_reason: length fails with a dedicated message naming the cap, the completion-token count, and the remedy (narrow the payload with selector, or ask the schema for less); any other parse failure carries the finish_reason alongside the excerpt. Two new unit tests pin both paths (test/extract.test.ts, 24).

v2.3.0

Choose a tag to compare

@github-actions github-actions released this 10 Sep 18:54

Drive focus-gated pages on a background tab without stealing OS window focus. Some sites gate a button behind document.hasFocus()/visibilitychange — the canonical case is Anthropic's Claude Code OAuth page (claude.ai/oauth/*), whose Authorize button stays disabled on a background tab, forcing an agent driving claude -p login to keep a human clicking. Two new Chrome-only tools port the mechanism of the danbuhler/claude-code-auto-authorize extension: Emulation.setFocusEmulationEnabled makes the page believe it is focused (hasFocus→true, visibility stays visible, focus events fire) while the real window stays put, and Input.dispatchMouseEvent delivers the trusted click (isTrusted:true) that a content script's synthesized events can never produce.

Added

  • focus_emulation { enabled } — the raw toggle: a tab reports focused/visible with no OS focus change. Call with enabled:false to restore.
  • click_focus_gated { selector|text, timeoutMs, pollMs, keepFocus } — the one-call compose: enable focus emulation, poll until the button (CSS selector or exact visible-text, e.g. "Authorize") exists and is enabled, trusted-click it, restore emulation (always, unless keepFocus, even on throw). Timeout error names the failure mode ("never located" vs "found but stayed disabled").
  • New capability emulate.focus (Chrome declares it; BiDi's emulation module has no focus primitive, so both tools are absent from tools/list under --browser firefox, per ADR-001).
  • New live harness test/focus-emulation-smoke.ts (bun run focus:smoke): a real headless Chrome serves a focus-gated Authorize fixture (disabled until hasFocus()), proves the gate is closed in a background tab (control), then click_focus_gated opens it and lands a trusted click — page log shows gate:unlocked + CLICKED trusted=true with the tab never activated. Plus unit tests test/focus-emulation.test.ts (16) pinning validation, capability gating, and manifest/docs/group registration.

v2.2.0

Choose a tag to compare

@github-actions github-actions released this 05 Sep 01:19

Several agent PROCESSES can now drive tabs of ONE user-launched Firefox at the same time. Firefox serves exactly one WebDriver BiDi session per browser (measured on 153.0.3: a second session.new gets "Maximum number of active sessions", a second WebSocket to /session/<id> is refused at the HTTP upgrade). Previously a second cdp-toolkit process attaching to the same Firefox was refused after the session-slot wait; now the process that wins the slot fronts the one real session with a loopback BiDi multiplexer, and every other process joins it instead of waiting. Measured (bun run firefox:multi:smoke, headless Firefox 153.0.3, 4 agents × 6 rounds): before, 4 server processes attached to one Firefox — 1 of 4 completed, the other 3 refused after the session wait; after, all 4 completed concurrently (40 calls, wall 437ms vs serial 1375ms, 4 distinct processes in flight), a killed server process left 3 survivors finishing with 0 retries, and a killed daemon was replaced within the recovery path with every survivor finishing on its original tab via the stale-id hint (firefox:coord:smoke 11/11, bun test 929 pass). See docs/design/2026-09-04-firefox-multi-agent-session-mux.md.

Added

  • src/bidi/mux.ts — a zero-dependency loopback WebSocket server that speaks BiDi over one real upstream session: answers session.new/end/subscribe/unsubscribe/status locally, forwards every other command on the single upstream connection, fans events out per client.
  • src/bidi/mux-daemon.ts (built to dist/bidi/mux-daemon.js, a fourth scripts/build.ts entrypoint) — a detached daemon that owns the real BiDi session and hosts the mux, so the session outlives every client process. The first process to attach an endpoint spawns it; every process, including the spawner, joins it. It exits on its own after CDP_FIREFOX_MUX_IDLE_MS (default 15000ms) with no clients, when Firefox goes away, or on SIGTERM/SIGINT/SIGHUP. Diagnostics: <CDP_ARTIFACT_DIR or /tmp/cdp-toolkit>/ff-mux-<endpoint>.log.
  • CDP_FIREFOX_MUX (off | host | daemon, default daemon when attaching and host when launching), CDP_FIREFOX_MUX_IDLE_MS (default 15000), CDP_FIREFOX_MUX_DAEMON (absolute path override of the daemon script; tests only).
  • A stale-target-id hint. Firefox assigns new browsing-context ids whenever its BiDi session is re-created (measured: true even after a clean session.end), so if the daemon dies and a new one takes over, ids issued before the handover are stale. The driver's no-such-target error now names this and tells the agent to re-resolve the tab with list_pages (url:/title: selectors) and claim it again.
  • src/bidi/session-lease.ts: SessionSlotRecord gains muxEndpoint; acquireSessionSlot returns joinMux immediately when a live holder advertises one, instead of polling out to SessionSlotBusyError; new advertiseMux() and muxDaemonLogFile().
  • New live harness test/firefox-multi-agent-smoke.ts (bun run firefox:multi:smoke): N agent PROCESSES each claim their own tab of one shared headless Firefox, navigate, write a marker, and run several rounds of isolated reads, so a call landing in another tab fails; every call is timestamped so concurrency is measured, not assumed. Also covers a SIGKILL of one server process (survivors finish with 0 retries) and a SIGKILL of the daemon itself (survivors hit the stale-target hint, re-resolve, and finish on the same tab).
  • New unit tests test/bidi-mux.test.ts, test/mux-daemon.test.ts, plus additions to test/session-lease.test.ts and test/session-coord.test.ts.

Changed

  • test/firefox-session-coord-smoke.ts: scenarios 1–4 are now pinned to CDP_FIREFOX_MUX=off (they test the no-mux refusal/orphan-recovery semantics specifically); a new scenario 5 covers the mux default, asserting a live holder is a detached daemon, a second process joins it instead of waiting, the daemon survives its spawner exiting, and a killed daemon's slot is recovered by the existing orphan-steal + Marionette force-clear path.

Fixed

  • evaluate_script.args now declares items: {}. It was the only array property in tools/list without an items schema, and Gemini function declarations reject exactly that shape: a host that forwarded the full tool list to a Gemini model (opencode through an OpenRouter Gemini lane, 135 tools) got no response headers at all until the gateway timed the request out. Every other array in the listing already carried items; the live bun src/mcp.ts tools/list now shows none missing.

v2.1.0

Choose a tag to compare

@github-actions github-actions released this 02 Sep 02:39

Re-based on the MCP 2026-07-28 revision: tools/list is now one complete, deterministic, cacheable listing that never changes, and the runtime browser_tools activation toggle 2.0.0 shipped three days ago is removed. The honest headline on cost: the default listing grows from ≈2,457 to ≈8,471 tokens (46 entries, 33,886 bytes, measured over raw stdio against the real entry point), because a standards-conformant list must be complete and static — still −58% against 1.x's ≈20,200. CDP_TOOL_PROFILE=core keeps the lean ≈2,266-token surface (13 entries, 9,066 bytes, −89% vs 1.x) for hosts that eagerly load every schema. Tool names, parameters, and schema shapes are unchanged, now gated by a durable wire-vs-manifest deep-equal check rather than the one-off strip-descriptions comparison 1.10.0 and 2.0.0 used. No new runtime dependency.

Added

  • The 2026-07-28 protocol era, actually served. src/mcp.ts connects through serveStdio (dual-era, default legacy: 'serve') instead of Server.connect(new StdioServerTransport()). A modern client that opens with server/discover gets supportedVersions: ["2026-07-28"], resultType: "complete", and the _meta['io.modelcontextprotocol/serverInfo'] envelope; a 2025-era client that opens with initialize still negotiates 2025-11-25 and sees no difference at all. The era is pinned per connection, and the server logs which one it pinned to stderr.
  • Cache hints on the listing. cacheHints now declares ttlMs: 3600000 + cacheScope: "public" for both tools/list and server/discover (emitted on modern-era connections), instead of the SDK's conservative { ttlMs: 0, cacheScope: 'private' } default. The listing is identical for every caller — a stdio server has no per-caller auth — and a restart under a different CDP_TOOL_PROFILE or --browser is a different server configuration, not a mid-life change to this one.
  • describe_tool now doubles as the catalog. Its name parameter became optional; called with no name it returns the grouped catalog of every tool the selected browser supports — a header line (cdp-toolkit 2.1.0 · browser=chrome · 45 tools available, 46 in tools/list (CDP_TOOL_PROFILE=full)), then one [listed]/[hidden] <group> (n): names line per group, then Unlisted tools are callable by name; describe_tool {name} documents any of them. The whole 45-tool catalog is 1,141 characters.
  • CDP_TOOL_PROFILE accepts a comma-separated group list on top of full and core — e.g. core,network,console (17 entries, 11,893 bytes, ≈2,973 tokens). core is always included, so a list can never strand the basics, and the label is canonicalised to group order so equivalent spellings read the same. An unknown group name is now a configuration error rather than a silent fallback: the server prints CDP_TOOL_PROFILE: unknown tool group 'x'. Known: full, core, input, … and exits 1.
  • src/version.ts — the VERSION constant moved out of src/mcp.ts into its own module, and a test asserts it equals package.json's version. The two drifted apart between 1.11.0 and 2.0.0; that can no longer happen quietly.
  • test/mcp-protocol.test.ts — raw-stdio protocol coverage: server/discover and tools/list field shapes, the cache hints, byte-identical deterministic ordering across repeated calls and connections, the wire-vs-manifest deep-equal gate (the durable replacement for the one-off strip-descriptions gate), the legacy initialize handshake, a -32022 negative control, each profile, and proof that an unlisted tool is still callable. test/tool-profile.test.ts covers resolveProfile directly.

Changed

  • The tool listing is static. It is computed once at startup — describe_tool first, then manifest order, filtered by backend availability and then by the profile — frozen, and returned as the same array to every tools/list on every connection. capabilities.tools.listChanged is now false; nothing will ever notify, because nothing ever changes. The 2026-07-28 revision makes it a MUST that a server's tool set not change as a side effect of other requests on the connection, and the client best-practices page notes that adding or removing tool definitions mid-conversation invalidates the host's prompt cache.
  • CDP_TOOL_PROFILE default flipped core → full. Three reasons, in order of weight: (1) the standard puts lazy discovery on the host — the client runs its own catalog → inspect → execute funnel — and a host that defers schemas (Claude Code lists MCP tool names and loads schemas on demand) pays little for a complete list; (2) consumers that hold per-tool permission allowlists, or per-tool interception keyed on the tool NAME, for non-core tools (the console and network readers, performance_analyze_insight) were silently broken by a core default, because those tools were simply absent from tools/list; (3) a list that never changes never invalidates the prompt cache. core remains a first-class choice for hosts that eagerly load every schema — it is now a deployment decision, not a default imposed on everyone.
  • Host protocol eras, MEASURED 2026-09-01. Two real hosts were pointed at the 2.1 server through a wrapper that tees its stderr: Claude Code 2.1.258 and Codex CLI 0.147.0 both connected and both logged connection pinned to the legacy protocol era — today they still open with initialize even though their binaries carry the 2026-07-28 client strings (capability present, not exercised by default). The SDK 2.0.0 Client does the same unless versionNegotiation opts in. So the dual-era default is load-bearing, not courtesy: a modern-only server would be unreachable from every host measured. The modern era is currently exercised by the raw-stdio test suite and by mcp:smoke's pinned client. The static listing pays off on either era; the ttlMs/cacheScope hints are emitted only on modern connections.
  • The server instructions block trades the activation walkthrough for how to read a fixed, cacheable listing (tools/list is complete and fixed for this process's life — cache it; describe_tool {name} / describe_tool {}; profile semantics; unlisted tools stay callable). 2,019 → 1,976 characters, still under the 2 KB cap.
  • SDK usage, deliberately unchanged in shape. The server stays on the low-level Server with string-method setRequestHandler('tools/list', …), which SDK 2.0 still supports for advanced use cases (and which McpServer itself is built on; McpServerFactory explicitly accepts a Server). McpServer.registerTool was evaluated and rejected on two grounds: it wants a Standard-Schema (zod) object for inputSchema, and CONTRACT.md rule 1 forbids any runtime dependency beyond the SDK — our manifest is plain JSON Schema — and it cannot express "unlisted tools remain callable by name", which is the whole point of CDP_TOOL_PROFILE.
  • test/mcp-smoke.ts additionally connects with the SDK client pinned to 2026-07-28 and asserts the modern era end to end, alongside the existing legacy handshake and live tools/call round trip.
  • Under CDP_BROWSER=firefox the default listing is 35 entries (26,533 bytes, ≈6,633 tokens): the 34 tools Firefox's BiDi surface supports, plus describe_tool.

Removed

  • browser_tools — the list_groups/activate/deactivate/set_profile meta-tool, removed three days after 2.0.0 shipped it. Mutating the advertised tool set at runtime and firing notifications/tools/list_changed is precisely what the 2026-07-28 revision forbids (the tool set MUST NOT change as a side effect of other requests) and what the client best-practices page warns invalidates the host's prompt cache. Its open question — "does any given client actually re-fetch on listChanged?" — becomes moot once the list is static. Calling it now returns unknown tool: browser_tools; nothing else needs it, because every tool it could have activated is either already listed under the new full default or reachable by name under a narrower profile, and describe_tool {} covers what list_groups reported.

Fixed

  • v2.0.0 was not on the current protocol revision, despite running on the 2.0 SDK. Probed raw over stdio, it answered server/discover with -32601 Method not found and served a tools/list carrying no resultType, ttlMs, or cacheScope: a 2025-11-25 (legacy-era) server, because Server.connect(new StdioServerTransport()) is the legacy path. The "2026-07-28" literal that appears inside the SDK packages is SUPPORTED_MODERN_PROTOCOL_VERSIONS in @modelcontextprotocol/server/stdio — the modern era is reachable only through serveStdio (or the HTTP createMcpHandler), never through connect(). Both eras are now served.