cdp-toolkit 1.0.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/mcp.ts ADDED
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * cdp-toolkit MCP server (stdio).
4
+ *
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
7
+ * tool call lazily opens a single-target CDP connection (with its own timeout),
8
+ * so the server loads cleanly even when Chrome isn't running; individual calls
9
+ * then fail with a clear error if the browser is unreachable.
10
+ *
11
+ * Launch: `bunx -y cdp-toolkit` (or `bun run src/mcp.ts` from a checkout)
12
+ * Config: CDP_BASE (default http://127.0.0.1:9222), CDP_TIMEOUT_MS, CDP_ARTIFACT_DIR.
13
+ *
14
+ * stdout is the JSON-RPC channel — all diagnostics go to stderr only.
15
+ */
16
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
17
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
18
+ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
19
+ import { TOOLS, TOOL_NAMES, BASE } from "./index.ts";
20
+ import { MANIFEST } from "./manifest.ts";
21
+
22
+ const VERSION = "1.0.0";
23
+
24
+ /** Loose dispatch view of the strongly-typed TOOLS registry. */
25
+ const dispatch = TOOLS as Record<string, (args: unknown) => Promise<unknown>>;
26
+
27
+ /** Warn (to stderr) about any registry/manifest drift, but don't fail startup. */
28
+ function auditCoverage(): void {
29
+ const manifestNames = new Set(MANIFEST.map((s) => s.name));
30
+ const registryNames = new Set<string>(TOOL_NAMES);
31
+ const missingSchema = [...registryNames].filter((n) => !manifestNames.has(n));
32
+ const orphanSchema = [...manifestNames].filter((n) => !registryNames.has(n));
33
+ if (missingSchema.length) console.error(`[cdp-toolkit] WARN: tools without a manifest schema: ${missingSchema.join(", ")}`);
34
+ if (orphanSchema.length) console.error(`[cdp-toolkit] WARN: manifest schemas with no registered tool: ${orphanSchema.join(", ")}`);
35
+ }
36
+
37
+ const server = new Server({ name: "cdp-toolkit", version: VERSION }, { capabilities: { tools: {} } });
38
+
39
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
40
+ tools: MANIFEST.map((s) => ({ name: s.name, description: s.description, inputSchema: s.inputSchema })),
41
+ }));
42
+
43
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
44
+ const { name } = request.params;
45
+ 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 };
49
+ }
50
+ try {
51
+ const result = await fn(args);
52
+ return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
53
+ } catch (err) {
54
+ const message = err instanceof Error ? err.message : String(err);
55
+ return { content: [{ type: "text" as const, text: `Error: ${message}` }], isError: true };
56
+ }
57
+ });
58
+
59
+ auditCoverage();
60
+ await server.connect(new StdioServerTransport());
61
+ console.error(`[cdp-toolkit] MCP server v${VERSION} ready — ${MANIFEST.length} tools, CDP_BASE=${BASE}`);
@@ -0,0 +1,199 @@
1
+ /**
2
+ * console.ts — read console output captured by the recorder.
3
+ *
4
+ * list_console_messages -> listConsoleMessages
5
+ * get_console_message -> getConsoleMessage
6
+ *
7
+ * READ vs CAPTURE model (see recorder.ts header):
8
+ * - default ({ reload: false }): read the target's shared "latest" buffer
9
+ * (rec-<targetId>.jsonl) and return its parsed console entries. If no capture
10
+ * has run for this target, returns an empty list.
11
+ * - { reload: true }: run a captureWindow (records BOTH console+network into a
12
+ * unique per-capture file), Page.reload, capture for `durationMs` (default
13
+ * 2500ms), stop, then read+return the console entries from that capture. The
14
+ * both-domains capture means a network reload never wipes console history.
15
+ */
16
+ import { readFile } from "node:fs/promises";
17
+ import { CdpError, resolveTarget } from "../client.ts";
18
+ import type { TargetSelector } from "../types.ts";
19
+ import { recFile, captureWindow } from "./recorder.ts";
20
+ import type { RecLine } from "./recorder.ts";
21
+
22
+ const DEFAULT_CAPTURE_MS = 2500;
23
+
24
+ /** A normalized console entry returned to callers. */
25
+ export interface ConsoleEntry {
26
+ index: number;
27
+ /** Origin domain: "console" (Runtime.consoleAPICalled), "exception" (Runtime.exceptionThrown), "log" (Log.entryAdded). */
28
+ source: "console" | "exception" | "log";
29
+ /** Severity level: log/info/warn/error/debug/verbose. */
30
+ level: string;
31
+ /** Best-effort flattened text of the message. */
32
+ text: string;
33
+ /** Page URL associated with the message, if present. */
34
+ url?: string;
35
+ /** Source line number, if present. */
36
+ lineNumber?: number;
37
+ /** Wall-clock capture time (ms since epoch). */
38
+ ts: number;
39
+ }
40
+
41
+ export interface ListConsoleMessagesArgs {
42
+ target?: TargetSelector;
43
+ /** Record fresh by reloading the page and capturing for a window. */
44
+ reload?: boolean;
45
+ /** Capture window for reload mode (ms). Default 2500. */
46
+ durationMs?: number;
47
+ }
48
+
49
+ export interface GetConsoleMessageArgs {
50
+ target?: TargetSelector;
51
+ /** Zero-based index into the parsed console entries. Default 0. */
52
+ index?: number;
53
+ }
54
+
55
+ /** Stringify a single CDP RemoteObject arg into readable text. */
56
+ function remoteObjectToText(arg: Record<string, unknown> | undefined): string {
57
+ if (!arg) return "";
58
+ if (arg.value !== undefined && arg.value !== null) return String(arg.value);
59
+ if (typeof arg.description === "string") return arg.description;
60
+ if (typeof arg.unserializableValue === "string") return arg.unserializableValue;
61
+ if (arg.type === "undefined") return "undefined";
62
+ if (arg.type === "object" && arg.subtype === "null") return "null";
63
+ if (typeof arg.type === "string") return `[${arg.type}]`;
64
+ return "";
65
+ }
66
+
67
+ /** Map a persisted JSONL line into a normalized ConsoleEntry. */
68
+ function lineToEntry(line: RecLine, index: number): ConsoleEntry | null {
69
+ const p = line.params;
70
+ if (line.method === "Runtime.consoleAPICalled") {
71
+ const args = Array.isArray(p.args) ? (p.args as Array<Record<string, unknown>>) : [];
72
+ return {
73
+ index,
74
+ source: "console",
75
+ level: typeof p.type === "string" ? p.type : "log",
76
+ text: args.map(remoteObjectToText).join(" "),
77
+ url: undefined,
78
+ ts: line.ts,
79
+ };
80
+ }
81
+ if (line.method === "Runtime.exceptionThrown") {
82
+ const detail = (p.exceptionDetails ?? {}) as Record<string, unknown>;
83
+ const exception = (detail.exception ?? {}) as Record<string, unknown>;
84
+ const text =
85
+ remoteObjectToText(exception) ||
86
+ (typeof detail.text === "string" ? detail.text : "uncaught exception");
87
+ return {
88
+ index,
89
+ source: "exception",
90
+ level: "error",
91
+ text,
92
+ url: typeof detail.url === "string" ? detail.url : undefined,
93
+ lineNumber: typeof detail.lineNumber === "number" ? detail.lineNumber : undefined,
94
+ ts: line.ts,
95
+ };
96
+ }
97
+ if (line.method === "Log.entryAdded") {
98
+ const entry = (p.entry ?? {}) as Record<string, unknown>;
99
+ return {
100
+ index,
101
+ source: "log",
102
+ level: typeof entry.level === "string" ? entry.level : "info",
103
+ text: typeof entry.text === "string" ? entry.text : "",
104
+ url: typeof entry.url === "string" ? entry.url : undefined,
105
+ lineNumber: typeof entry.lineNumber === "number" ? entry.lineNumber : undefined,
106
+ ts: line.ts,
107
+ };
108
+ }
109
+ return null;
110
+ }
111
+
112
+ /** Parse a JSONL buffer file into console entries (skips network lines). */
113
+ async function readConsoleEntries(file: string): Promise<ConsoleEntry[]> {
114
+ let raw: string;
115
+ try {
116
+ raw = await readFile(file, "utf8");
117
+ } catch {
118
+ return []; // no buffer yet
119
+ }
120
+ const out: ConsoleEntry[] = [];
121
+ for (const rawLine of raw.split("\n")) {
122
+ const trimmed = rawLine.trim();
123
+ if (!trimmed) continue;
124
+ let line: RecLine;
125
+ try {
126
+ line = JSON.parse(trimmed) as RecLine;
127
+ } catch {
128
+ continue;
129
+ }
130
+ if (line.kind !== "console") continue;
131
+ const entry = lineToEntry(line, out.length);
132
+ if (entry) out.push(entry);
133
+ }
134
+ return out;
135
+ }
136
+
137
+ /**
138
+ * list_console_messages — return console output for the target. With
139
+ * `reload:true`, records a fresh capture window by reloading the page.
140
+ */
141
+ export async function listConsoleMessages(args: ListConsoleMessagesArgs = {}): Promise<{
142
+ target: { id: string; url: string; title: string };
143
+ count: number;
144
+ messages: ConsoleEntry[];
145
+ droppedWrites?: number;
146
+ }> {
147
+ if (args.reload) {
148
+ const cap = await captureWindow(args.target, args.durationMs ?? DEFAULT_CAPTURE_MS);
149
+ await cap.stop();
150
+ const messages = await readConsoleEntries(cap.file);
151
+ return {
152
+ target: cap.resolved,
153
+ count: messages.length,
154
+ messages,
155
+ droppedWrites: cap.droppedWrites(),
156
+ };
157
+ }
158
+
159
+ const target = await resolveTarget(args.target);
160
+ const messages = await readConsoleEntries(recFile(target.id));
161
+ return {
162
+ target: { id: target.id, url: target.url, title: target.title },
163
+ count: messages.length,
164
+ messages,
165
+ };
166
+ }
167
+
168
+ /**
169
+ * get_console_message — return a single console entry by index from the
170
+ * existing buffer for the target. Throws if the index is out of range.
171
+ */
172
+ export async function getConsoleMessage(args: GetConsoleMessageArgs = {}): Promise<ConsoleEntry> {
173
+ const target = await resolveTarget(args.target);
174
+ const messages = await readConsoleEntries(recFile(target.id));
175
+ const index = args.index ?? 0;
176
+ const entry = messages[index];
177
+ if (!entry) {
178
+ throw new CdpError(
179
+ `no console message at index ${index} (have ${messages.length}); run list_console_messages or list with reload:true first`,
180
+ );
181
+ }
182
+ return entry;
183
+ }
184
+
185
+ /*
186
+ * CDP methods / domains used (via recorder.ts + this module):
187
+ * Page.enable (reload mode)
188
+ * Page.reload (reload mode)
189
+ * Runtime.enable / Runtime.consoleAPICalled / Runtime.exceptionThrown (recorder)
190
+ * Log.enable / Log.entryAdded (recorder)
191
+ *
192
+ * Parity gaps vs chrome-devtools-mcp:
193
+ * - RemoteObject args are flattened to text best-effort; deep object inspection
194
+ * (expandable previews) the DevTools UI shows is not reconstructed.
195
+ * - Stack traces from exceptions are summarized to the top message + url/line,
196
+ * not the full frame list.
197
+ * - Without reload:true the result reflects whatever a prior recorder buffered;
198
+ * the MCP keeps a live in-process buffer for its managed page.
199
+ */
@@ -0,0 +1,236 @@
1
+ /**
2
+ * dialogs.ts — JavaScript dialog handling (alert / confirm / prompt / beforeunload).
3
+ *
4
+ * Replicates chrome-devtools-mcp's `handle_dialog`. A JS dialog opened by the
5
+ * page (alert/confirm/prompt/beforeunload) blocks the renderer until the
6
+ * embedder responds via Page.handleJavaScriptDialog. Over raw CDP we:
7
+ * 1. Page.enable
8
+ * 2. subscribe to Page.javascriptDialogOpening
9
+ * 3. on fire, respond with Page.handleJavaScriptDialog({ accept, promptText })
10
+ *
11
+ * Two modes are supported per CONTRACT:
12
+ * - "wait for next dialog then handle" (default): arm the listener and resolve
13
+ * when the next dialog is handled, or time out if none appears.
14
+ * - "auto-handle for N ms" ({ autoMs }): handle every dialog that opens during
15
+ * a fixed window, then resolve with the list handled.
16
+ *
17
+ * Because a dialog blocks the renderer, the code that *triggers* the dialog
18
+ * (e.g. a Runtime.evaluate of `confirm("x")`) must NOT be awaited before the
19
+ * dialog is handled — otherwise it deadlocks. `handleDialogForExpression` arms
20
+ * the listener, fires the expression WITHOUT awaiting, waits for the dialog to
21
+ * be handled, then awaits the now-unblocked evaluate result.
22
+ */
23
+ import type { CdpConnection } from "../client.ts";
24
+ import { CdpError, openPage } from "../client.ts";
25
+ import type { TargetSelector } from "../types.ts";
26
+
27
+ /** Raw shape of the Page.javascriptDialogOpening event params. */
28
+ interface JavascriptDialogOpening {
29
+ url: string;
30
+ message: string;
31
+ type: "alert" | "confirm" | "prompt" | "beforeunload" | string;
32
+ hasBrowserHandler: boolean;
33
+ defaultPrompt?: string;
34
+ }
35
+
36
+ /** A single handled dialog. */
37
+ export interface HandledDialog {
38
+ type: string;
39
+ message: string;
40
+ url: string;
41
+ defaultPrompt?: string;
42
+ accept: boolean;
43
+ promptText?: string;
44
+ handled: true;
45
+ }
46
+
47
+ export interface HandleDialogArgs {
48
+ target?: TargetSelector;
49
+ /** Whether to accept (OK) or dismiss (Cancel) the dialog. */
50
+ accept: boolean;
51
+ /** Text to enter for a prompt() dialog when accepting. */
52
+ promptText?: string;
53
+ /** How long to wait for the next dialog (default 15000ms). */
54
+ timeoutMs?: number;
55
+ /**
56
+ * Auto-handle mode: keep handling every dialog that opens for this many ms,
57
+ * then resolve. When set, the return value is a list of handled dialogs and
58
+ * the function never throws on "no dialog" (an empty list is a valid result).
59
+ */
60
+ autoMs?: number;
61
+ }
62
+
63
+ /**
64
+ * Arm a dialog handler on a live page connection. Returns a disarm function and
65
+ * a promise that resolves with each dialog as it is handled (via the onHandled
66
+ * callback). This is the low-level primitive used by both public modes and by
67
+ * the expression helper.
68
+ */
69
+ function armDialogHandler(
70
+ conn: CdpConnection,
71
+ accept: boolean,
72
+ promptText: string | undefined,
73
+ onHandled: (d: HandledDialog) => void,
74
+ ): () => void {
75
+ return conn.on("Page.javascriptDialogOpening", (params) => {
76
+ const ev = params as unknown as JavascriptDialogOpening;
77
+ void (async () => {
78
+ try {
79
+ await conn.send("Page.handleJavaScriptDialog", {
80
+ accept,
81
+ ...(promptText !== undefined ? { promptText } : {}),
82
+ });
83
+ onHandled({
84
+ type: ev.type,
85
+ message: ev.message,
86
+ url: ev.url,
87
+ ...(ev.defaultPrompt !== undefined ? { defaultPrompt: ev.defaultPrompt } : {}),
88
+ accept,
89
+ ...(promptText !== undefined ? { promptText } : {}),
90
+ handled: true,
91
+ });
92
+ } catch {
93
+ /* connection may have raced closed; the awaiter will time out */
94
+ }
95
+ })();
96
+ });
97
+ }
98
+
99
+ /**
100
+ * handle_dialog — wait for the next JS dialog on a page and respond to it.
101
+ *
102
+ * Default mode: opens a persistent connection, enables Page, arms the listener,
103
+ * and resolves with the first handled dialog (or throws on timeout). Auto mode
104
+ * ({ autoMs }): handles every dialog for the window and resolves with the list.
105
+ *
106
+ * The caller is expected to trigger the dialog out-of-band (e.g. by clicking a
107
+ * button on the page). To trigger-and-handle in one call, use
108
+ * handleDialogForExpression.
109
+ */
110
+ export async function handleDialog(
111
+ args: HandleDialogArgs,
112
+ ): Promise<HandledDialog | { handled: HandledDialog[]; count: number }> {
113
+ const { target, accept, promptText, timeoutMs = 15_000, autoMs } = args;
114
+ const { conn } = await openPage(target);
115
+ try {
116
+ await conn.send("Page.enable");
117
+
118
+ if (autoMs !== undefined) {
119
+ const handled: HandledDialog[] = [];
120
+ const disarm = armDialogHandler(conn, accept, promptText, (d) => handled.push(d));
121
+ await new Promise<void>((resolve) => setTimeout(resolve, autoMs));
122
+ disarm();
123
+ return { handled, count: handled.length };
124
+ }
125
+
126
+ return await new Promise<HandledDialog>((resolve, reject) => {
127
+ const timer = setTimeout(() => {
128
+ disarm();
129
+ reject(new CdpError(`handle_dialog: no dialog opened within ${timeoutMs}ms`));
130
+ }, timeoutMs);
131
+ const disarm = armDialogHandler(conn, accept, promptText, (d) => {
132
+ clearTimeout(timer);
133
+ disarm();
134
+ resolve(d);
135
+ });
136
+ });
137
+ } finally {
138
+ conn.close();
139
+ }
140
+ }
141
+
142
+ export interface HandleDialogForExpressionArgs {
143
+ target?: TargetSelector;
144
+ /** A JS expression that opens a dialog, e.g. `confirm("ok?")` or `prompt("name?")`. */
145
+ expression: string;
146
+ accept: boolean;
147
+ promptText?: string;
148
+ timeoutMs?: number;
149
+ }
150
+
151
+ /**
152
+ * Arm a dialog handler, evaluate an expression that OPENS a dialog (without
153
+ * awaiting it — a blocking dialog would otherwise deadlock the evaluate), wait
154
+ * for the dialog to be handled, then await the now-unblocked evaluate result.
155
+ *
156
+ * Returns both the handled-dialog metadata and the value the expression
157
+ * resolved to (e.g. `true` for an accepted confirm, the entered text for an
158
+ * accepted prompt, `false`/`null` for a dismissed one). This is the
159
+ * trigger-and-handle helper used by the live smoke test.
160
+ */
161
+ export async function handleDialogForExpression(
162
+ args: HandleDialogForExpressionArgs,
163
+ ): Promise<{ dialog: HandledDialog; value: unknown }> {
164
+ const { target, expression, accept, promptText, timeoutMs = 15_000 } = args;
165
+ const { conn } = await openPage(target);
166
+ try {
167
+ await conn.send("Page.enable");
168
+ await conn.send("Runtime.enable");
169
+
170
+ let resolveDialog!: (d: HandledDialog) => void;
171
+ let rejectDialog!: (e: Error) => void;
172
+ const dialogPromise = new Promise<HandledDialog>((res, rej) => {
173
+ resolveDialog = res;
174
+ rejectDialog = rej;
175
+ });
176
+ const timer = setTimeout(
177
+ () => rejectDialog(new CdpError(`handle_dialog: expression opened no dialog within ${timeoutMs}ms`)),
178
+ timeoutMs,
179
+ );
180
+ const disarm = armDialogHandler(conn, accept, promptText, (d) => {
181
+ clearTimeout(timer);
182
+ resolveDialog(d);
183
+ });
184
+
185
+ // Fire the dialog-opening expression WITHOUT awaiting: a blocking dialog
186
+ // holds the evaluate open until we handle it. We capture the promise so we
187
+ // can await its (now-unblocked) value after the dialog is handled.
188
+ const evalPromise = conn
189
+ .send<{ result: { value?: unknown }; exceptionDetails?: { text?: string } }>("Runtime.evaluate", {
190
+ expression,
191
+ returnByValue: true,
192
+ awaitPromise: true,
193
+ // The evaluate itself must not time out before we handle the dialog.
194
+ userGesture: true,
195
+ })
196
+ .catch((e: unknown) => {
197
+ // Surface as an exception-shaped result so the await below can decide.
198
+ return { result: {}, __error: e } as { result: { value?: unknown }; __error: unknown };
199
+ });
200
+
201
+ const dialog = await dialogPromise;
202
+ disarm();
203
+
204
+ const evalResult = (await evalPromise) as {
205
+ result: { value?: unknown };
206
+ exceptionDetails?: { text?: string };
207
+ __error?: unknown;
208
+ };
209
+ if (evalResult.__error) throw evalResult.__error;
210
+ if (evalResult.exceptionDetails) {
211
+ throw new CdpError(`expression threw: ${evalResult.exceptionDetails.text ?? "unknown error"}`);
212
+ }
213
+
214
+ return { dialog, value: evalResult.result?.value };
215
+ } finally {
216
+ conn.close();
217
+ }
218
+ }
219
+
220
+ /* ----------------------------------------------------------------------------
221
+ * CDP methods / domains used:
222
+ * - Page.enable
223
+ * - Page.javascriptDialogOpening (event, subscribed)
224
+ * - Page.handleJavaScriptDialog ({ accept, promptText })
225
+ * - Runtime.enable (helper only)
226
+ * - Runtime.evaluate (helper only — fires the dialog-opening expr)
227
+ *
228
+ * Parity gaps vs chrome-devtools-mcp `handle_dialog`:
229
+ * - MCP auto-handles the single pending dialog tracked by its Page object;
230
+ * here the caller arms first and triggers out-of-band, OR uses the
231
+ * handleDialogForExpression helper to trigger-and-handle atomically.
232
+ * - MCP returns its standard page/snapshot envelope after handling; this
233
+ * returns just the handled-dialog metadata (type/message/url/accept/...).
234
+ * - beforeunload dialogs are handled the same way but are only emitted when a
235
+ * real navigation/close is attempted; arming alone does not provoke them.
236
+ * -------------------------------------------------------------------------- */