cdp-toolkit 1.11.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/src/mcp.ts CHANGED
@@ -10,14 +10,17 @@
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
  *
16
19
  * stdout is the JSON-RPC channel, all diagnostics go to stderr only.
17
20
  */
18
- import { Server } from "@modelcontextprotocol/sdk/server/index.js";
19
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
20
- import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
21
+ import { Server } from "@modelcontextprotocol/server";
22
+ import type { Tool } from "@modelcontextprotocol/server";
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,8 +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
-
29
- const VERSION = "1.11.0";
31
+ import { resolveProfile, TOOL_GROUP, TOOL_GROUPS, GROUP_TOOLS } from "./toolGroups.ts";
32
+ import { TOOL_DOCS } from "./toolDocs.ts";
33
+ import { VERSION } from "./version.ts";
30
34
 
31
35
  // Backend + (Firefox only) attach endpoint are read once at startup (MCP has no per-call notion
32
36
  // of backend): --browser flag / CDP_BROWSER env, else "chrome" (zero behavior change for existing
@@ -50,81 +54,206 @@ function auditCoverage(): void {
50
54
  if (orphanSchema.length) console.error(`[cdp-toolkit] WARN: manifest schemas with no registered tool: ${orphanSchema.join(", ")}`);
51
55
  }
52
56
 
53
- // Server `instructions` (MCP initialize result): the cross-cutting conventions that
54
- // used to be re-stated inside every tool's schema — the target-selector grammar, the
55
- // lease-token model, the MV3 worker/wake arm, the origin vocabulary. Stating them ONCE
56
- // 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
57
62
  // 42-way-duplicated `lease` param collapse to short pointers, cutting the tools/list
58
63
  // payload without losing a single behavior. Kept under Claude Code's 2KB instructions
59
64
  // cap and front-loaded (grammar + leases before origin) so nothing critical is clipped.
