cdp-toolkit 2.0.0 → 2.2.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.
@@ -35,8 +35,34 @@
35
35
  * ATOMICITY. Claims are exclusive creates (writeFile flag "wx"), exactly like
36
36
  * ../leases.ts claimLease, so "is the slot free, and if so take it" is atomic
37
37
  * against a racing process for free, with no registry lock.
38
+ *
39
+ * THE MUX: WHY A LIVE HOLDER IS NO LONGER A DEAD END. Everything above still
40
+ * holds — one process owns Firefox's single BiDi session — but the holder now
41
+ * also hosts a loopback BiDi multiplexer (./mux.ts) over that one real session
42
+ * and ADVERTISES its ws endpoint in this record (`muxEndpoint`). Two consequences
43
+ * for this module, and nothing else changes:
44
+ *
45
+ * - THE HOLDER advertises via advertiseMux() once its mux is listening. That is
46
+ * a rewrite of its OWN record, guarded by the same (pid, createdAt) ownership
47
+ * test release uses, and written atomically (tmp + rename) so a concurrent
48
+ * reader never sees a half-written record. Advertising is best-effort: a
49
+ * refusal (`advertised:false`) means the record is no longer ours and the
50
+ * holder simply runs alone.
51
+ * - A JOINER DOES NOT WAIT. When acquire finds a LIVE holder that carries a
52
+ * muxEndpoint, there is nothing to wait FOR: the slot will not free up (the
53
+ * holder is healthy) and it does not need to, because the mux serves that one
54
+ * session to as many processes as ask. So acquire returns { joinMux }
55
+ * immediately, with NO handle and nothing written to disk — the joiner holds
56
+ * no slot and must never release one. The record is re-read on every poll
57
+ * iteration, so a holder that took the slot a moment ago and advertises a beat
58
+ * later is joined as soon as it does, inside the existing wait window.
59
+ *
60
+ * A live holder with NO muxEndpoint (a foreign client, an older cdp-toolkit, or
61
+ * one running with CDP_FIREFOX_MUX=off) is still plain BUSY and still throws
62
+ * SessionSlotBusyError at the deadline — exactly today's behavior. CDP_FIREFOX_MUX=off
63
+ * makes acquire ignore muxEndpoint entirely, which is the opt-out.
38
64
  */
39
- import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
65
+ import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
40
66
  import { join } from "node:path";
41
67
  import { isPidAlive, leaseDir } from "../leases.ts";
42
68
 
@@ -55,6 +81,12 @@ export interface SessionSlotRecord {
55
81
  createdAt: number;
56
82
  /** Optional Marionette control port bound to this Firefox, when known. */
57
83
  marionettePort?: number;
84
+ /**
85
+ * Loopback ws endpoint of the BiDi multiplexer this holder hosts over the one
86
+ * real session (see ./mux.ts and the header). Absent means "this holder serves
87
+ * nobody but itself", which is what every pre-mux client's record looks like.
88
+ */
89
+ muxEndpoint?: string;
58
90
  }
59
91
 
60
92
  /**
@@ -68,7 +100,11 @@ export interface SessionSlotHandle {
68
100
  }
69
101
 
70
102
  export interface AcquireResult {
71
- handle: SessionSlotHandle;
103
+ /**
104
+ * Present iff WE took the slot. Absent on the joinMux path: a joiner owns no
105
+ * slot, wrote no record, and must never release one.
106
+ */
107
+ handle?: SessionSlotHandle;
72
108
  /**
73
109
  * Set only when we STOLE a dead holder's slot. Its presence tells the caller
74
110
  * that Firefox may still believe an orphaned BiDi session is active, so the
@@ -77,6 +113,12 @@ export interface AcquireResult {
77
113
  * sessions". Absent on a clean first acquire.
78
114
  */
79
115
  staleHolder?: SessionSlotRecord;
116
+ /**
117
+ * Set instead of `handle` when a LIVE holder advertises a BiDi multiplexer.
118
+ * The caller dials `endpoint` exactly as it would dial Firefox; `holder` is the
119
+ * record it came from, for error reporting. Nothing was written to disk.
120
+ */
121
+ joinMux?: { endpoint: string; holder: SessionSlotRecord };
80
122
  }
81
123
 
