Repository navigation
Releases: sblattj/cdp-toolkit
Release list
v3.0.2
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, and91775.10was mangled to91775.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--requestIdfrom 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
Fixed
bench:wedgenow 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
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, notfull. Measuredtools/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=fullis now 50 entries / 37,911 bytes on Chrome (the 2.7.0 listing plus the two meta-tools). describe_toolis no longer listed, but is still answered with 2.x output for back-compat;describe_tool {}returns the same catalog assearch_tools {}.- What is traded away. A tool reached through
call_toolappears to the host ascall_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; setCDP_TOOL_PROFILE=fullif 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 setfull. - Direct calls to unlisted tools by name still work over the wire (SDK and CLI consumers are unaffected).
call_toolexists because a model can only invoke tools its host puts in front of it, and hosts forwardtools/list. - This does not repeat 2.0's
browser_toolsdesign, which mutated the listing mid-connection (a spec MUST-NOT); the 3.0 listing never changes. Seedocs/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}.limitdefaults 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 (theleaseis read from the innerarguments) and error shapes are identical. Refuses to wrap a meta-tool. Verified live on headless Chrome 154 (bun run gateway:smoke):new_pageviacall_tool {claim:true}mints a lease,navigate_pagewithout it is refused naming the lease holder, with it succeeds, andclose_pagereleases it. (src/mcp.ts) -
The
gatewayprofile andgateway,<groups>lists.gatewayis the default;gateway,network,consoleadds only the named groups on top of the five gateway tools (11 entries, 7,899 bytes, about 1,975 tokens). A group list withoutgatewaystill always includescore, as in 2.x (core: 14 entries, 9,661 bytes;core,network,console: 18 entries, 12,488 bytes). Every profile listssearch_toolsandcall_toolfirst. Unknown groups exit at startup withCDP_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 wedgednavigaterejects 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/mcprestarts. 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_toolspluscall_tool. - Set
CDP_TOOL_PROFILE=fullif 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
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/"].frameis the frame id, which IS the iframe's CDP target id — feed it straight back astargetto any interaction tool. The marker appears ininteractiveOnlymode 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 throughDOM.getFrameOwner— deliberately notPage.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 oflist_pages {all:true}. Follows theworker: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 whosetargetflows through the shared resolver (evaluate_script, take_snapshot, click, screenshot, …);close_page/select_page/release_page/claim_pagerefuse it with a page-only message, and Firefox refuses it withunsupportednaming theframe.targetscapability, same asworker:. 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'stargetparam — 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.
backendDOMNodeIduids are per-renderer-process, so child-frame uids would collide with parent uids under every interaction tool'sDOM.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
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.jsaccepts--transport <stdio|streamable-http>(defaultstdio, byte-identical behavior to previous releases), plus--port(default 3000;0picks 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-standardcreateMcpHandlerbehind the samebuildServer()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
runtimejob that gates the badge. Boots the BUILTdist/mcp.jsunder bothnode(22, matching theengines.nodefloor) andbunon every push/PR: silent-import checks fordist/mcp.jsanddist/cli.jsunder both runtimes, a hand-rolled stdio JSON-RPCinitialize+tools/listhandshake under both runtimes (no SDK client, so the wire is what's asserted), and a streamable-httpinitializeround-trip under Bun. No browser involved, so every failure is a real regression. Newbun run test:runtimeruns 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.jsanddist/cli.jsused to start serving / print usage the moment any bundler orrequire.resolve-style host imported them. Both files now gate on a direct-run check (realpathSync(process.argv[1])againstimport.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.buildServeris exported fromsrc/mcp.tsfor embedders. (src/mcp.ts,src/cli.ts)
v2.5.1
Fixed
extract_page { prompt: "schematron" }no longer sendsresponse_format. Measured against a locally served open-weight Schematron-8B onmlx_vlm.server(llguidance-constrained decoding): withresponse_formatpresent, a real CNN zone came back as{"stories": []}in 6 completion tokens; the IDENTICAL messages withresponse_formatomitted 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_formatis now omitted entirely (never sent asnull) inschematronmode, unchanged inhtmlmode. Since nothing constrains the shape server-side anymore inschematronmode,extractPagenow also rejects a syntactically valid but non-object JSON response (array/null/primitive) with the same invalid-JSON error shape. One new unit test pinshtmlmode still sendingresponse_format; the existing schematron wire-shape test now asserts its absence (test/extract.test.ts).
v2.5.0
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 letresponse_format: json_schema strictcarry 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 MLXpchamart/schematron8B-mlx-8bitlane), 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 theinference-net/Schematron-8Bmodel card's own messages verbatim — aYou are a helpful assistantsystem message plus a user message with the compactJSON.stringify'd schema, the HTML, and theMAKE SURE ITS VALID JSON.tail.prompt:"html"(the default) is byte-identical to the previous wire shape;CDP_EXTRACT_PROMPTsets the default and the per-call arg wins over it; anything else is rejected before any page or network work.response_formatis 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 —maxCharscaps the HTML only, and the real number comes back inusage.promptTokens. Five new unit tests pin both wire shapes, the env/arg precedence and the rejection (test/extract.test.ts).
Fixed
extract_pagenames an output-cap truncation for what it is. A completion the endpoint cut off atmax_tokens(8192) used to surface asendpoint returned invalid JSON (Unterminated string), which reads as a model or quoting defect and sends the operator debugging the wrong layer. The tool now readschoices[0].finish_reason:lengthfails with a dedicated message naming the cap, the completion-token count, and the remedy (narrow the payload withselector, or ask the schema for less); any other parse failure carries thefinish_reasonalongside the excerpt. Two new unit tests pin both paths (test/extract.test.ts, 24).
v2.3.0
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 withenabled:falseto 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, unlesskeepFocus, 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 fromtools/listunder--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 untilhasFocus()), proves the gate is closed in a background tab (control), thenclick_focus_gatedopens it and lands a trusted click — page log showsgate:unlocked+CLICKED trusted=truewith the tab never activated. Plus unit teststest/focus-emulation.test.ts(16) pinning validation, capability gating, and manifest/docs/group registration.
v2.2.0
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: answerssession.new/end/subscribe/unsubscribe/statuslocally, forwards every other command on the single upstream connection, fans events out per client.src/bidi/mux-daemon.ts(built todist/bidi/mux-daemon.js, a fourthscripts/build.tsentrypoint) — 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 afterCDP_FIREFOX_MUX_IDLE_MS(default 15000ms) with no clients, when Firefox goes away, or onSIGTERM/SIGINT/SIGHUP. Diagnostics:<CDP_ARTIFACT_DIR or /tmp/cdp-toolkit>/ff-mux-<endpoint>.log.CDP_FIREFOX_MUX(off|host|daemon, defaultdaemonwhen attaching andhostwhen launching),CDP_FIREFOX_MUX_IDLE_MS(default15000),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'sno-such-targeterror now names this and tells the agent to re-resolve the tab withlist_pages(url:/title:selectors) and claim it again. src/bidi/session-lease.ts:SessionSlotRecordgainsmuxEndpoint;acquireSessionSlotreturnsjoinMuximmediately when a live holder advertises one, instead of polling out toSessionSlotBusyError; newadvertiseMux()andmuxDaemonLogFile().- 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 totest/session-lease.test.tsandtest/session-coord.test.ts.
Changed
test/firefox-session-coord-smoke.ts: scenarios 1–4 are now pinned toCDP_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.argsnow declaresitems: {}. It was the only array property intools/listwithout anitemsschema, 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 carrieditems; the livebun src/mcp.tstools/listnow shows none missing.
v2.1.0
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.tsconnects throughserveStdio(dual-era, defaultlegacy: 'serve') instead ofServer.connect(new StdioServerTransport()). A modern client that opens withserver/discovergetssupportedVersions: ["2026-07-28"],resultType: "complete", and the_meta['io.modelcontextprotocol/serverInfo']envelope; a 2025-era client that opens withinitializestill negotiates2025-11-25and 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.
cacheHintsnow declaresttlMs: 3600000+cacheScope: "public"for bothtools/listandserver/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 differentCDP_TOOL_PROFILEor--browseris a different server configuration, not a mid-life change to this one. describe_toolnow doubles as the catalog. Itsnameparameter 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): namesline per group, thenUnlisted tools are callable by name; describe_tool {name} documents any of them.The whole 45-tool catalog is 1,141 characters.CDP_TOOL_PROFILEaccepts a comma-separated group list on top offullandcore— e.g.core,network,console(17 entries, 11,893 bytes, ≈2,973 tokens).coreis 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 printsCDP_TOOL_PROFILE: unknown tool group 'x'. Known: full, core, input, …and exits 1.src/version.ts— theVERSIONconstant moved out ofsrc/mcp.tsinto its own module, and a test asserts it equalspackage.json'sversion. 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/discoverandtools/listfield 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 legacyinitializehandshake, a-32022negative control, each profile, and proof that an unlisted tool is still callable.test/tool-profile.test.tscoversresolveProfiledirectly.
Changed
- The tool listing is static. It is computed once at startup —
describe_toolfirst, then manifest order, filtered by backend availability and then by the profile — frozen, and returned as the same array to everytools/liston every connection.capabilities.tools.listChangedis nowfalse; 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_PROFILEdefault flippedcore→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 acoredefault, because those tools were simply absent fromtools/list; (3) a list that never changes never invalidates the prompt cache.coreremains 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 withinitializeeven though their binaries carry the 2026-07-28 client strings (capability present, not exercised by default). The SDK 2.0.0Clientdoes the same unlessversionNegotiationopts 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 bymcp:smoke's pinned client. The static listing pays off on either era; thettlMs/cacheScopehints are emitted only on modern connections. - The server
instructionsblock trades the activation walkthrough for how to read a fixed, cacheable listing (tools/listis 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
Serverwith string-methodsetRequestHandler('tools/list', …), which SDK 2.0 still supports for advanced use cases (and whichMcpServeritself is built on;McpServerFactoryexplicitly accepts aServer).McpServer.registerToolwas evaluated and rejected on two grounds: it wants a Standard-Schema (zod) object forinputSchema, andCONTRACT.mdrule 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 ofCDP_TOOL_PROFILE. test/mcp-smoke.tsadditionally connects with the SDK client pinned to2026-07-28and asserts the modern era end to end, alongside the existing legacy handshake and livetools/callround trip.- Under
CDP_BROWSER=firefoxthe default listing is 35 entries (26,533 bytes, ≈6,633 tokens): the 34 tools Firefox's BiDi surface supports, plusdescribe_tool.
Removed
browser_tools— thelist_groups/activate/deactivate/set_profilemeta-tool, removed three days after 2.0.0 shipped it. Mutating the advertised tool set at runtime and firingnotifications/tools/list_changedis 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 onlistChanged?" — becomes moot once the list is static. Calling it now returnsunknown tool: browser_tools; nothing else needs it, because every tool it could have activated is either already listed under the newfulldefault or reachable by name under a narrower profile, anddescribe_tool {}covers whatlist_groupsreported.
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/discoverwith-32601 Method not foundand served atools/listcarrying noresultType,ttlMs, orcacheScope: a 2025-11-25 (legacy-era) server, becauseServer.connect(new StdioServerTransport())is the legacy path. The"2026-07-28"literal that appears inside the SDK packages isSUPPORTED_MODERN_PROTOCOL_VERSIONSin@modelcontextprotocol/server/stdio— the modern era is reachable only throughserveStdio(or the HTTPcreateMcpHandler), never throughconnect(). Both eras are now served.