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.
- package/CHANGELOG.md +53 -0
- package/README.md +76 -20
- package/dist/bidi/mux-daemon.js +3773 -0
- package/dist/cli.js +6457 -5690
- package/dist/index.js +275 -210
- package/dist/mcp.js +8185 -7420
- package/package.json +2 -1
- package/src/bidi/driver.ts +329 -30
- package/src/bidi/mux-daemon.ts +122 -0
- package/src/bidi/mux.ts +610 -0
- package/src/bidi/session-lease.ts +95 -3
- package/src/manifest.ts +1 -0
- package/src/mcp.ts +147 -127
- package/src/toolGroups.ts +32 -0
- package/src/version.ts +9 -0
|
@@ -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
|
-
|
|
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
|
|
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
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.2.0";
|