82
124
  export interface AcquireOptions {
@@ -105,6 +147,16 @@ export function sessionSlotFile(endpoint: string): string {
105
147
  return join(leaseDir(), `ff-session-${safeEndpoint(endpoint)}.json`);
106
148
  }
107
149
 
150
+ /**
151
+ * Where the detached mux DAEMON (./mux-daemon.ts) appends its one-line diagnostics for this
152
+ * endpoint. It is spawned with stdio:"ignore" (no console at all), so this file is the ONLY
153
+ * record of why a daemon refused to start; the driver names it in the error it raises when a
154
+ * daemon it spawned never advertised. Sibling of the slot file, same safeEndpoint key.
155
+ */
156
+ export function muxDaemonLogFile(endpoint: string): string {
157
+ return join(leaseDir(), `ff-mux-${safeEndpoint(endpoint)}.log`);
158
+ }
159
+
108
160
  /** Read per call (like leaseDir/leaseTtlMs), so a test can redirect it. A value
109
161
  * that is not a finite number > 0 falls back to the default. */
110
162
  export function sessionWaitMs(): number {
@@ -236,7 +288,16 @@ export async function acquireSessionSlot(endpoint: string, opts: AcquireOptions)
236
288
  }
237
289
  }
238
290
 
239
- // 4. LIVE holder: legitimate contention. Poll until the deadline, then fail.
291
+ // 4. LIVE holder. If it advertises a mux, JOIN it instead of waiting: the slot
292
+ // is never going to free up, and it does not have to — the holder's mux serves
293
+ // the one real session to every joiner. Return immediately, writing nothing.
294
+ // (The re-read at step 2 happens every iteration, so a holder that advertises a
295
+ // beat after taking the slot is picked up on the next poll.) CDP_FIREFOX_MUX=off
296
+ // opts out and restores the pre-mux busy-wait exactly.
297
+ if (typeof holder.muxEndpoint === "string" && holder.muxEndpoint.length > 0 && process.env.CDP_FIREFOX_MUX !== "off") {
298
+ return { joinMux: { endpoint: holder.muxEndpoint, holder } };
299
+ }
300
+ // No mux: legitimate contention. Poll until the deadline, then fail.
240
301
  lastLiveHolder = holder;
241
302
  const remaining = deadline - Date.now();
242
303
  if (remaining <= 0) throw new SessionSlotBusyError(endpoint, holder);
@@ -244,6 +305,37 @@ export async function acquireSessionSlot(endpoint: string, opts: AcquireOptions)
244
305
  }
245
306
  }
246
307
 
