cdp-toolkit 1.2.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cdp-toolkit",
3
- "version": "1.2.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. 36 tools including built-in network mocking. 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,8 +75,10 @@
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
- "firefox:smoke": "bun run ./test/firefox-smoke.ts"
80
+ "firefox:smoke": "bun run ./test/firefox-smoke.ts",
81
+ "lease:smoke": "bun run ./test/lease-smoke.ts"
80
82
  },
81
83
  "devDependencies": {
82
84
  "@types/node": "22.10.10",
@@ -25,7 +25,8 @@
25
25
  import type { TargetSelector } from "../types.ts";
26
26
  import {
27
27
  UID_STAMP_ATTR, isDriverError,
28
- type BrowserDriver, type Capability, type HandledDialogInfo, type DriverError, type DriverErrorCode, type DriverEvent,
28
+ type BrowserCookie, type BrowserDriver, type Capability, type DeleteCookiesFilter, type HandledDialogInfo, type DriverError, type DriverErrorCode, type DriverEvent,
29
+ type SetCookieParams,
29
30
  type DriverUid, type ElementLocator, type EmulationOptions, type InterceptRule, type KeyPress, type LifetimeModel,
30
31
  type MouseButtonOptions, type NavigateOptions, type NavigateResult, type PageDriver, type PageInfo,
31
32
  type ScreenshotOptions, type SnapshotNode, type UidStability,
@@ -38,8 +39,60 @@ import type {
38
39
  BrowsingContextId, BrowsingContextInfo, BrowsingContextLocator, BrowsingContextReadinessState,
39
40
  BrowsingContextCaptureScreenshotParameters, ScriptSharedReference, ScriptRemoteValue, ScriptLocalValue,
40
41
  ScriptNodeRemoteValue, ScriptExceptionDetails, InputSourceActions, InputPointerSourceAction, InputKeySourceAction,
42
+ NetworkCookie,
41
43
  } from "./protocol.ts";
42
44
 
45
+ /* ---------------------------------- cookies ---------------------------------- */
46
+ /**
47
+ * BiDi's cookie shape to the neutral one. Three differences to absorb:
48
+ * - the value is a network.BytesValue, either a plain string or base64, so a
49
+ * base64 one is decoded rather than handed back as opaque base64;
50
+ * - `expiry` is optional and simply absent for a session cookie, which becomes
51
+ * the neutral -1 that CDP reports directly;
52
+ * - `session` has no BiDi field at all, so it is derived from that same
53
+ * absence, which is exactly what a session cookie means.
54
+ * `sameSite` already uses the lowercase vocabulary the neutral shape adopted.
55
+ */
56
+ export function normalizeBidiCookie(c: NetworkCookie): BrowserCookie {
57
+ const value = c.value.type === "base64" ? Buffer.from(c.value.value, "base64").toString("utf8") : c.value.value;
58
+ const hasExpiry = typeof c.expiry === "number";
59
+ return {
60
+ name: c.name, value, domain: c.domain, path: c.path,
61
+ expires: hasExpiry ? (c.expiry as number) : -1,
62
+ size: typeof c.size === "number" ? c.size : Buffer.byteLength(`${c.name}${value}`, "utf8"),
63
+ httpOnly: c.httpOnly === true, secure: c.secure === true,
64
+ sameSite: c.sameSite ?? "default", session: !hasExpiry,
65
+ };
66
+ }
67
+
68
+ /**
69
+ * The host of a url, for the BiDi cookie calls that require a `domain` and have
70
+ * no `url` parameter of their own.
71
+ *
72
+ * Exported so a test can pin the failure cases without a browser. Every branch
73
+ * that cannot produce a host THROWS: an absent url, an unparseable one, and a
74
+ * parseable one with an empty host (about:blank, data:, javascript:) all raise
75
+ * a message naming the tool and what it needs. The alternative, sending the
76
+ * call without a domain, is a request Firefox answers cleanly while writing or
77
+ * deleting nothing recognizable, which is the exact wrong-but-plausible ack
78
+ * this driver refuses to emit.
79
+ */
80
+ export function hostFromUrl(url: string | undefined, toolName: string): string {
81
+ if (url === undefined || url === "") {
82
+ throw driverError("unsupported", `${toolName} on Firefox needs a 'domain': BiDi's storage cookie calls have no 'url' parameter, and no url was given to derive one from.`);
83
+ }
84
+ let host = "";
85
+ try {
86
+ host = new URL(url).hostname;
87
+ } catch {
88
+ throw driverError("unsupported", `${toolName} could not parse the url '${url}' to derive the 'domain' BiDi requires. Pass 'domain' explicitly.`);
89
+ }
90
+ if (host === "") {
91
+ throw driverError("unsupported", `${toolName} cannot derive a 'domain' from '${url}': that url has no host. Pass 'domain' explicitly.`);
92
+ }
93
+ return host;
94
+ }
95
+
43
96
  /* ------------------------------ error + uid codec ------------------------------ */
44
97
  function driverError(code: DriverErrorCode, message: string, data?: unknown): DriverError {
45
98
  return Object.assign(new Error(message), { code, ...(data !== undefined ? { data } : {}) }) as DriverError;
@@ -216,6 +269,10 @@ const BUTTON_CODES: Record<"left" | "right" | "middle", number> = { left: 0, mid
216
269
  * supported yet" on real Firefox 153.0.3, contradicting the initial assumption that both text and
217
270
  * xpath locators were live; ElementLocator's `text` branch still resolves and correctly surfaces
218
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.
219
276
  * network.intercept rides network.addIntercept + network.beforeRequestSent, verified working
220
277
  * (see intercept()'s comment on why urlPatterns is never populated). screenshot.fullPage uses
221
278
  * captureScreenshot's origin:"document", screenshot.element uses its clip:{type:"element"}, both verified.
@@ -643,6 +700,88 @@ class BidiPageDriver implements PageDriver {
643
700
  return this.handleOneDialog(accept, promptText, opts?.timeoutMs ?? 15_000);
644
701
  }
645
702
 
703
+ /**
704
+ * storage.getCookies, partitioned by THIS browsing context.
705
+ *
706
+ * The context partition is the BiDi counterpart of the CDP driver's choice of
707
+ * the page-scoped Network.getCookies over the browser-wide jar: it asks for
708
+ * the cookies of the tab that was resolved, not for every cookie the profile
709
+ * holds. httpOnly cookies are included, since this reads the cookie store
710
+ * over the protocol rather than evaluating document.cookie in the page.
711
+ */
712
+ async getCookies(): Promise<BrowserCookie[]> {
713
+ try {
714
+ const { cookies } = await this.conn.send("storage.getCookies", { partition: { type: "context", context: this.contextId } });
715
+ return (cookies ?? []).map(normalizeBidiCookie);
716
+ } catch (e) {
717
+ throw mapBidiError(e);
718
+ }
719
+ }
720
+
721
+ /**
722
+ * storage.setCookie, partitioned by THIS browsing context.
723
+ *
724
+ * One real difference from CDP to absorb, and it is absorbed EXPLICITLY
725
+ * rather than papered over: BiDi has no `url` parameter and requires
726
+ * `domain`, while CDP accepts either and derives the rest from the url. So a
727
+ * caller who supplied only a url gets its host used as the domain, and a url
728
+ * with no host (about:blank, a data: URL, a malformed string) throws instead
729
+ * of being dropped, because a set that silently did nothing is the failure
730
+ * this tool must never produce.
731
+ *
732
+ * Note the derivation is narrower than CDP's: CDP also takes `path` and
733
+ * `secure` from the url, and this does not, so those stay exactly as the
734
+ * caller gave them on both backends.
735
+ */
736
+ async setCookie(params: SetCookieParams): Promise<void> {
737
+ const domain = params.domain ?? hostFromUrl(params.url, "set_cookie");
738
+ try {
739
+ await this.conn.send("storage.setCookie", {
740
+ cookie: {
741
+ name: params.name,
742
+ value: { type: "string", value: params.value },
743
+ domain,
744
+ ...(params.path !== undefined ? { path: params.path } : {}),
745
+ ...(params.expires !== undefined ? { expiry: params.expires } : {}),
746
+ ...(params.httpOnly !== undefined ? { httpOnly: params.httpOnly } : {}),
747
+ ...(params.secure !== undefined ? { secure: params.secure } : {}),
748
+ ...(params.sameSite !== undefined ? { sameSite: params.sameSite } : {}),
749
+ },
750
+ partition: { type: "context", context: this.contextId },
751
+ });
752
+ } catch (e) {
753
+ throw mapBidiError(e);
754
+ }
755
+ }
756
+
757
+ /**
758
+ * storage.deleteCookies with a filter, partitioned by THIS browsing context.
759
+ *
760
+ * Same url-to-domain derivation as setCookie and for the same reason: the
761
+ * BiDi filter has `domain` and no `url`, and a filter that quietly dropped
762
+ * the only site constraint would delete by name across the partition, which
763
+ * is a bigger deletion than the caller asked for.
764
+ *
765
+ * BiDi reports a partitionKey and no count, exactly as CDP reports nothing,
766
+ * so this returns nothing on both backends rather than one of them inventing
767
+ * a number the other cannot produce.
768
+ */
769
+ async deleteCookies(filter: DeleteCookiesFilter): Promise<void> {
770
+ const domain = filter.domain ?? hostFromUrl(filter.url, "delete_cookies");
771
+ try {
772
+ await this.conn.send("storage.deleteCookies", {
773
+ filter: {
774
+ name: filter.name,
775
+ domain,
776
+ ...(filter.path !== undefined ? { path: filter.path } : {}),
777
+ },
778
+ partition: { type: "context", context: this.contextId },
779
+ });
780
+ } catch (e) {
781
+ throw mapBidiError(e);
782
+ }
783
+ }
784
+
646
785
  // Response bodies need network.addDataCollector armed BEFORE the request, per the established
647
786
  // fact this driver was handed: no way to read a body for a request that happened before
648
787
  // capture was armed. That is the mirror of the CDP driver's own honest omission of
@@ -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
package/src/cdp/driver.ts CHANGED
@@ -7,10 +7,12 @@
7
7
  */
8
8
  import { type CdpConnection, CdpError, listTargets, openBrowser, openPage, resolveTarget } from "../client.ts";
9
9
  import type { Target, TargetSelector } from "../types.ts";
10
+ import { LeaseConflictError } from "../leases.ts";
10
11
  import { resolveUid } from "../tools/snapshot.ts";
11
12
  import { buildFulfillParams, effectiveAction, selectRule } from "../tools/network_mock.ts";
12
13
  import {
13
- LEGACY_NUMERIC_UID, type BrowserDriver, type Capability, type HandledDialogInfo, type DriverError, type DriverErrorCode, type DriverEvent,
14
+ LEGACY_NUMERIC_UID, type BrowserCookie, type BrowserDriver, type Capability, type DeleteCookiesFilter, type HandledDialogInfo, type DriverError, type DriverErrorCode, type DriverEvent,
15
+ type SetCookieParams,
14
16
  type DriverUid, type ElementLocator, type EmulationOptions, type InterceptRule, type KeyPress, type LifetimeModel, type MouseButtonOptions,
15
17
  type NavigateOptions, type NavigateResult, type PageDriver, type PageInfo, type ScreenshotOptions, type SnapshotNode, type UidStability,
16
18
  } from "../driver.ts";
@@ -29,6 +31,49 @@ function decodeUid(uid: DriverUid): number {
29
31
  if (!Number.isSafeInteger(n) || n <= 0) throw driverError("foreign-uid", `malformed cdp uid payload: ${uid}`);
30
32
  return n;
31
33
  }
34
+ /* ---------------------------------- cookies ---------------------------------- */
35
+ /** The subset of CDP's `Network.Cookie` this driver reads. */
36
+ export interface CdpCookie {
37
+ name: string; value: string; domain: string; path: string; expires: number; size: number;
38
+ httpOnly: boolean; secure: boolean; session: boolean; sameSite?: string;
39
+ }
40
+
41
+ /**
42
+ * CDP's cookie shape to the neutral one. Two mappings carry the difference:
43
+ * `sameSite` is capitalized by CDP and absent when the cookie never set the
44
+ * attribute, so it lowercases and falls back to "default" (BiDi's word for the
45
+ * same state); `expires` is already CDP's Unix seconds with -1 for a session
46
+ * cookie, so it passes through, with `session` kept as the browser's own flag
47
+ * rather than re-derived from the timestamp.
48
+ */
49
+ export function normalizeCdpCookie(c: CdpCookie): BrowserCookie {
50
+ const same = (c.sameSite ?? "").toLowerCase();
51
+ const sameSite: BrowserCookie["sameSite"] =
52
+ same === "strict" || same === "lax" || same === "none" ? same : "default";
53
+ return {
54
+ name: c.name, value: c.value, domain: c.domain, path: c.path,
55
+ expires: typeof c.expires === "number" ? c.expires : -1,
56
+ size: typeof c.size === "number" ? c.size : Buffer.byteLength(`${c.name}${c.value}`, "utf8"),
57
+ httpOnly: c.httpOnly === true, secure: c.secure === true, sameSite, session: c.session === true,
58
+ };
59
+ }
60
+
61
+ /**
62
+ * The neutral lowercase `sameSite` back to CDP's own vocabulary.
63
+ *
64
+ * The inverse of normalizeCdpCookie's mapping, and it has to be the inverse:
65
+ * CDP capitalizes the three real values and has NO word for "default", which
66
+ * is BiDi's name for a cookie that never set the attribute. So "default"
67
+ * returns undefined and the caller omits the field, which is exactly the state
68
+ * it describes.
69
+ */
70
+ export function cdpSameSite(sameSite?: "strict" | "lax" | "none" | "default"): "Strict" | "Lax" | "None" | undefined {
71
+ if (sameSite === "strict") return "Strict";
72
+ if (sameSite === "lax") return "Lax";
73
+ if (sameSite === "none") return "None";
74
+ return undefined;
75
+ }
76
+
32
77
  /* ---------------------- copied helpers, see attributions below ---------------------- */
33
78
  // Copied verbatim from src/tools/input.ts `centerOf`: scroll into view, read viewport-space center.
34
79
  async function centerOf(conn: CdpConnection, objectId: string): Promise<{ x: number; y: number }> {
@@ -214,12 +259,15 @@ async function elementClip(conn: CdpConnection, loc: ElementLocator): Promise<{
214
259
  * not "not armed in advance", so an honest capability set omits it rather than reading true by
215
260
  * construction. locate.text/locate.xpath both ride DOM.performSearch (see searchLocate above),
216
261
  * which is a real Chrome capability, not a line-budget artifact, so both ARE declared.
262
+ * capture.screencast rides Page.startScreencast / Page.screencastFrame / Page.screencastFrameAck,
263
+ * the streamed-repaint primitive behind start_screen_recording and stop_screen_recording.
217
264
  */
218
265
  const CDP_CAPABILITIES: ReadonlySet<Capability> = new Set<Capability>([
219
266
  "trace.performance", "heap.snapshot", "audit.lighthouse", "emulate.cpuThrottling",
220
267
  "emulate.mediaFeatures", "emulate.deviceMetrics", "emulate.networkConditions",
221
268
  "screenshot.fullPage", "screenshot.element", "network.intercept",
222
269
  "snapshot.accessibilityTree", "input.insertTextAtomic", "locate.text", "locate.xpath",
270
+ "capture.screencast",
223
271
  ]);
224
272
  /* ---------------------------------- PageDriver ---------------------------------- */
225
273
  class CdpPageDriver implements PageDriver {
@@ -535,6 +583,79 @@ class CdpPageDriver implements PageDriver {
535
583
  });
536
584
  });
537
585
  }
586
+ /**
587
+ * `Network.getCookies` over `Storage.getCookies`, deliberately.
588
+ *
589
+ * The two differ in SCOPE, not in detail: `Storage.getCookies` returns the
590
+ * whole browser jar (every cookie for every site the profile has), while
591
+ * `Network.getCookies`, called with no `urls`, returns the cookies of the
592
+ * page this connection is attached to and its subframes. This driver is
593
+ * always attached to one resolved target, so the page-scoped call is the one
594
+ * that answers the question the caller asked. It also keeps unrelated sites'
595
+ * session credentials out of a result that only named one tab.
596
+ *
597
+ * The Network domain is enabled with the same lazy ensureDomain the rest of
598
+ * the class uses. httpOnly cookies come back either way: this is a protocol
599
+ * read of the cookie store, not a `document.cookie` evaluation, which is the
600
+ * entire reason a tool is better here than a script.
601
+ */
602
+ async getCookies(): Promise<BrowserCookie[]> {
603
+ await this.ensureDomain("Network");
604
+ const { cookies } = await this.conn.send<{ cookies: CdpCookie[] }>("Network.getCookies");
605
+ return (cookies ?? []).map(normalizeCdpCookie);
606
+ }
607
+ /**
608
+ * `Network.setCookie`, which answers `{success: boolean}` rather than an
609
+ * error when it declines a cookie.
610
+ *
611
+ * That return is the whole reason this method is more than one line. Chrome
612
+ * refuses a cookie for plenty of ordinary reasons (a domain the url does not
613
+ * belong to, `secure` on an insecure origin, an oversized value) and reports
614
+ * the refusal as `success:false` with a perfectly successful protocol reply.
615
+ * Resolving on that would hand the caller a "cookie set" that is not set, so
616
+ * a false is turned into a thrown error naming the cookie.
617
+ *
618
+ * `path` is passed through as given, not defaulted: with a `url` CDP derives
619
+ * the path from it, and inventing "/" here would silently widen the scope of
620
+ * a cookie the caller asked to scope narrowly.
621
+ */
622
+ async setCookie(params: SetCookieParams): Promise<void> {
623
+ await this.ensureDomain("Network");
624
+ const sameSite = cdpSameSite(params.sameSite);
625
+ const { success } = await this.conn.send<{ success: boolean }>("Network.setCookie", {
626
+ name: params.name,
627
+ value: params.value,
628
+ ...(params.url !== undefined ? { url: params.url } : {}),
629
+ ...(params.domain !== undefined ? { domain: params.domain } : {}),
630
+ ...(params.path !== undefined ? { path: params.path } : {}),
631
+ ...(params.expires !== undefined ? { expires: params.expires } : {}),
632
+ ...(params.httpOnly !== undefined ? { httpOnly: params.httpOnly } : {}),
633
+ ...(params.secure !== undefined ? { secure: params.secure } : {}),
634
+ ...(sameSite !== undefined ? { sameSite } : {}),
635
+ });
636
+ if (success !== true) {
637
+ // "page-error" is this file's code for a browser-side refusal of an
638
+ // operation that reached the browser intact, which is precisely this.
639
+ throw driverError(
640
+ "page-error",
641
+ `Network.setCookie declined the cookie '${params.name}'. Chrome rejects a cookie whose domain does not match the url, a secure cookie on an insecure origin, and an oversized name or value.`,
642
+ );
643
+ }
644
+ }
645
+ /**
646
+ * `Network.deleteCookies`. Unlike setCookie it reports nothing at all, not
647
+ * even a count, so this resolves on the protocol acknowledgement and the tool
648
+ * above it returns a boolean ack instead of a fabricated number.
649
+ */
650
+ async deleteCookies(filter: DeleteCookiesFilter): Promise<void> {
651
+ await this.ensureDomain("Network");
652
+ await this.conn.send("Network.deleteCookies", {
653
+ name: filter.name,
654
+ ...(filter.url !== undefined ? { url: filter.url } : {}),
655
+ ...(filter.domain !== undefined ? { domain: filter.domain } : {}),
656
+ ...(filter.path !== undefined ? { path: filter.path } : {}),
657
+ });
658
+ }
538
659
  async startBodyCapture(): Promise<() => Promise<void>> {
539
660
  await this.ensureDomain("Network");
540
661
  return async () => {
@@ -587,7 +708,33 @@ async function withBrowserConn<T>(fn: (conn: CdpConnection) => Promise<T>): Prom
587
708
  conn.close();
588
709
  }
589
710
  }
711
+ /**
712
+ * Relabel a target-resolution failure as `no-such-target`, but ONLY when the
713
+ * failure carries no type of its own.
714
+ *
715
+ * This used to relabel everything, and that was wrong in a way no unit test
716
+ * caught, because the message text survived and the MCP surface reads message
717
+ * text. resolveTarget is where the Chrome lease gate lives, so a
718
+ * LeaseConflictError raised by assertLeaseOk came through here and lost its
719
+ * class, its targetId and its holder, and came out coded `no-such-target`,
720
+ * which tells a caller the selector matched nothing and it should stop. A lease
721
+ * conflict is the one case where retrying, or going to fetch the token, is the
722
+ * right move. readLease's deliberate rethrow on an UNREADABLE lease file was
723
+ * mislabeled the same way, which is precisely the confusion that throw exists
724
+ * to prevent. BiDi never had a blanket catch, so the two backends disagreed.
725
+ *
726
+ * The pass-through is by TYPE, not by an enumerated list of errors, so a typed
727
+ * error added anywhere below this frame is protected without anyone having to
728
+ * remember to come back here:
729
+ * - LeaseConflictError, named because it is the one the caller acts on;
730
+ * - anything carrying a STRING `code`, which covers every DriverError and
731
+ * every fs errno (an EACCES out of readLease reaches this frame).
732
+ * CdpError's `code` is a NUMBER (a CDP protocol error code), so a genuine
733
+ * "that target does not exist" from pickTarget still relabels, as it must.
734
+ */
590
735
  function noSuchTarget(e: unknown): never {
736
+ if (e instanceof LeaseConflictError) throw e;
737
+ if (typeof (e as { code?: unknown } | null | undefined)?.code === "string") throw e;
591
738
  throw driverError("no-such-target", e instanceof CdpError ? e.message : String(e));
592
739
  }
593
740
  class CdpBrowserDriver implements BrowserDriver {
package/src/driver.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * ADR-001: one browser-neutral Driver interface behind the 36 tools.
2
+ * ADR-001: one browser-neutral Driver interface behind the 41 tools.
3
3
  *
4
4
  * Context. Every tool in src/tools/ is a stateless one-shot on withPage():
5
5
  * open a socket, act, always close. That is only correct because a CDP `Uid`
@@ -126,6 +126,7 @@ export type Capability =
126
126
  | "trace.performance" // Chrome tracing, behind every performance_* tool
127
127
  | "heap.snapshot" // V8 heap snapshots
128
128
  | "audit.lighthouse" // shelling out to lighthouse against a debug port
129
+ | "capture.screencast" // streamed repaint frames (CDP Page.startScreencast); BiDi has no equivalent
129
130
  | "emulate.cpuThrottling" // CPU multiplier; absent from BiDi's emulation module
130
131
  | "emulate.mediaFeatures" // media type + features, e.g. prefers-color-scheme
131
132
  | "emulate.deviceMetrics" // past width/height: scale factor, mobile flag
@@ -151,6 +152,8 @@ export const REQUIRED_CAPABILITIES: Partial<Record<ToolName, readonly Capability
151
152
  performance_trace: ["trace.performance"],
152
153
  take_heapsnapshot: ["heap.snapshot"],
153
154
  lighthouse_audit: ["audit.lighthouse"],
155
+ start_screen_recording: ["capture.screencast"],
156
+ stop_screen_recording: ["capture.screencast"],
154
157
  mock_request: ["network.intercept"],
155
158
  list_mocks: ["network.intercept"],
156
159
  clear_mocks: ["network.intercept"],
@@ -281,6 +284,80 @@ export interface HandledDialogInfo extends DialogInfo {
281
284
  handled: true;
282
285
  }
283
286
 
287
+ /**
288
+ * One cookie, neutral over CDP's `Network.Cookie` and BiDi's `network.Cookie`.
289
+ *
290
+ * Every flag a caller needs to reason about a session credential is present:
291
+ * an httpOnly cookie is exactly the one `document.cookie` cannot read, so a
292
+ * shape that dropped `httpOnly` would make the reason this exists invisible.
293
+ *
294
+ * Two normalizations, applied by both drivers so the shape does not change
295
+ * with the backend:
296
+ * - `expires` is a Unix timestamp in SECONDS, or -1 for a session cookie.
297
+ * CDP reports exactly that; BiDi reports an optional `expiry` and omits it
298
+ * for a session cookie, which the BiDi driver maps to -1.
299
+ * - `sameSite` is lowercased to BiDi's vocabulary ("strict" | "lax" | "none"
300
+ * | "default"). CDP capitalizes it and omits it entirely when unset, which
301
+ * the CDP driver maps to "default".
302
+ */
303
+ export interface BrowserCookie {
304
+ name: string;
305
+ value: string;
306
+ domain: string;
307
+ path: string;
308
+ /** Unix seconds, or -1 for a session cookie. */
309
+ expires: number;
310
+ /** Byte length of name plus value, as the browser reports it. */
311
+ size: number;
312
+ httpOnly: boolean;
313
+ secure: boolean;
314
+ sameSite: "strict" | "lax" | "none" | "default";
315
+ /** True when the cookie expires with the browsing session (no expiry set). */
316
+ session: boolean;
317
+ }
318
+
319
+ /**
320
+ * One cookie to WRITE, neutral over CDP's `Network.setCookie` and BiDi's
321
+ * `storage.setCookie`.
322
+ *
323
+ * `url` and `domain` are both optional here but not both omittable: the cookie
324
+ * has to be attributed to a site somehow, and every backend refuses without
325
+ * one. The tool layer enforces that before either driver is reached, so the
326
+ * refusal is one message rather than two different protocol errors.
327
+ *
328
+ * `expires` follows the read shape: Unix seconds. Omit it for a session cookie,
329
+ * which is what both backends do when no expiry is supplied.
330
+ */
331
+ export interface SetCookieParams {
332
+ name: string;
333
+ value: string;
334
+ /** The URL the cookie is being set for. CDP derives domain, path and secure
335
+ * from it; BiDi has no url parameter, so its driver derives the domain. */
336
+ url?: string;
337
+ domain?: string;
338
+ path?: string;
339
+ /** Unix seconds. Omitted means a session cookie. */
340
+ expires?: number;
341
+ httpOnly?: boolean;
342
+ secure?: boolean;
343
+ sameSite?: "strict" | "lax" | "none" | "default";
344
+ }
345
+
346
+ /**
347
+ * Which cookies to DELETE, neutral over CDP's `Network.deleteCookies` and
348
+ * BiDi's `storage.deleteCookies` filter.
349
+ *
350
+ * `name` is required and there is no wildcard: this deletes a named cookie, not
351
+ * a swathe of them. As with SetCookieParams, one of `url` or `domain` must be
352
+ * present, and the tool layer enforces it.
353
+ */
354
+ export interface DeleteCookiesFilter {
355
+ name: string;
356
+ url?: string;
357
+ domain?: string;
358
+ path?: string;
359
+ }
360
+
284
361
  export interface InterceptRule {
285
362
  /** Glob: `*` any run, `?` one char. Both drivers normalize to this. */
286
363
  urlPattern: string;
@@ -403,6 +480,38 @@ export interface PageDriver {
403
480
  opts?: { timeoutMs?: number; autoMs?: number },
404
481
  ): Promise<HandledDialogInfo | { handled: HandledDialogInfo[]; count: number }>;
405
482
 
483
+ /* cookies */
484
+ /**
485
+ * Every cookie visible to THIS page, httpOnly ones included.
486
+ *
487
+ * Deliberately page-scoped, not browser-scoped. Both protocols offer a
488
+ * browser-wide jar (CDP's `Storage.getCookies` with no browserContextId,
489
+ * BiDi's storageKey partition), and both would hand back every credential
490
+ * for every site the browser has ever visited on a call that named one tab.
491
+ * A tool scoped to a target returns that target's cookies; a caller who
492
+ * wants another site's cookies points at a page on that site.
493
+ */
494
+ getCookies(): Promise<BrowserCookie[]>;
495
+
496
+ /**
497
+ * Write one cookie into the store this page reads from.
498
+ *
499
+ * Resolves only when the backend confirms the write. A backend that reports
500
+ * a refusal (CDP's `Network.setCookie` answers `{success:false}` for a cookie
501
+ * it will not accept) must throw rather than resolve, because a resolved
502
+ * promise here reads as "the cookie is set" and a caller will believe it.
503
+ */
504
+ setCookie(params: SetCookieParams): Promise<void>;
505
+
506
+ /**
507
+ * Remove the cookies matching the filter from the store this page reads from.
508
+ *
509
+ * Neither backend reports how many cookies it removed, so this returns
510
+ * nothing rather than an invented count. Deleting a cookie that was never
511
+ * there is not an error on either backend.
512
+ */
513
+ deleteCookies(filter: DeleteCookiesFilter): Promise<void>;
514
+
406
515
  /* network */
407
516
  /** Begin retaining response bodies. REQUIRED before getResponseBody unless
408
517
  * the driver declares "network.responseBodyPostHoc". Returns a stop fn. */
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The complete Firefox tool registry: the 20 tools shared with Chrome (src/shared-tools.ts), the
2
+ * The complete Firefox tool registry: the 23 tools shared with Chrome (src/shared-tools.ts), the
3
3
  * 7 Firefox-only console/network/mock tools (src/bidi-tools.ts), and the 3 lease tools
4
4
  * (src/leases-tools.ts), which are backend-neutral by construction. This is what src/neutral.ts
5
5
  * used to be; it now composes three files instead of reimplementing everything in one.
package/src/index.ts CHANGED
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * Two exports:
5
5
  * 1. `TOOLS` : the registry mapping every chrome-devtools-mcp tool name
6
- * (snake_case string, all 29) to its raw-CDP implementation function. The
6
+ * (snake_case string, all 29 of them) plus the toolkit's own superset
7
+ * tools, 41 entries in all, to its raw-CDP implementation function. The
7
8
  * CLI and any embedder dispatch through this single table.
8
9
  * 2. The client primitives, re-exported so consumers can build their own
9
10
  * flows on the same connection/timeout machinery the tools use.
@@ -12,8 +13,9 @@
12
13
  * value (or throws). See CONTRACT.md for the design rules each module follows.
13
14
  */
14
15
 
15
- // --- the 20 tools unified with Firefox onto one Driver-based implementation ---
16
+ // --- the 23 tools unified with Firefox onto one Driver-based implementation ---
16
17
  // (list_pages/new_page/close_page/select_page, navigate_page/wait_for, evaluate_script,
18
+ // list_cookies/set_cookie/delete_cookies,
17
19
  // take_snapshot, click/hover/drag/fill/fill_form/type_text/press_key/upload_file,
18
20
  // take_screenshot/emulate/resize_page, handle_dialog). See shared-tools.ts's file header for
19
21
  // the byte-identical-Chrome-behavior contract this bind honors, and for the 7 tools that stay
@@ -31,6 +33,8 @@ import {
31
33
  performanceAnalyzeInsight,
32
34
  performanceTrace,
33
35
  } from "./tools/performance.ts";
36
+ // --- screen recording (Page.startScreencast + ffmpeg) ---
37
+ import { startScreenRecording, stopScreenRecording } from "./tools/screencast.ts";
34
38
  // --- heap ---
35
39
  import { takeHeapsnapshot } from "./tools/heap.ts";
36
40
  // --- lighthouse (the sole non-CDP tool) ---
@@ -56,8 +60,15 @@ function onCdp<A>(fn: (driver: ReturnType<typeof createCdpDriver>, args: A) => P
56
60
  * their canonical MCP (snake_case) name, plus one convenience superset tool
57
61
  * (`performance_trace`, a robust single-call trace : the start/stop pair cannot
58
62
  * span two stateless CLI processes), plus a 3-tool network-mocking group
59
- * (mock_request/list_mocks/clear_mocks : a persistent per-target fake backend).
60
- * 36 entries total. Listed explicitly so the mapping is auditable at a glance and
63
+ * (mock_request/list_mocks/clear_mocks : a persistent per-target fake backend),
64
+ * plus a 3-tool cookie group (list_cookies/set_cookie/delete_cookies : the
65
+ * cookie store including httpOnly cookies, which no page script can read or
66
+ * write), plus a 3-tool lease group (claim_page/release_page/list_leases :
67
+ * opt-in tab ownership so many agents can share one browser), plus a 2-tool
68
+ * screen-recording pair (start_screen_recording/stop_screen_recording : a tab
69
+ * captured to an H.265 MP4 with per-frame durations, so a variable-rate
70
+ * repaint stream plays back at wallclock speed).
71
+ * 29 + 1 + 3 + 3 + 3 + 2 = 41 entries total. Listed explicitly so the mapping is auditable at a glance and
61
72
  * the CLI can `--list` it.
62
73
  *
63
74
  * 20 of the 29 MCP-parity tools (pages/navigation/evaluate/snapshot/interaction/
@@ -76,6 +87,10 @@ export const TOOLS = {
76
87
  wait_for: onCdp(SHARED_TOOLS.wait_for),
77
88
  // evaluate (1)
78
89
  evaluate_script: onCdp(SHARED_TOOLS.evaluate_script),
90
+ // cookies (3) : toolkit addition, reads and writes httpOnly cookies that document.cookie cannot touch
91
+ list_cookies: onCdp(SHARED_TOOLS.list_cookies),
92
+ set_cookie: onCdp(SHARED_TOOLS.set_cookie),
93
+ delete_cookies: onCdp(SHARED_TOOLS.delete_cookies),
79
94
  // snapshot (1)
80
95
  take_snapshot: onCdp(SHARED_TOOLS.take_snapshot),
81
96
  // interaction (8)
@@ -103,6 +118,9 @@ export const TOOLS = {
103
118
  performance_stop_trace: performanceStopTrace,
104
119
  performance_analyze_insight: performanceAnalyzeInsight,
105
120
  performance_trace: performanceTrace,
121
+ // screen recording (2) : toolkit addition, a tab-to-H.265-MP4 pair over Page.startScreencast
122
+ start_screen_recording: startScreenRecording,
123
+ stop_screen_recording: stopScreenRecording,
106
124
  // heap (1)
107
125
  take_heapsnapshot: takeHeapsnapshot,
108
126
  // lighthouse (1) : the only non-CDP tool
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * The three lease tools, shaped like src/shared-tools.ts entries
3
3
  * ((driver, args) => Promise<result>) so ONE implementation serves Chrome and
4
- * Firefox, exactly as the other 20 unified tools do.
4
+ * Firefox, exactly as the 23 unified tools in that file do.
5
5
  */
6
6
  import type { BrowserDriver } from "./driver.ts";
7
+ import { newTrackedPage } from "./origins.ts";
7
8
  import {
8
9
  claimLease,
10
+ defaultLabel,
9
11
  leaseTtlMs,
10
12
  listLeases,
11
13
  releaseLease,
@@ -41,12 +43,16 @@ export async function claimPage(
41
43
  args: { targetId?: string; url?: string; label?: string; ttlMs?: number } = {},
42
44
  ): Promise<ClaimPageResult> {
43
45
  const backend = backendOf(driver);
44
- const label = typeof args.label === "string" && args.label.length ? args.label : `pid-${process.pid}`;
46
+ const label = typeof args.label === "string" && args.label.length ? args.label : defaultLabel();
45
47
  const pages = await driver.listPages();
46
48
  let targetId = args.targetId;
47
49
  let url = args.url ?? "about:blank";
48
50
  if (targetId === undefined || targetId === "") {
49
- const page = await driver.newPage(args.url);
51
+ // Creation ledger, not the lease: this record survives release_page and
52
+ // expiry, so the tab stays attributable to `label` after the lease is gone.
53
+ // Claiming an EXISTING tab (the else branch) records nothing, because this
54
+ // toolkit did not create it and provenance is not ownership.
55
+ const page = await newTrackedPage(driver, backend, { url: args.url, label });
50
56
  targetId = page.id;
51
57
  url = page.url;
52
58
  } else {