60
65
  const INSTRUCTIONS = [
61
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:",
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.",
62
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`).",
63
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.",
64
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.",
65
71
  ].join("\n\n");
66
72
 
67
- const server = new Server({ name: "cdp-toolkit", version: VERSION }, { capabilities: { tools: {} }, instructions: INSTRUCTIONS });
68
-
69
- server.setRequestHandler(ListToolsRequestSchema, async () => ({
70
- // Filtered to whatever the selected backend can actually run, per REQUIRED_CAPABILITIES:
71
- // under Chrome this is byte-identical to the unfiltered MANIFEST (every capability CDP needs
72
- // is declared); under Firefox the performance/heap/lighthouse tools are simply absent.
73
- tools: MANIFEST.filter((s) => AVAILABLE_NAMES.has(s.name)).map((s) => ({ name: s.name, description: s.description, inputSchema: s.inputSchema })),
74
- }));
75
-
76
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
77
- const { name } = request.params;
78
- const args = (request.params.arguments ?? {}) as unknown;
79
-
80
- if (!AVAILABLE_NAMES.has(name)) {
81
- const gap = AVAILABILITY.unavailable.find((u) => u.name === name);
82
- return {
83
- content: [{ type: "text" as const, text: `unknown tool: ${name}${gap ? ` (not available under --browser ${BROWSER}, needs: ${gap.missing.join(", ")})` : ""}` }],
84
- isError: true,
85
- };
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();
83
+
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([
105
+ {
106
+ name: "describe_tool",
107
+ description:
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.",
109
+ inputSchema: {
110
+ type: "object",
111
+ properties: {
112
+ name: {
113
+ type: "string",
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.",
115
+ },
116
+ },
117
+ additionalProperties: false,
118
+ } as Tool["inputSchema"],
119
+ },
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[];
124
+
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
+ ];
130
+ for (const g of TOOL_GROUPS) {
131
+ const shown = GROUP_TOOLS[g].filter((n) => AVAILABLE_NAMES.has(n));
132
+ if (!shown.length) continue;
133
+ lines.push(`${PROFILE.groups.has(g) ? "[listed]" : "[hidden]"} ${g} (${shown.length}): ${shown.join(", ")}`);
86
134
  }
135
+ lines.push("Unlisted tools are callable by name; describe_tool {name} documents any of them.");
136
+ return lines.join("\n");
137
+ }
138
+
139
+ /** describe_tool body: full prose for one tool from the on-demand docs map. */
140
+ function renderToolDoc(toolName: string): string {
141
+ const doc = TOOL_DOCS[toolName];
142
+ if (!doc) return `unknown tool: ${toolName}`;
143
+ const group = TOOL_GROUP[toolName];
144
+ const params = Object.entries(doc.params);
145
+ const body = params.length ? params.map(([p, d]) => `- ${p}: ${d || "(no description)"}`).join("\n") : "(no parameters)";
146
+ return `${toolName}${group ? ` [group: ${group}]` : ""}\n\n${doc.description}\n\nParameters:\n${body}`;
147
+ }
148
+
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;
154
+
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
+ );
87
176
 
88
- // ONE lease scope per dispatch, wrapping BOTH backend branches. This is the
89
- // reason no tool takes a lease parameter: the token rides the async context
90
- // down to whichever resolution path the tool eventually reaches, so a tool
91
- // added tomorrow is covered with no action from whoever writes it. Reading
92
- // 'lease' off args here is the only place the MCP layer knows the key exists.
93
- return withLeaseScope(leaseFromArgs(args), async () => {
94
- if (BROWSER === "chrome") {
95
- const fn = dispatch[name];
96
- if (!fn) return { content: [{ type: "text" as const, text: `unknown tool: ${name}` }], isError: true };
177
+ server.setRequestHandler('tools/list', async () => ({ tools: LISTING }));
178
+
179
+ server.setRequestHandler('tools/call', async (request) => {
180
+ const { name } = request.params;
181
+ const args = (request.params.arguments ?? {}) as unknown;
182
+
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() }] };
190
+ }
191
+ const known = Boolean(TOOL_DOCS[requested]);
192
+ return { content: [{ type: "text" as const, text: renderToolDoc(requested) }], isError: !known };
193
+ }
194
+
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
+ };
201
+ }
202
+
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
+ }
220
+
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 };
97
225
  try {
98
- const result = await fn(args);
226
+ const session = await getOrCreateFirefoxSession({ endpoint: FIREFOX_ENDPOINT });
227
+ const result = await neutralFn(session.driver, args);
99
228
  return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
100
229
  } catch (err) {
101
230
  const message = err instanceof Error ? err.message : String(err);
102
231
  return { content: [{ type: "text" as const, text: `Error: ${message}` }], isError: true };
103
232
  }
104
- }
105
-
106
- // Firefox: one BiDi session memoized for the life of this server process (lifetime "session",
107
- // ADR-001); launched lazily on the first Firefox tool call, torn down on shutdown below.
108
- const neutralFn = neutralDispatch[name];
109
- if (!neutralFn) return { content: [{ type: "text" as const, text: `unknown tool: ${name}` }], isError: true };
110
- try {
111
- const session = await getOrCreateFirefoxSession({ endpoint: FIREFOX_ENDPOINT });
112
- const result = await neutralFn(session.driver, args);
113
- return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
114
- } catch (err) {
115
- const message = err instanceof Error ? err.message : String(err);
116
- return { content: [{ type: "text" as const, text: `Error: ${message}` }], isError: true };
117
- }
233
+ });
118
234
  });
119
- });
235
+
236
+ return server;
237
+ }
120
238
 
121
239
  auditCoverage();
122
240
  // This process is the long-lived MCP server, which is what makes strict mode
123
241
  // (CDP_REQUIRE_LEASE) safe to honor here and unsafe in cli.ts. See requireLease.
124
242
  markLongLivedProcess();
125
- 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
+ );
126
255
  console.error(
127
- `[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}`,
128
257
  );
129
258
 
130
259
  // LAUNCH-mode Firefox owns a real OS process (see bidi/launch.ts): it must be reaped on every
@@ -143,6 +272,9 @@ async function shutdown(): Promise<void> {
143
272
  // Track P3). Unlike Firefox it owns no OS process, so process.exit would collect it anyway; it is
144
273
  // closed explicitly so shutdown does not depend on that, and is a no-op when neither tool ran.
145
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);
146
278
  process.exit(0);
147
279
  }
148
280
  process.on("SIGINT", () => void shutdown());