cdp-toolkit 1.1.0 → 1.4.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 +203 -9
- package/CONTRACT.md +7 -5
- package/README.md +113 -33
- package/dist/cli.js +764 -64
- package/dist/index.js +650 -42
- package/dist/mcp.js +1091 -77
- package/package.json +4 -3
- package/src/bidi/driver.ts +167 -4
- package/src/capabilities.ts +1 -1
- package/src/cdp/driver.ts +145 -1
- package/src/cli.ts +47 -22
- package/src/client.ts +25 -7
- package/src/driver.ts +107 -1
- package/src/firefox-tools.ts +6 -3
- package/src/index.ts +14 -2
- package/src/leases-tools.ts +107 -0
- package/src/leases.ts +541 -0
- package/src/manifest.ts +339 -15
- package/src/mcp.ts +27 -19
- package/src/origins.ts +266 -0
- package/src/shared-tools.ts +365 -14
- package/src/tools/console.ts +3 -3
- package/src/tools/evaluate.ts +2 -2
- package/src/tools/heap.ts +3 -3
- package/src/tools/lighthouse.ts +4 -4
- package/src/tools/navigation.ts +1 -1
- package/src/tools/network.ts +7 -7
- package/src/tools/network_mock.ts +8 -8
- package/src/tools/performance.ts +11 -11
- package/src/tools/recorder.ts +5 -5
- package/src/tools/snapshot.ts +2 -2
package/src/mcp.ts
CHANGED
|
@@ -21,8 +21,9 @@ import { MANIFEST } from "./manifest.ts";
|
|
|
21
21
|
import { resolveBrowserKind, stripBrowserFlag, getOrCreateFirefoxSession, disposeFirefoxSession } from "./backend.ts";
|
|
22
22
|
import { toolAvailability } from "./capabilities.ts";
|
|
23
23
|
import { FIREFOX_TOOLS } from "./firefox-tools.ts";
|
|
24
|
+
import { leaseFromArgs, withLeaseScope } from "./leases.ts";
|
|
24
25
|
|
|
25
|
-
const VERSION = "1.
|
|
26
|
+
const VERSION = "1.4.0";
|
|
26
27
|
|
|
27
28
|
// --browser is read once at startup (MCP has no per-call notion of backend): explicit flag,
|
|
28
29
|
// else CDP_BROWSER env var, else "chrome" (zero behavior change for existing users/configs).
|
|
@@ -65,30 +66,37 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
65
66
|
};
|
|
66
67
|
}
|
|
67
68
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
// ONE lease scope per dispatch, wrapping BOTH backend branches. This is the
|
|
70
|
+
// reason no tool takes a lease parameter: the token rides the async context
|
|
71
|
+
// down to whichever resolution path the tool eventually reaches, so a tool
|
|
72
|
+
// added tomorrow is covered with no action from whoever writes it. Reading
|
|
73
|
+
// 'lease' off args here is the only place the MCP layer knows the key exists.
|
|
74
|
+
return withLeaseScope(leaseFromArgs(args), async () => {
|
|
75
|
+
if (BROWSER === "chrome") {
|
|
76
|
+
const fn = dispatch[name];
|
|
77
|
+
if (!fn) return { content: [{ type: "text" as const, text: `unknown tool: ${name}` }], isError: true };
|
|
78
|
+
try {
|
|
79
|
+
const result = await fn(args);
|
|
80
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
|
81
|
+
} catch (err) {
|
|
82
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
83
|
+
return { content: [{ type: "text" as const, text: `Error: ${message}` }], isError: true };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Firefox: one BiDi session memoized for the life of this server process (lifetime "session",
|
|
88
|
+
// ADR-001); launched lazily on the first Firefox tool call, torn down on shutdown below.
|
|
89
|
+
const neutralFn = neutralDispatch[name];
|
|
90
|
+
if (!neutralFn) return { content: [{ type: "text" as const, text: `unknown tool: ${name}` }], isError: true };
|
|
71
91
|
try {
|
|
72
|
-
const
|
|
92
|
+
const session = await getOrCreateFirefoxSession();
|
|
93
|
+
const result = await neutralFn(session.driver, args);
|
|
73
94
|
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
|
74
95
|
} catch (err) {
|
|
75
96
|
const message = err instanceof Error ? err.message : String(err);
|
|
76
97
|
return { content: [{ type: "text" as const, text: `Error: ${message}` }], isError: true };
|
|
77
98
|
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// Firefox: one BiDi session memoized for the life of this server process (lifetime "session",
|
|
81
|
-
// ADR-001); launched lazily on the first Firefox tool call, torn down on shutdown below.
|
|
82
|
-
const neutralFn = neutralDispatch[name];
|
|
83
|
-
if (!neutralFn) return { content: [{ type: "text" as const, text: `unknown tool: ${name}` }], isError: true };
|
|
84
|
-
try {
|
|
85
|
-
const session = await getOrCreateFirefoxSession();
|
|
86
|
-
const result = await neutralFn(session.driver, args);
|
|
87
|
-
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
|
88
|
-
} catch (err) {
|
|
89
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
90
|
-
return { content: [{ type: "text" as const, text: `Error: ${message}` }], isError: true };
|
|
91
|
-
}
|
|
99
|
+
});
|
|
92
100
|
});
|
|
93
101
|
|
|
94
102
|
auditCoverage();
|
package/src/origins.ts
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tab origins: a creation ledger recording every tab THIS TOOLKIT opened.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS IS NOT list_leases. A lease answers "who is DRIVING this tab right
|
|
5
|
+
* now". Provenance is a different and longer-lived question: "who OPENED this
|
|
6
|
+
* tab". The two diverge the moment an agent releases, expires, or dies, and
|
|
7
|
+
* that is exactly when a human most needs the answer, because from then on the
|
|
8
|
+
* agent's abandoned tab is indistinguishable from one the human opened. So a
|
|
9
|
+
* record here deliberately OUTLIVES the lease and is never removed by
|
|
10
|
+
* release_page, by expiry, or by reclamation.
|
|
11
|
+
*
|
|
12
|
+
* WHY ONE FILE PER TAB, IN THE LEASE DIRECTORY. Same reasoning as leases.ts:
|
|
13
|
+
* two MCP server processes drive one browser, so module state would protect
|
|
14
|
+
* one process and miss the collision that matters, and a single registry file
|
|
15
|
+
* would need its own lock. Exclusive-create ("wx") per tab gives the atomicity
|
|
16
|
+
* for free, and the filename carries enough to describe a row whose contents
|
|
17
|
+
* cannot be read.
|
|
18
|
+
*
|
|
19
|
+
* WHY THE ONLY DELETE IS A REAP ON READ. A background sweeper would be a
|
|
20
|
+
* second lifetime to reason about and a process that has to be running. The
|
|
21
|
+
* ledger is read rarely (list_pages) and the browser's own target list is
|
|
22
|
+
* already in hand at that moment, so dropping records for targets that no
|
|
23
|
+
* longer exist costs nothing and keeps the directory bounded without anything
|
|
24
|
+
* scheduled.
|
|
25
|
+
*
|
|
26
|
+
* WHAT THIS CANNOT PROVE. The absence of a record is not evidence that a human
|
|
27
|
+
* opened the tab: an agent driving Chrome by some other means, a tab restored
|
|
28
|
+
* from a previous session, or a record lost to a write failure all read the
|
|
29
|
+
* same way. That is why the annotation on list_pages says "unknown" and never
|
|
30
|
+
* "human". Never add a "human" value here.
|
|
31
|
+
*/
|
|
32
|
+
import { mkdir, readFile, readdir, unlink, writeFile } from "node:fs/promises";
|
|
33
|
+
import { join } from "node:path";
|
|
34
|
+
import type { BrowserDriver, PageInfo } from "./driver.ts";
|
|
35
|
+
import { defaultLabel, leaseDir, type LeaseBackend } from "./leases.ts";
|
|
36
|
+
|
|
37
|
+
/** What list_pages can say about where a tab came from. There is deliberately
|
|
38
|
+
* no "human": see the file header. */
|
|
39
|
+
export type PageOrigin = "agent" | "unknown";
|
|
40
|
+
|
|
41
|
+
export interface OriginRecord {
|
|
42
|
+
backend: LeaseBackend;
|
|
43
|
+
targetId: string;
|
|
44
|
+
/** Caller-supplied agent label, or leases.ts's pid-derived default. */
|
|
45
|
+
label: string;
|
|
46
|
+
/** The MCP server process that created the tab. Recorded for diagnosis only:
|
|
47
|
+
* unlike a lease, this record stays meaningful after that pid is gone. */
|
|
48
|
+
pid: number;
|
|
49
|
+
createdAt: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Same character policy as leaseFile, so the two stores agree on what a
|
|
53
|
+
* filename-safe id looks like and a reader can compare them. */
|
|
54
|
+
function safeId(id: string): string {
|
|
55
|
+
return id.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Sibling of leases.ts's lease-<backend>-<targetId>.json, same directory. */
|
|
59
|
+
export function originFile(backend: LeaseBackend, targetId: string): string {
|
|
60
|
+
return join(leaseDir(), `origin-${backend}-${safeId(targetId)}.json`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const ORIGIN_FILE_RE = /^origin-(chrome|firefox)-(.+)\.json$/;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Record that this toolkit just created `targetId`.
|
|
67
|
+
*
|
|
68
|
+
* ATOMICITY MATCHES leases.ts: exclusive create, never a modify in place. A
|
|
69
|
+
* record is written once and thereafter only deleted, so two processes racing
|
|
70
|
+
* on the same id cannot interleave a half-written file.
|
|
71
|
+
*
|
|
72
|
+
* ON EEXIST THE NEW RECORD WINS, and that is the answer to target-id reuse.
|
|
73
|
+
* A browser is free to hand out an id that a closed tab used to hold. Reaping
|
|
74
|
+
* on read makes that benign in the normal case: the old record is dropped the
|
|
75
|
+
* first time anyone lists pages after the old tab closed, so the id is usually
|
|
76
|
+
* free by the time it is reused. If nobody read the ledger in between, the
|
|
77
|
+
* stale record is still sitting there when this runs, and the unlink-then-
|
|
78
|
+
* recreate below replaces it. The residual case is a reused id whose new tab
|
|
79
|
+
* this toolkit did NOT create: that tab inherits the old record and reports
|
|
80
|
+
* origin "agent" with a createdAt older than the tab itself. It errs toward
|
|
81
|
+
* flagging a tab as agent-created, which is the safe direction for a tool whose
|
|
82
|
+
* job is to help a human find stray agent tabs, and the stale createdAt is the
|
|
83
|
+
* tell. Do not "fix" it by trusting the record blindly in the other direction.
|
|
84
|
+
*
|
|
85
|
+
* FAILURES ARE SWALLOWED DELIBERATELY. This is an annotation, not the work the
|
|
86
|
+
* caller asked for: new_page must not fail because a provenance record could
|
|
87
|
+
* not be persisted. The cost is real and worth stating plainly: a tab created
|
|
88
|
+
* while the artifact directory is unwritable reports origin "unknown" forever,
|
|
89
|
+
* with no signal anywhere. If agent tabs ever start reading as unknown, an
|
|
90
|
+
* ENOSPC or EACCES here is the first place to look.
|
|
91
|
+
*/
|
|
92
|
+
export async function recordOrigin(
|
|
93
|
+
backend: LeaseBackend,
|
|
94
|
+
targetId: string,
|
|
95
|
+
opts: { label?: string; now?: number } = {},
|
|
96
|
+
): Promise<void> {
|
|
97
|
+
const record: OriginRecord = {
|
|
98
|
+
backend,
|
|
99
|
+
targetId,
|
|
100
|
+
label: typeof opts.label === "string" && opts.label.length ? opts.label : defaultLabel(),
|
|
101
|
+
pid: process.pid,
|
|
102
|
+
createdAt: opts.now ?? Date.now(),
|
|
103
|
+
};
|
|
104
|
+
const file = originFile(backend, targetId);
|
|
105
|
+
try {
|
|
106
|
+
await mkdir(leaseDir(), { recursive: true });
|
|
107
|
+
try {
|
|
108
|
+
await writeFile(file, JSON.stringify(record), { flag: "wx" });
|
|
109
|
+
return;
|
|
110
|
+
} catch (err) {
|
|
111
|
+
if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
|
|
112
|
+
}
|
|
113
|
+
// A record already exists for this id: the browser reused it. Replace
|
|
114
|
+
// rather than modify in place, so a reader never sees a partial rewrite.
|
|
115
|
+
await unlink(file).catch(() => undefined);
|
|
116
|
+
await writeFile(file, JSON.stringify(record), { flag: "wx" });
|
|
117
|
+
} catch {
|
|
118
|
+
/* annotation only, never fail the caller's real work: see the doc comment */
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Read one origin record, or undefined if this toolkit did not create the tab.
|
|
124
|
+
*
|
|
125
|
+
* UNLIKE readLease, AN UNREADABLE FILE IS NOT RETHROWN HERE. The asymmetry is
|
|
126
|
+
* deliberate and rests on what each answer authorizes. readLease's answer gates
|
|
127
|
+
* ADMISSION to a tab, so "unreadable" reading as "unleased" would hand a held
|
|
128
|
+
* tab to a stranger, and failing the call is the lesser harm. This answer gates
|
|
129
|
+
* nothing: it decorates a listing. Failing list_pages because one provenance
|
|
130
|
+
* file is unreadable would break a working tool over a cosmetic field. The
|
|
131
|
+
* honesty requirement is met a different way, by listOrigins, which reports an
|
|
132
|
+
* unreadable row rather than omitting it, so "unreadable" never silently reads
|
|
133
|
+
* as "no record". Callers that need that distinction use listOrigins, not this.
|
|
134
|
+
*/
|
|
135
|
+
export async function readOrigin(backend: LeaseBackend, targetId: string): Promise<OriginRecord | undefined> {
|
|
136
|
+
let raw: string;
|
|
137
|
+
try {
|
|
138
|
+
raw = await readFile(originFile(backend, targetId), "utf8");
|
|
139
|
+
} catch {
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
const rec = JSON.parse(raw) as OriginRecord;
|
|
144
|
+
if (typeof rec?.targetId === "string" && typeof rec?.label === "string" && typeof rec?.createdAt === "number") return rec;
|
|
145
|
+
} catch {
|
|
146
|
+
/* unparseable: see listOrigins, which surfaces it as an unreadable row */
|
|
147
|
+
}
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface OriginSummary {
|
|
152
|
+
backend: LeaseBackend;
|
|
153
|
+
targetId: string;
|
|
154
|
+
label: string;
|
|
155
|
+
pid: number;
|
|
156
|
+
createdAt: number;
|
|
157
|
+
/**
|
|
158
|
+
* Present ONLY on a row this function could not read: the errno of the failed
|
|
159
|
+
* read, or "unparseable" for a file whose contents are not an origin record.
|
|
160
|
+
* Same convention, and the same reason, as LeaseSummary.unreadable: a
|
|
161
|
+
* silently skipped file is the one thing that makes a broken record
|
|
162
|
+
* impossible to diagnose, and it would make a tab that HAS a record read as
|
|
163
|
+
* a clean "no record at all". Every other field on such a row is a
|
|
164
|
+
* placeholder derived from the FILENAME and nothing else.
|
|
165
|
+
*/
|
|
166
|
+
unreadable?: string;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Enumerate the ledger, dropping records for targets the browser no longer has.
|
|
171
|
+
*
|
|
172
|
+
* `liveIds` is the reap set and is optional on purpose. When it is undefined
|
|
173
|
+
* nothing is reaped: a caller that could not enumerate the browser's targets
|
|
174
|
+
* must not be able to delete the ledger by failing. Passing an EMPTY array is
|
|
175
|
+
* a real statement ("the browser has no targets") and does reap.
|
|
176
|
+
*
|
|
177
|
+
* `liveBackend` scopes the reap to the browser those ids came from, exactly as
|
|
178
|
+
* listLeases does: a Chrome target list says nothing about whether a Firefox
|
|
179
|
+
* context is still open, and reaping on it would delete live Firefox records.
|
|
180
|
+
*/
|
|
181
|
+
export async function listOrigins(
|
|
182
|
+
opts: { liveIds?: readonly string[]; liveBackend?: LeaseBackend } = {},
|
|
183
|
+
): Promise<OriginSummary[]> {
|
|
184
|
+
let names: string[] = [];
|
|
185
|
+
try {
|
|
186
|
+
names = await readdir(leaseDir());
|
|
187
|
+
} catch {
|
|
188
|
+
// Unlike listLeases, an unreadable directory is reported as an empty
|
|
189
|
+
// ledger rather than thrown: every consumer of this list degrades to
|
|
190
|
+
// origin "unknown", which is the honest answer when provenance cannot be
|
|
191
|
+
// read, and no caller of this function is making an authorization decision.
|
|
192
|
+
return [];
|
|
193
|
+
}
|
|
194
|
+
const live = opts.liveIds === undefined ? undefined : new Set(opts.liveIds.map((id) => safeId(id)));
|
|
195
|
+
const out: OriginSummary[] = [];
|
|
196
|
+
for (const name of names) {
|
|
197
|
+
const parts = ORIGIN_FILE_RE.exec(name);
|
|
198
|
+
if (!parts) continue;
|
|
199
|
+
const backend = parts[1] as LeaseBackend;
|
|
200
|
+
const nameId = parts[2] as string;
|
|
201
|
+
// The reap test runs on the FILENAME id, which is the only id an unreadable
|
|
202
|
+
// row has. safeId() is applied to both sides so the comparison is like for
|
|
203
|
+
// like; an id that safeId rewrote is an identifying label, not a key, so a
|
|
204
|
+
// collision would at worst spare a record from reaping.
|
|
205
|
+
const scoped = live !== undefined && (opts.liveBackend === undefined || opts.liveBackend === backend);
|
|
206
|
+
if (scoped && !live.has(nameId)) {
|
|
207
|
+
await unlink(join(leaseDir(), name)).catch(() => undefined);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
const fromName: OriginSummary = { backend, targetId: nameId, label: "", pid: 0, createdAt: 0 };
|
|
211
|
+
let rec: OriginRecord;
|
|
212
|
+
try {
|
|
213
|
+
rec = JSON.parse(await readFile(join(leaseDir(), name), "utf8")) as OriginRecord;
|
|
214
|
+
} catch (err) {
|
|
215
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
216
|
+
// Vanished between readdir and readFile: reaped by a concurrent reader.
|
|
217
|
+
if (code === "ENOENT") continue;
|
|
218
|
+
out.push({ ...fromName, unreadable: code ?? "unparseable" });
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (typeof rec?.targetId !== "string" || typeof rec?.label !== "string" || typeof rec?.createdAt !== "number") {
|
|
222
|
+
out.push({ ...fromName, unreadable: "unparseable" });
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
out.push({ backend: rec.backend, targetId: rec.targetId, label: rec.label, pid: rec.pid, createdAt: rec.createdAt });
|
|
226
|
+
}
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* listOrigins keyed by targetId, for annotating a page listing.
|
|
232
|
+
*
|
|
233
|
+
* NEVER THROWS. The whole annotation is best-effort: a caller that cannot read
|
|
234
|
+
* the ledger reports every page as origin "unknown", which is what a consumer
|
|
235
|
+
* with no ledger at all has always seen. An empty map and a missing ledger are
|
|
236
|
+
* the same thing to the caller by construction.
|
|
237
|
+
*/
|
|
238
|
+
export async function originIndex(
|
|
239
|
+
backend: LeaseBackend,
|
|
240
|
+
liveIds?: readonly string[],
|
|
241
|
+
): Promise<Map<string, OriginSummary>> {
|
|
242
|
+
const rows = await listOrigins({ liveIds, liveBackend: backend }).catch(() => [] as OriginSummary[]);
|
|
243
|
+
const index = new Map<string, OriginSummary>();
|
|
244
|
+
for (const row of rows) {
|
|
245
|
+
if (row.backend !== backend) continue;
|
|
246
|
+
index.set(row.targetId, row);
|
|
247
|
+
}
|
|
248
|
+
return index;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Open a tab AND record its provenance. THE ONLY WAY THE TOOLKIT SHOULD CREATE
|
|
253
|
+
* A PAGE. Both creation paths (new_page in shared-tools.ts, claim_page opening
|
|
254
|
+
* a fresh tab in leases-tools.ts) route through here so a third path added
|
|
255
|
+
* later cannot forget the ledger write by omission: `driver.newPage(` should
|
|
256
|
+
* appear in exactly one place outside the drivers themselves, which is here.
|
|
257
|
+
*/
|
|
258
|
+
export async function newTrackedPage(
|
|
259
|
+
driver: BrowserDriver,
|
|
260
|
+
backend: LeaseBackend,
|
|
261
|
+
opts: { url?: string; label?: string } = {},
|
|
262
|
+
): Promise<PageInfo> {
|
|
263
|
+
const page = await driver.newPage(opts.url);
|
|
264
|
+
await recordOrigin(backend, page.id, { label: opts.label });
|
|
265
|
+
return page;
|
|
266
|
+
}
|