cdp-toolkit 2.0.0 → 2.1.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 +31 -0
- package/README.md +60 -18
- package/dist/mcp.js +101 -103
- package/package.json +1 -1
- package/src/mcp.ts +147 -127
- package/src/toolGroups.ts +32 -0
- package/src/version.ts +9 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,37 @@ 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
|
+
## [2.1.0] - 2026-09-01
|
|
9
|
+
|
|
10
|
+
**Re-based on the MCP 2026-07-28 revision: `tools/list` is now one complete, deterministic, cacheable listing that never changes, and the runtime `browser_tools` activation toggle 2.0.0 shipped three days ago is removed.** The honest headline on cost: the **default** listing grows from ≈2,457 to **≈8,471 tokens** (46 entries, 33,886 bytes, measured over raw stdio against the real entry point), because a standards-conformant list must be complete and static — still **−58% against 1.x's ≈20,200**. `CDP_TOOL_PROFILE=core` keeps the lean ≈2,266-token surface (13 entries, 9,066 bytes, −89% vs 1.x) for hosts that eagerly load every schema. Tool names, parameters, and schema shapes are unchanged, now gated by a durable wire-vs-manifest deep-equal check rather than the one-off strip-descriptions comparison 1.10.0 and 2.0.0 used. No new runtime dependency.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **The 2026-07-28 protocol era, actually served.** `src/mcp.ts` connects through `serveStdio` (dual-era, default `legacy: 'serve'`) instead of `Server.connect(new StdioServerTransport())`. A modern client that opens with `server/discover` gets `supportedVersions: ["2026-07-28"]`, `resultType: "complete"`, and the `_meta['io.modelcontextprotocol/serverInfo']` envelope; a 2025-era client that opens with `initialize` still negotiates `2025-11-25` and sees no difference at all. The era is pinned per connection, and the server logs which one it pinned to stderr.
|
|
15
|
+
- **Cache hints on the listing.** `cacheHints` now declares `ttlMs: 3600000` + `cacheScope: "public"` for both `tools/list` and `server/discover` (emitted on modern-era connections), instead of the SDK's conservative `{ ttlMs: 0, cacheScope: 'private' }` default. The listing is identical for every caller — a stdio server has no per-caller auth — and a restart under a different `CDP_TOOL_PROFILE` or `--browser` is a different server configuration, not a mid-life change to this one.
|
|
16
|
+
- **`describe_tool` now doubles as the catalog.** Its `name` parameter became optional; called with no name it returns the grouped catalog of every tool the selected browser supports — a header line (`cdp-toolkit 2.1.0 · browser=chrome · 45 tools available, 46 in tools/list (CDP_TOOL_PROFILE=full)`), then one `[listed]`/`[hidden] <group> (n): names` line per group, then `Unlisted tools are callable by name; describe_tool {name} documents any of them.` The whole 45-tool catalog is 1,141 characters.
|
|
17
|
+
- **`CDP_TOOL_PROFILE` accepts a comma-separated group list** on top of `full` and `core` — e.g. `core,network,console` (17 entries, 11,893 bytes, ≈2,973 tokens). `core` is always included, so a list can never strand the basics, and the label is canonicalised to group order so equivalent spellings read the same. An unknown group name is now a configuration error rather than a silent fallback: the server prints `CDP_TOOL_PROFILE: unknown tool group 'x'. Known: full, core, input, …` and exits 1.
|
|
18
|
+
- **`src/version.ts`** — the `VERSION` constant moved out of `src/mcp.ts` into its own module, and a test asserts it equals `package.json`'s `version`. The two drifted apart between 1.11.0 and 2.0.0; that can no longer happen quietly.
|
|
19
|
+
- **`test/mcp-protocol.test.ts`** — raw-stdio protocol coverage: `server/discover` and `tools/list` field shapes, the cache hints, byte-identical deterministic ordering across repeated calls and connections, the **wire-vs-manifest deep-equal gate** (the durable replacement for the one-off strip-descriptions gate), the legacy `initialize` handshake, a `-32022` negative control, each profile, and proof that an unlisted tool is still callable. **`test/tool-profile.test.ts`** covers `resolveProfile` directly.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- **The tool listing is static.** It is computed once at startup — `describe_tool` first, then manifest order, filtered by backend availability and then by the profile — frozen, and returned as the same array to every `tools/list` on every connection. `capabilities.tools.listChanged` is now **`false`**; nothing will ever notify, because nothing ever changes. The 2026-07-28 revision makes it a MUST that a server's tool set not change as a side effect of other requests on the connection, and the client best-practices page notes that adding or removing tool definitions mid-conversation invalidates the host's prompt cache.
|
|
24
|
+
- **`CDP_TOOL_PROFILE` default flipped `core` → `full`.** Three reasons, in order of weight: (1) the standard puts lazy discovery on the **host** — the client runs its own catalog → inspect → execute funnel — and a host that defers schemas (Claude Code lists MCP tool *names* and loads schemas on demand) pays little for a complete list; (2) consumers that hold per-tool permission allowlists, or per-tool interception keyed on the tool NAME, for non-core tools (the console and network readers, `performance_analyze_insight`) were silently broken by a `core` default, because those tools were simply absent from `tools/list`; (3) a list that never changes never invalidates the prompt cache. `core` remains a first-class choice for hosts that eagerly load every schema — it is now a deployment decision, not a default imposed on everyone.
|
|
25
|
+
- **Host protocol eras, MEASURED 2026-09-01.** Two real hosts were pointed at the 2.1 server through a wrapper that tees its stderr: Claude Code 2.1.258 and Codex CLI 0.147.0 both connected and both logged `connection pinned to the legacy protocol era` — today they still open with `initialize` even though their binaries carry the 2026-07-28 client strings (capability present, not exercised by default). The SDK 2.0.0 `Client` does the same unless `versionNegotiation` opts in. So the dual-era default is load-bearing, not courtesy: a modern-only server would be unreachable from every host measured. The modern era is currently exercised by the raw-stdio test suite and by `mcp:smoke`'s pinned client. The static listing pays off on either era; the `ttlMs`/`cacheScope` hints are emitted only on modern connections.
|
|
26
|
+
- **The server `instructions` block** trades the activation walkthrough for how to read a fixed, cacheable listing (`tools/list` is complete and fixed for this process's life — cache it; `describe_tool {name}` / `describe_tool {}`; profile semantics; unlisted tools stay callable). 2,019 → 1,976 characters, still under the 2 KB cap.
|
|
27
|
+
- **SDK usage, deliberately unchanged in shape.** The server stays on the low-level `Server` with string-method `setRequestHandler('tools/list', …)`, which SDK 2.0 still supports for advanced use cases (and which `McpServer` itself is built on; `McpServerFactory` explicitly accepts a `Server`). `McpServer.registerTool` was evaluated and rejected on two grounds: it wants a Standard-Schema (zod) object for `inputSchema`, and `CONTRACT.md` rule 1 forbids any runtime dependency beyond the SDK — our manifest is plain JSON Schema — and it cannot express "unlisted tools remain callable by name", which is the whole point of `CDP_TOOL_PROFILE`.
|
|
28
|
+
- **`test/mcp-smoke.ts`** additionally connects with the SDK client pinned to `2026-07-28` and asserts the modern era end to end, alongside the existing legacy handshake and live `tools/call` round trip.
|
|
29
|
+
- Under `CDP_BROWSER=firefox` the default listing is 35 entries (26,533 bytes, ≈6,633 tokens): the 34 tools Firefox's BiDi surface supports, plus `describe_tool`.
|
|
30
|
+
|
|
31
|
+
### Removed
|
|
32
|
+
|
|
33
|
+
- **`browser_tools`** — the `list_groups`/`activate`/`deactivate`/`set_profile` meta-tool, removed three days after 2.0.0 shipped it. Mutating the advertised tool set at runtime and firing `notifications/tools/list_changed` is precisely what the 2026-07-28 revision forbids (the tool set MUST NOT change as a side effect of other requests) and what the client best-practices page warns invalidates the host's prompt cache. Its open question — "does any given client actually re-fetch on `listChanged`?" — becomes moot once the list is static. Calling it now returns `unknown tool: browser_tools`; nothing else needs it, because every tool it could have activated is either already listed under the new `full` default or reachable by name under a narrower profile, and `describe_tool {}` covers what `list_groups` reported.
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
- **v2.0.0 was not on the current protocol revision, despite running on the 2.0 SDK.** Probed raw over stdio, it answered `server/discover` with `-32601 Method not found` and served a `tools/list` carrying no `resultType`, `ttlMs`, or `cacheScope`: a 2025-11-25 (legacy-era) server, because `Server.connect(new StdioServerTransport())` **is** the legacy path. The `"2026-07-28"` literal that appears inside the SDK packages is `SUPPORTED_MODERN_PROTOCOL_VERSIONS` in `@modelcontextprotocol/server/stdio` — the modern era is reachable only through `serveStdio` (or the HTTP `createMcpHandler`), never through `connect()`. Both eras are now served.
|
|
38
|
+
|
|
8
39
|
## [2.0.0] - 2026-08-29
|
|
9
40
|
|
|
10
41
|
**Progressive disclosure: the default `tools/list` an agent loads shrinks ~88% (≈20,200 → ≈2,500 tokens) to 12 core tools plus two meta-tools, with the full 45-tool surface, and the full per-tool documentation, one `browser_tools` call away.** This is the first npm publication since 1.11.0, so it also **carries the [1.11.1] changes below** (the two Firefox BiDi navigation fixes) that landed on main but were never released. Tool names, parameters, and schema shapes are unchanged — a strip-descriptions deep-equal of the manifest gates the change, the same check 1.10.0 used.
|
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. **45 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. **45 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).
|
|
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
|
|
|
@@ -129,7 +129,7 @@ bun run mcp:smoke # spawn the server + a real initialize/tools-list/tools-call
|
|
|
129
129
|
|
|
130
130
|
- **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
131
|
- **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
|
|
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` (≈8.5k tokens, −58% 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 45 regardless. They coexist with `chrome-devtools-mcp` in a separate namespace.
|
|
133
133
|
- **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
134
|
- **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
135
|
- **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.
|
|
@@ -224,32 +224,73 @@ await TOOLS.navigate_page({ target: "index:0", url: "https://example.com" });
|
|
|
224
224
|
| `CDP_STATE_DIR` | `/tmp/cdp-toolkit` | `select_page` selected-target file, in-flight trace state. |
|
|
225
225
|
| `CDP_LEASE_TTL_MS` | `900000` | How long a tab lease survives without use before another agent can reclaim it. Refreshed on every checked call. |
|
|
226
226
|
| `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. |
|
|
227
|
-
| `CDP_TOOL_PROFILE` | `
|
|
227
|
+
| `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. |
|
|
228
228
|
| `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. |
|
|
229
229
|
| `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. |
|
|
230
230
|
| `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. |
|
|
231
231
|
|
|
232
232
|
## Progressive disclosure
|
|
233
233
|
|
|
234
|
-
|
|
234
|
+
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.
|
|
235
235
|
|
|
236
|
-
The
|
|
236
|
+
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`.
|
|
237
237
|
|
|
238
|
-
`
|
|
238
|
+
**`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:
|
|
239
239
|
|
|
240
240
|
```json
|
|
241
|
-
{"tool": "browser_tools", "arguments": {"action": "list_groups"}}
|
|
242
|
-
{"tool": "browser_tools", "arguments": {"action": "activate", "group": "mocking"}}
|
|
243
241
|
{"tool": "describe_tool", "arguments": {"name": "wait_for_download"}}
|
|
242
|
+
{"tool": "describe_tool", "arguments": {}}
|
|
244
243
|
```
|
|
245
244
|
|
|
246
|
-
|
|
245
|
+
With no `name` it returns the grouped catalog of everything this server can run. The whole 45-tool surface costs 1,141 characters:
|
|
246
|
+
|
|
247
|
+
```
|
|
248
|
+
cdp-toolkit 2.1.0 · browser=chrome · 45 tools available, 46 in tools/list (CDP_TOOL_PROFILE=full)
|
|
249
|
+
[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
|
|
250
|
+
[listed] input (7): hover, drag, scroll, dispatch_mouse, press_key, fill_form, upload_file
|
|
251
|
+
[listed] cookies (3): list_cookies, set_cookie, delete_cookies
|
|
252
|
+
[listed] network (2): list_network_requests, get_network_request
|
|
253
|
+
[listed] console (2): list_console_messages, get_console_message
|
|
254
|
+
[listed] mocking (3): mock_request, list_mocks, clear_mocks
|
|
255
|
+
[listed] emulation (2): emulate, resize_page
|
|
256
|
+
[listed] performance (6): performance_start_trace, performance_stop_trace, performance_analyze_insight, performance_trace, take_heapsnapshot, lighthouse_audit
|
|
257
|
+
[listed] recording (2): start_screen_recording, stop_screen_recording
|
|
258
|
+
[listed] leases (3): claim_page, release_page, list_leases
|
|
259
|
+
[listed] permissions (1): grant_permissions
|
|
260
|
+
[listed] dialogs (1): handle_dialog
|
|
261
|
+
[listed] downloads (1): wait_for_download
|
|
262
|
+
Unlisted tools are callable by name; describe_tool {name} documents any of them.
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Under a narrower profile the groups it leaves out read `[hidden]` instead of `[listed]`, and the header's second count drops accordingly.
|
|
266
|
+
|
|
267
|
+
**`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):
|
|
268
|
+
|
|
269
|
+
| `CDP_TOOL_PROFILE` | entries in `tools/list` | bytes | ≈ tokens |
|
|
270
|
+
|---|---|---|---|
|
|
271
|
+
| unset / `full` — the default | 46 | 33,886 | ≈8,471 |
|
|
272
|
+
| `core` | 13 | 9,066 | ≈2,266 |
|
|
273
|
+
| `core,network,console` | 17 | 11,893 | ≈2,973 |
|
|
274
|
+
| `full` under `CDP_BROWSER=firefox` | 35 | 26,533 | ≈6,633 |
|
|
275
|
+
|
|
276
|
+
For comparison, 1.x advertised 45 tools with full prose at roughly 20,200 tokens. So the default listing is **−58% vs 1.x**, and `core` is **−89%**.
|
|
277
|
+
|
|
278
|
+
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.
|
|
247
279
|
|
|
248
280
|
Three rules worth knowing:
|
|
249
281
|
|
|
250
|
-
- **
|
|
251
|
-
- **`describe_tool` works for any tool by name,
|
|
252
|
-
- **
|
|
282
|
+
- **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.
|
|
283
|
+
- **`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.
|
|
284
|
+
- **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 45 tools no matter how `CDP_TOOL_PROFILE` is set.
|
|
285
|
+
|
|
286
|
+
### MCP protocol eras
|
|
287
|
+
|
|
288
|
+
The server serves **both** eras off the same stdio transport, and the era is pinned per connection by how the client opens:
|
|
289
|
+
|
|
290
|
+
- A modern client opens with **`server/discover`** and gets `protocolVersion` `2026-07-28`, `resultType: "complete"`, the `_meta['io.modelcontextprotocol/serverInfo']` envelope, and the `ttlMs`/`cacheScope` cache hints on both `server/discover` and `tools/list`.
|
|
291
|
+
- A 2025-era client opens with **`initialize`** and gets `protocolVersion` `2025-11-25`, exactly as before — this upgrade is invisible to it.
|
|
292
|
+
|
|
293
|
+
Measured 2026-09-01: Claude Code 2.1.258 and Codex CLI 0.147.0 both connect and both pin the **legacy** era — their binaries carry the 2026-07-28 client strings, but neither opens with `server/discover` by default, and the MCP SDK's own `Client` behaves the same unless it opts in. So serving both eras is load-bearing, not courtesy, and today the `ttlMs`/`cacheScope` hints reach only clients that ask for the modern era. The static listing pays off on either one — no mid-session tool churn, and no prompt-cache invalidation.
|
|
253
294
|
|
|
254
295
|
## Firefox (WebDriver BiDi)
|
|
255
296
|
|
|
@@ -327,7 +368,7 @@ This coordinates and recovers **around** Firefox's one-session limit; it does no
|
|
|
327
368
|
|
|
328
369
|
**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.
|
|
329
370
|
|
|
330
|
-
**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
|
|
371
|
+
**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:
|
|
331
372
|
|
|
332
373
|
- `performance_start_trace`, `performance_stop_trace`, `performance_analyze_insight`, `performance_trace` (needs `trace.performance`)
|
|
333
374
|
- `take_heapsnapshot` (needs `heap.snapshot`)
|
|
@@ -336,7 +377,7 @@ This coordinates and recovers **around** Firefox's one-session limit; it does no
|
|
|
336
377
|
- `dispatch_mouse` (needs `input.raw`): the raw move/down/up primitive is a direct `Input.dispatchMouseEvent` wrapper with no BiDi analogue.
|
|
337
378
|
- `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.
|
|
338
379
|
|
|
339
|
-
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, 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: 34 of the 45 tools. Under the MCP server's default
|
|
380
|
+
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, 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: 34 of the 45 tools. Under `CDP_BROWSER=firefox` the MCP server's default (`full`) listing is therefore 35 entries — those 34 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 agents under Firefox share and serialize on that single session (coordinated by the cross-process session lease above), not the independent concurrent sessions Chrome's unlimited CDP connections allow — see "Parallel tabs" below.
|
|
340
381
|
|
|
341
382
|
**Honest capability gaps, not oversold parity:**
|
|
342
383
|
|
|
@@ -351,7 +392,7 @@ Everything else, including `mock_request`/`list_mocks`/`clear_mocks` (Firefox's
|
|
|
351
392
|
|
|
352
393
|
## The tools (29 parity + 16 superset = 45)
|
|
353
394
|
|
|
354
|
-
This table is the **full-profile view** — all 45 tools, which is what `cdp --list` and `CDP_TOOL_PROFILE=full` show.
|
|
395
|
+
This table is the **full-profile view** — all 45 tools, which is what `cdp --list` and the MCP server's default listing (`CDP_TOOL_PROFILE=full`) both show. They are partitioned into 13 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` (7: hover, drag, scroll, dispatch_mouse, press_key, fill_form, upload_file), `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). `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).
|
|
355
396
|
|
|
356
397
|
The 29 parity tools are 1:1 with `chrome-devtools-mcp`; the 16 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`, and `grant_permissions`) are toolkit additions. Each row notes the underlying CDP method(s) and the precise parity gaps.
|
|
357
398
|
|
|
@@ -397,7 +438,7 @@ The 29 parity tools are 1:1 with `chrome-devtools-mcp`; the 16 superset tools (`
|
|
|
397
438
|
| `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`). |
|
|
398
439
|
| `list_mocks` *(superset)* | `Runtime.evaluate` (liveness probe) | Lists active mock sessions with rules + hit counts; prunes sessions whose tab closed. |
|
|
399
440
|
| `clear_mocks` *(superset)* | `Fetch.disable` | Tears down the resolved target's mock session (or all with `all:true`). |
|
|
400
|
-
| `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 `
|
|
441
|
+
| `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. |
|
|
401
442
|
| `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`. |
|
|
402
443
|
| `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. |
|
|
403
444
|
| `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`). |
|
|
@@ -555,9 +596,10 @@ src/
|
|
|
555
596
|
types.ts # Target, TargetSelector, Uid, CDP envelopes
|
|
556
597
|
index.ts # TOOLS registry (45) + re-exported client primitives
|
|
557
598
|
cli.ts # the Bun CLI
|
|
558
|
-
mcp.ts # stdio MCP server (
|
|
599
|
+
mcp.ts # stdio MCP server, dual-era (serveStdio): static cacheable tools/list
|
|
600
|
+
version.ts # the VERSION constant, single source of truth with package.json
|
|
559
601
|
manifest.ts # JSON Schemas advertised by the MCP server (one per tool)
|
|
560
|
-
toolGroups.ts # tool-to-group map +
|
|
602
|
+
toolGroups.ts # tool-to-group map + CDP_TOOL_PROFILE resolution (startup-only filter)
|
|
561
603
|
toolDocs.ts # full per-tool prose served on demand by describe_tool
|
|
562
604
|
leases.ts # lease records, staleness, reclamation, assertLeaseOk, CDP_REQUIRE_LEASE
|
|
563
605
|
leases-tools.ts # claim_page / release_page / list_leases
|
package/dist/mcp.js
CHANGED
|
@@ -5201,7 +5201,7 @@ var TOOL_NAMES = Object.keys(TOOLS);
|
|
|
5201
5201
|
|
|
5202
5202
|
// src/mcp.ts
|
|
5203
5203
|
import { Server } from "@modelcontextprotocol/server";
|
|
5204
|
-
import {
|
|
5204
|
+
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
5205
5205
|
|
|
5206
5206
|
// src/manifest.ts
|
|
5207
5207
|
var MANIFEST = [
|
|
@@ -9317,6 +9317,26 @@ var PROFILES = {
|
|
|
9317
9317
|
core: ["core"],
|
|
9318
9318
|
full: [...TOOL_GROUPS]
|
|
9319
9319
|
};
|
|
9320
|
+
function resolveProfile(spec) {
|
|
9321
|
+
const all = () => ({ groups: new Set(TOOL_GROUPS), label: "full" });
|
|
9322
|
+
const raw = (spec ?? "").trim();
|
|
9323
|
+
if (raw === "" || raw.toLowerCase() === "full")
|
|
9324
|
+
return all();
|
|
9325
|
+
const tokens = raw.split(",").map((t) => t.trim().toLowerCase()).filter((t) => t !== "");
|
|
9326
|
+
if (tokens.includes("full"))
|
|
9327
|
+
return all();
|
|
9328
|
+
const known = new Set(TOOL_GROUPS);
|
|
9329
|
+
const picked = new Set(["core"]);
|
|
9330
|
+
for (const token of tokens) {
|
|
9331
|
+
if (!known.has(token)) {
|
|
9332
|
+
throw new Error(`CDP_TOOL_PROFILE: unknown tool group '${token}'. Known: full, core, ${TOOL_GROUPS.filter((g) => g !== "core").join(", ")}`);
|
|
9333
|
+
}
|
|
9334
|
+
picked.add(token);
|
|
9335
|
+
}
|
|
9336
|
+
if (picked.size === TOOL_GROUPS.length)
|
|
9337
|
+
return all();
|
|
9338
|
+
return { groups: picked, label: TOOL_GROUPS.filter((g) => picked.has(g)).join(",") };
|
|
9339
|
+
}
|
|
9320
9340
|
|
|
9321
9341
|
// src/toolDocs.ts
|
|
9322
9342
|
var TOOL_DOCS = {
|
|
@@ -9785,8 +9805,10 @@ var TOOL_DOCS = {
|
|
|
9785
9805
|
}
|
|
9786
9806
|
};
|
|
9787
9807
|
|
|
9808
|
+
// src/version.ts
|
|
9809
|
+
var VERSION = "2.1.0";
|
|
9810
|
+
|
|
9788
9811
|
// src/mcp.ts
|
|
9789
|
-
var VERSION = "2.0.0";
|
|
9790
9812
|
var { kind: BROWSER, endpoint: FIREFOX_ENDPOINT } = resolveBackend(process.argv.slice(2));
|
|
9791
9813
|
var AVAILABILITY = toolAvailability(BROWSER);
|
|
9792
9814
|
var AVAILABLE_NAMES = new Set(AVAILABILITY.available);
|
|
@@ -9804,51 +9826,50 @@ function auditCoverage() {
|
|
|
9804
9826
|
}
|
|
9805
9827
|
var INSTRUCTIONS = [
|
|
9806
9828
|
"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:",
|
|
9807
|
-
"TOOL AVAILABILITY:
|
|
9829
|
+
"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.",
|
|
9808
9830
|
"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, from the origin ledger or a live lease) | a 32-hex '<targetId>'. 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 extension's background worker (worker:<extension-id> or worker:background.js); an idle-evicted worker is started first (see each tool's `wake`).",
|
|
9809
9831
|
"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. Without CDP_REQUIRE_LEASE an unleased tab needs no token at all.",
|
|
9810
9832
|
"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."
|
|
9811
9833
|
].join(`
|
|
9812
9834
|
|
|
9813
9835
|
`);
|
|
9814
|
-
var
|
|
9815
|
-
|
|
9816
|
-
|
|
9836
|
+
var PROFILE = startupProfile();
|
|
9837
|
+
function startupProfile() {
|
|
9838
|
+
try {
|
|
9839
|
+
return resolveProfile(process.env.CDP_TOOL_PROFILE);
|
|
9840
|
+
} catch (err) {
|
|
9841
|
+
console.error(`[cdp-toolkit] ${err instanceof Error ? err.message : String(err)}`);
|
|
9842
|
+
process.exit(1);
|
|
9843
|
+
}
|
|
9844
|
+
}
|
|
9845
|
+
var LISTING = Object.freeze([
|
|
9817
9846
|
{
|
|
9818
|
-
name: "
|
|
9819
|
-
description: "
|
|
9847
|
+
name: "describe_tool",
|
|
9848
|
+
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.",
|
|
9820
9849
|
inputSchema: {
|
|
9821
9850
|
type: "object",
|
|
9822
9851
|
properties: {
|
|
9823
|
-
|
|
9852
|
+
name: {
|
|
9824
9853
|
type: "string",
|
|
9825
|
-
|
|
9826
|
-
|
|
9827
|
-
},
|
|
9828
|
-
group: { type: "string", enum: [...TOOL_GROUPS], description: "Group for activate/deactivate." },
|
|
9829
|
-
profile: { type: "string", enum: ["core", "full"], description: "Profile for set_profile (core=lean, full=all groups)." }
|
|
9854
|
+
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."
|
|
9855
|
+
}
|
|
9830
9856
|
},
|
|
9831
|
-
required: ["action"],
|
|
9832
9857
|
additionalProperties: false
|
|
9833
9858
|
}
|
|
9834
9859
|
},
|
|
9835
|
-
{
|
|
9836
|
-
|
|
9837
|
-
|
|
9838
|
-
|
|
9839
|
-
|
|
9840
|
-
|
|
9841
|
-
required: ["name"],
|
|
9842
|
-
additionalProperties: false
|
|
9843
|
-
}
|
|
9844
|
-
}
|
|
9845
|
-
];
|
|
9846
|
-
function renderGroups() {
|
|
9847
|
-
const lines = [`profile start=${START_PROFILE}; active groups: ${[...activeGroups].join(", ") || "(none)"}`];
|
|
9860
|
+
...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 }))
|
|
9861
|
+
]);
|
|
9862
|
+
function renderCatalog() {
|
|
9863
|
+
const lines = [
|
|
9864
|
+
`cdp-toolkit ${VERSION} · browser=${BROWSER} · ${AVAILABILITY.available.length} tools available, ${LISTING.length} in tools/list (CDP_TOOL_PROFILE=${PROFILE.label})`
|
|
9865
|
+
];
|
|
9848
9866
|
for (const g of TOOL_GROUPS) {
|
|
9849
9867
|
const shown = GROUP_TOOLS[g].filter((n) => AVAILABLE_NAMES.has(n));
|
|
9850
|
-
|
|
9868
|
+
if (!shown.length)
|
|
9869
|
+
continue;
|
|
9870
|
+
lines.push(`${PROFILE.groups.has(g) ? "[listed]" : "[hidden]"} ${g} (${shown.length}): ${shown.join(", ")}`);
|
|
9851
9871
|
}
|
|
9872
|
+
lines.push("Unlisted tools are callable by name; describe_tool {name} documents any of them.");
|
|
9852
9873
|
return lines.join(`
|
|
9853
9874
|
`);
|
|
9854
9875
|
}
|
|
@@ -9867,93 +9888,67 @@ ${doc.description}
|
|
|
9867
9888
|
Parameters:
|
|
9868
9889
|
${body}`;
|
|
9869
9890
|
}
|
|
9870
|
-
var
|
|
9871
|
-
|
|
9872
|
-
|
|
9873
|
-
|
|
9874
|
-
|
|
9875
|
-
|
|
9876
|
-
})
|
|
9877
|
-
server.setRequestHandler("tools/
|
|
9878
|
-
|
|
9879
|
-
|
|
9880
|
-
|
|
9881
|
-
|
|
9882
|
-
|
|
9883
|
-
|
|
9884
|
-
|
|
9885
|
-
if (name === "browser_tools") {
|
|
9886
|
-
const a = args;
|
|
9887
|
-
const action = typeof a.action === "string" ? a.action : "";
|
|
9888
|
-
const before = [...activeGroups].sort().join(",");
|
|
9889
|
-
if (action === "list_groups") {
|
|
9890
|
-
return { content: [{ type: "text", text: renderGroups() }] };
|
|
9891
|
-
}
|
|
9892
|
-
if (action === "activate" || action === "deactivate") {
|
|
9893
|
-
const group = typeof a.group === "string" ? a.group : "";
|
|
9894
|
-
if (!TOOL_GROUPS.includes(group)) {
|
|
9895
|
-
return { content: [{ type: "text", text: `unknown group: ${group}. Known: ${TOOL_GROUPS.join(", ")}` }], isError: true };
|
|
9891
|
+
var LIST_CACHE_HINT = { ttlMs: 3600000, cacheScope: "public" };
|
|
9892
|
+
function buildServer() {
|
|
9893
|
+
const server = new Server({ name: "cdp-toolkit", version: VERSION }, {
|
|
9894
|
+
capabilities: { tools: { listChanged: false } },
|
|
9895
|
+
instructions: INSTRUCTIONS,
|
|
9896
|
+
cacheHints: { "tools/list": LIST_CACHE_HINT, "server/discover": LIST_CACHE_HINT }
|
|
9897
|
+
});
|
|
9898
|
+
server.setRequestHandler("tools/list", async () => ({ tools: LISTING }));
|
|
9899
|
+
server.setRequestHandler("tools/call", async (request) => {
|
|
9900
|
+
const { name } = request.params;
|
|
9901
|
+
const args = request.params.arguments ?? {};
|
|
9902
|
+
if (name === "describe_tool") {
|
|
9903
|
+
const requested = args.name;
|
|
9904
|
+
if (typeof requested !== "string" || requested === "") {
|
|
9905
|
+
return { content: [{ type: "text", text: renderCatalog() }] };
|
|
9896
9906
|
}
|
|
9897
|
-
|
|
9898
|
-
|
|
9899
|
-
|
|
9900
|
-
|
|
9901
|
-
|
|
9902
|
-
|
|
9903
|
-
|
|
9904
|
-
|
|
9905
|
-
|
|
9906
|
-
for (const g of PROFILES[profile])
|
|
9907
|
-
activeGroups.add(g);
|
|
9908
|
-
} else {
|
|
9909
|
-
return { content: [{ type: "text", text: `unknown action: ${action}. Use list_groups | activate | deactivate | set_profile.` }], isError: true };
|
|
9907
|
+
const known = Boolean(TOOL_DOCS[requested]);
|
|
9908
|
+
return { content: [{ type: "text", text: renderToolDoc(requested) }], isError: !known };
|
|
9909
|
+
}
|
|
9910
|
+
if (!AVAILABLE_NAMES.has(name)) {
|
|
9911
|
+
const gap = AVAILABILITY.unavailable.find((u) => u.name === name);
|
|
9912
|
+
return {
|
|
9913
|
+
content: [{ type: "text", text: `unknown tool: ${name}${gap ? ` (not available under --browser ${BROWSER}, needs: ${gap.missing.join(", ")})` : ""}` }],
|
|
9914
|
+
isError: true
|
|
9915
|
+
};
|
|
9910
9916
|
}
|
|
9911
|
-
|
|
9912
|
-
|
|
9913
|
-
|
|
9914
|
-
|
|
9915
|
-
|
|
9917
|
+
return withLeaseScope(leaseFromArgs(args), async () => {
|
|
9918
|
+
if (BROWSER === "chrome") {
|
|
9919
|
+
const fn = dispatch[name];
|
|
9920
|
+
if (!fn)
|
|
9921
|
+
return { content: [{ type: "text", text: `unknown tool: ${name}` }], isError: true };
|
|
9922
|
+
try {
|
|
9923
|
+
const result = await fn(args);
|
|
9924
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
9925
|
+
} catch (err) {
|
|
9926
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
9927
|
+
return { content: [{ type: "text", text: `Error: ${message}` }], isError: true };
|
|
9928
|
+
}
|
|
9916
9929
|
}
|
|
9917
|
-
|
|
9918
|
-
|
|
9919
|
-
}
|
|
9920
|
-
if (!AVAILABLE_NAMES.has(name)) {
|
|
9921
|
-
const gap = AVAILABILITY.unavailable.find((u) => u.name === name);
|
|
9922
|
-
return {
|
|
9923
|
-
content: [{ type: "text", text: `unknown tool: ${name}${gap ? ` (not available under --browser ${BROWSER}, needs: ${gap.missing.join(", ")})` : ""}` }],
|
|
9924
|
-
isError: true
|
|
9925
|
-
};
|
|
9926
|
-
}
|
|
9927
|
-
return withLeaseScope(leaseFromArgs(args), async () => {
|
|
9928
|
-
if (BROWSER === "chrome") {
|
|
9929
|
-
const fn = dispatch[name];
|
|
9930
|
-
if (!fn)
|
|
9930
|
+
const neutralFn = neutralDispatch[name];
|
|
9931
|
+
if (!neutralFn)
|
|
9931
9932
|
return { content: [{ type: "text", text: `unknown tool: ${name}` }], isError: true };
|
|
9932
9933
|
try {
|
|
9933
|
-
const
|
|
9934
|
+
const session2 = await getOrCreateFirefoxSession({ endpoint: FIREFOX_ENDPOINT });
|
|
9935
|
+
const result = await neutralFn(session2.driver, args);
|
|
9934
9936
|
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
9935
9937
|
} catch (err) {
|
|
9936
9938
|
const message = err instanceof Error ? err.message : String(err);
|
|
9937
9939
|
return { content: [{ type: "text", text: `Error: ${message}` }], isError: true };
|
|
9938
9940
|
}
|
|
9939
|
-
}
|
|
9940
|
-
const neutralFn = neutralDispatch[name];
|
|
9941
|
-
if (!neutralFn)
|
|
9942
|
-
return { content: [{ type: "text", text: `unknown tool: ${name}` }], isError: true };
|
|
9943
|
-
try {
|
|
9944
|
-
const session2 = await getOrCreateFirefoxSession({ endpoint: FIREFOX_ENDPOINT });
|
|
9945
|
-
const result = await neutralFn(session2.driver, args);
|
|
9946
|
-
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
9947
|
-
} catch (err) {
|
|
9948
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
9949
|
-
return { content: [{ type: "text", text: `Error: ${message}` }], isError: true };
|
|
9950
|
-
}
|
|
9941
|
+
});
|
|
9951
9942
|
});
|
|
9952
|
-
|
|
9943
|
+
return server;
|
|
9944
|
+
}
|
|
9953
9945
|
auditCoverage();
|
|
9954
9946
|
markLongLivedProcess();
|
|
9955
|
-
|
|
9956
|
-
console.error(`[cdp-toolkit]
|
|
9947
|
+
var handle = serveStdio(({ era }) => {
|
|
9948
|
+
console.error(`[cdp-toolkit] connection pinned to the ${era} protocol era`);
|
|
9949
|
+
return buildServer();
|
|
9950
|
+
}, { onerror: (err) => console.error(`[cdp-toolkit] stdio: ${err.message}`) });
|
|
9951
|
+
console.error(`[cdp-toolkit] MCP server v${VERSION} ready, browser=${BROWSER}${FIREFOX_ENDPOINT ? ` (attach ${FIREFOX_ENDPOINT})` : ""}, ${AVAILABILITY.available.length} tools available, ${LISTING.length} listed (CDP_TOOL_PROFILE=${PROFILE.label}), CDP_BASE=${BASE}`);
|
|
9957
9952
|
var shuttingDown = false;
|
|
9958
9953
|
async function shutdown() {
|
|
9959
9954
|
if (shuttingDown)
|
|
@@ -9961,6 +9956,9 @@ async function shutdown() {
|
|
|
9961
9956
|
shuttingDown = true;
|
|
9962
9957
|
await disposeFirefoxSession();
|
|
9963
9958
|
await disposeBrowserSession();
|
|
9959
|
+
await handle.close().catch(() => {
|
|
9960
|
+
return;
|
|
9961
|
+
});
|
|
9964
9962
|
process.exit(0);
|
|
9965
9963
|
}
|
|
9966
9964
|
process.on("SIGINT", () => void shutdown());
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cdp-toolkit",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.0",
|
|
4
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. 45 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.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/mcp.ts
CHANGED
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
*
|
|
11
11
|
* Launch: `bunx -y cdp-toolkit` (or `bun run src/mcp.ts` from a checkout)
|
|
12
12
|
* Config: CDP_BASE (default http://127.0.0.1:9222), CDP_TIMEOUT_MS, CDP_ARTIFACT_DIR,
|
|
13
|
+
* CDP_TOOL_PROFILE (`full` — the default — advertises every group; `core` advertises
|
|
14
|
+
* just the 12 everyday tools; or a comma-separated group list, e.g. `core,network`.
|
|
15
|
+
* Startup-only filter on what tools/list shows; unlisted tools stay callable by name),
|
|
13
16
|
* CDP_FIREFOX_ENDPOINT (attach to a user-launched Firefox instead of spawning one;
|
|
14
17
|
* see backend.ts's ATTACH mode).
|
|
15
18
|
*
|
|
@@ -17,7 +20,7 @@
|
|
|
17
20
|
*/
|
|
18
21
|
import { Server } from "@modelcontextprotocol/server";
|
|
19
22
|
import type { Tool } from "@modelcontextprotocol/server";
|
|
20
|
-
import {
|
|
23
|
+
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
21
24
|
import { TOOLS, TOOL_NAMES, BASE } from "./index.ts";
|
|
22
25
|
import { MANIFEST } from "./manifest.ts";
|
|
23
26
|
import { resolveBackend, getOrCreateFirefoxSession, disposeFirefoxSession } from "./backend.ts";
|
|
@@ -25,10 +28,9 @@ import { disposeBrowserSession } from "./tools/browser-session.ts";
|
|
|
25
28
|
import { toolAvailability } from "./capabilities.ts";
|
|
26
29
|
import { FIREFOX_TOOLS } from "./firefox-tools.ts";
|
|
27
30
|
import { leaseFromArgs, markLongLivedProcess, withLeaseScope } from "./leases.ts";
|
|
28
|
-
import { TOOL_GROUP, TOOL_GROUPS, GROUP_TOOLS
|
|
31
|
+
import { resolveProfile, TOOL_GROUP, TOOL_GROUPS, GROUP_TOOLS } from "./toolGroups.ts";
|
|
29
32
|
import { TOOL_DOCS } from "./toolDocs.ts";
|
|
30
|
-
|
|
31
|
-
const VERSION = "2.0.0";
|
|
33
|
+
import { VERSION } from "./version.ts";
|
|
32
34
|
|
|
33
35
|
// Backend + (Firefox only) attach endpoint are read once at startup (MCP has no per-call notion
|
|
34
36
|
// of backend): --browser flag / CDP_BROWSER env, else "chrome" (zero behavior change for existing
|
|
@@ -52,70 +54,85 @@ function auditCoverage(): void {
|
|
|
52
54
|
if (orphanSchema.length) console.error(`[cdp-toolkit] WARN: manifest schemas with no registered tool: ${orphanSchema.join(", ")}`);
|
|
53
55
|
}
|
|
54
56
|
|
|
55
|
-
// Server `instructions` (
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
57
|
+
// Server `instructions` (returned by server/discover on 2026-era connections, and by
|
|
58
|
+
// initialize on 2025-era ones): the cross-cutting conventions that used to be re-stated
|
|
59
|
+
// inside every tool's schema — the target-selector grammar, the lease-token model, the
|
|
60
|
+
// MV3 worker/wake arm, the origin vocabulary — plus how to read the (fixed, cacheable)
|
|
61
|
+
// tool listing. Stating them ONCE here is what let the per-tool descriptions and the
|
|
59
62
|
// 42-way-duplicated `lease` param collapse to short pointers, cutting the tools/list
|
|
60
63
|
// payload without losing a single behavior. Kept under Claude Code's 2KB instructions
|
|
61
64
|
// cap and front-loaded (grammar + leases before origin) so nothing critical is clipped.
|
|
62
65
|
const INSTRUCTIONS = [
|
|
63
66
|
"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:",
|
|
64
|
-
"TOOL AVAILABILITY:
|
|
67
|
+
"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.",
|
|
65
68
|
"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, from the origin ledger or a live lease) | a 32-hex '<targetId>'. 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 extension's background worker (worker:<extension-id> or worker:background.js); an idle-evicted worker is started first (see each tool's `wake`).",
|
|
66
69
|
"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. Without CDP_REQUIRE_LEASE an unleased tab needs no token at all.",
|
|
67
70
|
"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.",
|
|
68
71
|
].join("\n\n");
|
|
69
72
|
|
|
70
|
-
// Progressive disclosure:
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
|
|
73
|
+
// Progressive disclosure, 2.1 model: the tool listing is STATIC — computed once here and
|
|
74
|
+
// returned byte-identical to every tools/list on every connection, so a client may cache
|
|
75
|
+
// it (the 2026-07-28 revision requires the tool set not to change as a side effect of
|
|
76
|
+
// other requests, which is exactly what the 2.0 browser_tools toggle did). Discovery is
|
|
77
|
+
// the HOST's job per the MCP client best-practices: the client picks which of the listed
|
|
78
|
+
// tools to put in front of the model. `describe_tool` is the inspect layer — one call
|
|
79
|
+
// returns the full prose for any tool, listed or not — and CDP_TOOL_PROFILE is the only
|
|
80
|
+
// filter, applied once at startup by whoever configures the server. A tool that the
|
|
81
|
+
// profile hides is still callable by name; hiding only trims the listing's token cost.
|
|
82
|
+
const PROFILE = startupProfile();
|
|
78
83
|
|
|
79
|
-
/**
|
|
80
|
-
|
|
84
|
+
/** Read CDP_TOOL_PROFILE once; an unknown group name is a configuration error, not a warning. */
|
|
85
|
+
function startupProfile() {
|
|
86
|
+
try {
|
|
87
|
+
return resolveProfile(process.env.CDP_TOOL_PROFILE);
|
|
88
|
+
} catch (err) {
|
|
89
|
+
console.error(`[cdp-toolkit] ${err instanceof Error ? err.message : String(err)}`);
|
|
90
|
+
process.exit(1);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The one and only tools/list payload, in manifest order (a stable order is a spec
|
|
96
|
+
* SHOULD, and it keeps the response byte-identical across calls so caching is real):
|
|
97
|
+
* describe_tool first, then every manifest tool the selected backend can run whose
|
|
98
|
+
* group the profile advertises. Descriptions here are the compressed one-liners —
|
|
99
|
+
* full prose is served on demand by describe_tool.
|
|
100
|
+
*
|
|
101
|
+
* Frozen at runtime (the handler hands the SAME array to every caller); the cast back to
|
|
102
|
+
* a mutable Tool[] is only because ListToolsResult declares `tools` mutable.
|
|
103
|
+
*/
|
|
104
|
+
const LISTING: Tool[] = Object.freeze([
|
|
81
105
|
{
|
|
82
|
-
name: "
|
|
106
|
+
name: "describe_tool",
|
|
83
107
|
description:
|
|
84
|
-
"
|
|
108
|
+
"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.",
|
|
85
109
|
inputSchema: {
|
|
86
110
|
type: "object",
|
|
87
111
|
properties: {
|
|
88
|
-
|
|
112
|
+
name: {
|
|
89
113
|
type: "string",
|
|
90
|
-
|
|
91
|
-
description: "list_groups: show every group, its tools, and active state; activate/deactivate: toggle one group; set_profile: switch wholesale.",
|
|
114
|
+
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.",
|
|
92
115
|
},
|
|
93
|
-
group: { type: "string", enum: [...TOOL_GROUPS], description: "Group for activate/deactivate." },
|
|
94
|
-
profile: { type: "string", enum: ["core", "full"], description: "Profile for set_profile (core=lean, full=all groups)." },
|
|
95
116
|
},
|
|
96
|
-
required: ["action"],
|
|
97
|
-
additionalProperties: false,
|
|
98
|
-
} as Tool["inputSchema"],
|
|
99
|
-
},
|
|
100
|
-
{
|
|
101
|
-
name: "describe_tool",
|
|
102
|
-
description: "Return the full description and per-parameter docs for any cdp-toolkit tool by name — including tools not currently listed. Call this before using an unfamiliar tool; the live tools/list carries only terse summaries.",
|
|
103
|
-
inputSchema: {
|
|
104
|
-
type: "object",
|
|
105
|
-
properties: { name: { type: "string", description: "Tool name, e.g. take_screenshot." } },
|
|
106
|
-
required: ["name"],
|
|
107
117
|
additionalProperties: false,
|
|
108
118
|
} as Tool["inputSchema"],
|
|
109
119
|
},
|
|
110
|
-
|
|
120
|
+
...MANIFEST
|
|
121
|
+
.filter((s) => AVAILABLE_NAMES.has(s.name) && PROFILE.groups.has(TOOL_GROUP[s.name]!))
|
|
122
|
+
.map((s) => ({ name: s.name, description: s.description, inputSchema: s.inputSchema as Tool["inputSchema"] })),
|
|
123
|
+
] satisfies Tool[]) as Tool[];
|
|
111
124
|
|
|
112
|
-
/**
|
|
113
|
-
function
|
|
114
|
-
const lines = [
|
|
125
|
+
/** describe_tool with no name: every group the backend supports, and whether it is listed. */
|
|
126
|
+
function renderCatalog(): string {
|
|
127
|
+
const lines = [
|
|
128
|
+
`cdp-toolkit ${VERSION} · browser=${BROWSER} · ${AVAILABILITY.available.length} tools available, ${LISTING.length} in tools/list (CDP_TOOL_PROFILE=${PROFILE.label})`,
|
|
129
|
+
];
|
|
115
130
|
for (const g of TOOL_GROUPS) {
|
|
116
131
|
const shown = GROUP_TOOLS[g].filter((n) => AVAILABLE_NAMES.has(n));
|
|
117
|
-
|
|
132
|
+
if (!shown.length) continue;
|
|
133
|
+
lines.push(`${PROFILE.groups.has(g) ? "[listed]" : "[hidden]"} ${g} (${shown.length}): ${shown.join(", ")}`);
|
|
118
134
|
}
|
|
135
|
+
lines.push("Unlisted tools are callable by name; describe_tool {name} documents any of them.");
|
|
119
136
|
return lines.join("\n");
|
|
120
137
|
}
|
|
121
138
|
|
|
@@ -129,114 +146,114 @@ function renderToolDoc(toolName: string): string {
|
|
|
129
146
|
return `${toolName}${group ? ` [group: ${group}]` : ""}\n\n${doc.description}\n\nParameters:\n${body}`;
|
|
130
147
|
}
|
|
131
148
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
// here are the compressed form — full prose is served on demand by describe_tool.
|
|
138
|
-
tools: [
|
|
139
|
-
...META_TOOLS,
|
|
140
|
-
...MANIFEST
|
|
141
|
-
.filter((s) => AVAILABLE_NAMES.has(s.name) && activeGroups.has(TOOL_GROUP[s.name] ?? ""))
|
|
142
|
-
.map((s) => ({ name: s.name, description: s.description, inputSchema: s.inputSchema as Tool["inputSchema"] })),
|
|
143
|
-
],
|
|
144
|
-
}));
|
|
149
|
+
// The listing never changes, and it is identical for every caller (a stdio server has no
|
|
150
|
+
// per-caller auth), so it is `public` with a 1h TTL rather than the SDK's conservative
|
|
151
|
+
// { ttlMs: 0, cacheScope: 'private' } default. A restart under a different CDP_TOOL_PROFILE
|
|
152
|
+
// or --browser is a different server configuration, not a mid-life change to this one.
|
|
153
|
+
const LIST_CACHE_HINT = { ttlMs: 3_600_000, cacheScope: "public" } as const;
|
|
145
154
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
155
|
+
/**
|
|
156
|
+
* Build one server instance. serveStdio calls this per connection — and possibly twice
|
|
157
|
+
* per process (a server/discover probe instance that is discarded if the client falls
|
|
158
|
+
* back to initialize) — so it must be PURE: no I/O, no shared mutable state.
|
|
159
|
+
*
|
|
160
|
+
* We stay on the low-level `Server` rather than `McpServer` deliberately, despite the
|
|
161
|
+
* deprecation note. McpServer.registerTool wants a Standard-Schema (zod) object for
|
|
162
|
+
* inputSchema, and CONTRACT.md rule 1 forbids any runtime dependency beyond the SDK;
|
|
163
|
+
* our manifest is plain JSON Schema (src/manifest.ts). McpServer also cannot express
|
|
164
|
+
* "hidden tools remain callable by name", which is the whole point of CDP_TOOL_PROFILE.
|
|
165
|
+
*/
|
|
166
|
+
function buildServer(): Server {
|
|
167
|
+
const server = new Server(
|
|
168
|
+
{ name: "cdp-toolkit", version: VERSION },
|
|
169
|
+
{
|
|
170
|
+
// The list is fixed for the life of the process; nothing will ever notify.
|
|
171
|
+
capabilities: { tools: { listChanged: false } },
|
|
172
|
+
instructions: INSTRUCTIONS,
|
|
173
|
+
cacheHints: { "tools/list": LIST_CACHE_HINT, "server/discover": LIST_CACHE_HINT },
|
|
174
|
+
},
|
|
175
|
+
);
|
|
149
176
|
|
|
150
|
-
|
|
151
|
-
// and never enter the browser dispatch or a lease scope.
|
|
152
|
-
if (name === "describe_tool") {
|
|
153
|
-
const toolName = typeof (args as { name?: unknown }).name === "string" ? (args as { name: string }).name : "";
|
|
154
|
-
const known = Boolean(TOOL_DOCS[toolName]);
|
|
155
|
-
return { content: [{ type: "text" as const, text: renderToolDoc(toolName) }], isError: !known };
|
|
156
|
-
}
|
|
177
|
+
server.setRequestHandler('tools/list', async () => ({ tools: LISTING }));
|
|
157
178
|
|
|
158
|
-
|
|
159
|
-
const
|
|
160
|
-
const
|
|
161
|
-
const before = [...activeGroups].sort().join(",");
|
|
179
|
+
server.setRequestHandler('tools/call', async (request) => {
|
|
180
|
+
const { name } = request.params;
|
|
181
|
+
const args = (request.params.arguments ?? {}) as unknown;
|
|
162
182
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
if (
|
|
167
|
-
const
|
|
168
|
-
if (
|
|
169
|
-
return { content: [{ type: "text" as const, text:
|
|
183
|
+
// describe_tool is handled at the MCP layer (it reads only static docs) and never
|
|
184
|
+
// enters the browser dispatch or a lease scope. With a name it documents one tool —
|
|
185
|
+
// listed or not; with no name it returns the grouped catalog.
|
|
186
|
+
if (name === "describe_tool") {
|
|
187
|
+
const requested = (args as { name?: unknown }).name;
|
|
188
|
+
if (typeof requested !== "string" || requested === "") {
|
|
189
|
+
return { content: [{ type: "text" as const, text: renderCatalog() }] };
|
|
170
190
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
} else if (action === "set_profile") {
|
|
174
|
-
const profile = a.profile === "full" ? "full" : a.profile === "core" ? "core" : "";
|
|
175
|
-
if (!profile) return { content: [{ type: "text" as const, text: `unknown profile: ${String(a.profile)}. Known: core, full` }], isError: true };
|
|
176
|
-
activeGroups.clear();
|
|
177
|
-
for (const g of PROFILES[profile]) activeGroups.add(g);
|
|
178
|
-
} else {
|
|
179
|
-
return { content: [{ type: "text" as const, text: `unknown action: ${action}. Use list_groups | activate | deactivate | set_profile.` }], isError: true };
|
|
191
|
+
const known = Boolean(TOOL_DOCS[requested]);
|
|
192
|
+
return { content: [{ type: "text" as const, text: renderToolDoc(requested) }], isError: !known };
|
|
180
193
|
}
|
|
181
194
|
|
|
182
|
-
if (
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
}
|
|
195
|
+
if (!AVAILABLE_NAMES.has(name)) {
|
|
196
|
+
const gap = AVAILABILITY.unavailable.find((u) => u.name === name);
|
|
197
|
+
return {
|
|
198
|
+
content: [{ type: "text" as const, text: `unknown tool: ${name}${gap ? ` (not available under --browser ${BROWSER}, needs: ${gap.missing.join(", ")})` : ""}` }],
|
|
199
|
+
isError: true,
|
|
200
|
+
};
|
|
188
201
|
}
|
|
189
|
-
return { content: [{ type: "text" as const, text: renderGroups() }] };
|
|
190
|
-
}
|
|
191
202
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
203
|
+
// ONE lease scope per dispatch, wrapping BOTH backend branches. This is the
|
|
204
|
+
// reason no tool takes a lease parameter: the token rides the async context
|
|
205
|
+
// down to whichever resolution path the tool eventually reaches, so a tool
|
|
206
|
+
// added tomorrow is covered with no action from whoever writes it. Reading
|
|
207
|
+
// 'lease' off args here is the only place the MCP layer knows the key exists.
|
|
208
|
+
return withLeaseScope(leaseFromArgs(args), async () => {
|
|
209
|
+
if (BROWSER === "chrome") {
|
|
210
|
+
const fn = dispatch[name];
|
|
211
|
+
if (!fn) return { content: [{ type: "text" as const, text: `unknown tool: ${name}` }], isError: true };
|
|
212
|
+
try {
|
|
213
|
+
const result = await fn(args);
|
|
214
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
|
215
|
+
} catch (err) {
|
|
216
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
217
|
+
return { content: [{ type: "text" as const, text: `Error: ${message}` }], isError: true };
|
|
218
|
+
}
|
|
219
|
+
}
|
|
199
220
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
// 'lease' off args here is the only place the MCP layer knows the key exists.
|
|
205
|
-
return withLeaseScope(leaseFromArgs(args), async () => {
|
|
206
|
-
if (BROWSER === "chrome") {
|
|
207
|
-
const fn = dispatch[name];
|
|
208
|
-
if (!fn) return { content: [{ type: "text" as const, text: `unknown tool: ${name}` }], isError: true };
|
|
221
|
+
// Firefox: one BiDi session memoized for the life of this server process (lifetime "session",
|
|
222
|
+
// ADR-001); launched lazily on the first Firefox tool call, torn down on shutdown below.
|
|
223
|
+
const neutralFn = neutralDispatch[name];
|
|
224
|
+
if (!neutralFn) return { content: [{ type: "text" as const, text: `unknown tool: ${name}` }], isError: true };
|
|
209
225
|
try {
|
|
210
|
-
const
|
|
226
|
+
const session = await getOrCreateFirefoxSession({ endpoint: FIREFOX_ENDPOINT });
|
|
227
|
+
const result = await neutralFn(session.driver, args);
|
|
211
228
|
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
|
212
229
|
} catch (err) {
|
|
213
230
|
const message = err instanceof Error ? err.message : String(err);
|
|
214
231
|
return { content: [{ type: "text" as const, text: `Error: ${message}` }], isError: true };
|
|
215
232
|
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
// Firefox: one BiDi session memoized for the life of this server process (lifetime "session",
|
|
219
|
-
// ADR-001); launched lazily on the first Firefox tool call, torn down on shutdown below.
|
|
220
|
-
const neutralFn = neutralDispatch[name];
|
|
221
|
-
if (!neutralFn) return { content: [{ type: "text" as const, text: `unknown tool: ${name}` }], isError: true };
|
|
222
|
-
try {
|
|
223
|
-
const session = await getOrCreateFirefoxSession({ endpoint: FIREFOX_ENDPOINT });
|
|
224
|
-
const result = await neutralFn(session.driver, args);
|
|
225
|
-
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
|
226
|
-
} catch (err) {
|
|
227
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
228
|
-
return { content: [{ type: "text" as const, text: `Error: ${message}` }], isError: true };
|
|
229
|
-
}
|
|
233
|
+
});
|
|
230
234
|
});
|
|
231
|
-
|
|
235
|
+
|
|
236
|
+
return server;
|
|
237
|
+
}
|
|
232
238
|
|
|
233
239
|
auditCoverage();
|
|
234
240
|
// This process is the long-lived MCP server, which is what makes strict mode
|
|
235
241
|
// (CDP_REQUIRE_LEASE) safe to honor here and unsafe in cli.ts. See requireLease.
|
|
236
242
|
markLongLivedProcess();
|
|
237
|
-
|
|
243
|
+
|
|
244
|
+
// serveStdio owns the era decision for the connection: a modern opening (server/discover
|
|
245
|
+
// with the per-request _meta envelope) pins a 2026-07-28 instance, a 2025-era `initialize`
|
|
246
|
+
// pins a legacy one. legacy:'serve' (the default) is deliberate — 2025-era clients keep the
|
|
247
|
+
// initialize handshake they expect, so this upgrade is invisible to them.
|
|
248
|
+
const handle = serveStdio(
|
|
249
|
+
({ era }) => {
|
|
250
|
+
console.error(`[cdp-toolkit] connection pinned to the ${era} protocol era`);
|
|
251
|
+
return buildServer();
|
|
252
|
+
},
|
|
253
|
+
{ onerror: (err) => console.error(`[cdp-toolkit] stdio: ${err.message}`) },
|
|
254
|
+
);
|
|
238
255
|
console.error(
|
|
239
|
-
`[cdp-toolkit] MCP server v${VERSION} ready, browser=${BROWSER}${FIREFOX_ENDPOINT ? ` (attach ${FIREFOX_ENDPOINT})` : ""}, ${AVAILABILITY.available.length} tools, CDP_BASE=${BASE}`,
|
|
256
|
+
`[cdp-toolkit] MCP server v${VERSION} ready, browser=${BROWSER}${FIREFOX_ENDPOINT ? ` (attach ${FIREFOX_ENDPOINT})` : ""}, ${AVAILABILITY.available.length} tools available, ${LISTING.length} listed (CDP_TOOL_PROFILE=${PROFILE.label}), CDP_BASE=${BASE}`,
|
|
240
257
|
);
|
|
241
258
|
|
|
242
259
|
// LAUNCH-mode Firefox owns a real OS process (see bidi/launch.ts): it must be reaped on every
|
|
@@ -255,6 +272,9 @@ async function shutdown(): Promise<void> {
|
|
|
255
272
|
// Track P3). Unlike Firefox it owns no OS process, so process.exit would collect it anyway; it is
|
|
256
273
|
// closed explicitly so shutdown does not depend on that, and is a no-op when neither tool ran.
|
|
257
274
|
await disposeBrowserSession();
|
|
275
|
+
// Close the stdio connection (pinned instance + transport) last, so a tool call still
|
|
276
|
+
// in flight over it cannot outlive the browser resources it was driving.
|
|
277
|
+
await handle.close().catch(() => undefined);
|
|
258
278
|
process.exit(0);
|
|
259
279
|
}
|
|
260
280
|
process.on("SIGINT", () => void shutdown());
|
package/src/toolGroups.ts
CHANGED
|
@@ -47,3 +47,35 @@ export const PROFILES = {
|
|
|
47
47
|
} as const;
|
|
48
48
|
|
|
49
49
|
export type ProfileName = keyof typeof PROFILES;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Resolve CDP_TOOL_PROFILE into the set of groups whose tools tools/list advertises.
|
|
53
|
+
*
|
|
54
|
+
* The profile is a STARTUP filter, read once: the listing it selects is fixed for the
|
|
55
|
+
* life of the process (2.1 dropped the runtime `browser_tools` toggle, so the tool set
|
|
56
|
+
* can never change as a side effect of another request — a spec MUST). Accepted
|
|
57
|
+
* spellings: unset/empty or "full" (everything), "core" (the lean everyday set), or a
|
|
58
|
+
* comma-separated list of group names. `core` is always included, so a list can never
|
|
59
|
+
* strand the basics.
|
|
60
|
+
*/
|
|
61
|
+
export function resolveProfile(spec: string | undefined): { groups: ReadonlySet<ToolGroup>; label: string } {
|
|
62
|
+
const all = (): { groups: ReadonlySet<ToolGroup>; label: string } => ({ groups: new Set(TOOL_GROUPS), label: "full" });
|
|
63
|
+
const raw = (spec ?? "").trim();
|
|
64
|
+
if (raw === "" || raw.toLowerCase() === "full") return all();
|
|
65
|
+
|
|
66
|
+
const tokens = raw.split(",").map((t) => t.trim().toLowerCase()).filter((t) => t !== "");
|
|
67
|
+
if (tokens.includes("full")) return all();
|
|
68
|
+
|
|
69
|
+
const known = new Set<string>(TOOL_GROUPS);
|
|
70
|
+
const picked = new Set<ToolGroup>(["core"]);
|
|
71
|
+
for (const token of tokens) {
|
|
72
|
+
if (!known.has(token)) {
|
|
73
|
+
throw new Error(`CDP_TOOL_PROFILE: unknown tool group '${token}'. Known: full, core, ${TOOL_GROUPS.filter((g) => g !== "core").join(", ")}`);
|
|
74
|
+
}
|
|
75
|
+
picked.add(token as ToolGroup);
|
|
76
|
+
}
|
|
77
|
+
if (picked.size === TOOL_GROUPS.length) return all();
|
|
78
|
+
// Canonical label: TOOL_GROUPS order, not the order the caller typed, so the ready
|
|
79
|
+
// line and the catalog header read the same for every equivalent spelling.
|
|
80
|
+
return { groups: picked, label: TOOL_GROUPS.filter((g) => picked.has(g)).join(",") };
|
|
81
|
+
}
|
package/src/version.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single source of truth for the package version string.
|
|
3
|
+
*
|
|
4
|
+
* mcp.ts reports it in the MCP `serverInfo` (and thus in the 2026-07-28
|
|
5
|
+
* `_meta['io.modelcontextprotocol/serverInfo']` envelope) and on its stderr
|
|
6
|
+
* ready line; keeping it in its own module means a release bump touches one
|
|
7
|
+
* line here plus package.json, not a const buried in the server.
|
|
8
|
+
*/
|
|
9
|
+
export const VERSION = "2.1.0";
|