cdp-toolkit 1.9.1 → 1.9.4
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 +389 -0
- package/CONTRACT.md +59 -2
- package/README.md +53 -3
- package/dist/cli.js +2857 -114
- package/dist/index.js +934 -78
- package/dist/mcp.js +1130 -115
- package/package.json +2 -1
- package/src/backend.ts +156 -21
- package/src/bidi/client.ts +48 -10
- package/src/bidi/driver.ts +228 -33
- package/src/bidi/launch.ts +23 -13
- package/src/cdp/driver.ts +594 -9
- package/src/cli.ts +107 -10
- package/src/driver.ts +254 -3
- package/src/manifest.ts +20 -4
- package/src/mcp.ts +21 -12
- package/src/png.ts +809 -0
- package/src/shared-tools.ts +313 -11
package/src/cli.ts
CHANGED
|
@@ -23,17 +23,20 @@
|
|
|
23
23
|
* - --list -> all tool names (one per line) to stdout, exit 0.
|
|
24
24
|
*/
|
|
25
25
|
import { TOOLS, TOOL_NAMES, type ToolName } from "./index.ts";
|
|
26
|
-
import {
|
|
26
|
+
import { resolveBackend, stripBrowserFlag, stripConnectFlag, startFirefoxSession } from "./backend.ts";
|
|
27
27
|
import { toolAvailability } from "./capabilities.ts";
|
|
28
28
|
import { FIREFOX_TOOLS } from "./firefox-tools.ts";
|
|
29
29
|
import { leaseFromArgs, withLeaseScope } from "./leases.ts";
|
|
30
|
+
import { MANIFEST } from "./manifest.ts";
|
|
30
31
|
|
|
31
32
|
const USAGE = `cdp-toolkit: raw single-target CDP, 29-tool chrome-devtools-mcp parity, plus a Firefox backend over WebDriver BiDi.
|
|
32
33
|
|
|
33
34
|
Usage:
|
|
34
|
-
bun run src/cli.ts <tool> [--browser chrome|firefox] [--target <sel>] [--json '<obj>'] [--<key> <value> ...]
|
|
35
|
+
bun run src/cli.ts <tool> [--browser chrome|firefox] [--connect <port|host:port|ws-url>] [--target <sel>] [--json '<obj>'] [--<key> <value> ...]
|
|
35
36
|
bun run src/cli.ts --list [--browser chrome|firefox]
|
|
36
37
|
bun run src/cli.ts --capabilities [--browser chrome|firefox]
|
|
38
|
+
bun run src/cli.ts --help | -h # this message
|
|
39
|
+
bun run src/cli.ts <tool> --help | -h # that tool's arguments, sourced from its schema
|
|
37
40
|
|
|
38
41
|
Examples:
|
|
39
42
|
bun run src/cli.ts list_pages
|
|
@@ -43,10 +46,24 @@ Examples:
|
|
|
43
46
|
bun run src/cli.ts take_screenshot --target url:example --fullPage true
|
|
44
47
|
bun run src/cli.ts --browser firefox take_snapshot
|
|
45
48
|
bun run src/cli.ts --capabilities --browser firefox
|
|
49
|
+
bun run src/cli.ts take_screenshot --help
|
|
50
|
+
bun run src/cli.ts --connect 9223 take_snapshot # attach to your own logged-in Firefox (implies --browser firefox)
|
|
51
|
+
|
|
52
|
+
--help/-h is recognised ANYWHERE in argv, before any tool dispatch: it never touches the
|
|
53
|
+
browser (no CDP connection, no lease, no file written) and is never passed through as a tool
|
|
54
|
+
argument, even when it appears after a tool name.
|
|
46
55
|
|
|
47
56
|
Backend selection: --browser chrome|firefox, else the CDP_BROWSER env var, else "chrome" (default,
|
|
48
|
-
zero behavior change for existing users). Firefox
|
|
49
|
-
|
|
57
|
+
zero behavior change for existing users). Firefox runs in one of two modes:
|
|
58
|
+
- LAUNCH (default): a fresh throwaway-profile Firefox is spawned for this one invocation, used
|
|
59
|
+
for exactly one tool call, then disposed (session ended, process killed) before the process
|
|
60
|
+
exits. This is a login wall for anything that needs your real session.
|
|
61
|
+
- ATTACH (--connect <port|host:port|ws-url>, or the CDP_FIREFOX_ENDPOINT env var): connects to a
|
|
62
|
+
Firefox YOU already started with --remote-debugging-port, so tools see your real logged-in
|
|
63
|
+
profile. This process never launches or kills that Firefox; dispose only ends the BiDi session.
|
|
64
|
+
--connect implies --browser firefox and conflicts with an explicit --browser chrome. Firefox
|
|
65
|
+
allows only one active BiDi session at a time, so a client that dies without disposing cleanly
|
|
66
|
+
(e.g. SIGKILL) can leave the slot occupied until Firefox restarts.
|
|
50
67
|
|
|
51
68
|
Target selector grammar: active | index:N | url:<substr> | title:<substr> | label:<name> | <targetId>
|
|
52
69
|
plus worker:<substr> (Chrome only) for evaluate_script, list_network_requests, get_network_request
|
|
@@ -74,12 +91,13 @@ interface ParsedArgs {
|
|
|
74
91
|
tool?: string;
|
|
75
92
|
list: boolean;
|
|
76
93
|
capabilities: boolean;
|
|
94
|
+
help: boolean;
|
|
77
95
|
args: Record<string, unknown>;
|
|
78
96
|
}
|
|
79
97
|
|
|
80
98
|
/** Parse process argv (already sliced to drop the runtime + script, and with --browser already stripped). */
|
|
81
99
|
function parseArgv(argv: string[]): ParsedArgs {
|
|
82
|
-
const out: ParsedArgs = { list: false, capabilities: false, args: {} };
|
|
100
|
+
const out: ParsedArgs = { list: false, capabilities: false, help: false, args: {} };
|
|
83
101
|
// Collect flag pairs separately so --json can be merged BEFORE individual
|
|
84
102
|
// flags override it, regardless of token order.
|
|
85
103
|
let jsonObj: Record<string, unknown> | undefined;
|
|
@@ -95,6 +113,16 @@ function parseArgv(argv: string[]): ParsedArgs {
|
|
|
95
113
|
out.capabilities = true;
|
|
96
114
|
continue;
|
|
97
115
|
}
|
|
116
|
+
// Recognised ANYWHERE in argv (not just as the first token), and BEFORE the
|
|
117
|
+
// generic "--key value" branch below, so --help/-h can never be swallowed as
|
|
118
|
+
// a tool argument (the bug: `take_screenshot --help` used to run the tool
|
|
119
|
+
// with args.help=true; `-h` doesn't even start with "--", so without this
|
|
120
|
+
// explicit check it fell through to the positional branch and, if a tool
|
|
121
|
+
// name was already set, was silently dropped and the tool ran anyway).
|
|
122
|
+
if (token === "--help" || token === "-h") {
|
|
123
|
+
out.help = true;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
98
126
|
if (token.startsWith("--")) {
|
|
99
127
|
const key = token.slice(2);
|
|
100
128
|
const next = argv[i + 1];
|
|
@@ -132,10 +160,78 @@ function isToolName(name: string): name is ToolName {
|
|
|
132
160
|
return Object.prototype.hasOwnProperty.call(TOOLS, name);
|
|
133
161
|
}
|
|
134
162
|
|
|
163
|
+
/** A JSON-Schema property shape narrow enough for the fields --help prints. */
|
|
164
|
+
interface HelpProp {
|
|
165
|
+
type?: string | string[];
|
|
166
|
+
enum?: unknown[];
|
|
167
|
+
description?: string;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** "string", "string|array" (a union type array), or an enum rendered as "png|jpeg". */
|
|
171
|
+
function formatType(prop: HelpProp | undefined): string {
|
|
172
|
+
if (!prop) return "any";
|
|
173
|
+
if (Array.isArray(prop.enum) && prop.enum.length > 0) return prop.enum.map(String).join("|");
|
|
174
|
+
if (Array.isArray(prop.type)) return prop.type.join("|");
|
|
175
|
+
return prop.type ?? "any";
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Print one tool's usage sourced ENTIRELY from MANIFEST's inputSchema (src/manifest.ts) — the
|
|
180
|
+
* same JSON Schema the MCP server advertises via tools/list. This is a read of that single
|
|
181
|
+
* source of truth, not a second hand-maintained argument list that could drift from it.
|
|
182
|
+
*/
|
|
183
|
+
function printToolHelp(name: ToolName): void {
|
|
184
|
+
const spec = MANIFEST.find((t) => t.name === name);
|
|
185
|
+
if (!spec) {
|
|
186
|
+
// Every TOOLS entry has a MANIFEST entry (auditCoverage() in mcp.ts warns otherwise); this
|
|
187
|
+
// is a defensive fallback, not an expected path.
|
|
188
|
+
process.stdout.write(`${name}\n`);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const required = new Set(spec.inputSchema.required ?? []);
|
|
192
|
+
const props = (spec.inputSchema.properties ?? {}) as Record<string, HelpProp>;
|
|
193
|
+
const keys = Object.keys(props);
|
|
194
|
+
|
|
195
|
+
const lines = [spec.name, "", spec.description, ""];
|
|
196
|
+
if (keys.length === 0) {
|
|
197
|
+
lines.push("Arguments: none.");
|
|
198
|
+
} else {
|
|
199
|
+
lines.push("Arguments:");
|
|
200
|
+
for (const key of keys) {
|
|
201
|
+
const prop = props[key];
|
|
202
|
+
const reqTag = required.has(key) ? "required" : "optional";
|
|
203
|
+
lines.push(` --${key} <${formatType(prop)}> (${reqTag})`);
|
|
204
|
+
if (prop?.description) lines.push(` ${prop.description}`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
lines.push("", `Usage: cdp ${spec.name} [--target <sel>] [--json '<obj>'] [--<key> <value> ...]`);
|
|
208
|
+
process.stdout.write(`${lines.join("\n")}\n`);
|
|
209
|
+
}
|
|
210
|
+
|
|
135
211
|
async function main(): Promise<number> {
|
|
136
212
|
const rawArgv = process.argv.slice(2);
|
|
137
|
-
const browser =
|
|
138
|
-
const parsed = parseArgv(stripBrowserFlag(rawArgv));
|
|
213
|
+
const { kind: browser, endpoint } = resolveBackend(rawArgv);
|
|
214
|
+
const parsed = parseArgv(stripConnectFlag(stripBrowserFlag(rawArgv)));
|
|
215
|
+
|
|
216
|
+
// --help/-h short-circuits BEFORE any availability lookup or backend touch — no CDP
|
|
217
|
+
// connection, no lease, no file written. This must be the very first thing checked in
|
|
218
|
+
// main(), ahead of --list/--capabilities, so a --help gesture can never be downgraded
|
|
219
|
+
// into a real dispatch by any branch below it.
|
|
220
|
+
if (parsed.help) {
|
|
221
|
+
if (!parsed.tool) {
|
|
222
|
+
process.stdout.write(`${USAGE}\n`);
|
|
223
|
+
return 0;
|
|
224
|
+
}
|
|
225
|
+
if (!isToolName(parsed.tool)) {
|
|
226
|
+
process.stderr.write(
|
|
227
|
+
`${JSON.stringify({ error: `unknown tool '${parsed.tool}'. Run --list to see all ${TOOL_NAMES.length} tools.` })}\n`,
|
|
228
|
+
);
|
|
229
|
+
return 1;
|
|
230
|
+
}
|
|
231
|
+
printToolHelp(parsed.tool);
|
|
232
|
+
return 0;
|
|
233
|
+
}
|
|
234
|
+
|
|
139
235
|
const availability = toolAvailability(browser);
|
|
140
236
|
const availableSet = new Set(availability.available);
|
|
141
237
|
|
|
@@ -205,14 +301,15 @@ async function main(): Promise<number> {
|
|
|
205
301
|
return 0;
|
|
206
302
|
}
|
|
207
303
|
|
|
208
|
-
// Firefox: one process per invocation. Launch
|
|
209
|
-
//
|
|
304
|
+
// Firefox: one process per invocation. Launch (or, with --connect/CDP_FIREFOX_ENDPOINT,
|
|
305
|
+
// attach), run exactly one tool call, always dispose, even if the tool call itself throws —
|
|
306
|
+
// in launch mode that reaps the spawned process; in attach mode it only ends the BiDi session.
|
|
210
307
|
const neutralFn = FIREFOX_TOOLS[tool];
|
|
211
308
|
if (!neutralFn) {
|
|
212
309
|
process.stderr.write(`${JSON.stringify({ error: `tool '${tool}' has no Firefox implementation wired` })}\n`);
|
|
213
310
|
return 1;
|
|
214
311
|
}
|
|
215
|
-
const session = await startFirefoxSession();
|
|
312
|
+
const session = await startFirefoxSession({ endpoint });
|
|
216
313
|
try {
|
|
217
314
|
const result = await neutralFn(session.driver, parsed.args as never);
|
|
218
315
|
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
package/src/driver.ts
CHANGED
|
@@ -56,7 +56,13 @@
|
|
|
56
56
|
* breaks the CLI.
|
|
57
57
|
*/
|
|
58
58
|
|
|
59
|
+
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
60
|
+
import { join } from "node:path";
|
|
59
61
|
import type { ToolName } from "./index.ts";
|
|
62
|
+
// leaseDir only, for the viewport-override record below. leases.ts imports nothing from this file
|
|
63
|
+
// (node builtins only), so this is not a cycle; the record deliberately shares the lease directory
|
|
64
|
+
// rather than minting a second state location.
|
|
65
|
+
import { leaseDir } from "./leases.ts";
|
|
60
66
|
import type { TargetSelector } from "./types.ts";
|
|
61
67
|
|
|
62
68
|
/* ---------------------------------- uids ---------------------------------- */
|
|
@@ -133,6 +139,9 @@ export type Capability =
|
|
|
133
139
|
| "emulate.networkConditions" // throttled or offline conditions
|
|
134
140
|
| "screenshot.fullPage" // one-shot full scrollable page, not just the viewport
|
|
135
141
|
| "screenshot.element" // capture clipped to a single element
|
|
142
|
+
| "screenshot.scale" // per-capture output-pixel multiplier; no permanent emulation change
|
|
143
|
+
| "screenshot.renderSize" // emulate a viewport for the duration of ONE capture, then put the old one back
|
|
144
|
+
| "screenshot.tile" // capture past the backend's one-image encode limit as vertical bands, stitched losslessly
|
|
136
145
|
| "network.intercept" // fulfill, fail, or continue a matched request
|
|
137
146
|
| "network.responseBodyPostHoc" // read a body for a request not armed in advance
|
|
138
147
|
| "snapshot.accessibilityTree" // native a11y dump, not a DOM-walk approximation
|
|
@@ -344,6 +353,236 @@ export interface ScreenshotOptions {
|
|
|
344
353
|
quality?: number;
|
|
345
354
|
fullPage?: boolean; // requires "screenshot.fullPage"
|
|
346
355
|
clip?: ElementLocator; // requires "screenshot.element"
|
|
356
|
+
scale?: number; // requires "screenshot.scale"; absent or 1 leaves the capture byte-for-byte as it was
|
|
357
|
+
/**
|
|
358
|
+
* Render this capture at an emulated viewport, then put the previous one back. Both fields are
|
|
359
|
+
* required TOGETHER (the tool layer enforces it; a driver trusts its caller) and both require
|
|
360
|
+
* Capability "screenshot.renderSize". Absent, the capture is byte-for-byte what it was.
|
|
361
|
+
*
|
|
362
|
+
* NOT the same knob as `scale`, and the difference is measured, not asserted. `scale` multiplies
|
|
363
|
+
* output pixels while the page keeps believing it is its old size: window.devicePixelRatio read 2
|
|
364
|
+
* both before and after a --scale 3 capture, and innerWidth/innerHeight stayed 1390x1064. This
|
|
365
|
+
* pair changes what the page BELIEVES for the length of one capture, so media queries flip and
|
|
366
|
+
* the document re-lays-out — which is the point (capture a responsive page at desktop 1920x1080
|
|
367
|
+
* from a tab that is not that size) and also why it must be undone afterwards.
|
|
368
|
+
*
|
|
369
|
+
* `deviceScaleFactor` and `mobile` are deliberately INHERITED from whatever override was already
|
|
370
|
+
* in place rather than reset: the caller asked to change the render SIZE, and silently dropping
|
|
371
|
+
* a device pixel ratio they set earlier would be a second, unrequested change.
|
|
372
|
+
*/
|
|
373
|
+
renderWidth?: number; // requires "screenshot.renderSize"
|
|
374
|
+
renderHeight?: number; // requires "screenshot.renderSize"
|
|
375
|
+
/**
|
|
376
|
+
* Whether this capture may be taken as vertical BANDS and stitched, instead of as one image.
|
|
377
|
+
* Three-valued, and the middle value is the default:
|
|
378
|
+
*
|
|
379
|
+
* undefined AUTO. One shot when the projected output fits under the backend's per-image encode
|
|
380
|
+
* limit, banded when it does not. A capture that fits is byte-for-byte the call it
|
|
381
|
+
* always was — the same params on the wire, no clip synthesized that was not there
|
|
382
|
+
* before — so auto costs the common path nothing but the layout-metrics round trip
|
|
383
|
+
* the decision itself needs.
|
|
384
|
+
* false NEVER band. A capture past the limit is REFUSED by the encode guard instead. This
|
|
385
|
+
* is the escape hatch back to the exact pre-tiling behaviour.
|
|
386
|
+
* true ALWAYS band, even when one shot would have fitted. Mainly so the banded path is
|
|
387
|
+
* directly testable on an ordinary page; see the driver for why "always" is made to
|
|
388
|
+
* mean at least two bands rather than a one-band no-op.
|
|
389
|
+
*
|
|
390
|
+
* AUTO IS THE DEFAULT BECAUSE THE ALTERNATIVE IS A WEDGED TAB, not because banding is free.
|
|
391
|
+
* Measured (see MAX_ENCODED_SIDE_PX in ../cdp/driver.ts): a plain capture of a document past the
|
|
392
|
+
* limit does not fail fast — Page.captureScreenshot never answers, and because captureBeyondViewport
|
|
393
|
+
* resizes the page to the clip for the length of the capture, the tab is left stuck at that size
|
|
394
|
+
* with every later screenshot timing out too.
|
|
395
|
+
*
|
|
396
|
+
* Requires Capability "screenshot.tile" only for `true`. `undefined` and `false` ask a backend for
|
|
397
|
+
* nothing it cannot already do.
|
|
398
|
+
*/
|
|
399
|
+
tile?: boolean; // `true` requires "screenshot.tile"
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* A capture too big for one image, handed to the tool layer band by band.
|
|
404
|
+
*
|
|
405
|
+
* LAZY BY CONTRACT, not by convenience: the Nth band's Page.captureScreenshot runs only when the
|
|
406
|
+
* consumer pulls the Nth band, so a driver never holds two bands at once and a whole 1.4 GB image
|
|
407
|
+
* never exists in memory. Accumulating the bands into an array first would typecheck, work on a
|
|
408
|
+
* small page, and defeat the entire point of the streaming stitcher on a large one.
|
|
409
|
+
*
|
|
410
|
+
* VERTICAL ONLY, AND THAT IS A SCOPE DECISION RATHER THAN AN OVERSIGHT. Bands stack top to bottom
|
|
411
|
+
* because that is the one direction a PNG can be concatenated without decoding it whole: scanlines
|
|
412
|
+
* are the unit, so appending rows is cheap and appending columns is not. A region whose projected
|
|
413
|
+
* WIDTH alone is past the limit is therefore refused, naming the width, rather than silently
|
|
414
|
+
* truncated or split into a grid this stitcher cannot reassemble.
|
|
415
|
+
*/
|
|
416
|
+
export interface BandedCapture {
|
|
417
|
+
/** The bands, top to bottom, produced one at a time. */
|
|
418
|
+
bands: AsyncIterable<Uint8Array>;
|
|
419
|
+
/** How many bands the sequence will yield. Known before the first one is captured. */
|
|
420
|
+
bandCount: number;
|
|
421
|
+
/** Height of every band but the last, in CSS px. The last is the remainder. */
|
|
422
|
+
bandHeightCss: number;
|
|
423
|
+
/** The whole region being tiled, CSS px, in document coordinates. */
|
|
424
|
+
clip: { x: number; y: number; width: number; height: number };
|
|
425
|
+
/** Always "png": the stitch is a PNG-scanline concatenation, and there is no lossless JPEG one. */
|
|
426
|
+
format: "png";
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* The tool layer's half of a banded capture: consume the bands and return whatever was made of
|
|
431
|
+
* them. Called at most once per capture, BEFORE the first band is taken, so a consumer that cannot
|
|
432
|
+
* accept a banded capture at all (it was asked for JPEG, or for base64 of a 300 MB image) refuses
|
|
433
|
+
* by throwing here and no band is ever captured.
|
|
434
|
+
*
|
|
435
|
+
* This is why the driver takes a callback rather than returning the iterable: a banded capture may
|
|
436
|
+
* hold a viewport override for its whole duration, and the driver can only put that back once it
|
|
437
|
+
* knows the consumer is finished. Handing the iterable out would end the driver's call while the
|
|
438
|
+
* page was still emulated.
|
|
439
|
+
*/
|
|
440
|
+
export type BandConsumer<T> = (capture: BandedCapture) => Promise<T>;
|
|
441
|
+
|
|
442
|
+
/** What a banded capture returns in place of bytes: the consumer's own answer, plus how it was split. */
|
|
443
|
+
export interface TiledScreenshotResult<T> {
|
|
444
|
+
tiled: true;
|
|
445
|
+
format: "png";
|
|
446
|
+
bandCount: number;
|
|
447
|
+
bandHeightCss: number;
|
|
448
|
+
/** Whatever the BandConsumer returned — for take_screenshot, the stitcher's measured report. */
|
|
449
|
+
consumed: T;
|
|
450
|
+
/** Same three render-size fields a one-shot capture carries, same meaning. */
|
|
451
|
+
renderSize?: { width: number; height: number };
|
|
452
|
+
renderRestored?: boolean;
|
|
453
|
+
renderRestoreError?: string;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** One capture, delivered whole or in bands. Discriminate with isTiledCapture. */
|
|
457
|
+
export type CaptureDelivery<T> = ScreenshotResult | TiledScreenshotResult<T>;
|
|
458
|
+
|
|
459
|
+
export function isTiledCapture<T>(delivery: CaptureDelivery<T>): delivery is TiledScreenshotResult<T> {
|
|
460
|
+
return (delivery as { tiled?: unknown }).tiled === true;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* What a capture returns. `data`/`format` are the whole answer for an ordinary capture; the three
|
|
465
|
+
* render-size fields are present only when renderWidth/renderHeight were applied.
|
|
466
|
+
*
|
|
467
|
+
* `renderRestored:false` IS THE LOUD PART, and it is why this is a result shape rather than a bare
|
|
468
|
+
* byte buffer. A failed restore means the caller's page is still emulated at the render size after
|
|
469
|
+
* this call returns — a mutation they did not ask to keep — so it has to travel back with the image
|
|
470
|
+
* instead of being swallowed. On the THROWING path there is no result to carry it, so a driver
|
|
471
|
+
* appends the same fact to the error message instead (see CdpPageDriver.screenshot).
|
|
472
|
+
*/
|
|
473
|
+
export interface ScreenshotResult {
|
|
474
|
+
data: Uint8Array;
|
|
475
|
+
format: "png" | "jpeg";
|
|
476
|
+
/** The emulated viewport this capture ran at. Present only when renderWidth/renderHeight were given. */
|
|
477
|
+
renderSize?: { width: number; height: number };
|
|
478
|
+
/** Whether the previous viewport was put back. Present only when a render size was applied. */
|
|
479
|
+
renderRestored?: boolean;
|
|
480
|
+
/** Why the restore failed. Present only when renderRestored is false. */
|
|
481
|
+
renderRestoreError?: string;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/* --------------------- the viewport-override record (both drivers) --------------------- */
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* The device-metrics override this toolkit last applied to a target, as a file under the lease
|
|
488
|
+
* directory. Written by PageDriver.emulate, removed by emulate{clearOverrides}, read by
|
|
489
|
+
* PageDriver.screenshot so a render-size capture can put back what it displaced.
|
|
490
|
+
*
|
|
491
|
+
* WHY A FILE AND NOT A FIELD ON THE DRIVER. The spec for this feature called for a private field on
|
|
492
|
+
* the page driver, and that is provably not enough here — MEASURED against Chrome/151.0.7922.76:
|
|
493
|
+
*
|
|
494
|
+
* $ cli emulate --target T --width 800 --height 600 # one process, socket closes on exit
|
|
495
|
+
* $ cli evaluate_script --target T ...innerWidth... # a DIFFERENT process
|
|
496
|
+
* "[800,600,2]" # the override OUTLIVED the connection
|
|
497
|
+
*
|
|
498
|
+
* The CDP driver's LifetimeModel is "per-call": a PageDriver instance lives for exactly one tool
|
|
499
|
+
* call, so a field on it is empty by construction at the start of the screenshot call that needs
|
|
500
|
+
* it, and the override it must not destroy was set by a process that has already exited. Same
|
|
501
|
+
* reasoning leases.ts and origins.ts give for their own files, arrived at the same way.
|
|
502
|
+
*
|
|
503
|
+
* WHY THE RECORD IS LOAD-BEARING AND NOT A CONVENIENCE. Emulation.clearDeviceMetricsOverride does
|
|
504
|
+
* NOT mean "undo the override" — measured, twice, on the same tab:
|
|
505
|
+
*
|
|
506
|
+
* fresh connection, never set an override, clear it -> returns {}, page STAYS 800x600
|
|
507
|
+
* fresh connection, set 1920x1080, then clear -> page becomes 1390x1064, the REAL device
|
|
508
|
+
*
|
|
509
|
+
* So clearing is a no-op for an override this session did not set, and once a session DOES set one,
|
|
510
|
+
* clearing reverts to the physical device and takes any earlier override down with it. A
|
|
511
|
+
* render-size capture always sets one, so its clear always lands — which is exactly why restoring
|
|
512
|
+
* by clearing would silently destroy a caller's earlier `emulate` and why this record exists.
|
|
513
|
+
*
|
|
514
|
+
* WHAT IT CANNOT SEE, and this is a real limit rather than a caveat: an override applied by
|
|
515
|
+
* something that is not this toolkit — the DevTools UI, Puppeteer, another automation client, or a
|
|
516
|
+
* toolkit build older than this record — leaves no file, so a render-size capture restores by
|
|
517
|
+
* clearing and that foreign override is reset to the real device. There is no CDP or BiDi command
|
|
518
|
+
* that reads a device-metrics override back, so a driver genuinely cannot know it was there.
|
|
519
|
+
*/
|
|
520
|
+
export interface ViewportRecord {
|
|
521
|
+
width: number;
|
|
522
|
+
height: number;
|
|
523
|
+
/** CDP's Emulation.setDeviceMetricsOverride sense: 0 means "the real device's". */
|
|
524
|
+
deviceScaleFactor?: number;
|
|
525
|
+
mobile?: boolean;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** Sibling of leases.ts's lease-<backend>-<targetId>.json, in the same directory and with the same
|
|
529
|
+
* filename-safety policy. Keyed by the driver's uid SCHEME ("cdp" / "bidi") because a CDP targetId
|
|
530
|
+
* and a BiDi context id are not disjoint by construction — the same reason lease files carry a
|
|
531
|
+
* backend in the name. */
|
|
532
|
+
export function viewportRecordFile(scheme: string, targetId: string): string {
|
|
533
|
+
return join(leaseDir(), `viewport-${scheme}-${targetId.replace(/[^A-Za-z0-9._-]/g, "_")}.json`);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** The override this toolkit last applied to `targetId`, or undefined when it has applied none.
|
|
537
|
+
* A missing, unreadable, or malformed file all read as undefined: "we do not know" is the honest
|
|
538
|
+
* answer, and it is the one that makes the caller restore by clearing rather than by guessing. */
|
|
539
|
+
export async function readViewportRecord(scheme: string, targetId: string): Promise<ViewportRecord | undefined> {
|
|
540
|
+
try {
|
|
541
|
+
const raw = JSON.parse(await readFile(viewportRecordFile(scheme, targetId), "utf8")) as Partial<ViewportRecord>;
|
|
542
|
+
if (typeof raw?.width !== "number" || typeof raw?.height !== "number") return undefined;
|
|
543
|
+
return {
|
|
544
|
+
width: raw.width,
|
|
545
|
+
height: raw.height,
|
|
546
|
+
...(typeof raw.deviceScaleFactor === "number" ? { deviceScaleFactor: raw.deviceScaleFactor } : {}),
|
|
547
|
+
...(typeof raw.mobile === "boolean" ? { mobile: raw.mobile } : {}),
|
|
548
|
+
};
|
|
549
|
+
} catch {
|
|
550
|
+
return undefined;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** Record an override this toolkit just applied. Deliberately best-effort: emulate's job is to
|
|
555
|
+
* emulate, and a full disk must not turn a working override into a thrown error. The cost of a
|
|
556
|
+
* lost write is stated above — a later render-size capture restores by clearing instead. */
|
|
557
|
+
export async function writeViewportRecord(scheme: string, targetId: string, rec: ViewportRecord): Promise<void> {
|
|
558
|
+
try {
|
|
559
|
+
await mkdir(leaseDir(), { recursive: true });
|
|
560
|
+
await writeFile(viewportRecordFile(scheme, targetId), JSON.stringify({ scheme, targetId, ...rec }), "utf8");
|
|
561
|
+
} catch {
|
|
562
|
+
/* best-effort, see above */
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* What a driver appends to a capture's error when the render-size restore ALSO failed. Shared by
|
|
568
|
+
* both drivers because the sentence is the caller's only warning that their page is still emulated,
|
|
569
|
+
* and it must not read differently depending on which browser stranded it. Says what is wrong AND
|
|
570
|
+
* how to undo it by hand, because at this point the toolkit has already failed to.
|
|
571
|
+
*/
|
|
572
|
+
export function renderRestoreFailureNote(size: { width: number; height: number }, why: string): string {
|
|
573
|
+
return (
|
|
574
|
+
`— AND the ${size.width}x${size.height} render-size override could NOT be restored (${why}), so this page is STILL ` +
|
|
575
|
+
`emulated at ${size.width}x${size.height}: put it back with emulate{width,height} or emulate{clearOverrides:true}.`
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** Forget the recorded override. Best-effort for the same reason, and a missing file is success. */
|
|
580
|
+
export async function clearViewportRecord(scheme: string, targetId: string): Promise<void> {
|
|
581
|
+
try {
|
|
582
|
+
await unlink(viewportRecordFile(scheme, targetId));
|
|
583
|
+
} catch {
|
|
584
|
+
/* best-effort, see above */
|
|
585
|
+
}
|
|
347
586
|
}
|
|
348
587
|
|
|
349
588
|
/** Each optional field names the Capability it requires, if any. */
|
|
@@ -642,9 +881,21 @@ export interface PageDriver {
|
|
|
642
881
|
setFiles(loc: ElementLocator, files: readonly string[]): Promise<void>;
|
|
643
882
|
|
|
644
883
|
/* rendering */
|
|
645
|
-
/** Returns raw image bytes. Artifact naming and writing stay in the tool.
|
|
646
|
-
|
|
647
|
-
|
|
884
|
+
/** Returns raw image bytes. Artifact naming and writing stay in the tool. Under
|
|
885
|
+
* renderWidth/renderHeight it also reports the emulated size it ran at and whether the previous
|
|
886
|
+
* viewport was put back — see ScreenshotResult.
|
|
887
|
+
*
|
|
888
|
+
* `consumeBands` is how a caller says it can accept a capture too big to encode as one image
|
|
889
|
+
* (see BandedCapture). Omitting it is a real and honest state, not a shortcut: a caller with
|
|
890
|
+
* nowhere to put a band sequence gets the encode guard's refusal instead of a wedged tab, which
|
|
891
|
+
* is exactly the pre-tiling behaviour. Supplying it lets the driver band the capture when the
|
|
892
|
+
* projection demands it (the default) or when `tile:true` asks for it. The driver calls it at
|
|
893
|
+
* most once and awaits it, so the whole capture — every band — is finished before this method
|
|
894
|
+
* resolves and before any viewport override is put back. */
|
|
895
|
+
screenshot<T>(opts?: ScreenshotOptions, consumeBands?: BandConsumer<T>): Promise<CaptureDelivery<T>>;
|
|
896
|
+
/** Apply overrides. Returns the field names actually applied. An implementation that applies
|
|
897
|
+
* device metrics MUST record them with writeViewportRecord (and clear the record under
|
|
898
|
+
* clearOverrides), or a later render-size capture will restore by clearing and destroy them. */
|
|
648
899
|
emulate(opts: EmulationOptions): Promise<{ applied: string[] }>;
|
|
649
900
|
|
|
650
901
|
/* events */
|
package/src/manifest.ts
CHANGED
|
@@ -909,7 +909,7 @@ export const MANIFEST: ToolSpec[] = [
|
|
|
909
909
|
},
|
|
910
910
|
{
|
|
911
911
|
"name": "take_screenshot",
|
|
912
|
-
"description": "Capture the viewport (default), the full scrollable page (fullPage), or a single element (uid or selector, exactly one and mutually exclusive) via raw CDP Page.captureScreenshot. Writes a PNG/JPEG under /tmp/cdp-toolkit (override with savePath) and returns {path,bytes,format,target};
|
|
912
|
+
"description": "Capture the viewport (default), the full scrollable page (fullPage), or a single element (uid or selector, exactly one and mutually exclusive) via raw CDP Page.captureScreenshot — as one image when it fits, or as vertical bands stitched losslessly into one PNG when it does not (see tile). Writes a PNG/JPEG under /tmp/cdp-toolkit (override with savePath; its directory is created for you) and returns {path,bytes,format,target,scale} plus width/height MEASURED from the encoded bytes, which are omitted rather than guessed when the bytes cannot be decoded; renderSize/renderRestored (+renderRestoreError) accompany a renderWidth/renderHeight capture, tiled/bands a banded one. Raw base64 is only included when returnBase64 is set, and is refused on a banded capture. quality applies to jpeg only. Every capture costs one Page.getLayoutMetrics round trip, because Chrome does not refuse an oversized capture politely: past 16384 device px per side Page.captureScreenshot can simply never answer, and captureBeyondViewport leaves the tab resized to the clip, so the size is checked before the capture rather than after.",
|
|
913
913
|
"inputSchema": {
|
|
914
914
|
"type": "object",
|
|
915
915
|
"properties": {
|
|
@@ -937,6 +937,22 @@ export const MANIFEST: ToolSpec[] = [
|
|
|
937
937
|
"type": "boolean",
|
|
938
938
|
"description": "Capture the full scrollable content height computed from Page.getLayoutMetrics, not just the viewport."
|
|
939
939
|
},
|
|
940
|
+
"scale": {
|
|
941
|
+
"type": "number",
|
|
942
|
+
"description": "Output-pixel multiplier for this one capture: greater than 0, at most 8, default 1. Output px = ceil(css px x scale x devicePixelRatio), so on a ratio-2 display scale 3 is 6x the CSS size; the page is never told anything changed (devicePixelRatio and innerWidth/innerHeight read identically before and after). Chrome only (capability screenshot.scale) — WebDriver BiDi's browsingContext.captureScreenshot has no scale parameter, so under Firefox call emulate with deviceScaleFactor first and capture at scale 1. A projection past Chrome's 16384 device px per side is refused before the browser is dialled, naming the largest scale that fits."
|
|
943
|
+
},
|
|
944
|
+
"renderWidth": {
|
|
945
|
+
"type": "number",
|
|
946
|
+
"description": "Emulate this viewport width, in CSS px (integer, 1-16384), for the length of ONE capture, then put the previous viewport back. REQUIRED together with renderHeight. Not the same knob as scale: scale re-renders the same layout at more pixels, this changes what the page BELIEVES, so media queries flip and the document re-lays-out — which is how you capture a responsive page at 1920x1080 from a tab that is not that size. Declared by BOTH backends (capability screenshot.renderSize). The restore can only put back an override this toolkit itself applied: one set by the DevTools UI, another client, or a pre-1.9.2 build is invisible and gets reset to the real device. The result reports renderSize and renderRestored; renderRestored:false means the tab is STILL emulated and you have to fix it with emulate."
|
|
947
|
+
},
|
|
948
|
+
"renderHeight": {
|
|
949
|
+
"type": "number",
|
|
950
|
+
"description": "Emulate this viewport height, in CSS px (integer, 1-16384), for the length of one capture. Required together with renderWidth — a viewport is two numbers, and honouring one while inventing the other would render at a size you never named and never see in the result. Note a narrow renderWidth reflows most documents TALLER, which is the usual way a render-size capture trips the 16384-device-px encode limit."
|
|
951
|
+
},
|
|
952
|
+
"tile": {
|
|
953
|
+
"type": "boolean",
|
|
954
|
+
"description": "Whether this capture may be taken as vertical bands and stitched into one lossless PNG. Three-valued: omitted = AUTO (one shot when the projected output fits under Chrome's 16384 device px per side, bands when it does not — this is why a whole-page capture of a very long page now returns a file instead of hanging and wedging the tab); false = never band, refuse instead (the pre-1.9.2 behaviour); true = always band, even when one shot would have fitted. Only true costs a capability (screenshot.tile, Chrome only; Firefox was never driven for the band arithmetic). A banded capture is PNG-only and cannot also returnBase64, and only HEIGHT is tiled — a projected WIDTH past the limit is refused, not split, because bands are stitched by concatenating scanlines. Bands are a faithful crop of one document render: fixed and sticky elements do not repeat per band and seams are pixel-exact, but content the page only loads on real scroll (lazy images, virtualized lists) renders blank in bands the real viewport never reached, which is a property of the page and not of the stitch."
|
|
955
|
+
},
|
|
940
956
|
"uid": {
|
|
941
957
|
"type": "number",
|
|
942
958
|
"description": "Element to clip to: a CDP backendDOMNodeId obtained from take_snapshot. Mutually exclusive with selector."
|
|
@@ -947,11 +963,11 @@ export const MANIFEST: ToolSpec[] = [
|
|
|
947
963
|
},
|
|
948
964
|
"savePath": {
|
|
949
965
|
"type": "string",
|
|
950
|
-
"description": "Override the output file path. Default: /tmp/cdp-toolkit/screenshot-<id>-<stamp>.<ext
|
|
966
|
+
"description": "Override the output file path. Default: /tmp/cdp-toolkit/screenshot-<id>-<stamp>.<ext>, where the stamp is the capture's START (a banded capture writes as it goes and has no finished image to name a file after). The destination directory is created from this path's own dirname, so a savePath outside /tmp/cdp-toolkit works."
|
|
951
967
|
},
|
|
952
968
|
"returnBase64": {
|
|
953
969
|
"type": "boolean",
|
|
954
|
-
"description": "Also return the raw base64 image bytes in the result, in addition to writing the file."
|
|
970
|
+
"description": "Also return the raw base64 image bytes in the result, in addition to writing the file. Refused on a banded capture (see tile): a stitched image runs to hundreds of megabytes and base64 inflates it by a third, into the caller's response — read the file at the returned path instead."
|
|
955
971
|
}
|
|
956
972
|
},
|
|
957
973
|
"required": [],
|
|
@@ -960,7 +976,7 @@ export const MANIFEST: ToolSpec[] = [
|
|
|
960
976
|
},
|
|
961
977
|
{
|
|
962
978
|
"name": "emulate",
|
|
963
|
-
"description": "Apply any subset of Chrome emulation overrides in one call: device metrics (width/height together, plus deviceScaleFactor/mobile), userAgent, cpuThrottlingRate (>=1), emulated media type + mediaFeatures, and networkConditions. Pass clearOverrides:true to reset every override to the browser default (ignores all other fields). Non-metrics overrides are session-scoped and reset when the per-call connection closes; device-metrics overrides persist on the target until cleared or the renderer navigates/reloads.",
|
|
979
|
+
"description": "Apply any subset of Chrome emulation overrides in one call: device metrics (width/height together, plus deviceScaleFactor/mobile), userAgent, cpuThrottlingRate (>=1), emulated media type + mediaFeatures, and networkConditions. Pass clearOverrides:true to reset every override to the browser default (ignores all other fields); as of 1.9.2 that genuinely clears a device-metrics override set by a DIFFERENT process (Emulation.clearDeviceMetricsOverride is a no-op unless the clearing connection also set the override, so the driver re-asserts the tab's current size on its own connection first, then clears — and throws rather than answering cleared:true if it cannot verify that). Non-metrics overrides are session-scoped and reset when the per-call connection closes; device-metrics overrides persist on the target until cleared or the renderer navigates/reloads.",
|
|
964
980
|
"inputSchema": {
|
|
965
981
|
"type": "object",
|
|
966
982
|
"properties": {
|
package/src/mcp.ts
CHANGED
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
* then fail with a clear error if the browser is unreachable.
|
|
10
10
|
*
|
|
11
11
|
* Launch: `bunx -y cdp-toolkit` (or `bun run src/mcp.ts` from a checkout)
|
|
12
|
-
* Config: CDP_BASE (default http://127.0.0.1:9222), CDP_TIMEOUT_MS, CDP_ARTIFACT_DIR
|
|
12
|
+
* Config: CDP_BASE (default http://127.0.0.1:9222), CDP_TIMEOUT_MS, CDP_ARTIFACT_DIR,
|
|
13
|
+
* CDP_FIREFOX_ENDPOINT (attach to a user-launched Firefox instead of spawning one;
|
|
14
|
+
* see backend.ts's ATTACH mode).
|
|
13
15
|
*
|
|
14
16
|
* stdout is the JSON-RPC channel, all diagnostics go to stderr only.
|
|
15
17
|
*/
|
|
@@ -18,17 +20,19 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
18
20
|
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
19
21
|
import { TOOLS, TOOL_NAMES, BASE } from "./index.ts";
|
|
20
22
|
import { MANIFEST } from "./manifest.ts";
|
|
21
|
-
import {
|
|
23
|
+
import { resolveBackend, getOrCreateFirefoxSession, disposeFirefoxSession } from "./backend.ts";
|
|
22
24
|
import { disposeBrowserSession } from "./tools/browser-session.ts";
|
|
23
25
|
import { toolAvailability } from "./capabilities.ts";
|
|
24
26
|
import { FIREFOX_TOOLS } from "./firefox-tools.ts";
|
|
25
27
|
import { leaseFromArgs, markLongLivedProcess, withLeaseScope } from "./leases.ts";
|
|
26
28
|
|
|
27
|
-
const VERSION = "1.9.
|
|
29
|
+
const VERSION = "1.9.4";
|
|
28
30
|
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
|
|
31
|
+
// Backend + (Firefox only) attach endpoint are read once at startup (MCP has no per-call notion
|
|
32
|
+
// of backend): --browser flag / CDP_BROWSER env, else "chrome" (zero behavior change for existing
|
|
33
|
+
// users/configs); --connect flag / CDP_FIREFOX_ENDPOINT env selects Firefox ATTACH mode and
|
|
34
|
+
// implies browser=firefox (resolveBackend, backend.ts).
|
|
35
|
+
const { kind: BROWSER, endpoint: FIREFOX_ENDPOINT } = resolveBackend(process.argv.slice(2));
|
|
32
36
|
const AVAILABILITY = toolAvailability(BROWSER);
|
|
33
37
|
const AVAILABLE_NAMES = new Set<string>(AVAILABILITY.available);
|
|
34
38
|
|
|
@@ -90,7 +94,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
90
94
|
const neutralFn = neutralDispatch[name];
|
|
91
95
|
if (!neutralFn) return { content: [{ type: "text" as const, text: `unknown tool: ${name}` }], isError: true };
|
|
92
96
|
try {
|
|
93
|
-
const session = await getOrCreateFirefoxSession();
|
|
97
|
+
const session = await getOrCreateFirefoxSession({ endpoint: FIREFOX_ENDPOINT });
|
|
94
98
|
const result = await neutralFn(session.driver, args);
|
|
95
99
|
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
|
96
100
|
} catch (err) {
|
|
@@ -105,12 +109,17 @@ auditCoverage();
|
|
|
105
109
|
// (CDP_REQUIRE_LEASE) safe to honor here and unsafe in cli.ts. See requireLease.
|
|
106
110
|
markLongLivedProcess();
|
|
107
111
|
await server.connect(new StdioServerTransport());
|
|
108
|
-
console.error(
|
|
112
|
+
console.error(
|
|
113
|
+
`[cdp-toolkit] MCP server v${VERSION} ready, browser=${BROWSER}${FIREFOX_ENDPOINT ? ` (attach ${FIREFOX_ENDPOINT})` : ""}, ${AVAILABILITY.available.length} tools, CDP_BASE=${BASE}`,
|
|
114
|
+
);
|
|
109
115
|
|
|
110
|
-
// Firefox owns a real OS process (see bidi/launch.ts): it must be reaped on every
|
|
111
|
-
// not just a clean exit.
|
|
112
|
-
//
|
|
113
|
-
//
|
|
116
|
+
// LAUNCH-mode Firefox owns a real OS process (see bidi/launch.ts): it must be reaped on every
|
|
117
|
+
// shutdown path, not just a clean exit. ATTACH-mode Firefox (CDP_FIREFOX_ENDPOINT / --connect)
|
|
118
|
+
// owns no process here — disposeFirefoxSession() below only ends its BiDi session (freeing
|
|
119
|
+
// Firefox's single session slot) and closes the socket, leaving the user's browser running.
|
|
120
|
+
// SIGINT/SIGTERM cover ctrl-C and a supervising client killing the server; stdin 'close' covers
|
|
121
|
+
// the normal MCP shutdown (the client closes the stdio pipe). All three are idempotent through
|
|
122
|
+
// disposeFirefoxSession(), and a no-op entirely when Firefox was never launched/attached.
|
|
114
123
|
let shuttingDown = false;
|
|
115
124
|
async function shutdown(): Promise<void> {
|
|
116
125
|
if (shuttingDown) return;
|