cdp-toolkit 2.7.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,33 @@ All notable changes to cdp-toolkit are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [3.0.0] - 2026-09-29
9
+
10
+ **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.
11
+
12
+ ### Changed — BREAKING
13
+
14
+ - **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).
15
+ - **`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 {}`.
16
+ - **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`.
17
+ - 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`.
18
+ - 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`.
19
+
20
+ ### Added
21
+
22
+ - **`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`)
23
+ - **`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`)
24
+ - **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`)
25
+ - 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.
26
+
27
+ - **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`)
28
+
29
+ ### Migrating from 2.x
30
+
31
+ - **Do nothing** if your host and model can use `search_tools` plus `call_tool`.
32
+ - **Set `CDP_TOOL_PROFILE=full`** if you rely on per-tool host allowlists or permission rules, or simply want the old listing.
33
+ - **SDK and CLI callers** that call tools by name are unaffected.
34
+
8
35
  ## [2.7.0] - 2026-09-15
9
36
 
10
37
  **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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # cdp-toolkit
2
2
 
3
- **A lightweight, drop-in alternative to [`chrome-devtools-mcp`](https://github.com/ChromeDevTools/chrome-devtools-mcp) that won't wedge your agent.** It drives the Chrome tabs you point it at over the raw DevTools Protocol: any number of tabs, one explicitly named target per call over one direct socket, with a bounded timeout on every call, so a stuck page returns a clean error instead of hanging your agent and forcing a `/mcp` restart. Same idea, no all-target fan-out, plus tab leases so several agents can work one browser (and know when a human is using it too), plus a built-in network-mocking fake backend. **48 tools, 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](#progressive-disclosure)). Chrome is the flagship default; a second backend drives Firefox over WebDriver BiDi behind the same tool surface (see below).
3
+ **A lightweight, drop-in alternative to [`chrome-devtools-mcp`](https://github.com/ChromeDevTools/chrome-devtools-mcp) that won't wedge your agent.** It drives the Chrome tabs you point it at over the raw DevTools Protocol: any number of tabs, one explicitly named target per call over one direct socket, with a bounded timeout on every call, so a stuck page returns a clean error instead of hanging your agent and forcing a `/mcp` restart. Same idea, no all-target fan-out, plus tab leases so several agents can work one browser (and know when a human is using it too), plus a built-in network-mocking fake backend. **48 tools, behind a 7-entry static, cacheable MCP listing** (5 everyday tools plus `search_tools` and `call_tool` to find and run the rest); `CDP_TOOL_PROFILE=full` lists all of them (see [Progressive disclosure](#progressive-disclosure)). Chrome is the flagship default; a second backend drives Firefox over WebDriver BiDi behind the same tool surface (see below).
4
4
 
5
5
  > For AI-agent developers and Claude Code / Cursor users who need **the tabs they name driven reliably**, not a Puppeteer-managed browser.
6
6
 
@@ -40,6 +40,21 @@ If [`chrome-devtools-mcp`](https://github.com/ChromeDevTools/chrome-devtools-mcp
40
40
 
41
41
  **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__*`.
42
42
 
43
+ ## Measured, not marketed: the wedge benchmark
44
+
45
+ Every number above is runnable. `bun run bench:wedge` spawns an isolated headless Chrome, points a throwaway page at an endpoint that accepts the connection and never responds, and measures the full stuck-page lifecycle (re-run with `CDP_TIMEOUT_MS=3000` for a ~60s sample; defaults below use the 15s bound):
46
+
47
+ | Phase | Measured (2026-09-16, Chrome 153, isolated headless, 8 wedges) |
48
+ |---|---|
49
+ | Healthy `evaluate_script` (50×) | p50 **1ms**, p95 **2ms** |
50
+ | Wedged `navigate` → dead endpoint (8×) | **rejected at p50 15.03s, max 15.03s** — the configured bound, never a hang |
51
+ | Witness tab evaluated mid-brick (8×) | p95 **3ms** — other tabs never stall |
52
+ | Recovery: close bricked tab + reopen + evaluate (8×) | p95 **89ms** |
53
+ | `/mcp` restarts · server restarts | **0** · **0** |
54
+
55
+ One honest caveat the benchmark also reports: while a navigation to a dead endpoint is pending, *that page* stays unresponsive to new sessions — the blast radius is the tab, not the server. Closing it is a browser-level command (2ms) and a fresh page answers at healthy latency; your other tabs, and the MCP server, never noticed. Exit code is nonzero unless every wedge rejected in-bound and every recovery landed under 1s, so the benchmark is CI-able.
56
+
57
+
43
58
  ## Is this for you?
44
59
 
45
60
  **Yes, if you:**
@@ -89,6 +104,8 @@ npx -y --package cdp-toolkit cdp navigate_page --target index:0 --url https://ex
89
104
 
90
105
  **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.
91
106
 
107
+ **Upgrading from 2.x:** the MCP server's default `tools/list` went from 49 entries to 7 (the 5 gateway tools plus `search_tools` and `call_tool`); the other tools are still there, reached through `call_tool`. Set `CDP_TOOL_PROFILE=full` to get the old listing back. The CLI is unchanged. See [Progressive disclosure](#progressive-disclosure).
108
+
92
109
  ### MCP client setup
93
110
 
94
111
  <details>
@@ -129,7 +146,7 @@ bun run mcp:smoke # spawn the server + a real initialize/tools-list/tools-call
129
146
 
130
147
  - **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.
131
148
  - **Network mocking: build the UI before the backend exists.** `mock_request` arms a persistent per-target fake backend: return canned responses, force errors, or inject latency/fault rates. Mocks survive reloads and navigations until `clear_mocks`.
