cdp-toolkit 1.0.0 → 1.1.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/src/index.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * cdp-toolkit public surface.
3
3
  *
4
4
  * Two exports:
5
- * 1. `TOOLS` — the registry mapping every chrome-devtools-mcp tool name
5
+ * 1. `TOOLS` : the registry mapping every chrome-devtools-mcp tool name
6
6
  * (snake_case string, all 29) to its raw-CDP implementation function. The
7
7
  * CLI and any embedder dispatch through this single table.
8
8
  * 2. The client primitives, re-exported so consumers can build their own
@@ -12,21 +12,14 @@
12
12
  * value (or throws). See CONTRACT.md for the design rules each module follows.
13
13
  */
14
14
 
15
- // --- pages (browser endpoint: Target.*) ---
16
- import { listPages, newPage, closePage, selectPage } from "./tools/pages.ts";
17
- // --- navigation ---
18
- import { navigatePage, waitForText } from "./tools/navigation.ts";
19
- // --- evaluate ---
20
- import { evaluateScript } from "./tools/evaluate.ts";
21
- // --- snapshot (a11y tree + shared resolveUid helper) ---
22
- import { takeSnapshot } from "./tools/snapshot.ts";
23
- // --- input / interaction ---
24
- import { click, hover, drag, fill, fillForm, typeText, pressKey, uploadFile } from "./tools/input.ts";
25
- // --- screenshot + emulation ---
26
- import { takeScreenshot } from "./tools/screenshot.ts";
27
- import { emulate, resizePage } from "./tools/emulation.ts";
28
- // --- dialogs ---
29
- import { handleDialog } from "./tools/dialogs.ts";
15
+ // --- the 20 tools unified with Firefox onto one Driver-based implementation ---
16
+ // (list_pages/new_page/close_page/select_page, navigate_page/wait_for, evaluate_script,
17
+ // take_snapshot, click/hover/drag/fill/fill_form/type_text/press_key/upload_file,
18
+ // take_screenshot/emulate/resize_page, handle_dialog). See shared-tools.ts's file header for
19
+ // the byte-identical-Chrome-behavior contract this bind honors, and for the 7 tools that stay
20
+ // two implementations (console/network/mock, immediately below, unchanged from before).
21
+ import { createCdpDriver } from "./cdp/driver.ts";
22
+ import { SHARED_TOOLS } from "./shared-tools.ts";
30
23
  // --- console + network (read the recorder buffer) ---
31
24
  import { listConsoleMessages, getConsoleMessage } from "./tools/console.ts";
32
25
  import { listNetworkRequests, getNetworkRequest } from "./tools/network.ts";
