cdp-toolkit 1.9.1 → 1.9.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/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 { resolveBrowserKind, stripBrowserFlag, startFirefoxSession } from "./backend.ts";
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 is LAUNCHED per invocation (it cannot be attached
49
- to), used for exactly one tool call, then disposed before the process exits.
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 = resolveBrowserKind(rawArgv);
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, run exactly one tool call, always dispose,
209
- // even if the tool call itself throws, so the spawned Firefox process never leaks.
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
- screenshot(opts?: ScreenshotOptions): Promise<{ data: Uint8Array; format: "png" | "jpeg" }>;
647
- /** Apply overrides. Returns the field names actually applied. */
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}; raw base64 is only included when returnBase64 is set. quality applies to jpeg only.",
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 { resolveBrowserKind, stripBrowserFlag, getOrCreateFirefoxSession, disposeFirefoxSession } from "./backend.ts";
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.1";
29
+ const VERSION = "1.9.5";
28
30
 
29
- // --browser is read once at startup (MCP has no per-call notion of backend): explicit flag,
30
- // else CDP_BROWSER env var, else "chrome" (zero behavior change for existing users/configs).
31
- const BROWSER = resolveBrowserKind(stripBrowserFlag(process.argv.slice(2)));
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(`[cdp-toolkit] MCP server v${VERSION} ready, browser=${BROWSER}, ${AVAILABILITY.available.length} tools, CDP_BASE=${BASE}`);
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 shutdown path,
111
- // not just a clean exit. SIGINT/SIGTERM cover ctrl-C and a supervising client killing the server;
112
- // stdin 'close' covers the normal MCP shutdown (the client closes the stdio pipe). All three are
113
- // idempotent through disposeFirefoxSession(), and a no-op entirely when Firefox was never launched.
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;