132
- - **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, cacheable `tools/list` (≈9.3k tokens, −54% vs 1.x) and serves the full per-tool prose on demand through `describe_tool`; `CDP_TOOL_PROFILE=core` trims 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 with `chrome-devtools-mcp` in a separate namespace.
149
+ - **Full chrome-devtools-mcp parity + extras.** All 29 upstream tools, plus `performance_trace` (a robust single-call trace), Lighthouse audits, heap snapshots, a cookie group that reads, writes, and deletes httpOnly cookies, real HTML5 drag-and-drop, raw scroll/mouse dispatch, download capture, permission grants, and tab-to-video screen recording. The MCP server lists 7 of them by default on one static, cacheable `tools/list` (≈1.3k tokens, −86% vs 2.7.0's default) and serves the rest through `search_tools` (find a tool, read its docs and `inputSchema`) and `call_tool` (run it); `CDP_TOOL_PROFILE=full` lists all of them. The CLI exposes all 48 regardless. They coexist with `chrome-devtools-mcp` in a separate namespace.
133
150
  - **A whole page is a file, not a wedged tab.** Chrome cannot encode a screenshot past 16384 device px on either side, and past that `Page.captureScreenshot` does not error — it never answers, and leaves the tab resized to the clip it was capturing. On a ratio-2 display that ceiling arrives at about 8192 CSS px: an ordinary long article. `take_screenshot` measures the projection before every capture and, past the cap, takes the page as vertical bands stitched losslessly into one PNG — a 140,982 CSS px page comes back as a 2780×281964 file in 18 bands, in 17 seconds, with the tab still healthy. Also per-capture `scale`, and `renderWidth`/`renderHeight` to shoot one capture at an emulated viewport and restore the tab afterwards.
134
151
  - **Many agents, one browser, no stolen tabs.** `claim_page` hands out an opaque lease token for one tab; every other tool checks it at target resolution, so an unqualified call against a leased tab is refused by name rather than silently retargeted to whatever tab a different agent is driving.
135
152
  - **Knows when a human is already using the tab you're driving.** An in-page activity beacon distinguishes a person's clicks/keys/scrolls from the toolkit's own dispatched input, so `claim_page` and `list_leases` can report `humanActiveMs` and warn on contention instead of silently fighting someone for the keyboard. See "Staleness: is a human already using this tab?" below.
@@ -230,7 +247,7 @@ await TOOLS.navigate_page({ target: "index:0", url: "https://example.com" });
230
247
  | `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. |
231
248
  | `CDP_LEASE_TTL_MS` | `900000` | How long a tab lease survives without use before another agent can reclaim it. Refreshed on every checked call. |
232
249
  | `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. |
233
- | `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. |
250
+ | `CDP_TOOL_PROFILE` | `gateway` | MCP server only. Startup-only filter on what `tools/list` advertises, read once and fixed for the life of the process. `gateway` (the default, and what unset/empty means) lists 7 tools: `search_tools`, `call_tool`, and the 5 gateway tools (`navigate_page`, `evaluate_script`, `take_snapshot`, `click`, `fill`); `full` lists every group; `core` lists the 12 everyday tools plus `search_tools` and `call_tool`; or a comma-separated group list, e.g. `gateway,network,console` (adds only the named groups on top of the gateway tools) or `core,network,console` (a list without `gateway` always includes `core`). An unknown group name is a configuration error: the server prints `CDP_TOOL_PROFILE: unknown tool group 'x'. Known: gateway, full, core, input, …` and exits 1. Tools the profile leaves out stay callable through `call_tool` (or by name). See "Progressive disclosure" below. |
234
251
  | `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. |
235
252
  | `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. |
236
253
  | `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. |
@@ -240,58 +257,75 @@ await TOOLS.navigate_page({ target: "index:0", url: "https://example.com" });
240
257
 
241
258
  ## Progressive disclosure
242
259
 
243
- 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.
260
+ As of 3.0 the MCP server's default `tools/list` has **7 entries**: the 5 gateway tools (`navigate_page`, `evaluate_script`, `take_snapshot`, `click`, `fill`) plus two meta-tools, `search_tools` and `call_tool`. The other 43 tools are still there; a model finds and runs them through the two meta-tools. Wire order is `search_tools`, `call_tool`, then the gateway tools in manifest order.
244
261
 
245
- 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](https://modelcontextprotocol.io/docs/2026-07-28/develop/build-server) and [Client best practices](https://modelcontextprotocol.io/docs/2026-07-28/develop/clients/client-best-practices); the latter also notes that adding or removing tool definitions mid-conversation invalidates the host's prompt cache — which a list that never changes never does. 2.0.0's `browser_tools` runtime activation toggle was exactly that anti-pattern and is **removed in 2.1**: calling it now returns `unknown tool: browser_tools`.
262
+ The listing is still **static**. It is computed once at startup and frozen: byte-identical on every call, on every connection, and unchangeable by anything a client does mid-session. On a 2026-era connection it carries cache hints (`ttlMs: 3600000`, `cacheScope: "public"`) so a client can hold it for an hour instead of re-fetching, and `capabilities.tools.listChanged` is `false`: nothing will ever notify, because nothing ever changes. What 3.0 changed is the default profile, not that design.
246
263
 
247
- **`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:
264
+ That shape is deliberate. The MCP **2026-07-28** revision puts lazy discovery on the *host* side, and a server cooperates by publishing a complete, deterministic, cacheable list rather than by mutating its own. The revision makes it a MUST that a server's tool set not change as a side effect of other requests on the connection. See [Build an MCP server](https://modelcontextprotocol.io/docs/2026-07-28/develop/build-server) and [Client best practices](https://modelcontextprotocol.io/docs/2026-07-28/develop/clients/client-best-practices); the latter also notes that adding or removing tool definitions mid-conversation invalidates the host's prompt cache, which a list that never changes never does. 2.0.0's `browser_tools` runtime activation toggle mutated the listing mid-connection, which is exactly that anti-pattern; it was **removed in 2.1** and calling it returns `unknown tool: browser_tools`. `search_tools` and `call_tool` do not touch the listing.
265
+
266
+ **How a model reaches a tool that is not listed.** A model can only invoke tools its host puts in front of it, and hosts forward `tools/list`, so an unlisted tool is unreachable for a model without help. That is what the two meta-tools are for:
248
267
 
249
268
  ```json
250
- {"tool": "describe_tool", "arguments": {"name": "wait_for_download"}}
251
- {"tool": "describe_tool", "arguments": {}}
269
+ {"tool": "search_tools", "arguments": {"query": "new_page"}}
270
+ {"tool": "call_tool", "arguments": {"name": "new_page", "arguments": {"claim": true, "label": "demo"}}}
252
271
  ```
253
272
 
254
- With no `name` it returns the grouped catalog of everything this server can run. The whole 48-tool surface:
273
+ **`search_tools {query?, limit?}`** is the catalog and inspect layer:
274
+
275
+ - With no `query` it returns the grouped catalog text below.
276
+ - A `query` that is an exact tool name returns that one tool.
277
+ - Any other `query` ranks the available tools by name (strongest), group, and full docs, and returns JSON `{query, total, matches: [{name, group, listed, description, inputSchema}], next}`. `limit` defaults to 8, max 20.
278
+
279
+ The whole 48-tool surface, as `search_tools {}` prints it on the default profile under Chrome:
255
280
 
256
281
  ```
257
- cdp-toolkit 2.5.1 · browser=chrome · 48 tools available, 49 in tools/list (CDP_TOOL_PROFILE=full)
258
- [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
259
- [listed] input (9): hover, drag, scroll, dispatch_mouse, press_key, fill_form, upload_file, focus_emulation, click_focus_gated
260
- [listed] cookies (3): list_cookies, set_cookie, delete_cookies
261
- [listed] network (2): list_network_requests, get_network_request
262
- [listed] console (2): list_console_messages, get_console_message
263
- [listed] mocking (3): mock_request, list_mocks, clear_mocks
264
- [listed] emulation (2): emulate, resize_page
265
- [listed] performance (6): performance_start_trace, performance_stop_trace, performance_analyze_insight, performance_trace, take_heapsnapshot, lighthouse_audit
266
- [listed] recording (2): start_screen_recording, stop_screen_recording
267
- [listed] leases (3): claim_page, release_page, list_leases
268
- [listed] permissions (1): grant_permissions
269
- [listed] dialogs (1): handle_dialog
270
- [listed] downloads (1): wait_for_download
271
- [listed] extraction (1): extract_page
272
- Unlisted tools are callable by name; describe_tool {name} documents any of them.
282
+ cdp-toolkit 3.0.0 · browser=chrome · 48 tools available, 7 in tools/list (CDP_TOOL_PROFILE=gateway)
283
+ [partly listed] core (12): list_pages, new_page, close_page, select_page, navigate_page, wait_for, take_snapshot, click, fill, type_text, evaluate_script, take_screenshot
284
+ [hidden] input (9): hover, drag, scroll, dispatch_mouse, press_key, fill_form, upload_file, focus_emulation, click_focus_gated
285
+ [hidden] cookies (3): list_cookies, set_cookie, delete_cookies
286
+ [hidden] network (2): list_network_requests, get_network_request
287
+ [hidden] console (2): list_console_messages, get_console_message
288
+ [hidden] mocking (3): mock_request, list_mocks, clear_mocks
289
+ [hidden] emulation (2): emulate, resize_page
290
+ [hidden] performance (6): performance_start_trace, performance_stop_trace, performance_analyze_insight, performance_trace, take_heapsnapshot, lighthouse_audit
291
+ [hidden] recording (2): start_screen_recording, stop_screen_recording
292
+ [hidden] leases (3): claim_page, release_page, list_leases
293
+ [hidden] permissions (1): grant_permissions
294
+ [hidden] dialogs (1): handle_dialog
295
+ [hidden] downloads (1): wait_for_download
296
+ [hidden] extraction (1): extract_page
297
+ Run any tool with call_tool {name, arguments}; search_tools {query:<name>} returns its docs and inputSchema.
273
298
  ```
274
299
 
275
- Under a narrower profile the groups it leaves out read `[hidden]` instead of `[listed]`, and the header's second count drops accordingly.
300
+ **`call_tool {name, arguments?}`** unwraps and re-enters the same dispatch as a direct call, so availability errors, lease handling, and error shapes are identical to calling the tool directly. The `lease` is read from the inner `arguments`. It refuses to wrap a meta-tool (`call_tool: 'call_tool' is a meta-tool; call it directly`), requires `name` (``call_tool: `name` is required (find one with search_tools)``), and requires `arguments`, when given, to be an object (``call_tool: `arguments` must be an object``). `bun run gateway:smoke` (`test/gateway-smoke.ts`) proves the lease half against a real browser: without the inner `lease` a claimed tab is refused, with it the call succeeds.
301
+
302
+ **`CDP_TOOL_PROFILE` is the only filter, and it is startup-only** — set once by whoever configures the server, then fixed for the life of the process. Every profile lists `search_tools` and `call_tool` first. Measured over raw stdio at 1e9f25f (bytes = compact JSON of the `tools` array, tokens ≈ bytes ÷ 4):
303
+
304
+ | `CDP_TOOL_PROFILE` | browser | entries in `tools/list` | bytes | ≈ tokens |
305
+ |---|---|---|---|---|
306
+ | unset / `gateway` — the 3.0 default | chrome | 7 | 5,072 | ≈1,268 |
307
+ | unset / `gateway` | firefox | 7 | 5,072 | ≈1,268 |
308
+ | `gateway,network,console` | chrome | 11 | 7,899 | ≈1,975 |
309
+ | `core` | chrome | 14 | 9,661 | ≈2,415 |
310
+ | `core,network,console` | chrome | 18 | 12,488 | ≈3,122 |
311
+ | `full` | chrome | 50 | 37,911 | ≈9,478 |
312
+ | `full` | firefox | 37 | 28,899 | ≈7,225 |
313
+ | 2.7.0 default (`full`, for comparison) | chrome | 49 | 37,346 | ≈9,336 |
276
314
 
277
- **`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):
315
+ The default listing is **−86.4% bytes** vs 2.7.0's default.
278
316
 
279
- | `CDP_TOOL_PROFILE` | entries in `tools/list` | bytes | ≈ tokens |
280
- |---|---|---|---|
281
- | unset / `full` — the default | 49 | 37,088 | ≈9,272 |
282
- | `core` | 13 | 9,077 | ≈2,269 |
283
- | `core,network,console` | 17 | 11,904 | ≈2,976 |
284
- | `full` under `CDP_BROWSER=firefox` | 36 | 28,076 | ≈7,019 |
317
+ **What you trade away for that:**
285
318
 
286
- 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%**.
319
+ - **Name-keyed host allowlists and per-tool permission prompts.** A tool reached through `call_tool` shows up to the host as `call_tool`, so a host rule like "allow `click`, deny `evaluate_script`" cannot tell apart the 43 non-gateway tools. If you need per-tool host rules, set `CDP_TOOL_PROFILE=full`.
320
+ - **Drop-in parity with `chrome-devtools-mcp`'s listing.** Its 29 tool names still exist and are still callable under the same names, but only 5 are *listed* by default. `CDP_TOOL_PROFILE=full` restores a listing with all of them.
287
321
 
288
- 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.
322
+ A `CDP_TOOL_PROFILE` value is `gateway` (the default; also what unset or empty means), `full`, `core`, or a comma-separated group list. A list *without* `gateway` always includes `core` (the 2.x behaviour). A list *with* `gateway` adds only the named groups on top of the 5 gateway tools, e.g. `gateway,network,console`. An unknown group is a startup error: `CDP_TOOL_PROFILE: unknown tool group 'x'. Known: gateway, full, core, input, …`, exit 1.
289
323
 
290
- Three rules worth knowing:
324
+ Rules worth knowing:
291
325
 
292
- - **Unlisted does not mean blocked.** Only discovery is filtered. `tools/call` checks 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.
293
- - **`describe_tool` works for any tool by name, unlisted ones included** — the full description and per-parameter docs behind the terse one-liner `tools/list` carries.
294
- - **Profiles and `describe_tool` are MCP-only.** They are MCP-server concepts: `cdp describe_tool` fails with `unknown tool 'describe_tool'`, and the CLI keeps exposing all 48 tools no matter how `CDP_TOOL_PROFILE` is set.
326
+ - **Unlisted does not mean blocked.** Only discovery is filtered. Calling an unlisted tool directly by name still works over the wire for SDK and CLI consumers, and `call_tool` reaches it for a model. Only a tool the selected browser cannot run at all is refused by name.
327
+ - **Profiles, `search_tools`, and `call_tool` are MCP-only.** `cdp search_tools` fails as an unknown tool, and the CLI keeps exposing all 48 tools no matter how `CDP_TOOL_PROFILE` is set. The 48 underlying tools are unchanged.
328
+ - **`describe_tool` is unlisted but still answered** for 2.x callers, with the same output as 2.x; `describe_tool {}` returns the same catalog as `search_tools {}`.
295
329
 
296
330
  ### MCP protocol eras
297
331
 
@@ -404,7 +438,7 @@ This coordinates and recovers **around** Firefox's one-session limit; it does no
404
438
 
405
439
  **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.
406
440
 
407
- **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](#progressive-disclosure)). Under Firefox, six capability areas are absent because Firefox 153's BiDi implementation has no equivalent domain:
441
+ **Tool availability is filtered per backend**, not per call: `tools/list` (MCP) and `--list`/`--capabilities` (CLI) only ever advertise a tool the selected browser can actually run. A tool is never listed and then thrown from at call time. On the MCP server a second filter composes on top: a tool is listed only when the backend can run it *and* the startup profile includes it — backend availability first, `CDP_TOOL_PROFILE` second (see [Progressive disclosure](#progressive-disclosure)). Under Firefox, six capability areas are absent because Firefox 153's BiDi implementation has no equivalent domain:
408
442
 
409
443
  - `performance_start_trace`, `performance_stop_trace`, `performance_analyze_insight`, `performance_trace` (needs `trace.performance`)
410
444
  - `take_heapsnapshot` (needs `heap.snapshot`)
@@ -413,7 +447,7 @@ This coordinates and recovers **around** Firefox's one-session limit; it does no
413
447
  - `dispatch_mouse` (needs `input.raw`): the raw move/down/up primitive is a direct `Input.dispatchMouseEvent` wrapper with no BiDi analogue.
414
448
  - `wait_for_download`, `grant_permissions` (need `browser.downloads` / `browser.permissions`): both drive Chrome's `Browser.*` domain; WebDriver BiDi has no command to redirect a download or pre-grant a permission.
415
449
 
416
- 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.
450
+ Everything else, including `mock_request`/`list_mocks`/`clear_mocks` (Firefox's `network.addIntercept` covers the same fake-backend use case as Chrome's `Fetch` domain), the `claim_page`/`release_page`/`list_leases` lease group, the `extract_page` extraction tool (its page work runs entirely through the driver's evaluate, no CDP-only capability), and the new `scroll` tool (Chrome dispatches `Input.dispatchMouseEvent{type:'mouseWheel'}`, Firefox uses BiDi's `wheel` input source — both live-verified), is available under both backends: 35 of the 48 tools. Under `CDP_BROWSER=firefox` the MCP server's `CDP_TOOL_PROFILE=full` listing is therefore 37 entries — those 35 plus `search_tools` and `call_tool` — while the 3.0 default (`gateway`) lists 7 under either browser; the profile filter applies after this backend filter. One asymmetry to know before expecting Chrome-style concurrency: the lease group fences *tabs* on both backends, but Firefox permits only **one BiDi session per browser instance**, so multiple agent PROCESSES under Firefox share that one session rather than opening independent ones the way Chrome's unlimited CDP connections allow — coordinated by the cross-process session lease and, by default, joined through the BiDi multiplexer above, so sharing no longer means waiting a turn — see "Parallel tabs" below.
417
451
 
418
452
  **Honest capability gaps, not oversold parity:**
419
453
 
@@ -428,7 +462,7 @@ Everything else, including `mock_request`/`list_mocks`/`clear_mocks` (Firefox's
428
462
 
429
463
  ## The tools (29 parity + 19 superset = 48)
430
464
 
431
- 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](#progressive-disclosure).
465
+ This table is the **full view** — all 48 tools, which is what `cdp --list` and the MCP server's `CDP_TOOL_PROFILE=full` listing show (the MCP default lists only 7; see [Progressive disclosure](#progressive-disclosure)). They are partitioned into 14 static profile groups: `core` (12: list_pages, new_page, close_page, select_page, navigate_page, wait_for, take_snapshot, click, fill, type_text, evaluate_script, take_screenshot), `input` (9: hover, drag, scroll, dispatch_mouse, press_key, fill_form, upload_file, focus_emulation, click_focus_gated), `cookies` (3: list/set/delete_cookies), `network` (2: list/get_network_request), `console` (2: list/get_console_message), `mocking` (3: mock_request, list_mocks, clear_mocks), `emulation` (2: emulate, resize_page), `performance` (6: the four trace tools, take_heapsnapshot, lighthouse_audit), `recording` (2: start/stop_screen_recording), `leases` (3: claim_page, release_page, list_leases), `permissions` (1: grant_permissions), `dialogs` (1: handle_dialog), `downloads` (1: wait_for_download), `extraction` (1: extract_page). `CDP_TOOL_PROFILE=core` narrows the MCP listing to the first group; any group it leaves out stays callable by name. See [Progressive disclosure](#progressive-disclosure).
432
466
 
433
467
  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.
434
468
 
@@ -476,7 +510,7 @@ The 29 parity tools are 1:1 with `chrome-devtools-mcp`; the 19 superset tools (`
476
510
  | `mock_request` *(superset)* | `Fetch.*` (+ `Page.reload`) | **A fake backend.** Registers a rule on a target's persistent session: fulfill with a canned response, fail, or continue (with optional `delayMs`/`failRate`). Persists across reloads until `clear_mocks`. Request-stage only. Cached requests aren't intercepted (use `reload:true`). |
477
511
  | `list_mocks` *(superset)* | `Runtime.evaluate` (liveness probe) | Lists active mock sessions with rules + hit counts; prunes sessions whose tab closed. |
478
512
  | `clear_mocks` *(superset)* | `Fetch.disable` | Tears down the resolved target's mock session (or all with `all:true`). |
479
- | `claim_page` *(superset)* | `Target.createTarget` + lease file | Opens a fresh tab and claims it (no `target`/`targetId`), or takes over a tab that is already open (`target`, any selector, or the exact-id `targetId`) and returns an opaque lease token plus `opened` (true only when this call created the tab). Never steals a tab another live process holds. Also returns `humanActiveMs` (ms since input this server did not dispatch; `null` means no data, never "no human") and, on a takeover of a tab a human used within the last 30s, a `contention` warning — the claim is never refused for it. MCP only; the CLI refuses it. The `describe_tool` meta-tool is likewise MCP-only. |
513
+ | `claim_page` *(superset)* | `Target.createTarget` + lease file | Opens a fresh tab and claims it (no `target`/`targetId`), or takes over a tab that is already open (`target`, any selector, or the exact-id `targetId`) and returns an opaque lease token plus `opened` (true only when this call created the tab). Never steals a tab another live process holds. Also returns `humanActiveMs` (ms since input this server did not dispatch; `null` means no data, never "no human") and, on a takeover of a tab a human used within the last 30s, a `contention` warning — the claim is never refused for it. MCP only; the CLI refuses it. The `search_tools` and `call_tool` meta-tools are likewise MCP-only. |
480
514
  | `release_page` *(superset)* | lease file | Gives a lease back via `lease` (the token) or `target` (a selector, for a lease the gate auto-acquired and so never handed out a token for). Idempotent. Closes the tab by default when this toolkit's creation ledger says it opened it; leaves a merely-claimed tab open. Override either way with `close`. |
481
515
  | `list_leases` *(superset)* | lease file | Who holds what, with pid liveness and reclaimability, plus computed `idleMs`/`expiresAt` and, where the tab's beacon answers, `humanActiveMs` (absent, not null, when there's no answer). Needs no token; never returns the nonce. A lease file that could not be read or parsed is reported as an `unreadable` row instead of being skipped (with `idleMs`/`expiresAt` omitted). Under `CDP_REQUIRE_LEASE` also reaps abandoned agent tabs, reporting closures in an additive `reaped` array. |
482
516
  | `wait_for_download` *(superset)* | `Browser.setDownloadBehavior` + `Browser.downloadWillBegin`/`downloadProgress` on a standing browser-endpoint connection | Waits for a download to finish and returns it as a real file on disk: `{path,suggestedFilename,bytes,url,target}`. **Ordering rule, not optional:** call `wait_for_download{arm:true}` *before* the click that starts the download, then click, then call `wait_for_download` again to collect it — arming this late is not a preference, it's because Chrome reverts the download-behavior override the instant the arming client disconnects, and denies an unarmed download outright. **Browser-global side effect:** while armed, *every* download in the browser is redirected into the toolkit's downloads directory. **MCP-server only:** the arm lives on a connection this server process holds open; the one-shot CLI's connection dies with the process, so nothing is captured there. Chrome-only (`browser.downloads`). |
@@ -639,7 +673,7 @@ src/
639
673
  version.ts # the VERSION constant, single source of truth with package.json
640
674
  manifest.ts # JSON Schemas advertised by the MCP server (one per tool)
641
675
  toolGroups.ts # tool-to-group map + CDP_TOOL_PROFILE resolution (startup-only filter)
642
- toolDocs.ts # full per-tool prose served on demand by describe_tool
676
+ toolDocs.ts # full per-tool prose served on demand by search_tools (and describe_tool, unlisted, for 2.x callers)
643
677
  leases.ts # lease records, staleness, reclamation, assertLeaseOk, CDP_REQUIRE_LEASE
644
678
  leases-tools.ts # claim_page / release_page / list_leases
645
679
  origins.ts # tab creation ledger: who OPENED a tab, outliving its lease
@@ -654,6 +688,7 @@ test/
654
688
  network_mock.test.ts # pure-logic unit tests (bun test)
655
689
  mcp-smoke.ts # MCP handshake + live tools/call round-trip (bun run mcp:smoke)
656
690
  lease-smoke.ts # two-process live lease harness (bun run lease:smoke)
691
+ gateway-smoke.ts # call_tool lease pass-through on a live browser (bun run gateway:smoke)
657
692
  firefox-multi-agent-smoke.ts # N concurrent server PROCESSES on one shared Firefox (bun run firefox:multi:smoke)
658
693
  ```
659
694
 
package/dist/mcp.js CHANGED
@@ -10823,29 +10823,45 @@ for (const group of TOOL_GROUPS) {
10823
10823
  TOOL_GROUP[tool] = group;
10824
10824
  }
10825
10825
  }
10826
+ var GATEWAY_TOOLS = ["navigate_page", "take_snapshot", "click", "fill", "evaluate_script"];
10826
10827
  var PROFILES = {
10828
+ gateway: [],
10827
10829
  core: ["core"],
10828
10830
  full: [...TOOL_GROUPS]
10829
10831
  };
10832
+ function isListed(profile, name) {
10833
+ const group = TOOL_GROUP[name];
10834
+ return profile.tools.has(name) || group !== undefined && profile.groups.has(group);
10835
+ }
10830
10836
  function resolveProfile(spec) {
10831
- const all = () => ({ groups: new Set(TOOL_GROUPS), label: "full" });
10837
+ const all = () => ({ groups: new Set(TOOL_GROUPS), tools: new Set, label: "full" });
10832
10838
  const raw = (spec ?? "").trim();
10833
- if (raw === "" || raw.toLowerCase() === "full")
10839
+ if (raw === "")
10840
+ return { groups: new Set, tools: new Set(GATEWAY_TOOLS), label: "gateway" };
10841
+ if (raw.toLowerCase() === "full")
10834
10842
  return all();
10835
10843
  const tokens = raw.split(",").map((t) => t.trim().toLowerCase()).filter((t) => t !== "");
10836
10844
  if (tokens.includes("full"))
10837
10845
  return all();
10846
+ const gateway = tokens.includes("gateway");
10838
10847
  const known = new Set(TOOL_GROUPS);
10839
- const picked = new Set(["core"]);
10848
+ const picked = new Set(gateway ? [] : ["core"]);
10840
10849
  for (const token of tokens) {
10850
+ if (token === "gateway")
10851
+ continue;
10841
10852
  if (!known.has(token)) {
10842
- throw new Error(`CDP_TOOL_PROFILE: unknown tool group '${token}'. Known: full, core, ${TOOL_GROUPS.filter((g) => g !== "core").join(", ")}`);
10853
+ throw new Error(`CDP_TOOL_PROFILE: unknown tool group '${token}'. Known: gateway, full, core, ${TOOL_GROUPS.filter((g) => g !== "core").join(", ")}`);
10843
10854
  }
10844
10855
  picked.add(token);
10845
10856
  }
10846
10857
  if (picked.size === TOOL_GROUPS.length)
10847
10858
  return all();
10848
- return { groups: picked, label: TOOL_GROUPS.filter((g) => picked.has(g)).join(",") };
10859
+ const groupLabel = TOOL_GROUPS.filter((g) => picked.has(g));
10860
+ return {
10861
+ groups: picked,
10862
+ tools: new Set(gateway ? GATEWAY_TOOLS : []),
10863
+ label: (gateway ? ["gateway", ...groupLabel] : groupLabel).join(",")
10864
+ };
10849
10865
  }
10850
10866
 
10851
10867
  // src/toolDocs.ts
@@ -11353,7 +11369,7 @@ var TOOL_DOCS = {
11353
11369
  };
11354
11370
 
11355
11371
  // src/version.ts
11356
- var VERSION = "2.7.0";
11372
+ var VERSION = "3.0.0";
11357
11373
 
11358
11374
  // src/mcp.ts
11359
11375
  function isDirectRun() {
@@ -11411,8 +11427,8 @@ function auditCoverage() {
11411
11427
  console.error(`[cdp-toolkit] WARN: manifest schemas with no registered tool: ${orphanSchema.join(", ")}`);
11412
11428
  }
11413
11429
  var INSTRUCTIONS = [
11414
- "cdp-toolkit drives a real browser over Chrome DevTools Protocol (or Firefox WebDriver-BiDi): open and drive pages, click/fill/type, screenshot and accessibility-snapshot, read console + network, set cookies, emulate devices, run Lighthouse and performance traces, record screencasts. Conventions shared across its tools:",
11415
- "TOOL AVAILABILITY: tools/list is complete and fixed for this process's life — cache it. Its one-liners are not the full docs: call describe_tool {name} on an unfamiliar tool, or describe_tool {} for the grouped catalog. CDP_TOOL_PROFILE=core (or a group list) lists only those groups; unlisted tools stay callable by name.",
11430
+ "cdp-toolkit drives a real browser over CDP (or Firefox WebDriver-BiDi): pages, input, screenshots, snapshots, console, network, cookies, emulation, Lighthouse, traces, screencasts.",
11431
+ "TOOLS: by default only 5 are listed (navigate_page, take_snapshot, click, fill, evaluate_script). ~45 more exist: find one with search_tools {query} (returns its schema; no query = catalog), run it with call_tool {name, arguments}. No tab yet? call_tool {name:'new_page'}. The listing is fixed for the process's life; CDP_TOOL_PROFILE=full lists everything.",
11416
11432
  "TARGET SELECTOR (the `target` param, unless a tool says otherwise): 'active' (default = first page) | 'index:N' (0-based) | 'url:<substr>' | 'title:<substr>' | 'label:<name>' (exact, ledger or live lease) | a 32-hex '<targetId>' of any target type — an iframe id from list_pages{all:true} drives like a tab. Chrome tools also accept 'frame:<substr>' for OOPIF iframe targets. Four Chrome-only tools (evaluate_script, list_network_requests, get_network_request, list_console_messages) also accept 'worker:<substr>' to reach a service/shared worker (e.g. an MV3 background worker); an idle-evicted worker is started first (see `wake`).",
11417
11433
  "LEASES (the `lease` param): claim_page, and new_page{claim:true}, mint an opaque token. Omit it for a tab THIS process already holds. It is required for a tab held by ANOTHER process, or one claimed explicitly. Under CDP_REQUIRE_LEASE the gate auto-acquires a lease for any tab this process drives — no token is surfaced, so pass `target`, not `lease` — while an explicit claim:true still demands its token on every later call.",
11418
11434
  "ORIGIN: list_pages and list_leases tag each tab's `origin` as 'agent' (this toolkit created it) or 'unknown' — never 'human', because the toolkit cannot prove a person opened a tab. An 'agent' tab stays findable after its creator releases the lease or dies."
@@ -11420,6 +11436,9 @@ var INSTRUCTIONS = [
11420
11436
 
11421
11437
  `);
11422
11438
  var PROFILE = startupProfile();
11439
+ var META_TOOLS = new Set(["search_tools", "call_tool", "describe_tool"]);
11440
+ var SEARCH_DEFAULT_LIMIT = 8;
11441
+ var SEARCH_MAX_LIMIT = 20;
11423
11442
  function startupProfile() {
11424
11443
  try {
11425
11444
  return resolveProfile(process.env.CDP_TOOL_PROFILE);
@@ -11430,20 +11449,37 @@ function startupProfile() {
11430
11449
  }
11431
11450
  var LISTING = Object.freeze([
11432
11451
  {
11433
- name: "describe_tool",
11434
- description: "Full description and per-parameter docs for any cdp-toolkit tool by name, including tools not in tools/list (unlisted tools stay callable by name). Call it before using an unfamiliar tool: the live tools/list carries only terse one-liners. With no name it returns the grouped catalog of every tool the selected browser supports.",
11452
+ name: "search_tools",
11453
+ description: "Find a cdp-toolkit tool that is not in your tool list (cookies, network, console, screenshots, tabs, emulation, performance, recording, leases, downloads, …). Returns the best matches with full docs and inputSchema; run one with call_tool. With no query, returns the catalog of every tool by group.",
11435
11454
  inputSchema: {
11436
11455
  type: "object",
11437
11456
  properties: {
11438
- name: {
11457
+ query: {
11439
11458
  type: "string",
11440
- description: "Tool name, e.g. take_screenshot. Omit it to get the catalog: every group, its tool names, and whether the group is in tools/list."
11459
+ description: "Keywords or an exact tool name, e.g. 'screenshot', 'network requests', 'new_page'. Omit for the catalog."
11460
+ },
11461
+ limit: {
11462
+ type: "integer",
11463
+ description: `Max matches to return (default ${SEARCH_DEFAULT_LIMIT}, max ${SEARCH_MAX_LIMIT}).`
11441
11464
  }
11442
11465
  },
11443
11466
  additionalProperties: false
11444
11467
  }
11445
11468
  },
11446
- ...MANIFEST.filter((s) => AVAILABLE_NAMES.has(s.name) && PROFILE.groups.has(TOOL_GROUP[s.name])).map((s) => ({ name: s.name, description: s.description, inputSchema: s.inputSchema }))
11469
+ {
11470
+ name: "call_tool",
11471
+ description: "Run any cdp-toolkit tool by name, including the ones not in your tool list. Look its arguments up with search_tools first. Behaves exactly like calling the tool directly (same leases, same errors).",
11472
+ inputSchema: {
11473
+ type: "object",
11474
+ properties: {
11475
+ name: { type: "string", description: "Tool name from search_tools, e.g. new_page." },
11476
+ arguments: { type: "object", description: "That tool's arguments, per its inputSchema.", additionalProperties: true }
11477
+ },
11478
+ required: ["name"],
11479
+ additionalProperties: false
11480
+ }
11481
+ },
11482
+ ...MANIFEST.filter((s) => AVAILABLE_NAMES.has(s.name) && isListed(PROFILE, s.name)).map((s) => ({ name: s.name, description: s.description, inputSchema: s.inputSchema }))
11447
11483
  ]);
11448
11484
  function renderCatalog() {
11449
11485
  const lines = [
@@ -11453,12 +11489,65 @@ function renderCatalog() {
11453
11489
  const shown = GROUP_TOOLS[g].filter((n) => AVAILABLE_NAMES.has(n));
11454
11490
  if (!shown.length)
11455
11491
  continue;
11456
- lines.push(`${PROFILE.groups.has(g) ? "[listed]" : "[hidden]"} ${g} (${shown.length}): ${shown.join(", ")}`);
11492
+ const listed = shown.filter((n) => isListed(PROFILE, n));
11493
+ const state = listed.length === shown.length ? "[listed]" : listed.length === 0 ? "[hidden]" : "[partly listed]";
11494
+ lines.push(`${state} ${g} (${shown.length}): ${shown.join(", ")}`);
11457
11495
  }
11458
- lines.push("Unlisted tools are callable by name; describe_tool {name} documents any of them.");
11496
+ lines.push("Run any tool with call_tool {name, arguments}; search_tools {query:<name>} returns its docs and inputSchema.");
11459
11497
  return lines.join(`
11460
11498
  `);
11461
11499
  }
11500
+ function tokenize(text) {
11501
+ return text.toLowerCase().split(/[^a-z0-9]+/).filter((t) => t.length > 1);
11502
+ }
11503
+ var MANIFEST_BY_NAME = new Map(MANIFEST.map((s) => [s.name, s]));
11504
+ function searchTools(query, rawLimit) {
11505
+ const limit = typeof rawLimit === "number" && Number.isInteger(rawLimit) && rawLimit > 0 ? Math.min(rawLimit, SEARCH_MAX_LIMIT) : SEARCH_DEFAULT_LIMIT;
11506
+ const q = query.trim().toLowerCase();
11507
+ const describe = (name) => {
11508
+ const spec = MANIFEST_BY_NAME.get(name);
11509
+ return {
11510
+ name,
11511
+ group: TOOL_GROUP[name] ?? null,
11512
+ listed: isListed(PROFILE, name),
11513
+ description: TOOL_DOCS[name]?.description ?? spec.description,
11514
+ inputSchema: spec.inputSchema
11515
+ };
11516
+ };
11517
+ const candidates = MANIFEST.map((s) => s.name).filter((n) => AVAILABLE_NAMES.has(n));
11518
+ let ranked;
11519
+ if (candidates.includes(q)) {
11520
+ ranked = [q];
11521
+ } else {
11522
+ const tokens = tokenize(q);
11523
+ const scored = candidates.map((name, order) => {
11524
+ const nameTokens = tokenize(name);
11525
+ const group = TOOL_GROUP[name] ?? "";
11526
+ const docs = `${MANIFEST_BY_NAME.get(name).description} ${TOOL_DOCS[name]?.description ?? ""}`.toLowerCase();
11527
+ let score = 0;
11528
+ for (const t of tokens) {
11529
+ if (nameTokens.includes(t))
11530
+ score += 5;
11531
+ else if (name.includes(t))
11532
+ score += 3;
11533
+ if (group === t || group.includes(t))
11534
+ score += 2;
11535
+ if (docs.includes(t))
11536
+ score += 1;
11537
+ }
11538
+ return { name, order, score };
11539
+ });
11540
+ ranked = scored.filter((s) => s.score > 0).sort((a, b) => b.score - a.score || a.order - b.order).map((s) => s.name);
11541
+ }
11542
+ const unavailable = AVAILABILITY.unavailable.find((u) => u.name === q);
11543
+ return {
11544
+ query,
11545
+ total: ranked.length,
11546
+ matches: ranked.slice(0, limit).map(describe),
11547
+ ...unavailable ? { unavailable: `${q} is not available under --browser ${BROWSER} (needs: ${unavailable.missing.join(", ")})` } : {},
11548
+ next: ranked.length ? "Run one with call_tool {name, arguments}." : "No match. Call search_tools with no query for the full catalog."
11549
+ };
11550
+ }
11462
11551
  function renderToolDoc(toolName) {
11463
11552
  const doc = TOOL_DOCS[toolName];
11464
11553
  if (!doc)
@@ -11485,6 +11574,13 @@ function buildServer() {
11485
11574
  server.setRequestHandler("tools/call", async (request) => {
11486
11575
  const { name } = request.params;
11487
11576
  const args = request.params.arguments ?? {};
11577
+ if (name === "search_tools") {
11578
+ const { query, limit } = args;
11579
+ if (typeof query !== "string" || query.trim() === "") {
11580
+ return { content: [{ type: "text", text: renderCatalog() }] };
11581
+ }
11582
+ return { content: [{ type: "text", text: JSON.stringify(searchTools(query, limit), null, 2) }] };
11583
+ }
11488
11584
  if (name === "describe_tool") {
11489
11585
  const requested = args.name;
11490
11586
  if (typeof requested !== "string" || requested === "") {
@@ -11493,40 +11589,57 @@ function buildServer() {
11493
11589
  const known = Boolean(TOOL_DOCS[requested]);
11494
11590
  return { content: [{ type: "text", text: renderToolDoc(requested) }], isError: !known };
11495
11591
  }
11496
- if (!AVAILABLE_NAMES.has(name)) {
11497
- const gap = AVAILABILITY.unavailable.find((u) => u.name === name);
11498
- return {
11499
- content: [{ type: "text", text: `unknown tool: ${name}${gap ? ` (not available under --browser ${BROWSER}, needs: ${gap.missing.join(", ")})` : ""}` }],
11500
- isError: true
11501
- };
11502
- }
11503
- return withLeaseScope(leaseFromArgs(args), async () => {
11504
- if (BROWSER === "chrome") {
11505
- const fn = dispatch[name];
11506
- if (!fn)
11507
- return { content: [{ type: "text", text: `unknown tool: ${name}` }], isError: true };
11508
- try {
11509
- const result = await fn(args);
11510
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
11511
- } catch (err) {
11512
- const message = err instanceof Error ? err.message : String(err);
11513
- return { content: [{ type: "text", text: `Error: ${message}` }], isError: true };
11514
- }
11592
+ if (name === "call_tool") {
11593
+ const inner = args;
11594
+ if (typeof inner.name !== "string" || inner.name === "") {
11595
+ return { content: [{ type: "text", text: "call_tool: `name` is required (find one with search_tools)" }], isError: true };
11515
11596
  }
11516
- const neutralFn = neutralDispatch[name];
11517
- if (!neutralFn)
11597
+ if (META_TOOLS.has(inner.name)) {
11598
+ return { content: [{ type: "text", text: `call_tool: '${inner.name}' is a meta-tool; call it directly` }], isError: true };
11599
+ }
11600
+ const innerArgs = inner.arguments ?? {};
11601
+ if (typeof innerArgs !== "object" || Array.isArray(innerArgs)) {
11602
+ return { content: [{ type: "text", text: "call_tool: `arguments` must be an object" }], isError: true };
11603
+ }
11604
+ return runTool(inner.name, innerArgs);
11605
+ }
11606
+ return runTool(name, args);
11607
+ });
11608
+ return server;
11609
+ }
11610
+ async function runTool(name, args) {
11611
+ if (!AVAILABLE_NAMES.has(name)) {
11612
+ const gap = AVAILABILITY.unavailable.find((u) => u.name === name);
11613
+ return {
11614
+ content: [{ type: "text", text: `unknown tool: ${name}${gap ? ` (not available under --browser ${BROWSER}, needs: ${gap.missing.join(", ")})` : ""}` }],
11615
+ isError: true
11616
+ };
11617
+ }
11618
+ return withLeaseScope(leaseFromArgs(args), async () => {
11619
+ if (BROWSER === "chrome") {
11620
+ const fn = dispatch[name];
11621
+ if (!fn)
11518
11622
  return { content: [{ type: "text", text: `unknown tool: ${name}` }], isError: true };
11519
11623
  try {
11520
- const session = await getOrCreateFirefoxSession({ endpoint: FIREFOX_ENDPOINT });
11521
- const result = await neutralFn(session.driver, args);
11624
+ const result = await fn(args);
11522
11625
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
11523
11626
  } catch (err) {
11524
11627
  const message = err instanceof Error ? err.message : String(err);
11525
11628
  return { content: [{ type: "text", text: `Error: ${message}` }], isError: true };
11526
11629
  }
11527
- });
11630
+ }
11631
+ const neutralFn = neutralDispatch[name];
11632
+ if (!neutralFn)
11633
+ return { content: [{ type: "text", text: `unknown tool: ${name}` }], isError: true };
11634
+ try {
11635
+ const session = await getOrCreateFirefoxSession({ endpoint: FIREFOX_ENDPOINT });
11636
+ const result = await neutralFn(session.driver, args);
11637
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
11638
+ } catch (err) {
11639
+ const message = err instanceof Error ? err.message : String(err);
11640
+ return { content: [{ type: "text", text: `Error: ${message}` }], isError: true };
11641
+ }
11528
11642
  });
11529
- return server;
11530
11643
  }
11531
11644
  async function run2(argv) {
11532
11645
  const serve = parseServeOptions(argv);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cdp-toolkit",
3
- "version": "2.7.0",
4
- "description": "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 over CDP and Firefox over WebDriver BiDi, with tools a backend can't run absent from tools/list rather than throwing mid-task. 48 tools including built-in network mocking, a cookie group, tab leases with human-activity detection, real HTML5 drag-and-drop, raw mouse/scroll dispatch, downloads, permission grants, and tab-to-video screen recording so several agents can drive one browser without stealing each other's tabs or a human's. No Puppeteer, no all-target fan-out.",
3
+ "version": "3.0.0",
4
+ "description": "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 over CDP and Firefox over WebDriver BiDi, with tools a backend can't run absent from tools/list rather than throwing mid-task. 48 tools (only 5 core tools listed by default, plus search_tools/call_tool to reach the rest) including built-in network mocking, a cookie group, tab leases with human-activity detection, real HTML5 drag-and-drop, raw mouse/scroll dispatch, downloads, permission grants, and tab-to-video screen recording so several agents can drive one browser without stealing each other's tabs or a human's. No Puppeteer, no all-target fan-out.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": {
@@ -75,6 +75,7 @@
75
75
  "test": "bun test",
76
76
  "test:runtime": "bun test test/runtime-entry.test.ts",
77
77
  "smoke": "bun run ./test/smoke.ts",
78
+ "bench:wedge": "bun run scripts/wedge-bench.ts",
78
79
  "mock:smoke": "bun run ./test/mock-smoke.ts",
79
80
  "screencast:smoke": "bun run ./test/screencast-smoke.ts",
80
81
  "mcp:smoke": "bun run ./test/mcp-smoke.ts",
@@ -87,7 +88,8 @@
87
88
  "input:smoke": "bun run ./test/input-smoke.ts",
88
89
  "extension:smoke": "bun run ./test/extension-smoke.ts",
89
90
  "focus:smoke": "bun run ./test/focus-emulation-smoke.ts",
90
- "extract:smoke": "bun run ./test/extract-smoke.ts"
91
+ "extract:smoke": "bun run ./test/extract-smoke.ts",
92
+ "gateway:smoke": "bun run ./test/gateway-smoke.ts"
91
93
  },
92
94
  "devDependencies": {
93
95
  "@modelcontextprotocol/client": "^2.0.0",
package/src/mcp.ts CHANGED
@@ -43,7 +43,7 @@ import { disposeBrowserSession } from "./tools/browser-session.ts";
43
43
  import { toolAvailability } from "./capabilities.ts";
44
44
  import { FIREFOX_TOOLS } from "./firefox-tools.ts";
45
45
  import { leaseFromArgs, markLongLivedProcess, withLeaseScope } from "./leases.ts";
46
- import { resolveProfile, TOOL_GROUP, TOOL_GROUPS, GROUP_TOOLS } from "./toolGroups.ts";
46
+ import { isListed, resolveProfile, TOOL_GROUP, TOOL_GROUPS, GROUP_TOOLS } from "./toolGroups.ts";
47
47
  import { TOOL_DOCS } from "./toolDocs.ts";
48
48
  import { VERSION } from "./version.ts";
49
49
 
@@ -156,8 +156,8 @@ function auditCoverage(): void {
156
156
  // payload without losing a single behavior. Kept under Claude Code's 2KB instructions
157
157
  // cap and front-loaded (grammar + leases before origin) so nothing critical is clipped.
158
158
  const INSTRUCTIONS = [
159
- "cdp-toolkit drives a real browser over Chrome DevTools Protocol (or Firefox WebDriver-BiDi): open and drive pages, click/fill/type, screenshot and accessibility-snapshot, read console + network, set cookies, emulate devices, run Lighthouse and performance traces, record screencasts. Conventions shared across its tools:",
160
- "TOOL AVAILABILITY: tools/list is complete and fixed for this process's life — cache it. Its one-liners are not the full docs: call describe_tool {name} on an unfamiliar tool, or describe_tool {} for the grouped catalog. CDP_TOOL_PROFILE=core (or a group list) lists only those groups; unlisted tools stay callable by name.",
159
+ "cdp-toolkit drives a real browser over CDP (or Firefox WebDriver-BiDi): pages, input, screenshots, snapshots, console, network, cookies, emulation, Lighthouse, traces, screencasts.",
160
+ "TOOLS: by default only 5 are listed (navigate_page, take_snapshot, click, fill, evaluate_script). ~45 more exist: find one with search_tools {query} (returns its schema; no query = catalog), run it with call_tool {name, arguments}. No tab yet? call_tool {name:'new_page'}. The listing is fixed for the process's life; CDP_TOOL_PROFILE=full lists everything.",
161
161
  "TARGET SELECTOR (the `target` param, unless a tool says otherwise): 'active' (default = first page) | 'index:N' (0-based) | 'url:<substr>' | 'title:<substr>' | 'label:<name>' (exact, ledger or live lease) | a 32-hex '<targetId>' of any target type — an iframe id from list_pages{all:true} drives like a tab. Chrome tools also accept 'frame:<substr>' for OOPIF iframe targets. Four Chrome-only tools (evaluate_script, list_network_requests, get_network_request, list_console_messages) also accept 'worker:<substr>' to reach a service/shared worker (e.g. an MV3 background worker); an idle-evicted worker is started first (see `wake`).",
162
162
  "LEASES (the `lease` param): claim_page, and new_page{claim:true}, mint an opaque token. Omit it for a tab THIS process already holds. It is required for a tab held by ANOTHER process, or one claimed explicitly. Under CDP_REQUIRE_LEASE the gate auto-acquires a lease for any tab this process drives — no token is surfaced, so pass `target`, not `lease` — while an explicit claim:true still demands its token on every later call.",
163
163
  "ORIGIN: list_pages and list_leases tag each tab's `origin` as 'agent' (this toolkit created it) or 'unknown' — never 'human', because the toolkit cannot prove a person opened a tab. An 'agent' tab stays findable after its creator releases the lease or dies.",
@@ -168,12 +168,17 @@ const INSTRUCTIONS = [
168
168
  // it (the 2026-07-28 revision requires the tool set not to change as a side effect of
169
169
  // other requests, which is exactly what the 2.0 browser_tools toggle did). Discovery is
170
170
  // the HOST's job per the MCP client best-practices: the client picks which of the listed
171
- // tools to put in front of the model. `describe_tool` is the inspect layer — one call
172
- // returns the full prose for any tool, listed or not — and CDP_TOOL_PROFILE is the only
173
- // filter, applied once at startup by whoever configures the server. A tool that the
174
- // profile hides is still callable by name; hiding only trims the listing's token cost.
171
+ // tools to put in front of the model. CDP_TOOL_PROFILE is the only filter, applied once at
172
+ // startup by whoever configures the server. 3.0 defaults it to `gateway` (5 tools) and adds
173
+ // two STATIC meta-tools, search_tools (inspect) and call_tool (execute), so a model whose host
174
+ // forwards only the listing can still reach every tool — without the listing ever changing.
175
175
  const PROFILE = startupProfile();
176
176
 
177
+ /** Names handled at the MCP layer rather than dispatched to a browser tool. */
178
+ const META_TOOLS = new Set(["search_tools", "call_tool", "describe_tool"]);
179
+ const SEARCH_DEFAULT_LIMIT = 8;
180
+ const SEARCH_MAX_LIMIT = 20;
181
+
177
182
  /** Read CDP_TOOL_PROFILE once; an unknown group name is a configuration error, not a warning. */
178
183
  function startupProfile() {
179
184
  try {
@@ -187,35 +192,53 @@ function startupProfile() {
187
192
  /**
188
193
  * The one and only tools/list payload, in manifest order (a stable order is a spec
189
194
  * SHOULD, and it keeps the response byte-identical across calls so caching is real):
190
- * describe_tool first, then every manifest tool the selected backend can run whose
191
- * group the profile advertises. Descriptions here are the compressed one-liners —
192
- * full prose is served on demand by describe_tool.
195
+ * the two meta-tools first, then every manifest tool the selected backend can run that
196
+ * the profile advertises. Descriptions here are the compressed one-liners — full prose
197
+ * is served on demand by search_tools.
193
198
  *
194
199
  * Frozen at runtime (the handler hands the SAME array to every caller); the cast back to
195
200
  * a mutable Tool[] is only because ListToolsResult declares `tools` mutable.
196
201
  */
197
202
  const LISTING: Tool[] = Object.freeze([
198
203
  {
199
- name: "describe_tool",
204
+ name: "search_tools",
200
205
  description:
201
- "Full description and per-parameter docs for any cdp-toolkit tool by name, including tools not in tools/list (unlisted tools stay callable by name). Call it before using an unfamiliar tool: the live tools/list carries only terse one-liners. With no name it returns the grouped catalog of every tool the selected browser supports.",
206
+ "Find a cdp-toolkit tool that is not in your tool list (cookies, network, console, screenshots, tabs, emulation, performance, recording, leases, downloads, …). Returns the best matches with full docs and inputSchema; run one with call_tool. With no query, returns the catalog of every tool by group.",
202
207
  inputSchema: {
203
208
  type: "object",
204
209
  properties: {
205
- name: {
210
+ query: {
206
211
  type: "string",
207
- description: "Tool name, e.g. take_screenshot. Omit it to get the catalog: every group, its tool names, and whether the group is in tools/list.",
212
+ description: "Keywords or an exact tool name, e.g. 'screenshot', 'network requests', 'new_page'. Omit for the catalog.",
213
+ },
214
+ limit: {
215
+ type: "integer",
216
+ description: `Max matches to return (default ${SEARCH_DEFAULT_LIMIT}, max ${SEARCH_MAX_LIMIT}).`,
208
217
  },
209
218
  },
210
219
  additionalProperties: false,
211
220
  } as Tool["inputSchema"],
212
221
  },
222
+ {
223
+ name: "call_tool",
224
+ description:
225
+ "Run any cdp-toolkit tool by name, including the ones not in your tool list. Look its arguments up with search_tools first. Behaves exactly like calling the tool directly (same leases, same errors).",
226
+ inputSchema: {
227
+ type: "object",
228
+ properties: {
229
+ name: { type: "string", description: "Tool name from search_tools, e.g. new_page." },
230
+ arguments: { type: "object", description: "That tool's arguments, per its inputSchema.", additionalProperties: true },
231
+ },
232
+ required: ["name"],
233
+ additionalProperties: false,
234
+ } as Tool["inputSchema"],
235
+ },
213
236
  ...MANIFEST
214
- .filter((s) => AVAILABLE_NAMES.has(s.name) && PROFILE.groups.has(TOOL_GROUP[s.name]!))
237
+ .filter((s) => AVAILABLE_NAMES.has(s.name) && isListed(PROFILE, s.name))
215
238
  .map((s) => ({ name: s.name, description: s.description, inputSchema: s.inputSchema as Tool["inputSchema"] })),
216
239
  ] satisfies Tool[]) as Tool[];
217
240
 
218
- /** describe_tool with no name: every group the backend supports, and whether it is listed. */
241
+ /** search_tools with no query: every group the backend supports, and what of it is listed. */
219
242
  function renderCatalog(): string {
220
243
  const lines = [
221
244
  `cdp-toolkit ${VERSION} · browser=${BROWSER} · ${AVAILABILITY.available.length} tools available, ${LISTING.length} in tools/list (CDP_TOOL_PROFILE=${PROFILE.label})`,
@@ -223,12 +246,76 @@ function renderCatalog(): string {
223
246
  for (const g of TOOL_GROUPS) {
224
247
  const shown = GROUP_TOOLS[g].filter((n) => AVAILABLE_NAMES.has(n));
225
248
  if (!shown.length) continue;
226
- lines.push(`${PROFILE.groups.has(g) ? "[listed]" : "[hidden]"} ${g} (${shown.length}): ${shown.join(", ")}`);
249
+ const listed = shown.filter((n) => isListed(PROFILE, n));
250
+ const state = listed.length === shown.length ? "[listed]" : listed.length === 0 ? "[hidden]" : "[partly listed]";
251
+ lines.push(`${state} ${g} (${shown.length}): ${shown.join(", ")}`);
227
252
  }
228
- lines.push("Unlisted tools are callable by name; describe_tool {name} documents any of them.");
253
+ lines.push("Run any tool with call_tool {name, arguments}; search_tools {query:<name>} returns its docs and inputSchema.");
229
254
  return lines.join("\n");
230
255
  }
231
256
 
257
+ /** Lowercased word tokens: 'list_network_requests' and 'Network requests' both split cleanly. */
258
+ function tokenize(text: string): string[] {
259
+ return text.toLowerCase().split(/[^a-z0-9]+/).filter((t) => t.length > 1);
260
+ }
261
+
262
+ const MANIFEST_BY_NAME = new Map(MANIFEST.map((s) => [s.name, s]));
263
+
264
+ /**
265
+ * search_tools with a query: rank the backend's available tools against it. An exact tool
266
+ * name returns just that tool. Otherwise each query token scores against the tool's name
267
+ * (strongest), its group, and its full docs; ties keep manifest order. Capped, because an
268
+ * uncapped broad query would hand back the very 49-schema payload the gateway exists to avoid.
269
+ */
270
+ function searchTools(query: string, rawLimit: unknown) {
271
+ const limit = typeof rawLimit === "number" && Number.isInteger(rawLimit) && rawLimit > 0
272
+ ? Math.min(rawLimit, SEARCH_MAX_LIMIT)
273
+ : SEARCH_DEFAULT_LIMIT;
274
+ const q = query.trim().toLowerCase();
275
+ const describe = (name: string) => {
276
+ const spec = MANIFEST_BY_NAME.get(name)!;
277
+ return {
278
+ name,
279
+ group: TOOL_GROUP[name] ?? null,
280
+ listed: isListed(PROFILE, name),
281
+ description: TOOL_DOCS[name]?.description ?? spec.description,
282
+ inputSchema: spec.inputSchema,
283
+ };
284
+ };
285
+ const candidates = MANIFEST.map((s) => s.name).filter((n) => AVAILABLE_NAMES.has(n));
286
+ let ranked: string[];
287
+ if (candidates.includes(q)) {
288
+ ranked = [q];
289
+ } else {
290
+ const tokens = tokenize(q);
291
+ const scored = candidates.map((name, order) => {
292
+ const nameTokens = tokenize(name);
293
+ const group = TOOL_GROUP[name] ?? "";
294
+ const docs = `${MANIFEST_BY_NAME.get(name)!.description} ${TOOL_DOCS[name]?.description ?? ""}`.toLowerCase();
295
+ let score = 0;
296
+ for (const t of tokens) {
297
+ if (nameTokens.includes(t)) score += 5;
298
+ else if (name.includes(t)) score += 3;
299
+ if (group === t || group.includes(t)) score += 2;
300
+ if (docs.includes(t)) score += 1;
301
+ }
302
+ return { name, order, score };
303
+ });
304
+ ranked = scored
305
+ .filter((s) => s.score > 0)
306
+ .sort((a, b) => b.score - a.score || a.order - b.order)
307
+ .map((s) => s.name);
308
+ }
309
+ const unavailable = AVAILABILITY.unavailable.find((u) => u.name === q);
310
+ return {
311
+ query,
312
+ total: ranked.length,
313
+ matches: ranked.slice(0, limit).map(describe),
314
+ ...(unavailable ? { unavailable: `${q} is not available under --browser ${BROWSER} (needs: ${unavailable.missing.join(", ")})` } : {}),
315
+ next: ranked.length ? "Run one with call_tool {name, arguments}." : "No match. Call search_tools with no query for the full catalog.",
316
+ };
317
+ }
318
+
232
319
  /** describe_tool body: full prose for one tool from the on-demand docs map. */
233
320
  function renderToolDoc(toolName: string): string {
234
321
  const doc = TOOL_DOCS[toolName];
@@ -273,9 +360,19 @@ export function buildServer(): Server {
273
360
  const { name } = request.params;
274
361
  const args = (request.params.arguments ?? {}) as unknown;
275
362
 
276
- // describe_tool is handled at the MCP layer (it reads only static docs) and never
277
- // enters the browser dispatch or a lease scope. With a name it documents one tool —
278
- // listed or not; with no name it returns the grouped catalog.
363
+ // The meta-tools are handled at the MCP layer (they read only static docs) and never
364
+ // enter the browser dispatch or a lease scope.
365
+ if (name === "search_tools") {
366
+ const { query, limit } = args as { query?: unknown; limit?: unknown };
367
+ if (typeof query !== "string" || query.trim() === "") {
368
+ return { content: [{ type: "text" as const, text: renderCatalog() }] };
369
+ }
370
+ return { content: [{ type: "text" as const, text: JSON.stringify(searchTools(query, limit), null, 2) }] };
371
+ }
372
+
373
+ // describe_tool is the 2.x inspect tool: no longer listed (search_tools {query:<name>}
374
+ // replaces it), still answered so 2.x callers keep working. With a name it documents
375
+ // one tool — listed or not; with no name it returns the grouped catalog.
279
376
  if (name === "describe_tool") {
280
377
  const requested = (args as { name?: unknown }).name;
281
378
  if (typeof requested !== "string" || requested === "") {
@@ -285,48 +382,73 @@ export function buildServer(): Server {
285
382
  return { content: [{ type: "text" as const, text: renderToolDoc(requested) }], isError: !known };
286
383
  }
287
384
 
288
- if (!AVAILABLE_NAMES.has(name)) {
289
- const gap = AVAILABILITY.unavailable.find((u) => u.name === name);
290
- return {
291
- content: [{ type: "text" as const, text: `unknown tool: ${name}${gap ? ` (not available under --browser ${BROWSER}, needs: ${gap.missing.join(", ")})` : ""}` }],
292
- isError: true,
293
- };
385
+ // call_tool unwraps to the inner tool and re-enters the SAME dispatch below, so the
386
+ // availability check, the lease scope (read off the INNER arguments) and the error
387
+ // shapes are identical to calling the tool directly. It exists because a model can
388
+ // only invoke tools its host put in front of it — an unlisted tool is callable by name
389
+ // over the wire, but not by a model whose host only forwards tools/list.
390
+ if (name === "call_tool") {
391
+ const inner = args as { name?: unknown; arguments?: unknown };
392
+ if (typeof inner.name !== "string" || inner.name === "") {
393
+ return { content: [{ type: "text" as const, text: "call_tool: `name` is required (find one with search_tools)" }], isError: true };
394
+ }
395
+ if (META_TOOLS.has(inner.name)) {
396
+ return { content: [{ type: "text" as const, text: `call_tool: '${inner.name}' is a meta-tool; call it directly` }], isError: true };
397
+ }
398
+ const innerArgs = inner.arguments ?? {};
399
+ if (typeof innerArgs !== "object" || Array.isArray(innerArgs)) {
400
+ return { content: [{ type: "text" as const, text: "call_tool: `arguments` must be an object" }], isError: true };
401
+ }
402
+ return runTool(inner.name, innerArgs);
294
403
  }
295
404
 
296
- // ONE lease scope per dispatch, wrapping BOTH backend branches. This is the
297
- // reason no tool takes a lease parameter: the token rides the async context
298
- // down to whichever resolution path the tool eventually reaches, so a tool
299
- // added tomorrow is covered with no action from whoever writes it. Reading
300
- // 'lease' off args here is the only place the MCP layer knows the key exists.
301
- return withLeaseScope(leaseFromArgs(args), async () => {
302
- if (BROWSER === "chrome") {
303
- const fn = dispatch[name];
304
- if (!fn) return { content: [{ type: "text" as const, text: `unknown tool: ${name}` }], isError: true };
305
- try {
306
- const result = await fn(args);
307
- return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
308
- } catch (err) {
309
- const message = err instanceof Error ? err.message : String(err);
310
- return { content: [{ type: "text" as const, text: `Error: ${message}` }], isError: true };
311
- }
312
- }
405
+ return runTool(name, args);
406
+ });
407
+
408
+ return server;
409
+ }
313
410
 
314
- // Firefox: one BiDi session memoized for the life of this server process (lifetime "session",
315
- // ADR-001); launched lazily on the first Firefox tool call, torn down on shutdown below.
316
- const neutralFn = neutralDispatch[name];
317
- if (!neutralFn) return { content: [{ type: "text" as const, text: `unknown tool: ${name}` }], isError: true };
411
+ /** Dispatch one browser tool by name: the path both a direct call and call_tool take. */
412
+ async function runTool(name: string, args: unknown) {
413
+ if (!AVAILABLE_NAMES.has(name)) {
414
+ const gap = AVAILABILITY.unavailable.find((u) => u.name === name);
415
+ return {
416
+ content: [{ type: "text" as const, text: `unknown tool: ${name}${gap ? ` (not available under --browser ${BROWSER}, needs: ${gap.missing.join(", ")})` : ""}` }],
417
+ isError: true,
418
+ };
419
+ }
420
+
421
+ // ONE lease scope per dispatch, wrapping BOTH backend branches. This is the
422
+ // reason no tool takes a lease parameter: the token rides the async context
423
+ // down to whichever resolution path the tool eventually reaches, so a tool
424
+ // added tomorrow is covered with no action from whoever writes it. Reading
425
+ // 'lease' off args here is the only place the MCP layer knows the key exists.
426
+ return withLeaseScope(leaseFromArgs(args), async () => {
427
+ if (BROWSER === "chrome") {
428
+ const fn = dispatch[name];
429
+ if (!fn) return { content: [{ type: "text" as const, text: `unknown tool: ${name}` }], isError: true };
318
430
  try {
319
- const session = await getOrCreateFirefoxSession({ endpoint: FIREFOX_ENDPOINT });
320
- const result = await neutralFn(session.driver, args);
431
+ const result = await fn(args);
321
432
  return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
322
433
  } catch (err) {
323
434
  const message = err instanceof Error ? err.message : String(err);
324
435
  return { content: [{ type: "text" as const, text: `Error: ${message}` }], isError: true };
325
436
  }
326
- });
327
- });
437
+ }
328
438
 
329
- return server;
439
+ // Firefox: one BiDi session memoized for the life of this server process (lifetime "session",
440
+ // ADR-001); launched lazily on the first Firefox tool call, torn down on shutdown below.
441
+ const neutralFn = neutralDispatch[name];
442
+ if (!neutralFn) return { content: [{ type: "text" as const, text: `unknown tool: ${name}` }], isError: true };
443
+ try {
444
+ const session = await getOrCreateFirefoxSession({ endpoint: FIREFOX_ENDPOINT });
445
+ const result = await neutralFn(session.driver, args);
446
+ return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
447
+ } catch (err) {
448
+ const message = err instanceof Error ? err.message : String(err);
449
+ return { content: [{ type: "text" as const, text: `Error: ${message}` }], isError: true };
450
+ }
451
+ });
330
452
  }
331
453
 
332
454
  /**
package/src/toolGroups.ts CHANGED
@@ -42,41 +42,72 @@ for (const group of TOOL_GROUPS) {
42
42
  }
43
43
  }
44
44
 
45
+ /**
46
+ * The 3.0 default listing: the five tools nearly every browser task touches. Everything
47
+ * else is reached through the search_tools / call_tool meta-tools (src/mcp.ts), so an
48
+ * eagerly-loading host pays for 7 schemas instead of 49. evaluate_script is the escape
49
+ * hatch that covers most of what the other 43 do.
50
+ */
51
+ export const GATEWAY_TOOLS = ["navigate_page", "take_snapshot", "click", "fill", "evaluate_script"] as const;
52
+
45
53
  export const PROFILES = {
54
+ gateway: [],
46
55
  core: ["core"],
47
56
  full: [...TOOL_GROUPS],
48
57
  } as const;
49
58
 
50
59
  export type ProfileName = keyof typeof PROFILES;
51
60
 
61
+ /** What tools/list advertises: every tool in `groups`, plus the individual `tools`. */
62
+ export interface ResolvedProfile {
63
+ groups: ReadonlySet<ToolGroup>;
64
+ tools: ReadonlySet<string>;
65
+ label: string;
66
+ }
67
+
68
+ /** True when a profile advertises `name` in tools/list. */
69
+ export function isListed(profile: ResolvedProfile, name: string): boolean {
70
+ const group = TOOL_GROUP[name];
71
+ return profile.tools.has(name) || (group !== undefined && profile.groups.has(group));
72
+ }
73
+
52
74
  /**
53
- * Resolve CDP_TOOL_PROFILE into the set of groups whose tools tools/list advertises.
75
+ * Resolve CDP_TOOL_PROFILE into what tools/list advertises.
54
76
  *
55
77
  * The profile is a STARTUP filter, read once: the listing it selects is fixed for the
56
78
  * life of the process (2.1 dropped the runtime `browser_tools` toggle, so the tool set
57
79
  * can never change as a side effect of another request — a spec MUST). Accepted
58
- * spellings: unset/empty or "full" (everything), "core" (the lean everyday set), or a
59
- * comma-separated list of group names. `core` is always included, so a list can never
60
- * strand the basics.
80
+ * spellings: unset/empty or "gateway" (the 5 GATEWAY_TOOLS; the 3.0 default), "full"
81
+ * (everything), "core" (the 12-tool everyday group), or a comma-separated list of group
82
+ * names, optionally including "gateway". A group list without "gateway" always includes
83
+ * `core`, so it can never strand the basics.
61
84
  */
62
- export function resolveProfile(spec: string | undefined): { groups: ReadonlySet<ToolGroup>; label: string } {
63
- const all = (): { groups: ReadonlySet<ToolGroup>; label: string } => ({ groups: new Set(TOOL_GROUPS), label: "full" });
85
+ export function resolveProfile(spec: string | undefined): ResolvedProfile {
86
+ const all = (): ResolvedProfile => ({ groups: new Set(TOOL_GROUPS), tools: new Set(), label: "full" });
64
87
  const raw = (spec ?? "").trim();
65
- if (raw === "" || raw.toLowerCase() === "full") return all();
88
+ if (raw === "") return { groups: new Set(), tools: new Set(GATEWAY_TOOLS), label: "gateway" };
89
+ if (raw.toLowerCase() === "full") return all();
66
90
 
67
91
  const tokens = raw.split(",").map((t) => t.trim().toLowerCase()).filter((t) => t !== "");
68
92
  if (tokens.includes("full")) return all();
69
93
 
94
+ const gateway = tokens.includes("gateway");
70
95
  const known = new Set<string>(TOOL_GROUPS);
71
- const picked = new Set<ToolGroup>(["core"]);
96
+ const picked = new Set<ToolGroup>(gateway ? [] : ["core"]);
72
97
  for (const token of tokens) {
98
+ if (token === "gateway") continue;
73
99
  if (!known.has(token)) {
74
- throw new Error(`CDP_TOOL_PROFILE: unknown tool group '${token}'. Known: full, core, ${TOOL_GROUPS.filter((g) => g !== "core").join(", ")}`);
100
+ throw new Error(`CDP_TOOL_PROFILE: unknown tool group '${token}'. Known: gateway, full, core, ${TOOL_GROUPS.filter((g) => g !== "core").join(", ")}`);
75
101
  }
76
102
  picked.add(token as ToolGroup);
77
103
  }
78
104
  if (picked.size === TOOL_GROUPS.length) return all();
79
105
  // Canonical label: TOOL_GROUPS order, not the order the caller typed, so the ready
80
106
  // line and the catalog header read the same for every equivalent spelling.
81
- return { groups: picked, label: TOOL_GROUPS.filter((g) => picked.has(g)).join(",") };
107
+ const groupLabel = TOOL_GROUPS.filter((g) => picked.has(g));
108
+ return {
109
+ groups: picked,
110
+ tools: new Set(gateway ? GATEWAY_TOOLS : []),
111
+ label: (gateway ? ["gateway", ...groupLabel] : groupLabel).join(","),
112
+ };
82
113
  }
package/src/version.ts CHANGED
@@ -6,4 +6,4 @@
6
6
  * ready line; keeping it in its own module means a release bump touches one
7
7
  * line here plus package.json, not a const buried in the server.
8
8
  */
9
- export const VERSION = "2.7.0";
9
+ export const VERSION = "3.0.0";