@@ -41,49 +34,64 @@ import {
41
34
  import { takeHeapsnapshot } from "./tools/heap.ts";
42
35
  // --- lighthouse (the sole non-CDP tool) ---
43
36
  import { lighthouseAudit } from "./tools/lighthouse.ts";
44
- // --- network mocking (Fetch domain) — toolkit addition beyond the 29 parity tools ---
37
+ // --- network mocking (Fetch domain) : toolkit addition beyond the 29 parity tools ---
45
38
  import { mockRequest, listMocks, clearMocks } from "./tools/network_mock.ts";
46
39
 
47
40
  /** A toolkit tool: a single typed-args function returning JSON-serializable data. */
48
41
  export type ToolFn = (args: never) => Promise<unknown>;
49
42
 
43
+ // One stateless CDP driver, reused across calls (lifetime "per-call": every page() acquisition
44
+ // still opens its own throwaway connection, same as the pre-migration withPage per-call model).
45
+ // Constructing it does not dial a browser (capabilities.ts already relies on this).
46
+ const cdpDriver = createCdpDriver();
47
+
48
+ /** Bind a shared-tools.ts function to the module-level CDP driver, args-typed for the registry. */
49
+ function onCdp<A>(fn: (driver: ReturnType<typeof createCdpDriver>, args: A) => Promise<unknown>): (args: A) => Promise<unknown> {
50
+ return (args: A) => fn(cdpDriver, args);
51
+ }
52
+
50
53
  /**
51
54
  * The complete chrome-devtools-mcp parity surface: all 29 MCP tools keyed by
52
55
  * their canonical MCP (snake_case) name, plus one convenience superset tool
53
- * (`performance_trace`, a robust single-call trace — the start/stop pair cannot
56
+ * (`performance_trace`, a robust single-call trace : the start/stop pair cannot
54
57
  * span two stateless CLI processes), plus a 3-tool network-mocking group
55
- * (mock_request/list_mocks/clear_mocks — a persistent per-target fake backend).
58
+ * (mock_request/list_mocks/clear_mocks : a persistent per-target fake backend).
56
59
  * 33 entries total. Listed explicitly so the mapping is auditable at a glance and
57
60
  * the CLI can `--list` it.
61
+ *
62
+ * 20 of the 29 MCP-parity tools (pages/navigation/evaluate/snapshot/interaction/
63
+ * screenshot+emulation/dialogs) now route through shared-tools.ts's single Driver-based
64
+ * implementation via the CDP driver, instead of a Chrome-only copy in src/tools/*.ts.
65
+ * See shared-tools.ts's file header for the shape/behavior contract this preserves.
58
66
  */
59
67
  export const TOOLS = {
60
68
  // pages (4)
61
- list_pages: listPages,
62
- new_page: newPage,
63
- close_page: closePage,
64
- select_page: selectPage,
69
+ list_pages: onCdp(SHARED_TOOLS.list_pages),
70
+ new_page: onCdp(SHARED_TOOLS.new_page),
71
+ close_page: onCdp(SHARED_TOOLS.close_page),
72
+ select_page: onCdp(SHARED_TOOLS.select_page),
65
73
  // navigation (2)
66
- navigate_page: navigatePage,
67
- wait_for: waitForText,
74
+ navigate_page: onCdp(SHARED_TOOLS.navigate_page),
75
+ wait_for: onCdp(SHARED_TOOLS.wait_for),
68
76
  // evaluate (1)
69
- evaluate_script: evaluateScript,
77
+ evaluate_script: onCdp(SHARED_TOOLS.evaluate_script),
70
78
  // snapshot (1)
71
- take_snapshot: takeSnapshot,
79
+ take_snapshot: onCdp(SHARED_TOOLS.take_snapshot),
72
80
  // interaction (8)
73
- click: click,
74
- hover: hover,
75
- drag: drag,
76
- fill: fill,
77
- fill_form: fillForm,
78
- type_text: typeText,
79
- press_key: pressKey,
80
- upload_file: uploadFile,
81
+ click: onCdp(SHARED_TOOLS.click),
82
+ hover: onCdp(SHARED_TOOLS.hover),
83
+ drag: onCdp(SHARED_TOOLS.drag),
84
+ fill: onCdp(SHARED_TOOLS.fill),
85
+ fill_form: onCdp(SHARED_TOOLS.fill_form),
86
+ type_text: onCdp(SHARED_TOOLS.type_text),
87
+ press_key: onCdp(SHARED_TOOLS.press_key),
88
+ upload_file: onCdp(SHARED_TOOLS.upload_file),
81
89
  // screenshot + emulation (3)
82
- take_screenshot: takeScreenshot,
83
- emulate: emulate,
84
- resize_page: resizePage,
90
+ take_screenshot: onCdp(SHARED_TOOLS.take_screenshot),
91
+ emulate: onCdp(SHARED_TOOLS.emulate),
92
+ resize_page: onCdp(SHARED_TOOLS.resize_page),
85
93
  // dialogs (1)
86
- handle_dialog: handleDialog,
94
+ handle_dialog: onCdp(SHARED_TOOLS.handle_dialog),
87
95
  // console + network (4)
88
96
  list_console_messages: listConsoleMessages,
89
97
  get_console_message: getConsoleMessage,
@@ -96,9 +104,9 @@ export const TOOLS = {
96
104
  performance_trace: performanceTrace,
97
105
  // heap (1)
98
106
  take_heapsnapshot: takeHeapsnapshot,
99
- // lighthouse (1) — the only non-CDP tool
107
+ // lighthouse (1) : the only non-CDP tool
100
108
  lighthouse_audit: lighthouseAudit,
101
- // network mocking (3) — persistent per-target fake backend (toolkit addition, Fetch domain)
109
+ // network mocking (3) : persistent per-target fake backend (toolkit addition, Fetch domain)
102
110
  mock_request: mockRequest,
103
111
  list_mocks: listMocks,
104
112
  clear_mocks: clearMocks,
package/src/mcp.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * cdp-toolkit MCP server (stdio).
4
4
  *
5
5
  * Exposes the toolkit's raw-CDP tools to any MCP client (Claude Code, etc.) over
6
- * the standard stdio transport. It does NOT connect to Chrome at startup — each
6
+ * the standard stdio transport. It does NOT connect to Chrome at startup: each
7
7
  * tool call lazily opens a single-target CDP connection (with its own timeout),
8
8
  * so the server loads cleanly even when Chrome isn't running; individual calls
9
9
  * then fail with a clear error if the browser is unreachable.
@@ -11,18 +11,28 @@
11
11
  * Launch: `bunx -y cdp-toolkit` (or `bun run src/mcp.ts` from a checkout)
12
12
  * Config: CDP_BASE (default http://127.0.0.1:9222), CDP_TIMEOUT_MS, CDP_ARTIFACT_DIR.
13
13
  *
14
- * stdout is the JSON-RPC channel — all diagnostics go to stderr only.
14
+ * stdout is the JSON-RPC channel, all diagnostics go to stderr only.
15
15
  */
16
16
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
17
17
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
18
18
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
19
19
  import { TOOLS, TOOL_NAMES, BASE } from "./index.ts";
20
20
  import { MANIFEST } from "./manifest.ts";
21
+ import { resolveBrowserKind, stripBrowserFlag, getOrCreateFirefoxSession, disposeFirefoxSession } from "./backend.ts";
22
+ import { toolAvailability } from "./capabilities.ts";
23
+ import { FIREFOX_TOOLS } from "./firefox-tools.ts";
21
24
 
22
- const VERSION = "1.0.0";
25
+ const VERSION = "1.1.0";
26
+
27
+ // --browser is read once at startup (MCP has no per-call notion of backend): explicit flag,
28
+ // else CDP_BROWSER env var, else "chrome" (zero behavior change for existing users/configs).
29
+ const BROWSER = resolveBrowserKind(stripBrowserFlag(process.argv.slice(2)));
30
+ const AVAILABILITY = toolAvailability(BROWSER);
31
+ const AVAILABLE_NAMES = new Set<string>(AVAILABILITY.available);
23
32
 
24
33
  /** Loose dispatch view of the strongly-typed TOOLS registry. */
25
34
  const dispatch = TOOLS as Record<string, (args: unknown) => Promise<unknown>>;
35
+ const neutralDispatch = FIREFOX_TOOLS as Record<string, (driver: import("./driver.ts").BrowserDriver, args: unknown) => Promise<unknown>>;
26
36
 
27
37
  /** Warn (to stderr) about any registry/manifest drift, but don't fail startup. */
28
38
  function auditCoverage(): void {
@@ -37,18 +47,43 @@ function auditCoverage(): void {
37
47
  const server = new Server({ name: "cdp-toolkit", version: VERSION }, { capabilities: { tools: {} } });
38
48
 
39
49
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
40
- tools: MANIFEST.map((s) => ({ name: s.name, description: s.description, inputSchema: s.inputSchema })),
50
+ // Filtered to whatever the selected backend can actually run, per REQUIRED_CAPABILITIES:
51
+ // under Chrome this is byte-identical to the unfiltered MANIFEST (every capability CDP needs
52
+ // is declared); under Firefox the performance/heap/lighthouse tools are simply absent.
53
+ tools: MANIFEST.filter((s) => AVAILABLE_NAMES.has(s.name)).map((s) => ({ name: s.name, description: s.description, inputSchema: s.inputSchema })),
41
54
  }));
42
55
 
43
56
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
44
57
  const { name } = request.params;
45
58
  const args = (request.params.arguments ?? {}) as unknown;
46
- const fn = dispatch[name];
47
- if (!fn) {
48
- return { content: [{ type: "text" as const, text: `unknown tool: ${name}` }], isError: true };
59
+
60
+ if (!AVAILABLE_NAMES.has(name)) {
61
+ const gap = AVAILABILITY.unavailable.find((u) => u.name === name);
62
+ return {
63
+ content: [{ type: "text" as const, text: `unknown tool: ${name}${gap ? ` (not available under --browser ${BROWSER}, needs: ${gap.missing.join(", ")})` : ""}` }],
64
+ isError: true,
65
+ };
66
+ }
67
+
68
+ if (BROWSER === "chrome") {
69
+ const fn = dispatch[name];
70
+ if (!fn) return { content: [{ type: "text" as const, text: `unknown tool: ${name}` }], isError: true };
71
+ try {
72
+ const result = await fn(args);
73
+ return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
74
+ } catch (err) {
75
+ const message = err instanceof Error ? err.message : String(err);
76
+ return { content: [{ type: "text" as const, text: `Error: ${message}` }], isError: true };
77
+ }
49
78
  }
79
+
80
+ // Firefox: one BiDi session memoized for the life of this server process (lifetime "session",
81
+ // ADR-001); launched lazily on the first Firefox tool call, torn down on shutdown below.
82
+ const neutralFn = neutralDispatch[name];
83
+ if (!neutralFn) return { content: [{ type: "text" as const, text: `unknown tool: ${name}` }], isError: true };
50
84
  try {
51
- const result = await fn(args);
85
+ const session = await getOrCreateFirefoxSession();
86
+ const result = await neutralFn(session.driver, args);
52
87
  return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
53
88
  } catch (err) {
54
89
  const message = err instanceof Error ? err.message : String(err);
@@ -58,4 +93,19 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
58
93
 
59
94
  auditCoverage();
60
95
  await server.connect(new StdioServerTransport());
61
- console.error(`[cdp-toolkit] MCP server v${VERSION} ready — ${MANIFEST.length} tools, CDP_BASE=${BASE}`);
96
+ console.error(`[cdp-toolkit] MCP server v${VERSION} ready, browser=${BROWSER}, ${AVAILABILITY.available.length} tools, CDP_BASE=${BASE}`);
97
+
98
+ // Firefox owns a real OS process (see bidi/launch.ts): it must be reaped on every shutdown path,
99
+ // not just a clean exit. SIGINT/SIGTERM cover ctrl-C and a supervising client killing the server;
100
+ // stdin 'close' covers the normal MCP shutdown (the client closes the stdio pipe). All three are
101
+ // idempotent through disposeFirefoxSession(), and a no-op entirely when Firefox was never launched.
102
+ let shuttingDown = false;
103
+ async function shutdown(): Promise<void> {
104
+ if (shuttingDown) return;
105
+ shuttingDown = true;
106
+ await disposeFirefoxSession();
107
+ process.exit(0);
108
+ }
109
+ process.on("SIGINT", () => void shutdown());
110
+ process.on("SIGTERM", () => void shutdown());
111
+ process.stdin.on("close", () => void shutdown());
@@ -0,0 +1,420 @@
1
+ /**
2
+ * ONE implementation per tool, built only on the browser-neutral Driver contract in driver.ts
3
+ * (ADR-001), used by BOTH Chrome (via cdp/driver.ts's createCdpDriver()) and Firefox (via
4
+ * bidi/driver.ts's createFirefoxDriver()). This file replaces the split that used to exist
5
+ * between src/tools/*.ts (raw CDP, Chrome only) and src/neutral.ts (Driver-based, Firefox only)
6
+ * for the 20 tools below.
7
+ *
8
+ * THE HARD REQUIREMENT this file exists to satisfy: Chrome's return shape, timing, and quirks
9
+ * stay byte-identical to what src/tools/*.ts produced before this migration: that already
10
+ * shipped to npm. Where the clean Driver abstraction and that requirement conflicted, the
11
+ * requirement won; every such place is called out in a comment at the call site. The most
12
+ * significant ones:
13
+ * - take_snapshot's uid is rendered as a bare digit for the "cdp" scheme (matching the legacy
14
+ * backendDOMNodeId-as-uid contract) rather than the driver's own tagged "cdp:N" form, and its
15
+ * extras (checked/expanded/disabled/url/focused) keep the legacy per-key quoting AND the
16
+ * "expanded" quirk (included even when literally "false") rather than a clean uniform rule.
17
+ * - navigate_page's field names (frameId, not contextId) and waitedFor vocabulary (load /
18
+ * domcontentloaded / frameStoppedLoading / navigate-only) are the legacy CDP tool's, not the
19
+ * Driver's own (committed / timeout).
20
+ * - Every nested `target` object in a result is the legacy 3-field {id,url,title}: PageInfo's
21
+ * additive `type` field is deliberately dropped there (list_pages is the one place `type` is
22
+ * part of the contract).
23
+ *
24
+ * NOT here: list_console_messages / get_console_message / list_network_requests /
25
+ * get_network_request / mock_request / list_mocks / clear_mocks. Chrome's versions of those 7
26
+ * tools are built on src/tools/recorder.ts, a disk-persisted, cross-process capture keyed by
27
+ * target id (rec-<targetId>.jsonl) with a materially richer shape (ConsoleEntry/NetworkRequest
28
+ * correlation, per-rule hit counts, dedup-by-pattern+method). Firefox's existing implementation
29
+ * (now in src/bidi-tools.ts) is a same-process, in-memory event buffer with a different shape.
30
+ * Unifying them safely would mean either dropping Chrome's cross-process persistence (a real
31
+ * behavior change to something that ships today) or teaching Firefox to write the same JSONL
32
+ * format (new feature work, not a safety-preserving migration). Per the explicit instruction that
33
+ * behavior outranks the abstraction, these 7 stay two implementations; see the migration report.
34
+ */
35
+ import { mkdir, writeFile } from "node:fs/promises";
36
+ import { join } from "node:path";
37
+ import type {
38
+ BrowserDriver, DriverUid, ElementLocator, NavigateResult, PageDriver, PageInfo, SnapshotNode,
39
+ } from "./driver.ts";
40
+ import type { TargetSelector } from "./types.ts";
41
+
42
+ const ARTIFACT_DIR = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
43
+ const STATE_DIR = process.env.CDP_STATE_DIR ?? "/tmp/cdp-toolkit";
44
+
45
+ class SharedToolError extends Error {}
46
+
47
+ /** Trim a PageInfo down to the legacy 3-field {id,url,title} target shape used everywhere except list_pages. */
48
+ function target3(p: PageInfo): { id: string; url: string; title: string } {
49
+ return { id: p.id, url: p.url, title: p.title };
50
+ }
51
+
52
+ /* -------------------------------- page acquisition -------------------------------- */
53
+
54
+ /** Acquire, run, always release: the Driver counterpart of client.ts's withPage. */
55
+ async function withPage<T>(driver: BrowserDriver, target: TargetSelector | undefined, fn: (page: PageDriver) => Promise<T>): Promise<T> {
56
+ const page = await driver.page(target);
57
+ try {
58
+ return await fn(page);
59
+ } finally {
60
+ await page.release();
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Same TargetSelector grammar as client.ts's resolveTarget. Matches its one asymmetry exactly:
66
+ * every branch resolves against page-type targets only EXCEPT a bare-id lookup, which (like
67
+ * legacy resolveTarget's `targets.find`) searches the FULL unfiltered listing, so passing an
68
+ * exact non-page targetId (a worker, an iframe) still resolves.
69
+ */
70
+ async function resolvePage(driver: BrowserDriver, selector?: TargetSelector): Promise<PageInfo> {
71
+ const pages = await driver.listPages();
72
+ if (selector === undefined || selector === "active") {
73
+ if (!pages.length) throw new SharedToolError("no page targets open");
74
+ return pages[0]!;
75
+ }
76
+ if (selector.startsWith("index:")) {
77
+ const p = pages[Number(selector.slice(6))];
78
+ if (!p) throw new SharedToolError(`no page target at index ${selector.slice(6)} (have ${pages.length})`);
79
+ return p;
80
+ }
81
+ if (selector.startsWith("url:")) {
82
+ const needle = selector.slice(4);
83
+ const p = pages.find((x) => x.url.includes(needle));
84
+ if (!p) throw new SharedToolError(`no page url containing '${needle}'`);
85
+ return p;
86
+ }
87
+ if (selector.startsWith("title:")) {
88
+ const needle = selector.slice(6);
89
+ const p = pages.find((x) => x.title.includes(needle));
90
+ if (!p) throw new SharedToolError(`no page title containing '${needle}'`);
91
+ return p;
92
+ }
93
+ const exactPage = pages.find((x) => x.id === selector);
94
+ if (exactPage) return exactPage;
95
+ const all = await driver.listPages({ all: true });
96
+ const exact = all.find((x) => x.id === selector);
97
+ if (exact) return exact;
98
+ throw new SharedToolError(`no target with id '${selector}'`);
99
+ }
100
+
101
+ /* -------------------------------- element locators -------------------------------- */
102
+
103
+ function locatorOf(args: { uid?: DriverUid; selector?: string }): ElementLocator {
104
+ const hasUid = args.uid !== undefined && args.uid !== null && args.uid !== ("" as unknown);
105
+ const hasSelector = typeof args.selector === "string" && args.selector.length > 0;
106
+ if (hasUid === hasSelector) throw new SharedToolError("provide exactly one of { uid } or { selector }");
107
+ return hasUid ? { uid: args.uid as DriverUid } : { css: args.selector as string };
108
+ }
109
+
110
+ /* -------------------------------- pages (4) -------------------------------- */
111
+
112
+ export async function listPages(driver: BrowserDriver, args: { all?: boolean } = {}): Promise<{ pages: PageInfo[]; count: number }> {
113
+ const pages = await driver.listPages({ all: args.all });
114
+ return { pages, count: pages.length };
115
+ }
116
+
117
+ export async function newPage(driver: BrowserDriver, args: { url?: string } = {}): Promise<{ targetId: string; url: string }> {
118
+ const p = await driver.newPage(args.url);
119
+ return { targetId: p.id, url: p.url };
120
+ }
121
+
122
+ export async function closePage(driver: BrowserDriver, args: { target?: TargetSelector }): Promise<{ closed: string; success: boolean }> {
123
+ if (args.target === undefined || args.target === "") throw new SharedToolError("close_page requires an explicit target; refusing to guess which page to close");
124
+ const p = await resolvePage(driver, args.target);
125
+ const res = await driver.closePage(p.id);
126
+ return { closed: p.id, success: res.success };
127
+ }
128
+
129
+ async function writeSelectedFile(id: string): Promise<void> {
130
+ await mkdir(STATE_DIR, { recursive: true });
131
+ await writeFile(join(STATE_DIR, "selected"), id, "utf8");
132
+ }
133
+
134
+ export async function selectPage(driver: BrowserDriver, args: { target?: TargetSelector }): Promise<{ selected: string }> {
135
+ if (args.target === undefined || args.target === "") throw new SharedToolError("select_page requires an explicit target");
136
+ const p = await resolvePage(driver, args.target);
137
+ const activated = await driver.activatePage(p.id);
138
+ await writeSelectedFile(activated.id);
139
+ return { selected: activated.id };
140
+ }
141
+
142
+ /* ------------------------------ navigation (2) ------------------------------ */
143
+
144
+ /** Legacy tools/navigation.ts vocabulary, not the Driver's own (see file header). */
145
+ function toLegacyWaitedFor(w: NavigateResult["waitedFor"]): "load" | "domcontentloaded" | "frameStoppedLoading" | "navigate-only" {
146
+ if (w === "committed") return "frameStoppedLoading";
147
+ if (w === "timeout") return "navigate-only";
148
+ return w;
149
+ }
150
+
151
+ export interface LegacyNavigateResult {
152
+ url: string;
153
+ frameId: string;
154
+ reloaded?: boolean;
155
+ waitedFor: "load" | "domcontentloaded" | "frameStoppedLoading" | "navigate-only";
156
+ }
157
+
158
+ export async function navigatePage(
159
+ driver: BrowserDriver,
160
+ args: { target?: TargetSelector; url?: string; reload?: boolean; ignoreCache?: boolean; waitUntil?: "load" | "domcontentloaded"; timeoutMs?: number },
161
+ ): Promise<LegacyNavigateResult> {
162
+ const reload = args.reload === true;
163
+ if (!reload && (!args.url || typeof args.url !== "string")) {
164
+ throw new SharedToolError("navigate_page: 'url' is required (or pass reload:true to reload the current page)");
165
+ }
166
+ return withPage(driver, args.target, async (page) => {
167
+ const r = await page.navigate(args);
168
+ return {
169
+ url: r.url,
170
+ frameId: r.contextId,
171
+ ...(r.reloaded ? { reloaded: r.reloaded } : {}),
172
+ waitedFor: toLegacyWaitedFor(r.waitedFor),
173
+ };
174
+ });
175
+ }
176
+
177
+ export async function waitForText(driver: BrowserDriver, args: { target?: TargetSelector; text: string; timeoutMs?: number; pollMs?: number }): Promise<{ found: true; waitedMs: number }> {
178
+ if (typeof args.text !== "string" || args.text.length === 0) throw new SharedToolError("wait_for: 'text' is required");
179
+ return withPage(driver, args.target, async (page) => {
180
+ const r = await page.waitForText(args.text, args.timeoutMs ?? 15_000, args.pollMs ?? 250);
181
+ return { found: true, waitedMs: r.elapsedMs };
182
+ });
183
+ }
184
+ /** Contract-mandated alias, matching tools/navigation.ts's exported `waitFor`. */
185
+ export const waitFor = waitForText;
186
+
187
+ /* ------------------------------- evaluate (1) ------------------------------- */
188
+
189
+ export async function evaluateScript(
190
+ driver: BrowserDriver,
191
+ args: { target?: TargetSelector; expression: string; awaitPromise?: boolean; args?: unknown[] },
192
+ ): Promise<unknown> {
193
+ if (typeof args.expression !== "string" || args.expression.length === 0) {
194
+ throw new SharedToolError("evaluateScript: 'expression' must be a non-empty string");
195
+ }
196
+ return withPage(driver, args.target, (page) => page.evaluate(args.expression, { args: args.args, awaitPromise: args.awaitPromise ?? true }));
197
+ }
198
+
199
+ /* -------------------------------- snapshot (1) -------------------------------- */
200
+
201
+ /**
202
+ * Legacy-faithful render, keyed by field name (see the file header's uid/extras note). The
203
+ * "cdp" scheme strips its "cdp:" tag to reproduce the historical bare-backendDOMNodeId uid;
204
+ * every other scheme keeps its tag (a tool must never guess at a foreign driver's uid grammar,
205
+ * per driver.ts's THE UID CODEC block: this is a display choice made by the tool that already
206
+ * knows its own driver's scheme, not a parse of a uid it didn't mint).
207
+ */
208
+ const BARE_TOKEN_EXTRAS = new Set(["disabled", "focused"]);
209
+ const UNQUOTED_EXTRAS = new Set(["checked", "expanded", "url"]);
210
+
211
+ function renderSnapshotLine(n: SnapshotNode, scheme: string): string {
212
+ const indent = " ".repeat(n.depth);
213
+ const uidText = scheme === "cdp" && n.uid.startsWith("cdp:") ? n.uid.slice(4) : n.uid;
214
+ const name = n.name !== undefined ? ` ${JSON.stringify(n.name)}` : "";
215
+ const extrasToks: string[] = [];
216
+ if (n.extras) {
217
+ for (const [k, v] of Object.entries(n.extras)) {
218
+ if (BARE_TOKEN_EXTRAS.has(k)) extrasToks.push(k);
219
+ else if (UNQUOTED_EXTRAS.has(k)) extrasToks.push(`${k}=${v}`);
220
+ else extrasToks.push(`${k}=${JSON.stringify(v)}`);
221
+ }
222
+ }
223
+ const extra = extrasToks.length ? ` [${extrasToks.join(" ")}]` : "";
224
+ return `${indent}[${uidText}] ${n.role}${name}${extra}`;
225
+ }
226
+
227
+ export async function takeSnapshot(
228
+ driver: BrowserDriver,
229
+ args: { target?: TargetSelector; interactiveOnly?: boolean } = {},
230
+ ): Promise<{ snapshot: string; target: { id: string; url: string; title: string }; nodeCount: number }> {
231
+ return withPage(driver, args.target, async (page) => {
232
+ const nodes = await page.snapshot({ interactiveOnly: args.interactiveOnly ?? false });
233
+ const scheme = page.browser.scheme;
234
+ return { snapshot: nodes.map((n) => renderSnapshotLine(n, scheme)).join("\n"), target: target3(page.info), nodeCount: nodes.length };
235
+ });
236
+ }
237
+
238
+ /* ------------------------------ interaction (8) ------------------------------ */
239
+
240
+ export async function click(
241
+ driver: BrowserDriver,
242
+ args: { target?: TargetSelector; uid?: DriverUid; selector?: string; button?: "left" | "right" | "middle"; clickCount?: number },
243
+ ): Promise<{ clicked: true; x: number; y: number }> {
244
+ return withPage(driver, args.target, async (page) => {
245
+ const { x, y } = await page.click(locatorOf(args), { button: args.button ?? "left", clickCount: args.clickCount ?? 1 });
246
+ return { clicked: true, x, y };
247
+ });
248
+ }
249
+
250
+ export async function hover(driver: BrowserDriver, args: { target?: TargetSelector; uid?: DriverUid; selector?: string }): Promise<{ hovered: true; x: number; y: number }> {
251
+ return withPage(driver, args.target, async (page) => {
252
+ const { x, y } = await page.hover(locatorOf(args));
253
+ return { hovered: true, x, y };
254
+ });
255
+ }
256
+
257
+ export async function drag(
258
+ driver: BrowserDriver,
259
+ args: { target?: TargetSelector; from: { uid?: DriverUid; selector?: string }; to: { uid?: DriverUid; selector?: string } },
260
+ ): Promise<{ dragged: true; from: { x: number; y: number }; to: { x: number; y: number } }> {
261
+ if (!args.from || !args.to) throw new SharedToolError("drag requires { from } and { to }");
262
+ return withPage(driver, args.target, async (page) => {
263
+ const { from, to } = await page.drag(locatorOf(args.from), locatorOf(args.to));
264
+ return { dragged: true, from, to };
265
+ });
266
+ }
267
+
268
+ export async function fill(driver: BrowserDriver, args: { target?: TargetSelector; uid?: DriverUid; selector?: string; value: string }): Promise<{ filled: true; value: string }> {
269
+ if (typeof args.value !== "string") throw new SharedToolError("fill requires a string { value }");
270
+ return withPage(driver, args.target, async (page) => {
271
+ await page.setValue(locatorOf(args), args.value);
272
+ return { filled: true, value: args.value };
273
+ });
274
+ }
275
+
276
+ export async function fillForm(
277
+ driver: BrowserDriver,
278
+ args: { target?: TargetSelector; fields: Array<{ uid?: DriverUid; selector?: string; value: string }> },
279
+ ): Promise<{ filled: number; fields: number }> {
280
+ if (!Array.isArray(args.fields) || args.fields.length === 0) throw new SharedToolError("fill_form requires a non-empty { fields } array");
281
+ return withPage(driver, args.target, async (page) => {
282
+ let filled = 0;
283
+ for (const f of args.fields) {
284
+ if (typeof f.value !== "string") throw new SharedToolError("each fill_form field requires a string value");
285
+ await page.setValue(locatorOf(f), f.value);
286
+ filled++;
287
+ }
288
+ return { filled, fields: args.fields.length };
289
+ });
290
+ }
291
+
292
+ export async function typeText(driver: BrowserDriver, args: { target?: TargetSelector; uid?: DriverUid; selector?: string; text: string }): Promise<{ typed: true; text: string }> {
293
+ if (typeof args.text !== "string") throw new SharedToolError("type_text requires a string { text }");
294
+ return withPage(driver, args.target, async (page) => {
295
+ await page.typeText(locatorOf(args), args.text);
296
+ return { typed: true, text: args.text };
297
+ });
298
+ }
299
+
300
+ export async function pressKey(driver: BrowserDriver, args: { target?: TargetSelector; key: string; modifiers?: string[] }): Promise<{ pressed: string; modifiers: string[] }> {
301
+ if (typeof args.key !== "string" || !args.key.length) throw new SharedToolError("press_key requires a non-empty { key }");
302
+ const modifiers = args.modifiers ?? [];
303
+ return withPage(driver, args.target, async (page) => {
304
+ await page.pressKey({ key: args.key, modifiers });
305
+ return { pressed: args.key, modifiers };
306
+ });
307
+ }
308
+
309
+ export async function uploadFile(driver: BrowserDriver, args: { target?: TargetSelector; uid?: DriverUid; selector?: string; files: string | string[] }): Promise<{ uploaded: string[] }> {
310
+ const files = Array.isArray(args.files) ? args.files : [args.files];
311
+ if (!files.length || files.some((f) => typeof f !== "string" || !f.length)) {
312
+ throw new SharedToolError("upload_file requires { files } as a non-empty path or array of paths");
313
+ }
314
+ return withPage(driver, args.target, async (page) => {
315
+ await page.setFiles(locatorOf(args), files);
316
+ return { uploaded: files };
317
+ });
318
+ }
319
+
320
+ /* -------------------------- screenshot + emulation (3) -------------------------- */
321
+
322
+ function stamp(): string {
323
+ return new Date(Date.now()).toISOString().replace(/[:.]/g, "-");
324
+ }
325
+
326
+ export async function takeScreenshot(
327
+ driver: BrowserDriver,
328
+ args: {
329
+ target?: TargetSelector; format?: "png" | "jpeg"; quality?: number; fullPage?: boolean;
330
+ uid?: DriverUid; selector?: string; savePath?: string; returnBase64?: boolean;
331
+ } = {},
332
+ ): Promise<{ path: string; bytes: number; format: "png" | "jpeg"; target: { id: string; url: string; title: string }; base64?: string }> {
333
+ if ((args.uid !== undefined && args.uid !== "") && args.selector) throw new SharedToolError("provide exactly one of uid or selector, not both");
334
+ const format = args.format ?? "png";
335
+ return withPage(driver, args.target, async (page) => {
336
+ const clip = args.uid !== undefined || args.selector ? locatorOf(args) : undefined;
337
+ const { data, format: outFormat } = await page.screenshot({ format, quality: args.quality, fullPage: args.fullPage, ...(clip ? { clip } : {}) });
338
+ if (data.byteLength === 0) throw new SharedToolError("captureScreenshot returned empty data");
339
+ await mkdir(ARTIFACT_DIR, { recursive: true });
340
+ const ext = outFormat === "jpeg" ? "jpg" : "png";
341
+ const path = args.savePath ?? join(ARTIFACT_DIR, `screenshot-${page.info.id.slice(0, 8)}-${stamp()}.${ext}`);
342
+ await writeFile(path, Buffer.from(data));
343
+ const result: { path: string; bytes: number; format: "png" | "jpeg"; target: { id: string; url: string; title: string }; base64?: string } = {
344
+ path, bytes: data.byteLength, format: outFormat, target: target3(page.info),
345
+ };
346
+ if (args.returnBase64) result.base64 = Buffer.from(data).toString("base64");
347
+ return result;
348
+ });
349
+ }
350
+
351
+ export async function emulate(
352
+ driver: BrowserDriver,
353
+ args: {
354
+ target?: TargetSelector; width?: number; height?: number; deviceScaleFactor?: number; mobile?: boolean;
355
+ userAgent?: string; cpuThrottlingRate?: number; media?: string; mediaFeatures?: Array<{ name: string; value: string }>;
356
+ networkConditions?: { offline?: boolean; latency?: number; downloadThroughput?: number; uploadThroughput?: number; connectionType?: string };
357
+ clearOverrides?: boolean;
358
+ } = {},
359
+ ): Promise<{ target: { id: string; url: string; title: string }; applied: string[]; cleared?: boolean }> {
360
+ return withPage(driver, args.target, async (page) => {
361
+ if (args.clearOverrides) {
362
+ await page.emulate({ clearOverrides: true });
363
+ return { target: target3(page.info), applied: [], cleared: true };
364
+ }
365
+ const { applied } = await page.emulate(args);
366
+ if (applied.length === 0) throw new SharedToolError("emulate: no overrides specified (pass clearOverrides:true to reset)");
367
+ return { target: target3(page.info), applied };
368
+ });
369
+ }
370
+
371
+ export async function resizePage(
372
+ driver: BrowserDriver,
373
+ args: { target?: TargetSelector; width: number; height: number; deviceScaleFactor?: number; mobile?: boolean },
374
+ ): Promise<{ target: { id: string; url: string; title: string }; width: number; height: number; innerWidth: number; innerHeight: number }> {
375
+ if (!Number.isFinite(args.width) || !Number.isFinite(args.height) || args.width <= 0 || args.height <= 0) {
376
+ throw new SharedToolError("resize_page requires positive numeric width and height");
377
+ }
378
+ return withPage(driver, args.target, async (page) => {
379
+ await page.emulate({ width: args.width, height: args.height, deviceScaleFactor: args.deviceScaleFactor, mobile: args.mobile });
380
+ const v = (await page.evaluate("({ w: window.innerWidth, h: window.innerHeight })")) as { w?: number; h?: number } | undefined;
381
+ return { target: target3(page.info), width: args.width, height: args.height, innerWidth: v?.w ?? args.width, innerHeight: v?.h ?? args.height };
382
+ });
383
+ }
384
+
385
+ /* ---------------------------------- dialogs (1) ---------------------------------- */
386
+
387
+ export async function handleDialog(
388
+ driver: BrowserDriver,
389
+ args: { target?: TargetSelector; accept: boolean; promptText?: string; timeoutMs?: number; autoMs?: number },
390
+ ): Promise<unknown> {
391
+ if (args.accept === undefined) throw new SharedToolError("handle_dialog requires { accept }");
392
+ return withPage(driver, args.target, (page) => page.handleDialog(args.accept, args.promptText, { timeoutMs: args.timeoutMs, autoMs: args.autoMs }));
393
+ }
394
+
395
+ /* ------------------------------------- registry ------------------------------------- */
396
+
397
+ /** The 20 tools this file unifies, importable by both src/index.ts (Chrome) and
398
+ * src/firefox-tools.ts (Firefox). Each function is (driver, args) => Promise<result>. */
399
+ export const SHARED_TOOLS = {
400
+ list_pages: listPages,
401
+ new_page: newPage,
402
+ close_page: closePage,
403
+ select_page: selectPage,
404
+ navigate_page: navigatePage,
405
+ wait_for: waitForText,
406
+ evaluate_script: evaluateScript,
407
+ take_snapshot: takeSnapshot,
408
+ click,
409
+ hover,
410
+ drag,
411
+ fill,
412
+ fill_form: fillForm,
413
+ type_text: typeText,
414
+ press_key: pressKey,
415
+ upload_file: uploadFile,
416
+ take_screenshot: takeScreenshot,
417
+ emulate,
418
+ resize_page: resizePage,
419
+ handle_dialog: handleDialog,
420
+ } satisfies Record<string, (driver: BrowserDriver, args: never) => Promise<unknown>>;