cdp-toolkit 1.4.0 → 1.6.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/dist/mcp.js CHANGED
@@ -682,6 +682,8 @@ var REQUIRED_CAPABILITIES = {
682
682
  performance_trace: ["trace.performance"],
683
683
  take_heapsnapshot: ["heap.snapshot"],
684
684
  lighthouse_audit: ["audit.lighthouse"],
685
+ start_screen_recording: ["capture.screencast"],
686
+ stop_screen_recording: ["capture.screencast"],
685
687
  mock_request: ["network.intercept"],
686
688
  list_mocks: ["network.intercept"],
687
689
  clear_mocks: ["network.intercept"]
@@ -937,7 +939,8 @@ var CDP_CAPABILITIES = new Set([
937
939
  "snapshot.accessibilityTree",
938
940
  "input.insertTextAtomic",
939
941
  "locate.text",
940
- "locate.xpath"
942
+ "locate.xpath",
943
+ "capture.screencast"
941
944
  ]);
942
945
 
943
946
  class CdpPageDriver {
@@ -2579,11 +2582,375 @@ async function performanceAnalyzeInsight(args = {}) {
2579
2582
  return { ...analyzeEvents(events), tracePath: args.tracePath };
2580
2583
  }
2581
2584
 
2582
- // src/tools/heap.ts
2583
- import { mkdir as mkdir6, writeFile as writeFile6 } from "node:fs/promises";
2585
+ // src/tools/screencast.ts
2586
+ import { spawn } from "node:child_process";
2587
+ import { mkdir as mkdir6, rm, stat as stat2, writeFile as writeFile6 } from "node:fs/promises";
2584
2588
  import { join as join5 } from "node:path";
2585
2589
  var ARTIFACT_DIR4 = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
2586
- function stamp3(targetId) {
2590
+ var FFMPEG_BIN = process.env.CDP_FFMPEG ?? "ffmpeg";
2591
+ function frameDurationsMs(ledger, stopWallclockMs) {
2592
+ return ledger.map((entry, i) => {
2593
+ const next = i + 1 < ledger.length ? ledger[i + 1].timestamp : stopWallclockMs;
2594
+ return Math.max(1, Math.round(next - entry.timestamp));
2595
+ });
2596
+ }
2597
+ var CONCAT_GRID_MS = 40;
2598
+ function coalesceToConcatGrid(ledger, stopWallclockMs, gridMs = CONCAT_GRID_MS) {
2599
+ if (!ledger.length)
2600
+ return [];
2601
+ const raw = frameDurationsMs(ledger, stopWallclockMs);
2602
+ let elapsed = 0;
2603
+ const kept = [];
2604
+ let lastSlot = -1;
2605
+ for (let i = 0;i < ledger.length; i++) {
2606
+ const slot = Math.round(elapsed / gridMs);
2607
+ if (slot > lastSlot) {
2608
+ kept.push({ file: ledger[i].file, slot });
2609
+ lastSlot = slot;
2610
+ }
2611
+ elapsed += raw[i];
2612
+ }
2613
+ const endSlot = Math.max(lastSlot + 1, Math.round(elapsed / gridMs));
2614
+ return kept.map((frame, i) => ({
2615
+ file: frame.file,
2616
+ durationMs: ((i + 1 < kept.length ? kept[i + 1].slot : endSlot) - frame.slot) * gridMs
2617
+ }));
2618
+ }
2619
+ function buildConcatManifest(frames) {
2620
+ if (!frames.length)
2621
+ throw new CdpError("cannot build a concat manifest from an empty frame ledger");
2622
+ const lines = ["ffconcat version 1.0"];
2623
+ for (const frame of frames) {
2624
+ lines.push(`file ${frame.file}`);
2625
+ lines.push(`duration ${(frame.durationMs / 1000).toFixed(6)}`);
2626
+ }
2627
+ lines.push(`file ${frames[frames.length - 1].file}`);
2628
+ return `${lines.join(`
2629
+ `)}
2630
+ `;
2631
+ }
2632
+ function evenDimensions(width, height) {
2633
+ return { width: Math.max(0, Math.trunc(width / 2) * 2), height: Math.max(0, Math.trunc(height / 2) * 2) };
2634
+ }
2635
+ function pngSize(buf) {
2636
+ if (buf.length < 24)
2637
+ return;
2638
+ if (buf.readUInt32BE(0) !== 2303741511 || buf.readUInt32BE(4) !== 218765834)
2639
+ return;
2640
+ if (buf.toString("latin1", 12, 16) !== "IHDR")
2641
+ return;
2642
+ return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
2643
+ }
2644
+ var JPEG_SOF = new Set([192, 193, 194, 195, 197, 198, 199, 201, 202, 203, 205, 206, 207]);
2645
+ function jpegSize(buf) {
2646
+ if (buf.length < 4 || buf[0] !== 255 || buf[1] !== 216)
2647
+ return;
2648
+ let i = 2;
2649
+ while (i + 3 < buf.length) {
2650
+ if (buf[i] !== 255) {
2651
+ i += 1;
2652
+ continue;
2653
+ }
2654
+ const marker = buf[i + 1];
2655
+ if (marker === 255) {
2656
+ i += 1;
2657
+ continue;
2658
+ }
2659
+ if (marker === 216 || marker === 1 || marker >= 208 && marker <= 215) {
2660
+ i += 2;
2661
+ continue;
2662
+ }
2663
+ if (i + 3 >= buf.length)
2664
+ return;
2665
+ const segLen = buf.readUInt16BE(i + 2);
2666
+ if (JPEG_SOF.has(marker)) {
2667
+ if (i + 9 > buf.length)
2668
+ return;
2669
+ return { width: buf.readUInt16BE(i + 7), height: buf.readUInt16BE(i + 5) };
2670
+ }
2671
+ if (segLen < 2)
2672
+ return;
2673
+ i += 2 + segLen;
2674
+ }
2675
+ return;
2676
+ }
2677
+ function imageSize(buf) {
2678
+ return pngSize(buf) ?? jpegSize(buf);
2679
+ }
2680
+ var ENCODER_LADDER = [
2681
+ { encoder: "hevc_videotoolbox", codec: "hevc", tag: "hvc1" },
2682
+ { encoder: "h264_videotoolbox", codec: "h264" },
2683
+ { encoder: "libx265", codec: "hevc", tag: "hvc1" },
2684
+ { encoder: "libx264", codec: "h264" }
2685
+ ];
2686
+ function ffmpegEncoderNames(encodersOutput) {
2687
+ const names = new Set;
2688
+ for (const line of encodersOutput.split(`
2689
+ `)) {
2690
+ const m = /^\s*[VAS][A-Z.]{5}\s+(\S+)/.exec(line);
2691
+ if (m)
2692
+ names.add(m[1]);
2693
+ }
2694
+ return names;
2695
+ }
2696
+ function selectEncoder(encodersOutput) {
2697
+ const available = ffmpegEncoderNames(encodersOutput);
2698
+ for (const candidate of ENCODER_LADDER) {
2699
+ if (available.has(candidate.encoder))
2700
+ return candidate;
2701
+ }
2702
+ throw new CdpError(`ffmpeg reports none of the supported video encoders (${ENCODER_LADDER.map((c) => c.encoder).join(", ")}). ` + "Install a full ffmpeg build: 'brew install ffmpeg' (macOS) or 'apt install ffmpeg' (Debian/Ubuntu).");
2703
+ }
2704
+ function buildFfmpegArgs(manifestPath, outPath, choice) {
2705
+ return [
2706
+ "-hide_banner",
2707
+ "-loglevel",
2708
+ "error",
2709
+ "-y",
2710
+ "-f",
2711
+ "concat",
2712
+ "-safe",
2713
+ "0",
2714
+ "-i",
2715
+ manifestPath,
2716
+ "-vsync",
2717
+ "vfr",
2718
+ "-vf",
2719
+ "scale=trunc(iw/2)*2:trunc(ih/2)*2",
2720
+ "-c:v",
2721
+ choice.encoder,
2722
+ ...choice.tag ? ["-tag:v", choice.tag] : [],
2723
+ "-pix_fmt",
2724
+ "yuv420p",
2725
+ "-movflags",
2726
+ "+faststart",
2727
+ "-an",
2728
+ outPath
2729
+ ];
2730
+ }
2731
+ function run(bin, args) {
2732
+ return new Promise((resolve, reject) => {
2733
+ const child = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"] });
2734
+ let stdout = "";
2735
+ let stderr = "";
2736
+ child.stdout?.on("data", (chunk) => {
2737
+ stdout += chunk.toString("utf8");
2738
+ });
2739
+ child.stderr?.on("data", (chunk) => {
2740
+ stderr += chunk.toString("utf8");
2741
+ });
2742
+ child.on("error", reject);
2743
+ child.on("close", (code) => resolve({ code, stdout, stderr }));
2744
+ });
2745
+ }
2746
+ var encoderProbe;
2747
+ var FFMPEG_MISSING = `ffmpeg is required to encode a screen recording but '${FFMPEG_BIN}' could not be executed. ` + "Install it with 'brew install ffmpeg' (macOS) or 'apt install ffmpeg' (Debian/Ubuntu), " + "or point CDP_FFMPEG at the binary.";
2748
+ async function probeEncoder() {
2749
+ if (!encoderProbe) {
2750
+ encoderProbe = (async () => {
2751
+ let res;
2752
+ try {
2753
+ res = await run(FFMPEG_BIN, ["-hide_banner", "-encoders"]);
2754
+ } catch (err) {
2755
+ throw new CdpError(`${FFMPEG_MISSING} (${err.message})`);
2756
+ }
2757
+ if (res.code !== 0)
2758
+ throw new CdpError(`${FFMPEG_MISSING} ('-encoders' exited ${res.code}: ${res.stderr.trim().slice(0, 300)})`);
2759
+ return selectEncoder(`${res.stdout}
2760
+ ${res.stderr}`);
2761
+ })();
2762
+ encoderProbe.catch(() => {
2763
+ encoderProbe = undefined;
2764
+ });
2765
+ }
2766
+ return encoderProbe;
2767
+ }
2768
+ var liveRecordings = new Map;
2769
+ function shortId2(id) {
2770
+ return id.slice(0, 8);
2771
+ }
2772
+ function assertNotRecording(targetId, activeIds) {
2773
+ if (activeIds.includes(targetId)) {
2774
+ throw new CdpError(`a screen recording is already in progress for target ${shortId2(targetId)}; call stop_screen_recording first`);
2775
+ }
2776
+ }
2777
+ function chooseRecordingId(activeIds, selector) {
2778
+ if (!activeIds.length) {
2779
+ throw new CdpError("no screen recording is in progress in this process. start_screen_recording parks a live CDP connection in memory, so a recording cannot be stopped from a different process than the one that started it (the CLI is one process per call: use the MCP server for the pair).");
2780
+ }
2781
+ if (selector === undefined || selector === "active") {
2782
+ if (activeIds.length === 1)
2783
+ return activeIds[0];
2784
+ throw new CdpError(`${activeIds.length} screen recordings are in progress (targets: ${activeIds.map(shortId2).join(", ")}); pass 'target' to say which one to stop`);
2785
+ }
2786
+ return activeIds.find((id) => id === selector);
2787
+ }
2788
+ function stamp3() {
2789
+ return new Date(Date.now()).toISOString().replace(/[:.]/g, "-");
2790
+ }
2791
+ function target32(t) {
2792
+ return { id: t.id, url: t.url, title: t.title };
2793
+ }
2794
+ async function startScreenRecording(args = {}) {
2795
+ const format = args.format ?? "jpeg";
2796
+ if (format !== "jpeg" && format !== "png")
2797
+ throw new CdpError("start_screen_recording: format must be 'jpeg' or 'png'");
2798
+ if (args.quality !== undefined && (!Number.isFinite(args.quality) || args.quality < 0 || args.quality > 100)) {
2799
+ throw new CdpError("start_screen_recording: quality must be a number between 0 and 100 (jpeg only)");
2800
+ }
2801
+ const encoder = await probeEncoder();
2802
+ const { conn, target } = await openPage(args.target);
2803
+ try {
2804
+ assertNotRecording(target.id, [...liveRecordings.keys()]);
2805
+ } catch (err) {
2806
+ conn.close();
2807
+ throw err;
2808
+ }
2809
+ const spoolDir = join5(ARTIFACT_DIR4, `screencast-${shortId2(target.id)}-${stamp3()}`);
2810
+ try {
2811
+ await mkdir6(spoolDir, { recursive: true });
2812
+ } catch (err) {
2813
+ conn.close();
2814
+ throw new CdpError(`cannot create the screencast spool directory '${spoolDir}': ${err.message}`);
2815
+ }
2816
+ const ext = format === "png" ? "png" : "jpg";
2817
+ const rec = {
2818
+ conn,
2819
+ target,
2820
+ spoolDir,
2821
+ ledger: [],
2822
+ startedAt: Date.now(),
2823
+ format,
2824
+ encoder,
2825
+ writes: Promise.resolve(),
2826
+ dropped: 0,
2827
+ unwritten: new Set,
2828
+ off: () => {}
2829
+ };
2830
+ let seq = 0;
2831
+ rec.off = conn.on("Page.screencastFrame", (params) => {
2832
+ const sessionId = params.sessionId;
2833
+ if (typeof sessionId === "number") {
2834
+ conn.send("Page.screencastFrameAck", { sessionId }).catch(() => {});
2835
+ }
2836
+ const data = params.data;
2837
+ if (typeof data !== "string" || !data.length) {
2838
+ rec.dropped += 1;
2839
+ return;
2840
+ }
2841
+ const meta = params.metadata;
2842
+ const timestamp = typeof meta?.timestamp === "number" ? Math.round(meta.timestamp * 1000) : Date.now();
2843
+ const buf = Buffer.from(data, "base64");
2844
+ const size = imageSize(buf);
2845
+ if (!size || size.width <= 0 || size.height <= 0) {
2846
+ rec.dropped += 1;
2847
+ return;
2848
+ }
2849
+ seq += 1;
2850
+ const file = `frame-${String(seq).padStart(6, "0")}.${ext}`;
2851
+ rec.ledger.push({ file, timestamp, width: size.width, height: size.height });
2852
+ rec.writes = rec.writes.then(() => writeFile6(join5(spoolDir, file), buf)).catch(() => {
2853
+ rec.dropped += 1;
2854
+ rec.unwritten.add(file);
2855
+ });
2856
+ });
2857
+ try {
2858
+ await conn.send("Page.enable");
2859
+ if (args.bringToFront)
2860
+ await conn.send("Page.bringToFront").catch(() => {});
2861
+ const params = { format };
2862
+ if (format === "jpeg" && args.quality !== undefined)
2863
+ params.quality = args.quality;
2864
+ if (args.maxWidth !== undefined)
2865
+ params.maxWidth = args.maxWidth;
2866
+ if (args.maxHeight !== undefined)
2867
+ params.maxHeight = args.maxHeight;
2868
+ if (args.everyNthFrame !== undefined)
2869
+ params.everyNthFrame = args.everyNthFrame;
2870
+ await conn.send("Page.startScreencast", params);
2871
+ } catch (err) {
2872
+ rec.off();
2873
+ conn.close();
2874
+ await rm(spoolDir, { recursive: true, force: true }).catch(() => {});
2875
+ throw err;
2876
+ }
2877
+ liveRecordings.set(target.id, rec);
2878
+ return {
2879
+ target: target32(target),
2880
+ format,
2881
+ spoolDir,
2882
+ encoder: encoder.encoder,
2883
+ codec: encoder.codec,
2884
+ startedAt: rec.startedAt,
2885
+ note: "Recording. Call stop_screen_recording in THIS process to encode the MP4; frames only arrive on repaint, so a still page yields one long-held frame."
2886
+ };
2887
+ }
2888
+ async function pickRecording(selector) {
2889
+ const chosen = chooseRecordingId([...liveRecordings.keys()], selector);
2890
+ if (chosen)
2891
+ return liveRecordings.get(chosen);
2892
+ const { conn, target } = await openPage(selector);
2893
+ conn.close();
2894
+ const hit = liveRecordings.get(target.id);
2895
+ if (!hit) {
2896
+ throw new CdpError(`no screen recording is in progress for target ${shortId2(target.id)} (recording: ${[...liveRecordings.keys()].map(shortId2).join(", ") || "none"})`);
2897
+ }
2898
+ return hit;
2899
+ }
2900
+ async function stopScreenRecording(args = {}) {
2901
+ const rec = await pickRecording(args.target);
2902
+ rec.off();
2903
+ try {
2904
+ await rec.conn.send("Page.stopScreencast");
2905
+ } catch {}
2906
+ const stoppedAt = Date.now();
2907
+ await rec.writes.catch(() => {});
2908
+ rec.conn.close();
2909
+ liveRecordings.delete(rec.target.id);
2910
+ const ledger = rec.unwritten.size ? rec.ledger.filter((entry) => !rec.unwritten.has(entry.file)) : rec.ledger;
2911
+ if (!ledger.length) {
2912
+ await rm(rec.spoolDir, { recursive: true, force: true }).catch(() => {});
2913
+ throw new CdpError(`screen recording captured 0 frames for target ${shortId2(rec.target.id)}. Chrome emits a screencast frame only on repaint: a fully backgrounded or occluded tab may never paint. Retry with bringToFront:true, or drive the page while recording.`);
2914
+ }
2915
+ await mkdir6(ARTIFACT_DIR4, { recursive: true });
2916
+ const outPath = args.savePath ?? join5(ARTIFACT_DIR4, `screen-recording-${shortId2(rec.target.id)}-${stamp3()}.mp4`);
2917
+ const frames = coalesceToConcatGrid(ledger, stoppedAt);
2918
+ const manifestPath = join5(rec.spoolDir, "frames.ffconcat");
2919
+ await writeFile6(manifestPath, buildConcatManifest(frames), "utf8");
2920
+ const durationMs = frames.reduce((total, f) => total + f.durationMs, 0);
2921
+ const { width, height } = evenDimensions(ledger[0].width, ledger[0].height);
2922
+ let res;
2923
+ try {
2924
+ res = await run(FFMPEG_BIN, buildFfmpegArgs(manifestPath, outPath, rec.encoder));
2925
+ } catch (err) {
2926
+ throw new CdpError(`${FFMPEG_MISSING} Spooled frames kept at ${rec.spoolDir}. (${err.message})`);
2927
+ }
2928
+ if (res.code !== 0) {
2929
+ throw new CdpError(`ffmpeg (${rec.encoder.encoder}) exited ${res.code} encoding ${frames.length} frames. Spooled frames kept at ${rec.spoolDir}, re-run by hand with: ` + `${FFMPEG_BIN} ${buildFfmpegArgs(manifestPath, outPath, rec.encoder).join(" ")}
2930
+ ffmpeg said: ${res.stderr.trim().slice(0, 800)}`);
2931
+ }
2932
+ const { size } = await stat2(outPath);
2933
+ await rm(rec.spoolDir, { recursive: true, force: true }).catch(() => {});
2934
+ return {
2935
+ path: outPath,
2936
+ bytes: size,
2937
+ durationMs,
2938
+ frameCount: ledger.length,
2939
+ encodedFrames: frames.length,
2940
+ codec: rec.encoder.codec,
2941
+ encoder: rec.encoder.encoder,
2942
+ width,
2943
+ height,
2944
+ droppedFrames: rec.dropped,
2945
+ target: target32(rec.target)
2946
+ };
2947
+ }
2948
+
2949
+ // src/tools/heap.ts
2950
+ import { mkdir as mkdir7, writeFile as writeFile7 } from "node:fs/promises";
2951
+ import { join as join6 } from "node:path";
2952
+ var ARTIFACT_DIR5 = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
2953
+ function stamp4(targetId) {
2587
2954
  const short = targetId.slice(0, 8);
2588
2955
  const iso = new Date().toISOString().replace(/[:.]/g, "-");
2589
2956
  return `${short}-${iso}`;
@@ -2606,9 +2973,9 @@ async function takeHeapsnapshot(args = {}) {
2606
2973
  if (snapshot.length === 0) {
2607
2974
  throw new Error("HeapProfiler.takeHeapSnapshot produced no chunks");
2608
2975
  }
2609
- await mkdir6(ARTIFACT_DIR4, { recursive: true });
2610
- const path = args.savePath ? args.savePath.startsWith("/") ? args.savePath : join5(ARTIFACT_DIR4, args.savePath) : join5(ARTIFACT_DIR4, `take_heapsnapshot-${stamp3(target.id)}.heapsnapshot`);
2611
- await writeFile6(path, snapshot, "utf8");
2976
+ await mkdir7(ARTIFACT_DIR5, { recursive: true });
2977
+ const path = args.savePath ? args.savePath.startsWith("/") ? args.savePath : join6(ARTIFACT_DIR5, args.savePath) : join6(ARTIFACT_DIR5, `take_heapsnapshot-${stamp4(target.id)}.heapsnapshot`);
2978
+ await writeFile7(path, snapshot, "utf8");
2612
2979
  const bytes = Buffer.byteLength(snapshot, "utf8");
2613
2980
  return {
2614
2981
  path,
@@ -2620,15 +2987,15 @@ async function takeHeapsnapshot(args = {}) {
2620
2987
  }
2621
2988
 
2622
2989
  // src/tools/lighthouse.ts
2623
- import { spawn } from "node:child_process";
2624
- import { mkdir as mkdir7, readFile as readFile6, stat as stat2 } from "node:fs/promises";
2625
- var ARTIFACT_DIR5 = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
2990
+ import { spawn as spawn2 } from "node:child_process";
2991
+ import { mkdir as mkdir8, readFile as readFile6, stat as stat3 } from "node:fs/promises";
2992
+ var ARTIFACT_DIR6 = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
2626
2993
  function cdpPort() {
2627
2994
  const base = process.env.CDP_BASE ?? "http://127.0.0.1:9222";
2628
2995
  const m = base.match(/:(\d+)/);
2629
2996
  return m?.[1] ? Number(m[1]) : 9222;
2630
2997
  }
2631
- function stamp4() {
2998
+ function stamp5() {
2632
2999
  return new Date(Date.now()).toISOString().replace(/[:.]/g, "-");
2633
3000
  }
2634
3001
  function shortHost(url) {
@@ -2645,7 +3012,7 @@ function runProcess(cmd, args, opts) {
2645
3012
  let stdout = "";
2646
3013
  let stderr = "";
2647
3014
  let settled = false;
2648
- const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
3015
+ const child = spawn2(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
2649
3016
  const timer = setTimeout(() => {
2650
3017
  if (settled)
2651
3018
  return;
@@ -2682,8 +3049,8 @@ async function lighthouseAudit(args) {
2682
3049
  const url = args.url.trim();
2683
3050
  const timeoutMs = args.timeoutMs ?? 120000;
2684
3051
  const port = cdpPort();
2685
- await mkdir7(ARTIFACT_DIR5, { recursive: true });
2686
- const outPath = args.savePath ?? `${ARTIFACT_DIR5}/lighthouse-${shortHost(url)}-${stamp4()}.json`;
3052
+ await mkdir8(ARTIFACT_DIR6, { recursive: true });
3053
+ const outPath = args.savePath ?? `${ARTIFACT_DIR6}/lighthouse-${shortHost(url)}-${stamp5()}.json`;
2687
3054
  const formFactor = args.formFactor ?? "desktop";
2688
3055
  const lhArgs = [
2689
3056
  "--yes",
@@ -2738,7 +3105,7 @@ async function lighthouseAudit(args) {
2738
3105
  if (Object.keys(categories).length === 0) {
2739
3106
  throw new CdpError(`lighthouse_audit report contained no category scores (exit code ${res.code}). stderr: ${truncate(res.stderr)}`);
2740
3107
  }
2741
- const { size } = await stat2(outPath);
3108
+ const { size } = await stat3(outPath);
2742
3109
  return {
2743
3110
  path: outPath,
2744
3111
  bytes: size,
@@ -2792,6 +3159,8 @@ var TOOLS = {
2792
3159
  performance_stop_trace: performanceStopTrace,
2793
3160
  performance_analyze_insight: performanceAnalyzeInsight,
2794
3161
  performance_trace: performanceTrace,
3162
+ start_screen_recording: startScreenRecording,
3163
+ stop_screen_recording: stopScreenRecording,
2795
3164
  take_heapsnapshot: takeHeapsnapshot,
2796
3165
  lighthouse_audit: lighthouseAudit,
2797
3166
  mock_request: mockRequest,
@@ -3905,6 +4274,76 @@ var MANIFEST = [
3905
4274
  additionalProperties: false
3906
4275
  }
3907
4276
  },
4277
+ {
4278
+ name: "start_screen_recording",
4279
+ description: "Start recording the target tab to video over raw CDP (Page.startScreencast), holding one persistent connection open and spooling every Page.screencastFrame to disk. Chrome emits a frame ON REPAINT, not on a clock, so the stream is variable-rate: each frame is timestamped into a ledger and stop_screen_recording turns those timestamps into per-frame display durations, which is what makes a still page hold one long frame instead of the video racing or freezing. Every frame is acked immediately (an unacked frame stalls the stream). ffmpeg is probed here, not at stop, so a missing encoder fails before a recording is captured and thrown away. Must be paired with stop_screen_recording WITHIN THE SAME PROCESS (the frames are events on this connection and cannot be re-attached from another process, so the pair works under the MCP server, not the one-process-per-call CLI). Throws if a recording is already in progress for the target; recordings on DIFFERENT targets run concurrently. Chrome only: WebDriver BiDi has no screencast primitive, so both tools are absent under --browser firefox.",
4280
+ inputSchema: {
4281
+ type: "object",
4282
+ properties: {
4283
+ lease: {
4284
+ type: "string",
4285
+ description: "Opaque lease token from claim_page. Required only when the tab this call resolves to is leased by someone; omit it for unleased tabs, which behave exactly as before."
4286
+ },
4287
+ target: {
4288
+ type: "string",
4289
+ description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | '<targetId>'."
4290
+ },
4291
+ format: {
4292
+ type: "string",
4293
+ enum: [
4294
+ "jpeg",
4295
+ "png"
4296
+ ],
4297
+ description: "Frame image format. Defaults to jpeg (far smaller per repaint); png is lossless. quality applies to jpeg only."
4298
+ },
4299
+ quality: {
4300
+ type: "number",
4301
+ description: "JPEG frame quality 0-100. Ignored for png. Omitted by default, which leaves Chrome's own default in place."
4302
+ },
4303
+ maxWidth: {
4304
+ type: "number",
4305
+ description: "Cap the streamed frame width in pixels. Also the way to pin frame size if the viewport might change mid-recording."
4306
+ },
4307
+ maxHeight: {
4308
+ type: "number",
4309
+ description: "Cap the streamed frame height in pixels."
4310
+ },
4311
+ everyNthFrame: {
4312
+ type: "number",
4313
+ description: "Capture only every Nth repaint (1 = every frame). Reduces spool size on a busy page; per-frame durations keep playback at wallclock speed either way."
4314
+ },
4315
+ bringToFront: {
4316
+ type: "boolean",
4317
+ description: "Activate the tab (Page.bringToFront) before recording. Default false. A fully backgrounded or occluded tab may never repaint, and a recording of one captures 0 frames."
4318
+ }
4319
+ },
4320
+ required: [],
4321
+ additionalProperties: false
4322
+ }
4323
+ },
4324
+ {
4325
+ name: "stop_screen_recording",
4326
+ description: "Stop the recording started by start_screen_recording, assemble the spooled frames into an MP4 with ffmpeg using PER-FRAME durations from the ledger (frame N is held until frame N+1 painted; the last frame is held until this call), and return {path,bytes,durationMs,frameCount,encodedFrames,codec,encoder,width,height,droppedFrames,target}. Encoder ladder, probed once at start: hevc_videotoolbox then h264_videotoolbox then libx265 then libx264, with -tag:v hvc1 on HEVC so QuickTime plays it, +faststart, yuv420p, and even dimensions forced. With one recording in flight 'target' may be omitted; with several it is required, because guessing which to stop can lose another agent's recording. Throws if nothing is recording in this process, and throws rather than writing a silent empty video when 0 frames arrived. On an ffmpeg failure the spooled frames are KEPT and the spool path plus the exact command are named in the error. Encoded video tops out at 25 fps: ffmpeg's concat demuxer represents image timestamps on a 1/25s grid, so frames captured less than 40ms apart are coalesced deliberately (frameCount reports what was captured, encodedFrames what reached the video) instead of being dropped silently by ffmpeg. durationMs is the sum of the encoded durations; the file itself runs one 40ms step longer, because the concat demuxer needs the final frame repeated for its hold to count.",
4327
+ inputSchema: {
4328
+ type: "object",
4329
+ properties: {
4330
+ lease: {
4331
+ type: "string",
4332
+ description: "Opaque lease token from claim_page. Required only when the tab this call resolves to is leased by someone; omit it for unleased tabs, which behave exactly as before."
4333
+ },
4334
+ target: {
4335
+ type: "string",
4336
+ description: "Which recording to stop: 'active' (default, valid only when exactly one recording is in flight) | 'index:N' | 'url:<substr>' | 'title:<substr>' | '<targetId>'."
4337
+ },
4338
+ savePath: {
4339
+ type: "string",
4340
+ description: "Override the output file path. Default: /tmp/cdp-toolkit/screen-recording-<targetIdShort>-<stamp>.mp4."
4341
+ }
4342
+ },
4343
+ required: [],
4344
+ additionalProperties: false
4345
+ }
4346
+ },
3908
4347
  {
3909
4348
  name: "take_heapsnapshot",
3910
4349
  description: "Capture a V8 heap snapshot of the selected page target over raw CDP (HeapProfiler.takeHeapSnapshot, accumulating addHeapSnapshotChunk events) and write it as a .heapsnapshot JSON file loadable by the DevTools Memory panel; returns {path,bytes,chunks,target} only and does not parse/summarize the snapshot. Writes under /tmp/cdp-toolkit (CDP_ARTIFACT_DIR) unless savePath is given.",
@@ -4678,8 +5117,8 @@ function hostFromUrl(url, toolName) {
4678
5117
  function driverError2(code, message, data) {
4679
5118
  return Object.assign(new Error(message), { code, ...data !== undefined ? { data } : {} });
4680
5119
  }
4681
- function encodeUid2(stamp5) {
4682
- return `bidi:${stamp5}`;
5120
+ function encodeUid2(stamp6) {
5121
+ return `bidi:${stamp6}`;
4683
5122
  }
4684
5123
  function decodeStamp(uid) {
4685
5124
  if (!uid.startsWith("bidi:"))
@@ -4786,7 +5225,7 @@ function parseSnapshotLine(line) {
4786
5225
  const m = LINE_RE.exec(line);
4787
5226
  if (!m)
4788
5227
  return;
4789
- const indent = m[1] ?? "", stamp5 = m[2], role = m[3], nameLit = m[4], extrasRaw = m[5];
5228
+ const indent = m[1] ?? "", stamp6 = m[2], role = m[3], nameLit = m[4], extrasRaw = m[5];
4790
5229
  const depth = indent.length / 2;
4791
5230
  let name;
4792
5231
  if (nameLit) {
@@ -4814,7 +5253,7 @@ function parseSnapshotLine(line) {
4814
5253
  extras[key] = val;
4815
5254
  }
4816
5255
  }
4817
- return { uid: encodeUid2(stamp5), role, depth, ...name !== undefined ? { name } : {}, ...Object.keys(extras).length ? { extras } : {} };
5256
+ return { uid: encodeUid2(stamp6), role, depth, ...name !== undefined ? { name } : {}, ...Object.keys(extras).length ? { extras } : {} };
4818
5257
  }
4819
5258
  var INTERACTIVE_ROLES3 = new Set([
4820
5259
  "button",
@@ -4847,8 +5286,8 @@ function sharedRefOf(node, describe) {
4847
5286
  }
4848
5287
  async function resolveElementLocator2(conn, contextId, loc) {
4849
5288
  if ("uid" in loc) {
4850
- const stamp5 = decodeStamp(loc.uid);
4851
- const nodes2 = await locateNodes(conn, contextId, { type: "css", value: `[${UID_STAMP_ATTR}="${cssEscape(stamp5)}"]` });
5289
+ const stamp6 = decodeStamp(loc.uid);
5290
+ const nodes2 = await locateNodes(conn, contextId, { type: "css", value: `[${UID_STAMP_ATTR}="${cssEscape(stamp6)}"]` });
4852
5291
  if (!nodes2.length)
4853
5292
  throw driverError2("stale-uid", `uid does not resolve: ${loc.uid}`);
4854
5293
  return sharedRefOf(nodes2[0], loc.uid);
@@ -5060,14 +5499,14 @@ class BidiPageDriver {
5060
5499
  return loc.uid;
5061
5500
  }
5062
5501
  const ref = await resolveElementLocator2(this.conn, this.contextId, loc);
5063
- const stamp5 = await this.callFunctionValue(STAMP_ONE_SOURCE, [], ref);
5064
- if (typeof stamp5 !== "string")
5502
+ const stamp6 = await this.callFunctionValue(STAMP_ONE_SOURCE, [], ref);
5503
+ if (typeof stamp6 !== "string")
5065
5504
  throw driverError2("page-error", "stamping the located element did not return a string");
5066
- return encodeUid2(stamp5);
5505
+ return encodeUid2(stamp6);
5067
5506
  }
5068
5507
  async resolve(uid) {
5069
- const stamp5 = decodeStamp(uid);
5070
- const nodes = await locateNodes(this.conn, this.contextId, { type: "css", value: `[${UID_STAMP_ATTR}="${cssEscape(stamp5)}"]` });
5508
+ const stamp6 = decodeStamp(uid);
5509
+ const nodes = await locateNodes(this.conn, this.contextId, { type: "css", value: `[${UID_STAMP_ATTR}="${cssEscape(stamp6)}"]` });
5071
5510
  if (!nodes.length)
5072
5511
  throw driverError2("stale-uid", `uid does not resolve: ${uid}`);
5073
5512
  }
@@ -5496,11 +5935,11 @@ function createFirefoxDriver(port, opts) {
5496
5935
  }
5497
5936
 
5498
5937
  // src/bidi/launch.ts
5499
- import { spawn as spawn2 } from "node:child_process";
5938
+ import { spawn as spawn3 } from "node:child_process";
5500
5939
  import { mkdtempSync, rmSync } from "node:fs";
5501
5940
  import { createConnection, createServer } from "node:net";
5502
5941
  import { tmpdir } from "node:os";
5503
- import { join as join6 } from "node:path";
5942
+ import { join as join7 } from "node:path";
5504
5943
  function resolveBinary() {
5505
5944
  const candidates = [
5506
5945
  process.env.FIREFOX_BIN,
@@ -5574,11 +6013,11 @@ async function launchFirefox(opts = {}) {
5574
6013
  const binary = resolveBinary();
5575
6014
  const port = await pickPort(opts.port);
5576
6015
  const isThrowaway = opts.profilePath === undefined;
5577
- const profilePath = opts.profilePath ?? mkdtempSync(join6(tmpdir(), "cdp-toolkit-ff-profile-"));
6016
+ const profilePath = opts.profilePath ?? mkdtempSync(join7(tmpdir(), "cdp-toolkit-ff-profile-"));
5578
6017
  const args = ["--profile", profilePath, "--remote-debugging-port", String(port), "--no-remote"];
5579
6018
  if (opts.headless)
5580
6019
  args.push("--headless");
5581
- const proc = spawn2(binary, args, { stdio: "ignore", detached: false });
6020
+ const proc = spawn3(binary, args, { stdio: "ignore", detached: false });
5582
6021
  let closed = false;
5583
6022
  const close = async () => {
5584
6023
  if (closed)
@@ -5844,7 +6283,7 @@ var FIREFOX_TOOLS = {
5844
6283
  };
5845
6284
 
5846
6285
  // src/mcp.ts
5847
- var VERSION = "1.4.0";
6286
+ var VERSION = "1.6.0";
5848
6287
  var BROWSER = resolveBrowserKind(stripBrowserFlag(process.argv.slice(2)));
5849
6288
  var AVAILABILITY = toolAvailability(BROWSER);
5850
6289
  var AVAILABLE_NAMES = new Set(AVAILABILITY.available);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cdp-toolkit",
3
- "version": "1.4.0",
4
- "description": "A lightweight chrome-devtools-mcp alternative: a single-target MCP server + CLI that drives one tab over a direct WebSocket with a bounded timeout on every call, so a stuck page can't wedge your agent or force a /mcp restart. Chrome over CDP and Firefox over WebDriver BiDi, with tools a backend can't run absent from tools/list rather than throwing mid-task. 39 tools including built-in network mocking and a cookie group. No Puppeteer, no all-target fan-out.",
3
+ "version": "1.6.0",
4
+ "description": "A lightweight chrome-devtools-mcp alternative: an MCP server + CLI that drives the tabs you name, one target per call over one direct WebSocket, with a bounded timeout on every call, so a stuck page can't wedge your agent or force a /mcp restart. Chrome over CDP and Firefox over WebDriver BiDi, with tools a backend can't run absent from tools/list rather than throwing mid-task. 41 tools including built-in network mocking, a cookie group, tab leases, and tab-to-video screen recording so several agents can drive one browser without stealing each other's tabs. No Puppeteer, no all-target fan-out.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": {
@@ -75,6 +75,7 @@
75
75
  "test": "bun test",
76
76
  "smoke": "bun run ./test/smoke.ts",
77
77
  "mock:smoke": "bun run ./test/mock-smoke.ts",
78
+ "screencast:smoke": "bun run ./test/screencast-smoke.ts",
78
79
  "mcp:smoke": "bun run ./test/mcp-smoke.ts",
79
80
  "firefox:smoke": "bun run ./test/firefox-smoke.ts",
80
81
  "lease:smoke": "bun run ./test/lease-smoke.ts"
@@ -269,6 +269,10 @@ const BUTTON_CODES: Record<"left" | "right" | "middle", number> = { left: 0, mid
269
269
  * supported yet" on real Firefox 153.0.3, contradicting the initial assumption that both text and
270
270
  * xpath locators were live; ElementLocator's `text` branch still resolves and correctly surfaces
271
271
  * "unsupported" rather than silently misbehaving, it is simply not offered as a capability.
272
+ * capture.screencast is absent because WebDriver BiDi has no streamed-frame primitive at all:
273
+ * the spec offers only the one-shot browsingContext.captureScreenshot, with no screencast
274
+ * start/stop command and no per-repaint frame event to subscribe to, so the screen-recording
275
+ * pair is honestly missing under Firefox rather than present and throwing.
272
276
  * network.intercept rides network.addIntercept + network.beforeRequestSent, verified working
273
277
  * (see intercept()'s comment on why urlPatterns is never populated). screenshot.fullPage uses
274
278
  * captureScreenshot's origin:"document", screenshot.element uses its clip:{type:"element"}, both verified.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Per-backend tool availability: filters the 36-tool registry down to what a
2
+ * Per-backend tool availability: filters the 41-tool registry down to what a
3
3
  * given DriverKind can actually run, per ADR-001's rule that tools/list
4
4
  * discovers gaps, not runtime "unsupported" throws. Pure and I/O-free: driver
5
5
  * construction reads a static readonly `capabilities` set, it does not dial a