308
+ /**
309
+ * Publish this holder's BiDi multiplexer endpoint into its OWN slot record, so a
310
+ * contending process joins the mux instead of waiting out the slot.
311
+ *
312
+ * Ownership-guarded by the same (pid, createdAt) test releaseSessionSlot uses: if
313
+ * the record on disk is missing or belongs to someone else (we were stolen from
314
+ * as a dead-looking holder, or we already released), we refuse with
315
+ * { advertised: false } and leave the file byte-for-byte untouched rather than
316
+ * stamping our mux onto a stranger's claim.
317
+ *
318
+ * Atomic: the merged record is written to `<file>.tmp-<pid>` and renamed over the
319
+ * original, so a concurrent acquire's read sees either the old record or the new
320
+ * one, never a truncated one. Every other field is preserved.
321
+ */
322
+ export async function advertiseMux(handle: SessionSlotHandle, muxEndpoint: string): Promise<{ advertised: boolean }> {
323
+ const current = await readSessionSlot(handle.endpoint);
324
+ if (!current) return { advertised: false };
325
+ if (current.pid !== process.pid || current.createdAt !== handle.createdAt) return { advertised: false };
326
+ const file = sessionSlotFile(handle.endpoint);
327
+ const tmp = `${file}.tmp-${process.pid}`;
328
+ const next: SessionSlotRecord = { ...current, muxEndpoint };
329
+ try {
330
+ await writeFile(tmp, JSON.stringify(next), "utf8");
331
+ await rename(tmp, file);
332
+ } catch {
333
+ await unlink(tmp).catch(() => undefined);
334
+ return { advertised: false };
335
+ }
336
+ return { advertised: true };
337
+ }
338
+
247
339
  /**
248
340
  * Release the slot, but ONLY if the record on disk is still the one we wrote.
249
341
  *
package/src/manifest.ts CHANGED
@@ -230,6 +230,7 @@ export const MANIFEST: ToolSpec[] = [
230
230
  },
231
231
  "args": {
232
232
  "type": "array",
233
+ "items": {},
233
234
  "description": "Positional JSON arguments."
234
235
  },
235
236
  "savePath": {
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 { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
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, PROFILES, type ProfileName } from "./toolGroups.ts";
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` (MCP initialize result): the cross-cutting conventions that
56
- // used to be re-stated inside every tool's schema — the target-selector grammar, the
57
- // lease-token model, the MV3 worker/wake arm, the origin vocabulary. Stating them ONCE
58
- // here (loaded a single time at init) is what let the per-tool descriptions and the
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: the server starts on the core profile (12 everyday tools); the rest are organized into groups you can discover and activate with `browser_tools` (or start with CDP_TOOL_PROFILE=full for everything at once). `describe_tool` returns full docs for any tool by name, including hidden ones. Hidden tools are still callable by name even while unlisted.",
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: start with a lean profile (default "core") and reveal
71
- // further tool groups on demand via the browser_tools meta-tool, which fires
72
- // notifications/tools/list_changed so the client re-fetches tools/list. This keeps
73
- // the baseline tools/list payload — and thus the context tokens the toolkit costs —
74
- // a fraction of the full 45-tool surface until a group is actually needed. Set
75
- // CDP_TOOL_PROFILE=full to advertise everything at startup (prior behavior).
76
- const START_PROFILE: ProfileName = process.env.CDP_TOOL_PROFILE === "full" ? "full" : "core";
77
- const activeGroups = new Set<string>(PROFILES[START_PROFILE]);
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
- /** Always-listed meta-tools that drive disclosure; never part of a group. */
80
- const META_TOOLS: ReadonlyArray<{ name: string; description: string; inputSchema: Tool["inputSchema"] }> = [
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: "browser_tools",
106
+ name: "describe_tool",
83
107
  description:
84
- "Manage which cdp-toolkit tool groups are exposed (progressive disclosure). Most tools are hidden until their group is activated; activating one reveals its tools via tools/list_changed. Use action:list_groups first to see what exists.",
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
- action: {
112
+ name: {
89
113
  type: "string",
90
- enum: ["list_groups", "activate", "deactivate", "set_profile"],
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
- /** Human-readable dump of every group, its tools, and whether it's currently active. */
113
- function renderGroups(): string {
114
- const lines = [`profile start=${START_PROFILE}; active groups: ${[...activeGroups].join(", ") || "(none)"}`];
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
- lines.push(`${activeGroups.has(g) ? "[on] " : "[off]"} ${g} (${shown.length}): ${shown.join(", ")}`);
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
- const server = new Server({ name: "cdp-toolkit", version: VERSION }, { capabilities: { tools: { listChanged: true } }, instructions: INSTRUCTIONS });
133
-
134
- server.setRequestHandler('tools/list', async () => ({
135
- // Two-axis disclosure: (1) the always-present meta-tools; (2) manifest tools the
136
- // selected backend can run AND whose group is currently active. Descriptions/schemas
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
- server.setRequestHandler('tools/call', async (request) => {
147
- const { name } = request.params;
148
- const args = (request.params.arguments ?? {}) as unknown;
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
- // Meta-tools are handled at the MCP layer (they mutate what tools/list advertises)
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
- if (name === "browser_tools") {
159
- const a = args as { action?: unknown; group?: unknown; profile?: unknown };
160
- const action = typeof a.action === "string" ? a.action : "";
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
- if (action === "list_groups") {
164
- return { content: [{ type: "text" as const, text: renderGroups() }] };
165
- }
166
- if (action === "activate" || action === "deactivate") {
167
- const group = typeof a.group === "string" ? a.group : "";
168
- if (!(TOOL_GROUPS as readonly string[]).includes(group)) {
169
- return { content: [{ type: "text" as const, text: `unknown group: ${group}. Known: ${TOOL_GROUPS.join(", ")}` }], isError: true };
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
- if (action === "activate") activeGroups.add(group);
172
- else activeGroups.delete(group);
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 ([...activeGroups].sort().join(",") !== before) {
183
- try {
184
- await server.sendToolListChanged();
185
- } catch (err) {
186
- console.error(`[cdp-toolkit] WARN: sendToolListChanged failed: ${err instanceof Error ? err.message : String(err)}`);
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
- if (!AVAILABLE_NAMES.has(name)) {
193
- const gap = AVAILABILITY.unavailable.find((u) => u.name === name);
194
- return {
195
- content: [{ type: "text" as const, text: `unknown tool: ${name}${gap ? ` (not available under --browser ${BROWSER}, needs: ${gap.missing.join(", ")})` : ""}` }],
196
- isError: true,
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
- // ONE lease scope per dispatch, wrapping BOTH backend branches. This is the
201
- // reason no tool takes a lease parameter: the token rides the async context
202
- // down to whichever resolution path the tool eventually reaches, so a tool
203
- // added tomorrow is covered with no action from whoever writes it. Reading
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 result = await fn(args);
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
- await server.connect(new StdioServerTransport());
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.2.0";