@sensaiorg/adapter-chrome 0.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.
@@ -0,0 +1,743 @@
1
+ /**
2
+ * Chrome MCP Tools — registered when Chrome is connected via CDP.
3
+ *
4
+ * Uses the CdpBridge to send Chrome DevTools Protocol commands
5
+ * and collect console/network events.
6
+ */
7
+
8
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
9
+ import { z } from "zod";
10
+ import type { CdpBridge } from "../transport/cdp-bridge.js";
11
+
12
+ /* ── Stored event types ──────────────────────────────────────────── */
13
+
14
+ interface ConsoleEntry {
15
+ level: string;
16
+ text: string;
17
+ timestamp: number;
18
+ url?: string;
19
+ line?: number;
20
+ }
21
+
22
+ interface NetworkEntry {
23
+ requestId: string;
24
+ method: string;
25
+ url: string;
26
+ status?: number;
27
+ statusText?: string;
28
+ type?: string;
29
+ startTime: number;
30
+ endTime?: number;
31
+ encodedDataLength?: number;
32
+ error?: string;
33
+ }
34
+
35
+ /* ── Event collector ─────────────────────────────────────────────── */
36
+
37
+ /**
38
+ * Manages CDP domain subscriptions and stores events for later retrieval.
39
+ */
40
+ export class CdpEventCollector {
41
+ private readonly consoleLogs: ConsoleEntry[] = [];
42
+ private readonly networkEntries = new Map<string, NetworkEntry>();
43
+ private readonly maxEntries = 500;
44
+ private domainsEnabled = false;
45
+
46
+ /** Enable Console + Network domains and start collecting events. */
47
+ async enableDomains(bridge: CdpBridge): Promise<void> {
48
+ if (this.domainsEnabled) return;
49
+
50
+ // Listen for CDP events via the bridge
51
+ bridge.onEvent("Runtime.consoleAPICalled", (params) => {
52
+ const p = params as {
53
+ type: string;
54
+ args?: Array<{ value?: unknown; description?: string }>;
55
+ timestamp: number;
56
+ stackTrace?: { callFrames?: Array<{ url?: string; lineNumber?: number }> };
57
+ };
58
+ const text = (p.args ?? [])
59
+ .map((a) => a.value !== undefined ? String(a.value) : (a.description ?? ""))
60
+ .join(" ");
61
+ const frame = p.stackTrace?.callFrames?.[0];
62
+ this.addConsoleEntry({
63
+ level: p.type ?? "log",
64
+ text,
65
+ timestamp: p.timestamp ?? Date.now(),
66
+ url: frame?.url,
67
+ line: frame?.lineNumber,
68
+ });
69
+ });
70
+
71
+ bridge.onEvent("Runtime.exceptionThrown", (params) => {
72
+ const p = params as {
73
+ timestamp: number;
74
+ exceptionDetails?: {
75
+ text?: string;
76
+ exception?: { description?: string };
77
+ url?: string;
78
+ lineNumber?: number;
79
+ };
80
+ };
81
+ const details = p.exceptionDetails;
82
+ this.addConsoleEntry({
83
+ level: "error",
84
+ text: details?.exception?.description ?? details?.text ?? "Unknown exception",
85
+ timestamp: p.timestamp ?? Date.now(),
86
+ url: details?.url,
87
+ line: details?.lineNumber,
88
+ });
89
+ });
90
+
91
+ bridge.onEvent("Network.requestWillBeSent", (params) => {
92
+ const p = params as {
93
+ requestId: string;
94
+ request: { method: string; url: string };
95
+ type?: string;
96
+ timestamp: number;
97
+ };
98
+ this.networkEntries.set(p.requestId, {
99
+ requestId: p.requestId,
100
+ method: p.request.method,
101
+ url: p.request.url,
102
+ type: p.type,
103
+ startTime: p.timestamp,
104
+ });
105
+ this.trimNetwork();
106
+ });
107
+
108
+ bridge.onEvent("Network.responseReceived", (params) => {
109
+ const p = params as {
110
+ requestId: string;
111
+ response: { status: number; statusText: string };
112
+ timestamp: number;
113
+ };
114
+ const entry = this.networkEntries.get(p.requestId);
115
+ if (entry) {
116
+ entry.status = p.response.status;
117
+ entry.statusText = p.response.statusText;
118
+ entry.endTime = p.timestamp;
119
+ }
120
+ });
121
+
122
+ bridge.onEvent("Network.loadingFinished", (params) => {
123
+ const p = params as {
124
+ requestId: string;
125
+ encodedDataLength: number;
126
+ timestamp: number;
127
+ };
128
+ const entry = this.networkEntries.get(p.requestId);
129
+ if (entry) {
130
+ entry.encodedDataLength = p.encodedDataLength;
131
+ entry.endTime = p.timestamp;
132
+ }
133
+ });
134
+
135
+ bridge.onEvent("Network.loadingFailed", (params) => {
136
+ const p = params as {
137
+ requestId: string;
138
+ errorText: string;
139
+ timestamp: number;
140
+ };
141
+ const entry = this.networkEntries.get(p.requestId);
142
+ if (entry) {
143
+ entry.error = p.errorText;
144
+ entry.endTime = p.timestamp;
145
+ }
146
+ });
147
+
148
+ // Enable CDP domains
149
+ await bridge.send("Runtime.enable");
150
+ await bridge.send("Network.enable");
151
+ await bridge.send("Page.enable");
152
+ this.domainsEnabled = true;
153
+ }
154
+
155
+ getConsoleLogs(maxCount?: number): ConsoleEntry[] {
156
+ const limit = maxCount ?? this.consoleLogs.length;
157
+ return this.consoleLogs.slice(-limit);
158
+ }
159
+
160
+ getNetworkEntries(maxCount?: number): NetworkEntry[] {
161
+ const all = Array.from(this.networkEntries.values());
162
+ const limit = maxCount ?? all.length;
163
+ return all.slice(-limit);
164
+ }
165
+
166
+ private addConsoleEntry(entry: ConsoleEntry): void {
167
+ this.consoleLogs.push(entry);
168
+ if (this.consoleLogs.length > this.maxEntries) {
169
+ this.consoleLogs.splice(0, this.consoleLogs.length - this.maxEntries);
170
+ }
171
+ }
172
+
173
+ private trimNetwork(): void {
174
+ if (this.networkEntries.size > this.maxEntries) {
175
+ const keys = Array.from(this.networkEntries.keys());
176
+ const toRemove = keys.slice(0, keys.length - this.maxEntries);
177
+ for (const k of toRemove) {
178
+ this.networkEntries.delete(k);
179
+ }
180
+ }
181
+ }
182
+ }
183
+
184
+ /* ── Tool registration ───────────────────────────────────────────── */
185
+
186
+ export function registerChromeTools(
187
+ server: McpServer,
188
+ bridge: CdpBridge,
189
+ collector: CdpEventCollector,
190
+ prefix: string,
191
+ ): void {
192
+
193
+ // ── chrome_diagnose_page ──────────────────────────────────────
194
+ server.tool(
195
+ `${prefix}diagnose_page`,
196
+ "Get a comprehensive diagnosis of the current web page: URL, title, console errors, network failures, DOM element counts. START HERE for any Chrome debugging session.",
197
+ {
198
+ includeNetwork: z.boolean().optional().describe("Include recent network requests (default: true)"),
199
+ },
200
+ async ({ includeNetwork }) => {
201
+ try {
202
+ const [pageInfo, docResult] = await Promise.all([
203
+ bridge.send("Runtime.evaluate", {
204
+ expression: "JSON.stringify({ url: location.href, title: document.title, readyState: document.readyState, viewport: { width: window.innerWidth, height: window.innerHeight }, cookieCount: document.cookie.split(';').filter(c => c.trim()).length })",
205
+ returnByValue: true,
206
+ }) as Promise<{ result: { value?: string } }>,
207
+ bridge.send("Runtime.evaluate", {
208
+ expression: "JSON.stringify({ elements: document.querySelectorAll('*').length, forms: document.forms.length, images: document.images.length, links: document.links.length, scripts: document.scripts.length })",
209
+ returnByValue: true,
210
+ }) as Promise<{ result: { value?: string } }>,
211
+ ]);
212
+
213
+ const page = pageInfo.result.value ? JSON.parse(pageInfo.result.value) : {};
214
+ const dom = docResult.result.value ? JSON.parse(docResult.result.value) : {};
215
+
216
+ const consoleErrors = collector.getConsoleLogs()
217
+ .filter((e) => e.level === "error" || e.level === "warning")
218
+ .slice(-20);
219
+
220
+ const result: Record<string, unknown> = {
221
+ page,
222
+ dom,
223
+ consoleErrors: consoleErrors.length,
224
+ recentErrors: consoleErrors,
225
+ };
226
+
227
+ if (includeNetwork !== false) {
228
+ const networkEntries = collector.getNetworkEntries();
229
+ const failures = networkEntries.filter(
230
+ (e) => e.error || (e.status && e.status >= 400),
231
+ );
232
+ result.networkSummary = {
233
+ totalRequests: networkEntries.length,
234
+ failedRequests: failures.length,
235
+ failures: failures.slice(-10),
236
+ };
237
+ }
238
+
239
+ return {
240
+ content: [{ type: "text" as const, text: JSON.stringify(result) }],
241
+ };
242
+ } catch (err) {
243
+ return {
244
+ content: [{ type: "text" as const, text: `diagnose_page failed: ${err instanceof Error ? err.message : String(err)}` }],
245
+ isError: true,
246
+ };
247
+ }
248
+ },
249
+ );
250
+
251
+ // ── chrome_take_screenshot ────────────────────────────────────
252
+ server.tool(
253
+ `${prefix}take_screenshot`,
254
+ "Capture the current web page as a PNG screenshot via CDP.",
255
+ {
256
+ fullPage: z.boolean().optional().describe("Capture full page including scroll (default: false)"),
257
+ },
258
+ async ({ fullPage }) => {
259
+ try {
260
+ let clip: { x: number; y: number; width: number; height: number; scale: number } | undefined;
261
+
262
+ if (fullPage) {
263
+ const metrics = await bridge.send("Page.getLayoutMetrics") as {
264
+ contentSize: { width: number; height: number };
265
+ };
266
+ clip = {
267
+ x: 0,
268
+ y: 0,
269
+ width: metrics.contentSize.width,
270
+ height: metrics.contentSize.height,
271
+ scale: 1,
272
+ };
273
+ }
274
+
275
+ const result = await bridge.send("Page.captureScreenshot", {
276
+ format: "png",
277
+ ...(clip ? { clip } : {}),
278
+ }) as { data: string };
279
+
280
+ return {
281
+ content: [{
282
+ type: "image" as const,
283
+ data: result.data,
284
+ mimeType: "image/png",
285
+ }],
286
+ };
287
+ } catch (err) {
288
+ return {
289
+ content: [{ type: "text" as const, text: `Screenshot failed: ${err instanceof Error ? err.message : String(err)}` }],
290
+ isError: true,
291
+ };
292
+ }
293
+ },
294
+ );
295
+
296
+ // ── chrome_get_console_logs ───────────────────────────────────
297
+ server.tool(
298
+ `${prefix}get_console_logs`,
299
+ "Get recent console messages (log, warn, error, info) from the web page.",
300
+ {
301
+ level: z.enum(["log", "warn", "error", "info", "debug", "all"]).optional()
302
+ .describe("Filter by log level (default: all)"),
303
+ maxLines: z.number().optional().describe("Maximum entries to return (default: 100)"),
304
+ grep: z.string().optional().describe("Filter messages by regex pattern"),
305
+ },
306
+ async ({ level, maxLines, grep }) => {
307
+ try {
308
+ let entries = collector.getConsoleLogs(maxLines ?? 100);
309
+
310
+ if (level && level !== "all") {
311
+ entries = entries.filter((e) => e.level === level);
312
+ }
313
+
314
+ if (grep) {
315
+ const regex = new RegExp(grep, "i");
316
+ entries = entries.filter((e) => regex.test(e.text));
317
+ }
318
+
319
+ return {
320
+ content: [{
321
+ type: "text" as const,
322
+ text: JSON.stringify({
323
+ totalCollected: collector.getConsoleLogs().length,
324
+ returned: entries.length,
325
+ entries,
326
+ }),
327
+ }],
328
+ };
329
+ } catch (err) {
330
+ return {
331
+ content: [{ type: "text" as const, text: `get_console_logs failed: ${err instanceof Error ? err.message : String(err)}` }],
332
+ isError: true,
333
+ };
334
+ }
335
+ },
336
+ );
337
+
338
+ // ── chrome_get_network ────────────────────────────────────────
339
+ server.tool(
340
+ `${prefix}get_network`,
341
+ "Get recent network requests with URL, status, method, timing, and errors.",
342
+ {
343
+ maxEntries: z.number().optional().describe("Maximum entries to return (default: 100)"),
344
+ onlyFailed: z.boolean().optional().describe("Only return failed requests (4xx/5xx or network errors)"),
345
+ urlPattern: z.string().optional().describe("Filter by URL substring"),
346
+ },
347
+ async ({ maxEntries, onlyFailed, urlPattern }) => {
348
+ try {
349
+ let entries = collector.getNetworkEntries(maxEntries ?? 100);
350
+
351
+ if (onlyFailed) {
352
+ entries = entries.filter(
353
+ (e) => e.error || (e.status && e.status >= 400),
354
+ );
355
+ }
356
+
357
+ if (urlPattern) {
358
+ const lower = urlPattern.toLowerCase();
359
+ entries = entries.filter((e) => e.url.toLowerCase().includes(lower));
360
+ }
361
+
362
+ return {
363
+ content: [{
364
+ type: "text" as const,
365
+ text: JSON.stringify({
366
+ totalCollected: collector.getNetworkEntries().length,
367
+ returned: entries.length,
368
+ entries: entries.map((e) => ({
369
+ method: e.method,
370
+ url: e.url,
371
+ status: e.status ?? null,
372
+ statusText: e.statusText ?? null,
373
+ type: e.type ?? null,
374
+ error: e.error ?? null,
375
+ durationMs: e.endTime && e.startTime
376
+ ? Math.round((e.endTime - e.startTime) * 1000)
377
+ : null,
378
+ size: e.encodedDataLength ?? null,
379
+ })),
380
+ }),
381
+ }],
382
+ };
383
+ } catch (err) {
384
+ return {
385
+ content: [{ type: "text" as const, text: `get_network failed: ${err instanceof Error ? err.message : String(err)}` }],
386
+ isError: true,
387
+ };
388
+ }
389
+ },
390
+ );
391
+
392
+ // ── chrome_evaluate ───────────────────────────────────────────
393
+ server.tool(
394
+ `${prefix}evaluate`,
395
+ "Execute JavaScript in the page context and return the result. The expression is evaluated via CDP Runtime.evaluate.",
396
+ {
397
+ expression: z.string().describe("JavaScript expression to evaluate"),
398
+ awaitPromise: z.boolean().optional().describe("If true, await the result if it is a Promise (default: false)"),
399
+ },
400
+ async ({ expression, awaitPromise }) => {
401
+ try {
402
+ const result = await bridge.send("Runtime.evaluate", {
403
+ expression,
404
+ returnByValue: true,
405
+ awaitPromise: awaitPromise ?? false,
406
+ generatePreview: true,
407
+ }) as {
408
+ result: { type: string; value?: unknown; description?: string; subtype?: string };
409
+ exceptionDetails?: { text?: string; exception?: { description?: string } };
410
+ };
411
+
412
+ if (result.exceptionDetails) {
413
+ const ex = result.exceptionDetails;
414
+ return {
415
+ content: [{
416
+ type: "text" as const,
417
+ text: JSON.stringify({
418
+ error: true,
419
+ message: ex.exception?.description ?? ex.text ?? "Evaluation error",
420
+ }),
421
+ }],
422
+ isError: true,
423
+ };
424
+ }
425
+
426
+ const value = result.result.value !== undefined
427
+ ? result.result.value
428
+ : result.result.description ?? `[${result.result.type}${result.result.subtype ? `:${result.result.subtype}` : ""}]`;
429
+
430
+ return {
431
+ content: [{
432
+ type: "text" as const,
433
+ text: JSON.stringify({ type: result.result.type, value }),
434
+ }],
435
+ };
436
+ } catch (err) {
437
+ return {
438
+ content: [{ type: "text" as const, text: `evaluate failed: ${err instanceof Error ? err.message : String(err)}` }],
439
+ isError: true,
440
+ };
441
+ }
442
+ },
443
+ );
444
+
445
+ // ── chrome_get_dom ────────────────────────────────────────────
446
+ server.tool(
447
+ `${prefix}get_dom`,
448
+ "Get a simplified DOM tree of the visible page. Returns tag names, ids, classes, and truncated text content.",
449
+ {
450
+ selector: z.string().optional().describe("CSS selector to scope the tree (default: 'body')"),
451
+ maxDepth: z.number().optional().describe("Maximum depth to traverse (default: 5)"),
452
+ maxNodes: z.number().optional().describe("Maximum nodes to return (default: 200)"),
453
+ },
454
+ async ({ selector, maxDepth, maxNodes }) => {
455
+ try {
456
+ const sel = selector ?? "body";
457
+ const depth = maxDepth ?? 5;
458
+ const limit = maxNodes ?? 200;
459
+
460
+ // Use Runtime.evaluate to traverse the DOM in-page for efficiency
461
+ const expression = `
462
+ (function() {
463
+ const root = document.querySelector(${JSON.stringify(sel)});
464
+ if (!root) return JSON.stringify({ error: "Selector not found: " + ${JSON.stringify(sel)} });
465
+ const nodes = [];
466
+ let count = 0;
467
+ function walk(el, d) {
468
+ if (count >= ${limit} || d > ${depth}) return;
469
+ count++;
470
+ const tag = el.tagName ? el.tagName.toLowerCase() : '#text';
471
+ const node = { tag: tag };
472
+ if (el.id) node.id = el.id;
473
+ if (el.className && typeof el.className === 'string' && el.className.trim())
474
+ node.classes = el.className.trim().split(/\\s+/).slice(0, 5);
475
+ if (el.childNodes.length === 1 && el.childNodes[0].nodeType === 3) {
476
+ const txt = el.textContent.trim();
477
+ if (txt) node.text = txt.slice(0, 100);
478
+ }
479
+ if (el.children && el.children.length > 0) {
480
+ node.children = [];
481
+ for (let i = 0; i < el.children.length; i++) {
482
+ walk(el.children[i], d + 1);
483
+ if (count < ${limit}) node.children.push(nodes[nodes.length - 1]);
484
+ }
485
+ }
486
+ nodes.push(node);
487
+ }
488
+ walk(root, 0);
489
+ return JSON.stringify({ nodeCount: count, tree: nodes[nodes.length - 1] });
490
+ })()
491
+ `;
492
+
493
+ const result = await bridge.send("Runtime.evaluate", {
494
+ expression,
495
+ returnByValue: true,
496
+ }) as { result: { value?: string } };
497
+
498
+ const parsed = result.result.value ? JSON.parse(result.result.value) : { error: "No result" };
499
+ return {
500
+ content: [{ type: "text" as const, text: JSON.stringify(parsed) }],
501
+ };
502
+ } catch (err) {
503
+ return {
504
+ content: [{ type: "text" as const, text: `get_dom failed: ${err instanceof Error ? err.message : String(err)}` }],
505
+ isError: true,
506
+ };
507
+ }
508
+ },
509
+ );
510
+
511
+ // ── chrome_click ──────────────────────────────────────────────
512
+ server.tool(
513
+ `${prefix}click`,
514
+ "Click on an element by CSS selector or at specific x,y coordinates.",
515
+ {
516
+ selector: z.string().optional().describe("CSS selector of element to click"),
517
+ x: z.number().optional().describe("X coordinate to click (used if no selector)"),
518
+ y: z.number().optional().describe("Y coordinate to click (used if no selector)"),
519
+ },
520
+ async ({ selector, x, y }) => {
521
+ try {
522
+ if (selector) {
523
+ // Use Runtime.evaluate to find element and click it, returning center coordinates
524
+ const result = await bridge.send("Runtime.evaluate", {
525
+ expression: `
526
+ (function() {
527
+ const el = document.querySelector(${JSON.stringify(selector)});
528
+ if (!el) return JSON.stringify({ error: "Element not found: " + ${JSON.stringify(selector)} });
529
+ const rect = el.getBoundingClientRect();
530
+ const cx = Math.round(rect.left + rect.width / 2);
531
+ const cy = Math.round(rect.top + rect.height / 2);
532
+ el.click();
533
+ return JSON.stringify({ ok: true, selector: ${JSON.stringify(selector)}, clicked: { x: cx, y: cy } });
534
+ })()
535
+ `,
536
+ returnByValue: true,
537
+ }) as { result: { value?: string } };
538
+
539
+ const parsed = result.result.value ? JSON.parse(result.result.value) : { error: "No result" };
540
+ if (parsed.error) {
541
+ return {
542
+ content: [{ type: "text" as const, text: JSON.stringify(parsed) }],
543
+ isError: true,
544
+ };
545
+ }
546
+ return {
547
+ content: [{ type: "text" as const, text: JSON.stringify(parsed) }],
548
+ };
549
+ }
550
+
551
+ if (x !== undefined && y !== undefined) {
552
+ // Use CDP Input.dispatchMouseEvent for coordinate-based click
553
+ await bridge.send("Input.dispatchMouseEvent", {
554
+ type: "mousePressed",
555
+ x,
556
+ y,
557
+ button: "left",
558
+ clickCount: 1,
559
+ });
560
+ await bridge.send("Input.dispatchMouseEvent", {
561
+ type: "mouseReleased",
562
+ x,
563
+ y,
564
+ button: "left",
565
+ clickCount: 1,
566
+ });
567
+ return {
568
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: true, clicked: { x, y } }) }],
569
+ };
570
+ }
571
+
572
+ return {
573
+ content: [{ type: "text" as const, text: JSON.stringify({ error: "Provide either 'selector' or x/y coordinates" }) }],
574
+ isError: true,
575
+ };
576
+ } catch (err) {
577
+ return {
578
+ content: [{ type: "text" as const, text: `click failed: ${err instanceof Error ? err.message : String(err)}` }],
579
+ isError: true,
580
+ };
581
+ }
582
+ },
583
+ );
584
+
585
+ // ── chrome_type_text ──────────────────────────────────────────
586
+ server.tool(
587
+ `${prefix}type_text`,
588
+ "Type text into the currently focused element or a specific element by selector. Optionally clear existing content first.",
589
+ {
590
+ text: z.string().describe("Text to type"),
591
+ selector: z.string().optional().describe("CSS selector to focus before typing"),
592
+ clearFirst: z.boolean().optional().describe("Select all and clear before typing (default: false)"),
593
+ },
594
+ async ({ text, selector, clearFirst }) => {
595
+ try {
596
+ // Focus the element if selector is provided
597
+ if (selector) {
598
+ await bridge.send("Runtime.evaluate", {
599
+ expression: `
600
+ (function() {
601
+ const el = document.querySelector(${JSON.stringify(selector)});
602
+ if (el) { el.focus(); el.scrollIntoView({ block: 'center' }); }
603
+ })()
604
+ `,
605
+ });
606
+ }
607
+
608
+ // Clear existing content if requested
609
+ if (clearFirst) {
610
+ // Select all (Ctrl+A / Cmd+A), then delete
611
+ await bridge.send("Input.dispatchKeyEvent", {
612
+ type: "keyDown",
613
+ key: "a",
614
+ code: "KeyA",
615
+ windowsVirtualKeyCode: 65,
616
+ nativeVirtualKeyCode: 65,
617
+ modifiers: 2, // Ctrl
618
+ });
619
+ await bridge.send("Input.dispatchKeyEvent", {
620
+ type: "keyUp",
621
+ key: "a",
622
+ code: "KeyA",
623
+ windowsVirtualKeyCode: 65,
624
+ nativeVirtualKeyCode: 65,
625
+ modifiers: 2,
626
+ });
627
+ await bridge.send("Input.dispatchKeyEvent", {
628
+ type: "keyDown",
629
+ key: "Backspace",
630
+ code: "Backspace",
631
+ windowsVirtualKeyCode: 8,
632
+ nativeVirtualKeyCode: 8,
633
+ });
634
+ await bridge.send("Input.dispatchKeyEvent", {
635
+ type: "keyUp",
636
+ key: "Backspace",
637
+ code: "Backspace",
638
+ windowsVirtualKeyCode: 8,
639
+ nativeVirtualKeyCode: 8,
640
+ });
641
+ }
642
+
643
+ // Type text using insertText for reliable input
644
+ await bridge.send("Input.insertText", { text });
645
+
646
+ return {
647
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: true, typed: text.length }) }],
648
+ };
649
+ } catch (err) {
650
+ return {
651
+ content: [{ type: "text" as const, text: `type_text failed: ${err instanceof Error ? err.message : String(err)}` }],
652
+ isError: true,
653
+ };
654
+ }
655
+ },
656
+ );
657
+
658
+ // ── chrome_navigate ───────────────────────────────────────────
659
+ server.tool(
660
+ `${prefix}navigate`,
661
+ "Navigate the current tab to a URL via CDP Page.navigate.",
662
+ {
663
+ url: z.string().describe("URL to navigate to"),
664
+ waitForLoad: z.boolean().optional().describe("Wait for page load event (default: true)"),
665
+ },
666
+ async ({ url, waitForLoad }) => {
667
+ try {
668
+ const result = await bridge.send("Page.navigate", { url }) as {
669
+ frameId?: string;
670
+ errorText?: string;
671
+ };
672
+
673
+ if (result.errorText) {
674
+ return {
675
+ content: [{ type: "text" as const, text: JSON.stringify({ error: result.errorText, url }) }],
676
+ isError: true,
677
+ };
678
+ }
679
+
680
+ // Optionally wait for load
681
+ if (waitForLoad !== false) {
682
+ // Wait up to 30s for the page load by polling readyState
683
+ const deadline = Date.now() + 30_000;
684
+ while (Date.now() < deadline) {
685
+ try {
686
+ const state = await bridge.send("Runtime.evaluate", {
687
+ expression: "document.readyState",
688
+ returnByValue: true,
689
+ }) as { result: { value?: string } };
690
+ if (state.result.value === "complete") break;
691
+ } catch {
692
+ // page may be navigating, retry
693
+ }
694
+ await new Promise((r) => setTimeout(r, 200));
695
+ }
696
+ }
697
+
698
+ return {
699
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: true, url, frameId: result.frameId }) }],
700
+ };
701
+ } catch (err) {
702
+ return {
703
+ content: [{ type: "text" as const, text: `navigate failed: ${err instanceof Error ? err.message : String(err)}` }],
704
+ isError: true,
705
+ };
706
+ }
707
+ },
708
+ );
709
+
710
+ // ── chrome_get_page_info ──────────────────────────────────────
711
+ server.tool(
712
+ `${prefix}get_page_info`,
713
+ "Get current page URL, title, viewport size, cookies count, and readyState.",
714
+ {},
715
+ async () => {
716
+ try {
717
+ const result = await bridge.send("Runtime.evaluate", {
718
+ expression: `JSON.stringify({
719
+ url: location.href,
720
+ title: document.title,
721
+ readyState: document.readyState,
722
+ viewport: { width: window.innerWidth, height: window.innerHeight },
723
+ screenSize: { width: screen.width, height: screen.height },
724
+ cookieCount: document.cookie.split(';').filter(c => c.trim()).length,
725
+ localStorage: Object.keys(localStorage).length,
726
+ sessionStorage: Object.keys(sessionStorage).length,
727
+ })`,
728
+ returnByValue: true,
729
+ }) as { result: { value?: string } };
730
+
731
+ const info = result.result.value ? JSON.parse(result.result.value) : {};
732
+ return {
733
+ content: [{ type: "text" as const, text: JSON.stringify(info) }],
734
+ };
735
+ } catch (err) {
736
+ return {
737
+ content: [{ type: "text" as const, text: `get_page_info failed: ${err instanceof Error ? err.message : String(err)}` }],
738
+ isError: true,
739
+ };
740
+ }
741
+ },
742
+ );
743
+ }