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 +27 -0
- package/README.md +79 -44
- package/dist/mcp.js +153 -40
- package/package.json +5 -3
- package/src/mcp.ts +175 -53
- package/src/toolGroups.ts +41 -10
- package/src/version.ts +1 -1
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,
|
|
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
|
|
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` | `
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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": "
|
|
251
|
-
{"tool": "
|
|
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
|
-
|
|
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
|
|
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
|
-
[
|
|
260
|
-
[
|
|
261
|
-
[
|
|
262
|
-
[
|
|
263
|
-
[
|
|
264
|
-
[
|
|
265
|
-
[
|
|
266
|
-
[
|
|
267
|
-
[
|
|
268
|
-
[
|
|
269
|
-
[
|
|
270
|
-
[
|
|
271
|
-
[
|
|
272
|
-
|
|
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
|
-
|
|
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
|
-
|
|
315
|
+
The default listing is **−86.4% bytes** vs 2.7.0's default.
|
|
278
316
|
|
|
279
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
324
|
+
Rules worth knowing:
|
|
291
325
|
|
|
292
|
-
- **Unlisted does not mean blocked.** Only discovery is filtered.
|
|
293
|
-
-
|
|
294
|
-
-
|
|
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*
|
|
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
|
|
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
|
|
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 `
|
|
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 === ""
|
|
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
|
-
|
|
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 = "
|
|
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
|
|
11415
|
-
"
|
|
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: "
|
|
11434
|
-
description: "
|
|
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
|
-
|
|
11457
|
+
query: {
|
|
11439
11458
|
type: "string",
|
|
11440
|
-
description: "
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
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 (
|
|
11497
|
-
const
|
|
11498
|
-
|
|
11499
|
-
content: [{ type: "text", text:
|
|
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
|
-
|
|
11517
|
-
|
|
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
|
|
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": "
|
|
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
|
|
160
|
-
"
|
|
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.
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
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
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
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: "
|
|
204
|
+
name: "search_tools",
|
|
200
205
|
description:
|
|
201
|
-
"
|
|
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
|
-
|
|
210
|
+
query: {
|
|
206
211
|
type: "string",
|
|
207
|
-
description: "
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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("
|
|
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
|
-
//
|
|
277
|
-
//
|
|
278
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 "
|
|
59
|
-
*
|
|
60
|
-
*
|
|
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):
|
|
63
|
-
const all = ():
|
|
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 === ""
|
|
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
|
-
|
|
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