chrome-bridge-mcp 1.8.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,1728 @@
1
+ /**
2
+ * Registra i tool MCP sul server.
3
+ *
4
+ * Ogni tool crea un comando WebSocket, lo invia tramite il WSManager
5
+ * e restituisce il risultato al client MCP.
6
+ */
7
+
8
+ import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
9
+ import { homedir } from 'node:os';
10
+ import { basename, extname, join } from 'node:path';
11
+ import { parse as parseHtml } from 'node-html-parser';
12
+ import { z } from 'zod';
13
+ import { runAssert } from './assertions.js';
14
+ import { ensureStubServer, addStub, clearStubs, listStubs, stubHost } from './stub-server.js';
15
+ import { MessageType, VERSION } from './protocol.js';
16
+ import { checkLinksBatch } from './link-checker.js';
17
+ import { toHar } from './har.js';
18
+ import { evaluateSecurityHeaders } from './security-headers.js';
19
+ import { consoleLines, networkLines, interactivesLines, linksLines } from './formatters.js';
20
+
21
+ const SESSIONS_DIR = join(homedir(), '.config', 'chrome-bridge', 'sessions');
22
+ const RECORDINGS_DIR = process.env.CHROME_BRIDGE_RECORD_DIR || join(homedir(), '.config', 'chrome-bridge', 'recordings');
23
+
24
+ // Comandi rumore per un replay: letture interne (tabSnapshot) o senza effetto
25
+ const RECORD_EXCLUDE = new Set([MessageType.GET_TABS]);
26
+
27
+ // `hint` è il parametro REALE del tool chiamante che riduce i dati. Suggerire
28
+ // max_length quando 56 tool su 59 non lo espongono mandava il modello a
29
+ // ritentare con un argomento inesistente: un turno bruciato per un consiglio
30
+ // sbagliato.
31
+ function truncateText(text, max, hint = null) {
32
+ if (typeof text !== 'string' || text.length <= max) return text;
33
+ const remedy = hint
34
+ ? `use ${hint} to get less data`
35
+ : "narrow the request (limit / max_rows / scope / selector) — this tool has no max_length";
36
+ return text.slice(0, max) + `\n…[truncated, ${text.length - max} more chars — ${remedy}]`;
37
+ }
38
+
39
+ // Limite di default sull'output testuale di ogni tool: protegge il contesto
40
+ // del client MCP da payload fuori scala (es. buffer console/network pieni).
41
+ const DEFAULT_MAX_OUTPUT = 20000;
42
+
43
+ /**
44
+ * Serializza compatto (niente pretty-print: solo token sprecati per il modello).
45
+ *
46
+ * Se il payload supera il cap, si riducono gli ELEMENTI e non i caratteri:
47
+ * tagliare la stringa a metà di un valore produceva JSON non parsabile su ogni
48
+ * path format=json/har (verificato: `JSON.parse` falliva con "Bad control
49
+ * character in string literal"). Qui il risultato resta sempre valido e dichiara
50
+ * quanto è stato omesso.
51
+ */
52
+ function jsonText(data, max = DEFAULT_MAX_OUTPUT, hint = null) {
53
+ const text = JSON.stringify(data);
54
+ if (text == null || text.length <= max) return text;
55
+
56
+ // Caso array puro
57
+ if (Array.isArray(data)) {
58
+ const kept = fitArray(data, max, (items) => JSON.stringify({ shown: items.length, total: data.length, items }));
59
+ return JSON.stringify({ shown: kept.length, total: data.length, truncated: true, hint: hint ?? undefined, items: kept });
60
+ }
61
+
62
+ // Caso oggetto con un array dominante (requests, messages, violations, rows…)
63
+ if (data && typeof data === 'object') {
64
+ let key = null;
65
+ let best = -1;
66
+ for (const [k, v] of Object.entries(data)) {
67
+ if (Array.isArray(v) && v.length > best) { key = k; best = v.length; }
68
+ }
69
+ if (key) {
70
+ const full = data[key];
71
+ const kept = fitArray(full, max, (items) => JSON.stringify({ ...data, [key]: items }));
72
+ return JSON.stringify({
73
+ ...data,
74
+ [key]: kept,
75
+ shown: kept.length,
76
+ total: full.length,
77
+ truncated: kept.length < full.length,
78
+ ...(hint && kept.length < full.length ? { hint } : {}),
79
+ });
80
+ }
81
+ }
82
+
83
+ // Nessun array da ridurre: resta il taglio testuale, ma con il rimedio giusto.
84
+ return truncateText(text, max, hint);
85
+ }
86
+
87
+ /** Il più lungo prefisso di `items` la cui serializzazione sta sotto `max` (ricerca binaria). */
88
+ function fitArray(items, max, serialize) {
89
+ if (serialize(items).length <= max) return items;
90
+ let lo = 0;
91
+ let hi = items.length;
92
+ while (lo < hi) {
93
+ const mid = Math.ceil((lo + hi) / 2);
94
+ if (serialize(items.slice(0, mid)).length <= max) lo = mid; else hi = mid - 1;
95
+ }
96
+ return items.slice(0, lo);
97
+ }
98
+
99
+ /** Una riga (oggetto colonna→valore o array di celle) soddisfa il filtro where. */
100
+ function tableRowMatches(row, where) {
101
+ return Object.entries(where).every(([col, needle]) => {
102
+ const n = String(needle).toLowerCase();
103
+ if (Array.isArray(row)) return row.some((cell) => String(cell).toLowerCase().includes(n));
104
+ if (col === 'any') return Object.values(row).some((cell) => String(cell).toLowerCase().includes(n));
105
+ const cell = row[col];
106
+ return cell != null && String(cell).toLowerCase().includes(n);
107
+ });
108
+ }
109
+
110
+ /** Proietta solo le colonne richieste (solo righe-oggetto; array intatti). */
111
+ function projectCols(row, columns) {
112
+ if (Array.isArray(row)) return row;
113
+ const o = {};
114
+ for (const c of columns) o[c] = row[c] ?? '';
115
+ return o;
116
+ }
117
+
118
+ /**
119
+ * Modella la risposta grezza di extract_table lato server: filtra (where),
120
+ * pagina (offset/max_rows) e proietta (columns) PRIMA di spedire al modello,
121
+ * così il payload resta piccolo anche su tabelle enormi. row_count resta il
122
+ * totale reale della tabella; match_count è quante righe passano il filtro.
123
+ */
124
+ function shapeTable(data, { where, columns, offset = 0, max_rows = 100 } = {}) {
125
+ let rows = Array.isArray(data.rows) ? data.rows : [];
126
+ const total = data.row_count ?? rows.length;
127
+ const hasWhere = where && Object.keys(where).length > 0;
128
+ let match_count;
129
+ if (hasWhere) {
130
+ rows = rows.filter((r) => tableRowMatches(r, where));
131
+ match_count = rows.length;
132
+ }
133
+ const available = rows.length;
134
+ const page = rows.slice(offset, offset + max_rows);
135
+ const projected = (columns && columns.length) ? page.map((r) => projectCols(r, columns)) : page;
136
+ const out = {
137
+ caption: data.caption ?? null,
138
+ headers: data.headers ?? [],
139
+ row_count: total,
140
+ rows: projected,
141
+ truncated: (offset + page.length) < available || Boolean(data.truncated && !hasWhere),
142
+ tables_found: data.tables_found ?? 0,
143
+ };
144
+ if (hasWhere) out.match_count = match_count;
145
+ if (offset) out.offset = offset;
146
+ return out;
147
+ }
148
+
149
+ const MIME_BY_EXT = {
150
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
151
+ '.webp': 'image/webp', '.svg': 'image/svg+xml', '.pdf': 'application/pdf',
152
+ '.txt': 'text/plain', '.csv': 'text/csv', '.json': 'application/json',
153
+ '.zip': 'application/zip', '.mp4': 'video/mp4', '.webm': 'video/webm',
154
+ };
155
+
156
+ /**
157
+ * Dopo un'azione (click/type/fill), attende navigazione o network idle se richiesto.
158
+ *
159
+ * @param {(type: string, params?: object) => Promise<object>} send - invio comandi (con default tab di sessione)
160
+ * @param {'none'|'navigation'|'networkidle'} wait_after - tipo di attesa
161
+ * @param {number} [tab_id] - tab target
162
+ * @returns {Promise<object|null>} risultato dell'attesa, o null se none
163
+ */
164
+ async function applyWaitAfter(send, wait_after, tab_id) {
165
+ if (wait_after === 'navigation') {
166
+ return await send(MessageType.WAIT_FOR_NAVIGATION, { timeout: 15000, tab_id });
167
+ }
168
+ if (wait_after === 'networkidle') {
169
+ return await send(MessageType.WAIT_FOR_NETWORK_IDLE, { idle_ms: 500, timeout: 15000, tab_id });
170
+ }
171
+ return null;
172
+ }
173
+
174
+ /**
175
+ * Gruppi capability opt-in. I tool non elencati qui sono il set core,
176
+ * sempre registrato. Gli altri si attivano con --caps group1,group2 o
177
+ * CHROME_BRIDGE_CAPS (valore speciale "all" = tutto).
178
+ */
179
+ export const TOOL_CAPS = {
180
+ audits: ['accessibility_audit', 'seo_audit', 'security_headers', 'check_links', 'unused_css', 'web_vitals', 'get_performance'],
181
+ visual: ['screenshot_diff', 'highlight_elements', 'inject_css', 'measure_spacing', 'emulate_media', 'viewport_resize', 'set_zoom'],
182
+ network: ['network_rules', 'monitor_websocket', 'http_auth', 'set_geolocation'],
183
+ storage: ['get_storage', 'set_storage', 'session_fixture'],
184
+ dom: ['modify_dom', 'watch_dom', 'list_event_listeners', 'drag_and_drop'],
185
+ files: ['save_page', 'manage_downloads', 'extract_table', 'session_record'],
186
+ };
187
+
188
+ const TOOL_TO_CAP = new Map();
189
+ for (const [group, names] of Object.entries(TOOL_CAPS)) {
190
+ for (const n of names) TOOL_TO_CAP.set(n, group);
191
+ }
192
+
193
+ /**
194
+ * Registra tutti i tool MCP.
195
+ *
196
+ * @param {import('@modelcontextprotocol/sdk/server/index.js').McpServer} server - MCP Server
197
+ * @param {import('./ws-manager.js').WSManager} wsManager - WebSocket manager
198
+ * @param {string} [caps='all'] - 'all', 'core', o lista di gruppi "audits,visual"
199
+ */
200
+ export function registerTools(server, wsManager, caps = 'all') {
201
+ const startedAt = Date.now();
202
+ const activeCaps = caps === 'all'
203
+ ? ['core', ...Object.keys(TOOL_CAPS)]
204
+ : ['core', ...String(caps).split(',').map((s) => s.trim()).filter((s) => s && s !== 'core')];
205
+
206
+ // Filtro capability: i tool opt-in fuori dai gruppi attivi non vengono registrati
207
+ if (caps !== 'all') {
208
+ const enabled = new Set(String(caps).split(',').map((s) => s.trim()).filter(Boolean));
209
+ const target = server;
210
+ server = {
211
+ tool(name, desc, schema, handler) {
212
+ const group = TOOL_TO_CAP.get(name);
213
+ if (group && !enabled.has(group) && !enabled.has('all')) return;
214
+ target.tool(name, desc, schema, handler);
215
+ },
216
+ };
217
+ }
218
+
219
+ // Ultimo tab toccato da navigate/create_tab in questa sessione: è il default
220
+ // dei comandi senza tab_id esplicito. "Tab attivo" è una landmine quando
221
+ // l'utente usa Chrome durante l'automazione (il comando colpirebbe la pagina
222
+ // che sta guardando lui, non quella navigata dall'agente).
223
+ let sessionTabId = null;
224
+
225
+ // Recording attivo: { name, file }. I comandi (senza tab_id, che in un
226
+ // replay sarebbe stale) vengono appesi come jsonl replayabile dal CLI.
227
+ let recording = null;
228
+ // Gli append del recording erano fire-and-forget: un comando registrato
229
+ // poteva non essere ancora sul disco quando session_record stop tornava, e
230
+ // uno step mancante rende un replay silenziosamente sbagliato. La catena
231
+ // serializza le scritture e stop() la attende.
232
+ let recordChain = Promise.resolve();
233
+ // >0 mentre un tool composito (assert) esegue query interne che nel
234
+ // recording sarebbero rumore: registra il tool, non le sue query.
235
+ let recordSuppressed = 0;
236
+
237
+ const send = async (type, params = {}) => {
238
+ if (recording && !recordSuppressed && !RECORD_EXCLUDE.has(type)) {
239
+ const { tab_id: _tab, ...rest } = params;
240
+ const file = recording.file;
241
+ const line = JSON.stringify({ command: type, params: rest }) + '\n';
242
+ recordChain = recordChain.then(() => appendFile(file, line)).catch(() => {});
243
+ }
244
+ const implicitTab = params.tab_id == null && sessionTabId != null;
245
+ if (implicitTab) params = { ...params, tab_id: sessionTabId };
246
+ try {
247
+ return await wsManager.sendCommand(type, params);
248
+ } catch (err) {
249
+ // Se l'utente chiude a mano la tab di sessione, ogni comando successivo
250
+ // fallisce fino al prossimo navigate. Un solo ritentativo sulla tab
251
+ // attiva salva un turno intero al modello.
252
+ const gone = /No tab with id|No active tab|No tab found/i.test(err?.message ?? '');
253
+ if (!gone || !implicitTab) throw err;
254
+ sessionTabId = null;
255
+ const { tab_id: _drop, ...retry } = params;
256
+ const data = await wsManager.sendCommand(type, retry);
257
+ return data;
258
+ }
259
+ };
260
+
261
+ // Mappa ref → selector per tab, popolata da get_interactives.
262
+ // Permette click/type_text/hover per ref (n1, n2…) senza ripetere selettori lunghi.
263
+ const interactivesRefs = new Map();
264
+
265
+ const refsKey = (tab_id) => tab_id ?? sessionTabId ?? 'active';
266
+
267
+ function resolveTarget(selector, ref, tab_id) {
268
+ if (selector) return selector;
269
+ if (ref) {
270
+ const sel = interactivesRefs.get(refsKey(tab_id))?.get(ref);
271
+ if (!sel) throw new Error(`Unknown ref ${ref} — run get_interactives first`);
272
+ return sel;
273
+ }
274
+ throw new Error('Either selector or ref is required');
275
+ }
276
+
277
+ // Snapshot url/title del tab target, per il delta post-azione.
278
+ async function tabSnapshot(tab_id) {
279
+ try {
280
+ const tabs = await send(MessageType.GET_TABS);
281
+ const list = Array.isArray(tabs) ? tabs : [];
282
+ const eff = tab_id ?? sessionTabId;
283
+ const tab = eff != null ? list.find((t) => t.id === eff) : list.find((t) => t.active);
284
+ return tab ? { url: tab.url, title: tab.title } : null;
285
+ } catch { return null; }
286
+ }
287
+
288
+ // Delta compatto dopo un'azione: presente solo se url/title sono cambiati.
289
+ // Costa pochi token quando scatta, zero quando la pagina è stabile, e
290
+ // risparmia al client un giro di ispezione per capire "cosa è successo".
291
+ function pageDelta(before, after) {
292
+ if (!before || !after) return null;
293
+ const delta = {};
294
+ if (after.url !== before.url) delta.url = after.url;
295
+ if (after.title !== before.title) delta.title = after.title;
296
+ return Object.keys(delta).length ? delta : null;
297
+ }
298
+
299
+ // Anteprima compatta dei primi interactives (con ref), allegata a navigate:
300
+ // il client può agire subito senza un giro di discovery. Cappata e best-effort.
301
+ async function interactivesPreview(tab_id, limit = 12) {
302
+ try {
303
+ const data = await send(MessageType.GET_INTERACTIVES, { limit, visible_only: true, tab_id });
304
+ const refMap = new Map();
305
+ (data?.elements ?? []).forEach((e, i) => {
306
+ e.ref = `n${i + 1}`;
307
+ if (e.selector) refMap.set(e.ref, e.selector);
308
+ });
309
+ interactivesRefs.set(refsKey(tab_id), refMap);
310
+ return refMap.size ? truncateText(interactivesLines(data), 1500) : null;
311
+ } catch { return null; }
312
+ }
313
+
314
+ // --- get_status ---
315
+ server.tool(
316
+ 'get_status',
317
+ 'Check bridge status: extension connection, server mode (primary/relay), port, version',
318
+ {},
319
+ async () => {
320
+ return {
321
+ content: [{
322
+ type: 'text',
323
+ text: jsonText({
324
+ connected: wsManager.isConnected(),
325
+ mode: wsManager.mode,
326
+ host: wsManager.host,
327
+ port: wsManager.port,
328
+ version: VERSION,
329
+ extension_version: wsManager.extVersion ?? null,
330
+ // Un agente che non trova accessibility_audit non aveva modo di
331
+ // scoprire che esiste ma è in un gruppo disattivato.
332
+ caps_active: activeCaps,
333
+ caps_available: ['core', ...Object.keys(TOOL_CAPS)],
334
+ session_tab_id: sessionTabId,
335
+ uptime_sec: Math.round((Date.now() - startedAt) / 1000),
336
+ }),
337
+ }],
338
+ };
339
+ }
340
+ );
341
+
342
+ // --- get_tabs ---
343
+ server.tool(
344
+ 'get_tabs',
345
+ 'List all open Chrome tabs',
346
+ {},
347
+ async () => {
348
+ const data = await send(MessageType.GET_TABS);
349
+ return {
350
+ content: [{
351
+ type: 'text',
352
+ text: jsonText(data),
353
+ }],
354
+ };
355
+ }
356
+ );
357
+
358
+ // --- navigate ---
359
+ server.tool(
360
+ 'navigate',
361
+ 'Navigate a Chrome tab to a URL. Returns a preview of interactive elements with refs usable in click/type_text/hover.',
362
+ {
363
+ url: z.string(),
364
+ tab_id: z.number().optional(),
365
+ },
366
+ async ({ url, tab_id }) => {
367
+ const data = await send(MessageType.NAVIGATE, { url, tab_id });
368
+ // Il tab navigato diventa il default di sessione per i comandi successivi
369
+ if (data?.tabId != null) sessionTabId = data.tabId;
370
+ const preview = await interactivesPreview(data?.tabId ?? tab_id);
371
+ return {
372
+ content: [{
373
+ type: 'text',
374
+ text: jsonText(data) + (preview ? `\n${preview}` : ''),
375
+ }],
376
+ };
377
+ }
378
+ );
379
+
380
+ // --- screenshot ---
381
+ server.tool(
382
+ 'screenshot',
383
+ 'Screenshot a tab (PNG). Activates the tab in the background without stealing window focus, then restores the previous tab.',
384
+ {
385
+ tab_id: z.number().optional(),
386
+ },
387
+ async ({ tab_id }) => {
388
+ const data = await send(MessageType.SCREENSHOT, { tab_id });
389
+ // data.image è base64 PNG
390
+ if (data && data.image) {
391
+ return {
392
+ content: [{
393
+ type: 'image',
394
+ data: data.image,
395
+ mimeType: 'image/png',
396
+ }],
397
+ };
398
+ }
399
+ return {
400
+ content: [{
401
+ type: 'text',
402
+ text: jsonText(data),
403
+ }],
404
+ };
405
+ }
406
+ );
407
+
408
+ // --- execute_js ---
409
+ server.tool(
410
+ 'execute_js',
411
+ 'Run JavaScript in the page (MAIN world). Requires the extension\'s "Allow user scripts" toggle; errors explain setup if disabled.',
412
+ {
413
+ code: z.string(),
414
+ tab_id: z.number().optional(),
415
+ frame_id: z.number().optional(),
416
+ max_length: z.number().optional().default(20000).describe('Max output chars'),
417
+ },
418
+ async ({ code, tab_id, frame_id, max_length }) => {
419
+ const data = await send(MessageType.EXECUTE_JS, { code, tab_id, frame_id });
420
+ return {
421
+ content: [{
422
+ type: 'text',
423
+ text: truncateText(JSON.stringify(data), max_length ?? DEFAULT_MAX_OUTPUT, 'max_length'),
424
+ }],
425
+ };
426
+ }
427
+ );
428
+
429
+ // --- click ---
430
+ server.tool(
431
+ 'click',
432
+ 'Click an element by CSS selector or by ref from get_interactives.',
433
+ {
434
+ selector: z.string().optional(),
435
+ ref: z.string().optional().describe('From get_interactives, e.g. "n3"'),
436
+ force: z.boolean().optional().default(false).describe('Click even if occluded'),
437
+ wait_after: z.enum(['none', 'navigation', 'networkidle']).optional().default('none'),
438
+ tab_id: z.number().optional(),
439
+ frame_id: z.number().optional(),
440
+ },
441
+ async ({ selector, ref, force, wait_after, tab_id, frame_id }) => {
442
+ const target = resolveTarget(selector, ref, tab_id);
443
+ const before = await tabSnapshot(tab_id);
444
+ const data = await send(MessageType.CLICK, { selector: target, force, frame_id, tab_id });
445
+ // Niente attesa se il click non è andato a buon fine (es. elemento occluso)
446
+ const waited = data?.occluded ? null : await applyWaitAfter(send, wait_after, tab_id);
447
+ const changed = data?.occluded ? null : pageDelta(before, await tabSnapshot(tab_id));
448
+ const out = { ...data, ...(waited && { wait_after: waited }), ...(changed && { page_changed: changed }) };
449
+ return {
450
+ content: [{
451
+ type: 'text',
452
+ text: jsonText(out),
453
+ }],
454
+ };
455
+ }
456
+ );
457
+
458
+ // --- type_text ---
459
+ server.tool(
460
+ 'type_text',
461
+ 'Type text into an input, by CSS selector or by ref from get_interactives.',
462
+ {
463
+ selector: z.string().optional(),
464
+ ref: z.string().optional().describe('From get_interactives, e.g. "n3"'),
465
+ text: z.string(),
466
+ mode: z.enum(['set', 'keys']).optional().default('set').describe('set = assign value; keys = per-char events (autocomplete/masked)'),
467
+ wait_after: z.enum(['none', 'navigation', 'networkidle']).optional().default('none'),
468
+ tab_id: z.number().optional(),
469
+ frame_id: z.number().optional(),
470
+ },
471
+ async ({ selector, ref, text, mode, wait_after, tab_id, frame_id }) => {
472
+ const target = resolveTarget(selector, ref, tab_id);
473
+ const data = await send(MessageType.TYPE_TEXT, { selector: target, text, mode, tab_id, frame_id });
474
+ const waited = await applyWaitAfter(send, wait_after, tab_id);
475
+ const out = waited ? { ...data, wait_after: waited } : data;
476
+ return {
477
+ content: [{
478
+ type: 'text',
479
+ text: jsonText(out),
480
+ }],
481
+ };
482
+ }
483
+ );
484
+
485
+ // --- read_page ---
486
+ server.tool(
487
+ 'read_page',
488
+ 'Read the content of a Chrome tab page',
489
+ {
490
+ mode: z.enum(['text', 'html', 'accessibility']).default('text'),
491
+ tab_id: z.number().optional(),
492
+ frame_id: z.number().optional(),
493
+ max_length: z.number().optional().default(50000).describe('Max output chars'),
494
+ },
495
+ async ({ mode, tab_id, frame_id, max_length }) => {
496
+ const data = await send(MessageType.READ_PAGE, { mode, tab_id, frame_id });
497
+ return {
498
+ content: [{
499
+ type: 'text',
500
+ text: truncateText(typeof data === 'string' ? data : JSON.stringify(data), max_length ?? 50000, 'max_length'),
501
+ }],
502
+ };
503
+ }
504
+ );
505
+
506
+ // --- get_page_info ---
507
+ server.tool(
508
+ 'get_page_info',
509
+ 'Get page metadata: meta tags, scripts, stylesheets, links, and forms',
510
+ {
511
+ tab_id: z.number().optional(),
512
+ frame_id: z.number().optional(),
513
+ },
514
+ async ({ tab_id, frame_id }) => {
515
+ const data = await send(MessageType.GET_PAGE_INFO, { tab_id, frame_id });
516
+ return {
517
+ content: [{
518
+ type: 'text',
519
+ text: jsonText(data),
520
+ }],
521
+ };
522
+ }
523
+ );
524
+
525
+ // --- get_storage ---
526
+ server.tool(
527
+ 'get_storage',
528
+ 'Get page storage: localStorage, sessionStorage, and cookies',
529
+ {
530
+ type: z.enum(['all', 'localStorage', 'sessionStorage', 'cookies']).default('all'),
531
+ tab_id: z.number().optional(),
532
+ },
533
+ async ({ type, tab_id }) => {
534
+ const data = await send(MessageType.GET_STORAGE, { type, tab_id });
535
+ return {
536
+ content: [{
537
+ type: 'text',
538
+ text: jsonText(data),
539
+ }],
540
+ };
541
+ }
542
+ );
543
+
544
+ // --- get_performance ---
545
+ server.tool(
546
+ 'get_performance',
547
+ 'Get page performance metrics: navigation timing, paint metrics, memory, and resource loading',
548
+ {
549
+ tab_id: z.number().optional(),
550
+ },
551
+ async ({ tab_id }) => {
552
+ const data = await send(MessageType.GET_PERFORMANCE, { tab_id });
553
+ return {
554
+ content: [{
555
+ type: 'text',
556
+ text: jsonText(data),
557
+ }],
558
+ };
559
+ }
560
+ );
561
+
562
+ // --- query_dom ---
563
+ server.tool(
564
+ 'query_dom',
565
+ 'Query DOM elements by CSS selector, returning structure, attributes, bounding rect, and computed styles.',
566
+ {
567
+ selector: z.string(),
568
+ properties: z.array(z.string()).optional().describe('Computed styles to include, e.g. ["color"]'),
569
+ limit: z.number().optional().default(50),
570
+ tab_id: z.number().optional(),
571
+ frame_id: z.number().optional(),
572
+ },
573
+ async ({ selector, properties, limit, tab_id, frame_id }) => {
574
+ const data = await send(MessageType.QUERY_DOM, { selector, properties, limit, tab_id, frame_id });
575
+ return {
576
+ content: [{
577
+ type: 'text',
578
+ text: jsonText(data),
579
+ }],
580
+ };
581
+ }
582
+ );
583
+
584
+ // --- modify_dom ---
585
+ server.tool(
586
+ 'modify_dom',
587
+ 'Modify DOM elements: setAttribute, removeAttribute, addClass, removeClass, setStyle, setTextContent',
588
+ {
589
+ selector: z.string(),
590
+ action: z.enum(['setAttribute', 'removeAttribute', 'addClass', 'removeClass', 'setStyle', 'setTextContent']),
591
+ name: z.string().optional().describe('Attribute name'),
592
+ value: z.string().optional(),
593
+ className: z.string().optional(),
594
+ tab_id: z.number().optional(),
595
+ frame_id: z.number().optional(),
596
+ },
597
+ async ({ selector, action, name, value, className, tab_id, frame_id }) => {
598
+ const data = await send(MessageType.MODIFY_DOM, { selector, action, name, value, className, tab_id, frame_id });
599
+ return {
600
+ content: [{
601
+ type: 'text',
602
+ text: jsonText(data),
603
+ }],
604
+ };
605
+ }
606
+ );
607
+
608
+ // --- inject_css ---
609
+ server.tool(
610
+ 'inject_css',
611
+ 'Inject CSS rules into a Chrome tab page',
612
+ {
613
+ css: z.string(),
614
+ tab_id: z.number().optional(),
615
+ },
616
+ async ({ css, tab_id }) => {
617
+ const data = await send(MessageType.INJECT_CSS, { css, tab_id });
618
+ return {
619
+ content: [{
620
+ type: 'text',
621
+ text: jsonText(data),
622
+ }],
623
+ };
624
+ }
625
+ );
626
+
627
+ // --- read_console ---
628
+ server.tool(
629
+ 'read_console',
630
+ 'Read console messages captured since page load, incl. uncaught errors and unhandled rejections.',
631
+ {
632
+ clear: z.boolean().optional().default(false).describe('Clear buffer after read'),
633
+ level: z.enum(['all', 'log', 'warn', 'error', 'info', 'debug']).optional().default('all'),
634
+ limit: z.number().optional().default(50).describe('Most recent; buffer 1000'),
635
+ format: z.enum(['lines', 'json']).optional().default('lines'),
636
+ tab_id: z.number().optional(),
637
+ },
638
+ async ({ clear, level, limit, format, tab_id }) => {
639
+ // limit va all'estensione: taglia in pagina e cancella (con clear) solo
640
+ // ciò che ha restituito. Lo slice qui resta come fallback per estensioni
641
+ // più vecchie che ignorano il parametro.
642
+ const data = await send(MessageType.READ_CONSOLE, { clear, level, limit, tab_id });
643
+ const all = data?.messages ?? [];
644
+ const tail = all.slice(-(limit ?? 50));
645
+ const total = data?.count ?? all.length;
646
+ const note = data?.note;
647
+ if ((format ?? 'lines') === 'json') {
648
+ return { content: [{ type: 'text', text: jsonText({ total, shown: tail.length, ...(note ? { note } : {}), messages: tail }) }] };
649
+ }
650
+ return {
651
+ content: [{
652
+ type: 'text',
653
+ text: truncateText(
654
+ (note ? `note=${note}\n` : '') + consoleLines(tail, total),
655
+ DEFAULT_MAX_OUTPUT,
656
+ ),
657
+ }],
658
+ };
659
+ }
660
+ );
661
+
662
+ // --- monitor_network ---
663
+ server.tool(
664
+ 'monitor_network',
665
+ 'Monitor network requests. source=page: XHR/fetch hook (installed on first call); source=browser: all requests incl. static assets. format=har exports HAR 1.2.',
666
+ {
667
+ clear: z.boolean().optional().default(false).describe('Clear buffer after read'),
668
+ source: z.enum(['page', 'browser']).optional().default('page'),
669
+ format: z.enum(['lines', 'json', 'har']).optional().default('lines'),
670
+ limit: z.number().optional().default(100).describe('Most recent; buffer 1000'),
671
+ tab_id: z.number().optional(),
672
+ },
673
+ async ({ clear, source, format, limit, tab_id }) => {
674
+ // limit va all'estensione (taglia in pagina, clear solo del restituito);
675
+ // lo slice qui resta come fallback per estensioni non ancora aggiornate.
676
+ const data = await send(MessageType.MONITOR_NETWORK, { clear, source, limit, tab_id });
677
+ const { requests, count, note, ...rest } = data ?? {};
678
+ const all = requests ?? [];
679
+ const tail = all.slice(-(limit ?? 100));
680
+ const total = count ?? all.length;
681
+ const fmt = format ?? 'lines';
682
+ if (fmt !== 'lines') {
683
+ const out = fmt === 'har'
684
+ ? toHar(tail)
685
+ : { ...rest, total, shown: tail.length, ...(note ? { note } : {}), requests: tail };
686
+ return { content: [{ type: 'text', text: jsonText(out) }] };
687
+ }
688
+ return {
689
+ content: [{
690
+ type: 'text',
691
+ text: truncateText(
692
+ (note ? `note=${note}\n` : '') + networkLines(tail, total),
693
+ DEFAULT_MAX_OUTPUT,
694
+ ),
695
+ }],
696
+ };
697
+ }
698
+ );
699
+
700
+ // --- create_tab ---
701
+ server.tool(
702
+ 'create_tab',
703
+ 'Create a new Chrome tab, optionally navigating to a URL',
704
+ {
705
+ url: z.string().optional().describe('URL to open (default: new tab page)'),
706
+ active: z.boolean().optional().default(true),
707
+ },
708
+ async ({ url, active }) => {
709
+ const data = await send(MessageType.CREATE_TAB, { url, active });
710
+ if (data?.id != null) sessionTabId = data.id;
711
+ return {
712
+ content: [{
713
+ type: 'text',
714
+ text: jsonText(data),
715
+ }],
716
+ };
717
+ }
718
+ );
719
+
720
+ // --- wait_for ---
721
+ server.tool(
722
+ 'wait_for',
723
+ 'Wait for: element (selector in DOM), function (JS expression truthy; needs "Allow user scripts"), navigation (mode=spa for route changes), network_idle.',
724
+ {
725
+ condition: z.enum(['element', 'function', 'navigation', 'network_idle']),
726
+ selector: z.string().optional().describe('condition=element'),
727
+ expression: z.string().optional().describe('JS expression (condition=function)'),
728
+ visible: z.boolean().optional().default(false).describe('Element must also be visible'),
729
+ mode: z.enum(['load', 'spa']).optional().default('load').describe('spa = pushState/popstate/hashchange'),
730
+ idle_ms: z.number().optional().default(500).describe('Quiet period ms (network_idle)'),
731
+ timeout: z.number().optional().describe('Max ms (default 10000; 15000 navigation/network_idle)'),
732
+ interval: z.number().optional().describe('Poll ms, min 50'),
733
+ tab_id: z.number().optional(),
734
+ frame_id: z.number().optional(),
735
+ },
736
+ async ({ condition, selector, expression, visible, mode, idle_ms, timeout, interval, tab_id, frame_id }) => {
737
+ let data;
738
+ if (condition === 'element') {
739
+ data = await send(MessageType.WAIT_FOR_ELEMENT, {
740
+ selector, timeout: timeout ?? 10000, interval: interval ?? 200, visible: visible ?? false, tab_id, frame_id,
741
+ });
742
+ } else if (condition === 'function') {
743
+ data = await send(MessageType.WAIT_FOR_FUNCTION, {
744
+ expression, timeout: timeout ?? 10000, polling_ms: interval ?? 100, tab_id, frame_id,
745
+ });
746
+ } else if (condition === 'navigation') {
747
+ data = await send(MessageType.WAIT_FOR_NAVIGATION, {
748
+ timeout: timeout ?? 15000, mode: mode ?? 'load', tab_id,
749
+ });
750
+ } else {
751
+ data = await send(MessageType.WAIT_FOR_NETWORK_IDLE, {
752
+ idle_ms: idle_ms ?? 500, timeout: timeout ?? 15000, tab_id,
753
+ });
754
+ }
755
+ return { content: [{ type: 'text', text: jsonText(data) }] };
756
+ }
757
+ );
758
+
759
+ // --- scroll ---
760
+ server.tool(
761
+ 'scroll',
762
+ 'Scroll. action=to: once to element/coordinates; action=until: repeatedly until element visible, network idle, or no new content (infinite scroll).',
763
+ {
764
+ action: z.enum(['to', 'until']).optional().default('to'),
765
+ selector: z.string().optional().describe('Target (to) or stop element (until=element)'),
766
+ x: z.number().optional(),
767
+ y: z.number().optional(),
768
+ behavior: z.enum(['smooth', 'instant', 'auto']).optional().default('auto'),
769
+ offset_y: z.number().optional().default(0).describe('px offset for fixed headers (to)'),
770
+ until: z.enum(['element', 'network_idle', 'no_new_content']).optional().default('no_new_content'),
771
+ max_scrolls: z.number().optional().default(20),
772
+ step_px: z.number().optional().describe('px per step, default viewport height'),
773
+ settle_ms: z.number().optional().default(400).describe('Pause ms after each step'),
774
+ tab_id: z.number().optional(),
775
+ frame_id: z.number().optional().describe('action=to only'),
776
+ },
777
+ async ({ action, selector, x, y, behavior, offset_y, until, max_scrolls, step_px, settle_ms, tab_id, frame_id }) => {
778
+ const data = (action ?? 'to') === 'until'
779
+ ? await send(MessageType.SCROLL_UNTIL, { until, selector, max_scrolls, step_px, settle_ms, tab_id })
780
+ : await send(MessageType.SCROLL_TO, { selector, x, y, behavior, offset_y, tab_id, frame_id });
781
+ return { content: [{ type: 'text', text: jsonText(data) }] };
782
+ }
783
+ );
784
+
785
+ // --- set_storage ---
786
+ server.tool(
787
+ 'set_storage',
788
+ 'Write, delete, or clear localStorage, sessionStorage, or cookies',
789
+ {
790
+ type: z.enum(['localStorage', 'sessionStorage', 'cookie']),
791
+ action: z.enum(['set', 'delete', 'clear']),
792
+ key: z.string().optional().describe('Required for set/delete'),
793
+ value: z.string().optional(),
794
+ path: z.string().optional().describe('Cookie path (default /)'),
795
+ domain: z.string().optional().describe('Cookie domain'),
796
+ expires: z.string().optional().describe('UTC date string (cookie)'),
797
+ secure: z.boolean().optional(),
798
+ sameSite: z.enum(['Strict', 'Lax', 'None']).optional(),
799
+ http_only: z.boolean().optional(),
800
+ tab_id: z.number().optional(),
801
+ },
802
+ async ({ type, action, key, value, path, domain, expires, secure, sameSite, http_only, tab_id }) => {
803
+ const data = await send(MessageType.SET_STORAGE, { type, action, key, value, path, domain, expires, secure, sameSite, http_only, tab_id });
804
+ return {
805
+ content: [{
806
+ type: 'text',
807
+ text: jsonText(data),
808
+ }],
809
+ };
810
+ }
811
+ );
812
+
813
+ // --- fill_form ---
814
+ server.tool(
815
+ 'fill_form',
816
+ 'Batch fill form fields with React-compatible events. Handles input, select, checkbox, radio, and textarea.',
817
+ {
818
+ fields: z.array(z.object({
819
+ selector: z.string(),
820
+ value: z.string(),
821
+ })).describe('{selector, value} pairs'),
822
+ submit_selector: z.string().optional().describe('Submit button to click after filling'),
823
+ wait_after: z.enum(['none', 'navigation', 'networkidle']).optional().default('none'),
824
+ tab_id: z.number().optional(),
825
+ frame_id: z.number().optional(),
826
+ },
827
+ async ({ fields, submit_selector, wait_after, tab_id, frame_id }) => {
828
+ const before = await tabSnapshot(tab_id);
829
+ const data = await send(MessageType.FILL_FORM, { fields, submit_selector, tab_id, frame_id });
830
+ const waited = await applyWaitAfter(send, wait_after, tab_id);
831
+ const changed = pageDelta(before, await tabSnapshot(tab_id));
832
+ const out = { ...data, ...(waited && { wait_after: waited }), ...(changed && { page_changed: changed }) };
833
+ return {
834
+ content: [{
835
+ type: 'text',
836
+ text: jsonText(out),
837
+ }],
838
+ };
839
+ }
840
+ );
841
+
842
+ // --- viewport_resize ---
843
+ server.tool(
844
+ 'viewport_resize',
845
+ 'Resize Chrome window to preset (mobile/tablet/desktop) or custom dimensions',
846
+ {
847
+ preset: z.enum(['mobile', 'tablet', 'desktop']).optional().describe('375x812, 768x1024, 1440x900'),
848
+ width: z.number().optional().describe('Overrides preset'),
849
+ height: z.number().optional().describe('Overrides preset'),
850
+ tab_id: z.number().optional(),
851
+ },
852
+ async ({ preset, width, height, tab_id }) => {
853
+ const data = await send(MessageType.VIEWPORT_RESIZE, { preset, width, height, tab_id });
854
+ return {
855
+ content: [{
856
+ type: 'text',
857
+ text: jsonText(data),
858
+ }],
859
+ };
860
+ }
861
+ );
862
+
863
+ // --- element_screenshot ---
864
+ server.tool(
865
+ 'element_screenshot',
866
+ 'Screenshot a single element (scrolled into view, cropped). PNG.',
867
+ {
868
+ selector: z.string(),
869
+ tab_id: z.number().optional(),
870
+ },
871
+ async ({ selector, tab_id }) => {
872
+ const data = await send(MessageType.ELEMENT_SCREENSHOT, { selector, tab_id });
873
+ if (data && data.image) {
874
+ return { content: [{ type: 'image', data: data.image, mimeType: 'image/png' }] };
875
+ }
876
+ return { content: [{ type: 'text', text: jsonText(data) }] };
877
+ }
878
+ );
879
+
880
+ // --- full_page_screenshot ---
881
+ server.tool(
882
+ 'full_page_screenshot',
883
+ 'Full-page capture by scrolling: stitched segments of ~2 viewports (≤1568px, top→bottom), or one image per viewport with stitch=false.',
884
+ {
885
+ max_scrolls: z.number().optional().default(20),
886
+ delay: z.number().optional().default(500).describe('ms between captures (min 500, Chrome quota)'),
887
+ stitch: z.boolean().optional().default(true).describe('false = one image per viewport'),
888
+ max_segments: z.number().optional().default(3).describe('Images returned, from the top (each ≈2.7k image tokens); raise or use segment_offset for the rest'),
889
+ segment_offset: z.number().optional().default(0).describe('Skip the first N segments'),
890
+ tab_id: z.number().optional(),
891
+ },
892
+ async ({ max_scrolls, delay, stitch, max_segments, segment_offset, tab_id }) => {
893
+ const data = await send(MessageType.FULL_PAGE_SCREENSHOT, { max_scrolls, delay, stitch, tab_id });
894
+ if (data && data.images) {
895
+ // DEFAULT_MAX_OUTPUT protegge solo il testo: senza questo cap una sola
896
+ // chiamata su una pagina lunga restituiva 10 immagini (~4 MB base64,
897
+ // ~27k token) ed era l'unico output del set capace di saturare il contesto.
898
+ const off = Math.max(0, segment_offset ?? 0);
899
+ const cap = Math.max(1, max_segments ?? 3);
900
+ const slice = data.images.slice(off, off + cap);
901
+ const more = data.images.length - (off + slice.length);
902
+ const note = `Full page: ${data.totalCaptures} captures, ${data.images.length} segments (top→bottom), scrollHeight=${data.scrollHeight}`
903
+ + `, showing ${off + 1}-${off + slice.length}`
904
+ + (more > 0 ? ` — ${more} more: call with segment_offset=${off + slice.length}` : '')
905
+ + (data.truncated ? ' (page continues beyond captured area — raise max_scrolls to capture more)' : '');
906
+ return {
907
+ content: [
908
+ { type: 'text', text: note },
909
+ ...slice.map((img) => ({ type: 'image', data: img, mimeType: 'image/png' })),
910
+ ],
911
+ };
912
+ }
913
+ // Retrocompatibilità: extension non ancora ricaricata → singola immagine stitched
914
+ if (data && data.image) {
915
+ const note = `Full page: ${data.totalCaptures} captures stitched, scrollHeight=${data.scrollHeight}${data.truncated ? ' (truncated at 16384px canvas limit)' : ''}`;
916
+ return { content: [{ type: 'text', text: note }, { type: 'image', data: data.image, mimeType: 'image/png' }] };
917
+ }
918
+ const content = [{ type: 'text', text: `Full page screenshot: ${data.captures?.length || 0} captures, scrollHeight=${data.scrollHeight}, viewportHeight=${data.viewportHeight}` }];
919
+ for (const img of data.captures || []) {
920
+ content.push({ type: 'image', data: img, mimeType: 'image/png' });
921
+ }
922
+ return { content };
923
+ }
924
+ );
925
+
926
+ // --- highlight_elements ---
927
+ server.tool(
928
+ 'highlight_elements',
929
+ 'Add colored overlay on elements matching a CSS selector. remove=true clears all highlights.',
930
+ {
931
+ selector: z.string().optional(),
932
+ color: z.string().optional().default('rgba(255,0,0,0.3)'),
933
+ border: z.string().optional().default('2px solid red'),
934
+ label: z.boolean().optional().default(false).describe('Show tag.class (WxH) label'),
935
+ remove: z.boolean().optional().default(false),
936
+ tab_id: z.number().optional(),
937
+ },
938
+ async ({ selector, color, border, label, remove, tab_id }) => {
939
+ const data = await send(MessageType.HIGHLIGHT_ELEMENTS, { selector, color, border, label, remove, tab_id });
940
+ return {
941
+ content: [{
942
+ type: 'text',
943
+ text: jsonText(data),
944
+ }],
945
+ };
946
+ }
947
+ );
948
+
949
+ // --- accessibility_audit ---
950
+ server.tool(
951
+ 'accessibility_audit',
952
+ 'A11y audit: missing alt, empty links, heading hierarchy, ARIA, contrast (approximate), form labels.',
953
+ {
954
+ scope: z.string().optional().describe('Limit scope (CSS selector)'),
955
+ checks: z.array(z.enum(['images', 'links', 'headings', 'aria', 'contrast', 'forms', 'all'])).optional().default(['all']),
956
+ tab_id: z.number().optional(),
957
+ },
958
+ async ({ scope, checks, tab_id }) => {
959
+ const data = await send(MessageType.ACCESSIBILITY_AUDIT, { scope, checks, tab_id });
960
+ return {
961
+ content: [{
962
+ type: 'text',
963
+ text: jsonText(data),
964
+ }],
965
+ };
966
+ }
967
+ );
968
+
969
+ // --- check_links ---
970
+ server.tool(
971
+ 'check_links',
972
+ 'Check page links for broken URLs, verified server-side (no CORS limits, real HTTP status).',
973
+ {
974
+ scope: z.enum(['same-origin', 'all', 'external']).optional().default('all'),
975
+ selector: z.string().optional().default('a[href]'),
976
+ timeout: z.number().optional().default(5000).describe('Per-link ms'),
977
+ max_links: z.number().optional().default(50),
978
+ format: z.enum(['lines', 'json']).optional().default('lines'),
979
+ tab_id: z.number().optional(),
980
+ },
981
+ async ({ scope, selector, timeout, max_links, format, tab_id }) => {
982
+ const data = await send(MessageType.COLLECT_LINKS, { scope, selector, max_links, tab_id });
983
+ const links = data.links ?? [];
984
+ const results = await checkLinksBatch(links, timeout);
985
+ const broken = results.filter((r) => r.broken).length;
986
+ if ((format ?? 'lines') === 'json') {
987
+ return {
988
+ content: [{
989
+ type: 'text',
990
+ text: jsonText({ total: links.length, checked: results.length, broken, totalAnchors: data.totalAnchors, results }),
991
+ }],
992
+ };
993
+ }
994
+ return {
995
+ content: [{
996
+ type: 'text',
997
+ text: truncateText(linksLines(results, { total: links.length, broken, anchors: data.totalAnchors }), DEFAULT_MAX_OUTPUT),
998
+ }],
999
+ };
1000
+ }
1001
+ );
1002
+
1003
+ // --- measure_spacing ---
1004
+ server.tool(
1005
+ 'measure_spacing',
1006
+ 'Measure pixel distance, gap, overlap, and margin/padding between two elements',
1007
+ {
1008
+ selector1: z.string(),
1009
+ selector2: z.string(),
1010
+ tab_id: z.number().optional(),
1011
+ },
1012
+ async ({ selector1, selector2, tab_id }) => {
1013
+ const data = await send(MessageType.MEASURE_SPACING, { selector1, selector2, tab_id });
1014
+ return {
1015
+ content: [{
1016
+ type: 'text',
1017
+ text: jsonText(data),
1018
+ }],
1019
+ };
1020
+ }
1021
+ );
1022
+
1023
+ // --- watch_dom ---
1024
+ server.tool(
1025
+ 'watch_dom',
1026
+ 'Watch DOM mutations (MutationObserver). First call installs the watcher; later calls read accumulated mutations.',
1027
+ {
1028
+ selector: z.string().optional().default('body'),
1029
+ attributes: z.boolean().optional().default(true),
1030
+ childList: z.boolean().optional().default(true),
1031
+ characterData: z.boolean().optional().default(false),
1032
+ subtree: z.boolean().optional().default(true),
1033
+ clear: z.boolean().optional().default(false).describe('Clear buffer after read'),
1034
+ stop: z.boolean().optional().default(false).describe('Disconnect observer'),
1035
+ limit: z.number().optional().default(100).describe('Most recent mutations; buffer 1000'),
1036
+ tab_id: z.number().optional(),
1037
+ },
1038
+ async ({ selector, attributes, childList, characterData, subtree, clear, stop, limit, tab_id }) => {
1039
+ const data = await send(MessageType.WATCH_DOM, { selector, attributes, childList, characterData, subtree, clear, stop, limit, tab_id });
1040
+ return {
1041
+ content: [{
1042
+ type: 'text',
1043
+ text: jsonText(data),
1044
+ }],
1045
+ };
1046
+ }
1047
+ );
1048
+
1049
+ // --- emulate_media ---
1050
+ server.tool(
1051
+ 'emulate_media',
1052
+ 'Emulate prefers-color-scheme, prefers-reduced-motion, print mode (matchMedia override + CSS).',
1053
+ {
1054
+ colorScheme: z.enum(['dark', 'light', 'no-preference']).optional(),
1055
+ reducedMotion: z.enum(['reduce', 'no-preference']).optional(),
1056
+ printMode: z.boolean().optional().default(false),
1057
+ reset: z.boolean().optional().default(false).describe('Remove all emulations'),
1058
+ tab_id: z.number().optional(),
1059
+ },
1060
+ async ({ colorScheme, reducedMotion, printMode, reset, tab_id }) => {
1061
+ const data = await send(MessageType.EMULATE_MEDIA, { colorScheme, reducedMotion, printMode, reset, tab_id });
1062
+ return {
1063
+ content: [{
1064
+ type: 'text',
1065
+ text: jsonText(data),
1066
+ }],
1067
+ };
1068
+ }
1069
+ );
1070
+
1071
+ // --- hover ---
1072
+ server.tool(
1073
+ 'hover',
1074
+ 'Hover over an element (mouseenter/mouseover), by CSS selector or ref.',
1075
+ {
1076
+ selector: z.string().optional(),
1077
+ ref: z.string().optional().describe('From get_interactives'),
1078
+ tab_id: z.number().optional(),
1079
+ frame_id: z.number().optional(),
1080
+ },
1081
+ async ({ selector, ref, tab_id, frame_id }) => {
1082
+ const data = await send(MessageType.HOVER, { selector: resolveTarget(selector, ref, tab_id), tab_id, frame_id });
1083
+ return {
1084
+ content: [{
1085
+ type: 'text',
1086
+ text: jsonText(data),
1087
+ }],
1088
+ };
1089
+ }
1090
+ );
1091
+
1092
+ // --- press_key ---
1093
+ server.tool(
1094
+ 'press_key',
1095
+ 'Press a keyboard key with optional modifiers (keydown/keypress/keyup).',
1096
+ {
1097
+ key: z.string().describe('e.g. "Enter", "Escape", "Tab", "ArrowDown"'),
1098
+ selector: z.string().optional().describe('Target (default: activeElement)'),
1099
+ ctrl: z.boolean().optional().default(false),
1100
+ shift: z.boolean().optional().default(false),
1101
+ alt: z.boolean().optional().default(false),
1102
+ meta: z.boolean().optional().default(false),
1103
+ tab_id: z.number().optional(),
1104
+ frame_id: z.number().optional(),
1105
+ },
1106
+ async ({ key, selector, ctrl, shift, alt, meta, tab_id, frame_id }) => {
1107
+ const data = await send(MessageType.PRESS_KEY, { key, selector, ctrl, shift, alt, meta, tab_id, frame_id });
1108
+ return {
1109
+ content: [{
1110
+ type: 'text',
1111
+ text: jsonText(data),
1112
+ }],
1113
+ };
1114
+ }
1115
+ );
1116
+
1117
+ // --- get_frames ---
1118
+ server.tool(
1119
+ 'get_frames',
1120
+ 'List frames (main + iframes) with frameId, parent, URL — for the frame_id parameter of DOM tools.',
1121
+ {
1122
+ tab_id: z.number().optional(),
1123
+ },
1124
+ async ({ tab_id }) => {
1125
+ const data = await send(MessageType.GET_FRAMES, { tab_id });
1126
+ return { content: [{ type: 'text', text: jsonText(data) }] };
1127
+ }
1128
+ );
1129
+
1130
+ // --- tab_action ---
1131
+ server.tool(
1132
+ 'tab_action',
1133
+ 'Tab lifecycle actions: close, activate (focus), reload (optional cache bypass), back, forward',
1134
+ {
1135
+ action: z.enum(['close', 'activate', 'reload', 'back', 'forward']),
1136
+ bypass_cache: z.boolean().optional().default(false).describe('reload only'),
1137
+ tab_id: z.number().optional(),
1138
+ },
1139
+ async ({ action, bypass_cache, tab_id }) => {
1140
+ const data = await send(MessageType.TAB_ACTION, { action, bypass_cache, tab_id });
1141
+ if (action === 'close' && (tab_id == null || tab_id === sessionTabId)) sessionTabId = null;
1142
+ if (action === 'activate' && tab_id != null) sessionTabId = tab_id;
1143
+ return { content: [{ type: 'text', text: jsonText(data) }] };
1144
+ }
1145
+ );
1146
+
1147
+ // --- upload_file ---
1148
+ server.tool(
1149
+ 'upload_file',
1150
+ 'Set a file on input[type=file] from the server filesystem via DataTransfer (max 10MB).',
1151
+ {
1152
+ selector: z.string(),
1153
+ path: z.string().describe('Absolute path on the server machine'),
1154
+ mime_type: z.string().optional().describe('Default: inferred from extension'),
1155
+ tab_id: z.number().optional(),
1156
+ },
1157
+ async ({ selector, path, mime_type, tab_id }) => {
1158
+ const buf = await readFile(path);
1159
+ if (buf.length > 10 * 1024 * 1024) throw new Error(`File too large: ${buf.length} bytes (max 10MB)`);
1160
+ const mime = mime_type || MIME_BY_EXT[extname(path).toLowerCase()] || 'application/octet-stream';
1161
+ const data = await send(MessageType.UPLOAD_FILE, {
1162
+ selector, name: basename(path), mime_type: mime, content_b64: buf.toString('base64'), tab_id,
1163
+ });
1164
+ return { content: [{ type: 'text', text: jsonText(data) }] };
1165
+ }
1166
+ );
1167
+
1168
+ // --- dismiss_overlays ---
1169
+ server.tool(
1170
+ 'dismiss_overlays',
1171
+ 'Dismiss cookie banners/modal overlays: OneTrust, Cookiebot, Usercentrics, then generic heuristic. Idempotent.',
1172
+ {
1173
+ tab_id: z.number().optional(),
1174
+ },
1175
+ async ({ tab_id }) => {
1176
+ const data = await send(MessageType.DISMISS_OVERLAYS, { tab_id });
1177
+ return { content: [{ type: 'text', text: jsonText(data) }] };
1178
+ }
1179
+ );
1180
+
1181
+ // --- handle_dialogs ---
1182
+ server.tool(
1183
+ 'handle_dialogs',
1184
+ 'Auto-accept/dismiss future JS dialogs (alert/confirm/prompt), logging them. reset restores native dialogs and returns the log.',
1185
+ {
1186
+ action: z.enum(['accept', 'dismiss', 'reset']).optional().default('accept'),
1187
+ prompt_text: z.string().optional().describe('Returned by window.prompt on accept'),
1188
+ tab_id: z.number().optional(),
1189
+ },
1190
+ async ({ action, prompt_text, tab_id }) => {
1191
+ const data = await send(MessageType.HANDLE_DIALOGS, { action, prompt_text, tab_id });
1192
+ return { content: [{ type: 'text', text: jsonText(data) }] };
1193
+ }
1194
+ );
1195
+
1196
+ // --- find_text ---
1197
+ server.tool(
1198
+ 'find_text',
1199
+ 'Find text on the page: parent selector, context, visibility, position per match. Attaches nearby interactive elements (with refs for click/type_text/hover) for the first visible match.',
1200
+ {
1201
+ text: z.string(),
1202
+ case_sensitive: z.boolean().optional().default(false),
1203
+ max_results: z.number().optional().default(20),
1204
+ tab_id: z.number().optional(),
1205
+ },
1206
+ async ({ text, case_sensitive, max_results, tab_id }) => {
1207
+ const data = await send(MessageType.FIND_TEXT, { text, case_sensitive, max_results, tab_id });
1208
+ // Interactives vicini al primo match visibile, con ref: rende il match
1209
+ // azionabile subito (click sul bottone della stessa riga/sezione).
1210
+ // Nota coordinate: match.position è in coordinate pagina, i rect degli
1211
+ // interactives in coordinate viewport — coincidono a scroll 0 (il flusso
1212
+ // tipico navigate → find_text). Con pagina scrollata il filtro per
1213
+ // distanza non trova candidati e non allega nulla: degradazione sicura.
1214
+ let near = null;
1215
+ const first = (data?.matches ?? []).find((m) => m.visible && m.position);
1216
+ if (first) {
1217
+ try {
1218
+ const inter = await send(MessageType.GET_INTERACTIVES, { limit: 3000, visible_only: true, tab_id });
1219
+ const els = (inter?.elements ?? [])
1220
+ .map((e) => ({ e, dy: Math.abs((e.rect?.y ?? Infinity) - first.position.y), dx: Math.abs((e.rect?.x ?? Infinity) - first.position.x) }))
1221
+ .filter((c) => c.dy <= 150)
1222
+ .sort((a, b) => (a.dy * 4 + a.dx) - (b.dy * 4 + b.dx))
1223
+ .slice(0, 5)
1224
+ .map((c) => c.e);
1225
+ if (els.length) {
1226
+ const refMap = new Map();
1227
+ els.forEach((e, i) => {
1228
+ e.ref = `n${i + 1}`;
1229
+ if (e.selector) refMap.set(e.ref, e.selector);
1230
+ });
1231
+ interactivesRefs.set(refsKey(tab_id), refMap);
1232
+ near = truncateText(interactivesLines({ count: els.length, elements: els, note: 'near first match' }), 1200);
1233
+ }
1234
+ } catch {}
1235
+ }
1236
+ return { content: [{ type: 'text', text: jsonText(data) + (near ? `\n${near}` : '') }] };
1237
+ }
1238
+ );
1239
+
1240
+ // --- network_rules ---
1241
+ server.tool(
1242
+ 'network_rules',
1243
+ 'Network interception, browser-wide, survives reloads until cleared: block requests, redirect URLs, set/remove request headers, or stub responses with a synthetic body (served by a local helper; from HTTPS pages the stub host must be trustworthy).',
1244
+ {
1245
+ action: z.enum(['block', 'redirect', 'modify_header', 'stub', 'list', 'clear']),
1246
+ url_filter: z.string().optional().describe('declarativeNetRequest urlFilter, e.g. "||example.com/api/*"'),
1247
+ redirect_url: z.string().optional(),
1248
+ header: z.string().optional(),
1249
+ header_value: z.string().optional().describe('Omit to remove header'),
1250
+ header_target: z.enum(['request', 'response']).optional().default('request').describe("response = strip content-security-policy / x-frame-options, inject CORS"),
1251
+ body: z.string().optional().describe('Response body (action=stub)'),
1252
+ status: z.number().optional().default(200).describe('action=stub'),
1253
+ content_type: z.string().optional().default('application/json').describe('action=stub'),
1254
+ resource_types: z.array(z.enum(['main_frame', 'sub_frame', 'stylesheet', 'script', 'image', 'font', 'object', 'xmlhttprequest', 'ping', 'csp_report', 'media', 'websocket', 'other'])).optional(),
1255
+ },
1256
+ async ({ action, url_filter, redirect_url, header, header_value, header_target, body, status, content_type, resource_types }) => {
1257
+ if (action === 'stub') {
1258
+ if (!url_filter) throw new Error('url_filter is required for action=stub');
1259
+ if (body == null) throw new Error('body is required for action=stub');
1260
+ const port = await ensureStubServer();
1261
+ const id = addStub({ body, status: status ?? 200, content_type: content_type ?? 'application/json' });
1262
+ const stub_url = `http://${stubHost()}:${port}/__stub__/${id}`;
1263
+ const data = await send(MessageType.NETWORK_RULES, { action: 'redirect', url_filter, redirect_url: stub_url, resource_types });
1264
+ return { content: [{ type: 'text', text: jsonText({ ...data, stub: id, stub_url }) }] };
1265
+ }
1266
+ if (action === 'clear') clearStubs();
1267
+ const data = await send(MessageType.NETWORK_RULES, { action, url_filter, redirect_url, header, header_value, header_target, resource_types });
1268
+ if (action === 'list') {
1269
+ const stubsInfo = listStubs();
1270
+ if (stubsInfo.length) return { content: [{ type: 'text', text: jsonText({ ...data, stubs: stubsInfo }) }] };
1271
+ }
1272
+ return { content: [{ type: 'text', text: jsonText(data) }] };
1273
+ }
1274
+ );
1275
+
1276
+ // --- screenshot_diff ---
1277
+ server.tool(
1278
+ 'screenshot_diff',
1279
+ 'Visual regression: save a named baseline (viewport or element), compare later — returns changed-pixel % and red-highlighted diff image. Baselines are in-memory (lost on service worker restart).',
1280
+ {
1281
+ action: z.enum(['baseline', 'compare', 'list', 'clear']),
1282
+ name: z.string().optional().default('default'),
1283
+ selector: z.string().optional().describe('Capture one element (default viewport)'),
1284
+ threshold: z.number().optional().default(10).describe('Per-channel tolerance 0-255'),
1285
+ tab_id: z.number().optional(),
1286
+ },
1287
+ async ({ action, name, selector, threshold, tab_id }) => {
1288
+ const data = await send(MessageType.SCREENSHOT_DIFF, { action, name, selector, threshold, tab_id });
1289
+ if (data && data.diff_image) {
1290
+ const { diff_image, ...rest } = data;
1291
+ return {
1292
+ content: [
1293
+ { type: 'text', text: jsonText(rest) },
1294
+ { type: 'image', data: diff_image, mimeType: 'image/png' },
1295
+ ],
1296
+ };
1297
+ }
1298
+ return { content: [{ type: 'text', text: jsonText(data) }] };
1299
+ }
1300
+ );
1301
+
1302
+ // --- web_vitals ---
1303
+ server.tool(
1304
+ 'web_vitals',
1305
+ 'Core Web Vitals since page load: CLS, LCP, FCP, TTFB, long tasks, INP approximation.',
1306
+ {
1307
+ tab_id: z.number().optional(),
1308
+ },
1309
+ async ({ tab_id }) => {
1310
+ const data = await send(MessageType.WEB_VITALS, { tab_id });
1311
+ return { content: [{ type: 'text', text: jsonText(data) }] };
1312
+ }
1313
+ );
1314
+
1315
+ // --- list_event_listeners ---
1316
+ server.tool(
1317
+ 'list_event_listeners',
1318
+ 'List addEventListener registrations since page load: counts by type + recent entries.',
1319
+ {
1320
+ type: z.string().optional().describe('e.g. "click"'),
1321
+ limit: z.number().optional().default(100),
1322
+ tab_id: z.number().optional(),
1323
+ },
1324
+ async ({ type, limit, tab_id }) => {
1325
+ const data = await send(MessageType.LIST_EVENT_LISTENERS, { type, limit, tab_id });
1326
+ return { content: [{ type: 'text', text: jsonText(data) }] };
1327
+ }
1328
+ );
1329
+
1330
+ // --- monitor_websocket ---
1331
+ server.tool(
1332
+ 'monitor_websocket',
1333
+ 'Monitor WebSocket connections/messages (500-char previews). Hook installs on first call; earlier connections are missed.',
1334
+ {
1335
+ clear: z.boolean().optional().default(false).describe('Clear buffer after read'),
1336
+ tab_id: z.number().optional(),
1337
+ },
1338
+ async ({ clear, tab_id }) => {
1339
+ const data = await send(MessageType.MONITOR_WEBSOCKET, { clear, tab_id });
1340
+ return { content: [{ type: 'text', text: jsonText(data) }] };
1341
+ }
1342
+ );
1343
+
1344
+ // --- seo_audit ---
1345
+ server.tool(
1346
+ 'seo_audit',
1347
+ 'SEO audit: title/description lengths, canonical, robots, h1 count, Open Graph, Twitter card, JSON-LD validity, hreflang, lang, viewport, favicon',
1348
+ {
1349
+ tab_id: z.number().optional(),
1350
+ },
1351
+ async ({ tab_id }) => {
1352
+ const data = await send(MessageType.SEO_AUDIT, { tab_id });
1353
+ return { content: [{ type: 'text', text: jsonText(data) }] };
1354
+ }
1355
+ );
1356
+
1357
+ // --- extract_table ---
1358
+ server.tool(
1359
+ 'extract_table',
1360
+ 'Extract an HTML table as JSON (thead headers, rows as objects). Use `where` to pull only matching rows from a large table without reading it all (server-side filter). `row_count` is always the table total; `match_count` is how many rows pass the filter. index picks among multiple matches.',
1361
+ {
1362
+ selector: z.string().optional().default('table'),
1363
+ index: z.number().optional().default(0),
1364
+ max_rows: z.number().optional().default(100).describe('Max rows returned to you (output cap).'),
1365
+ where: z.record(z.string(), z.string()).optional().describe('Keep only rows where each {column: substring} matches (case-insensitive contains). Use key "any" (or when the table has no headers) to match against any cell.'),
1366
+ columns: z.array(z.string()).optional().describe('Return only these columns per row.'),
1367
+ offset: z.number().optional().default(0).describe('Skip N rows of the (filtered) set before applying max_rows.'),
1368
+ scan_rows: z.number().optional().default(2000).describe('Max rows materialized in-page to scan/filter; raise for very large tables.'),
1369
+ tab_id: z.number().optional(),
1370
+ },
1371
+ async ({ selector, index, max_rows, where, columns, offset, scan_rows, tab_id }) => {
1372
+ const data = await send(MessageType.EXTRACT_TABLE, { selector, index, scan_rows, tab_id });
1373
+ const shaped = shapeTable(data, { where, columns, offset, max_rows });
1374
+ return { content: [{ type: 'text', text: jsonText(shaped) }] };
1375
+ }
1376
+ );
1377
+
1378
+ // --- unused_css ---
1379
+ server.tool(
1380
+ 'unused_css',
1381
+ 'List CSS selectors matching nothing in the current DOM (approximate; cross-origin sheets unreadable).',
1382
+ {
1383
+ max_selectors: z.number().optional().default(200),
1384
+ tab_id: z.number().optional(),
1385
+ },
1386
+ async ({ max_selectors, tab_id }) => {
1387
+ const data = await send(MessageType.UNUSED_CSS, { max_selectors, tab_id });
1388
+ return { content: [{ type: 'text', text: jsonText(data) }] };
1389
+ }
1390
+ );
1391
+
1392
+ // --- drag_and_drop ---
1393
+ server.tool(
1394
+ 'drag_and_drop',
1395
+ 'Drag an element onto another. html5 = DragEvent+DataTransfer; pointer = pointer/mouse events (sortable libraries).',
1396
+ {
1397
+ source_selector: z.string(),
1398
+ target_selector: z.string(),
1399
+ mode: z.enum(['html5', 'pointer']).optional().default('html5'),
1400
+ frame_id: z.number().optional(),
1401
+ tab_id: z.number().optional(),
1402
+ },
1403
+ async ({ source_selector, target_selector, mode, frame_id, tab_id }) => {
1404
+ const data = await send(MessageType.DRAG_AND_DROP, { source_selector, target_selector, mode, frame_id, tab_id });
1405
+ return { content: [{ type: 'text', text: jsonText(data) }] };
1406
+ }
1407
+ );
1408
+
1409
+ // --- clipboard ---
1410
+ server.tool(
1411
+ 'clipboard',
1412
+ 'Read or write the system clipboard (text). Activates the tab first.',
1413
+ {
1414
+ action: z.enum(['read', 'write']),
1415
+ text: z.string().optional().describe('For write'),
1416
+ tab_id: z.number().optional(),
1417
+ },
1418
+ async ({ action, text, tab_id }) => {
1419
+ const data = await send(MessageType.CLIPBOARD, { action, text, tab_id });
1420
+ return { content: [{ type: 'text', text: jsonText(data) }] };
1421
+ }
1422
+ );
1423
+
1424
+ // --- set_geolocation ---
1425
+ server.tool(
1426
+ 'set_geolocation',
1427
+ 'Override navigator.geolocation with fixed coordinates (page-level patch). reset restores native.',
1428
+ {
1429
+ latitude: z.number().optional(),
1430
+ longitude: z.number().optional(),
1431
+ accuracy: z.number().optional().default(10).describe('Meters'),
1432
+ reset: z.boolean().optional().default(false),
1433
+ tab_id: z.number().optional(),
1434
+ },
1435
+ async ({ latitude, longitude, accuracy, reset, tab_id }) => {
1436
+ const data = await send(MessageType.SET_GEOLOCATION, { latitude, longitude, accuracy, reset, tab_id });
1437
+ return { content: [{ type: 'text', text: jsonText(data) }] };
1438
+ }
1439
+ );
1440
+
1441
+ // --- manage_downloads ---
1442
+ server.tool(
1443
+ 'manage_downloads',
1444
+ 'List downloads or wait for one to complete. Files land in the browser Downloads folder, not on the server.',
1445
+ {
1446
+ action: z.enum(['list', 'wait_for_complete']),
1447
+ timeout: z.number().optional().default(30000).describe('Max ms (wait_for_complete)'),
1448
+ limit: z.number().optional().default(10),
1449
+ },
1450
+ async ({ action, timeout, limit }) => {
1451
+ const data = await send(MessageType.MANAGE_DOWNLOADS, { action, timeout, limit });
1452
+ return { content: [{ type: 'text', text: jsonText(data) }] };
1453
+ }
1454
+ );
1455
+
1456
+ // --- save_page ---
1457
+ server.tool(
1458
+ 'save_page',
1459
+ 'Save the full page (DOM, styles, images) as an MHTML archive file on the server filesystem.',
1460
+ {
1461
+ output_path: z.string().describe('Absolute file path to write (e.g. /tmp/page.mhtml)'),
1462
+ tab_id: z.number().optional(),
1463
+ },
1464
+ async ({ output_path, tab_id }) => {
1465
+ const data = await send(MessageType.SAVE_PAGE, { tab_id });
1466
+ await writeFile(output_path, Buffer.from(data.mhtml_b64, 'base64'));
1467
+ return { content: [{ type: 'text', text: jsonText({ saved: output_path, size: data.size }) }] };
1468
+ }
1469
+ );
1470
+
1471
+ // --- set_zoom ---
1472
+ server.tool(
1473
+ 'set_zoom',
1474
+ 'Get or set tab zoom (0.25–5). No factor = read current; reset restores default.',
1475
+ {
1476
+ factor: z.number().optional().describe('1 = 100%'),
1477
+ reset: z.boolean().optional().default(false),
1478
+ tab_id: z.number().optional(),
1479
+ },
1480
+ async ({ factor, reset, tab_id }) => {
1481
+ const data = await send(MessageType.SET_ZOOM, { factor, reset, tab_id });
1482
+ return { content: [{ type: 'text', text: jsonText(data) }] };
1483
+ }
1484
+ );
1485
+
1486
+ // --- http_auth ---
1487
+ server.tool(
1488
+ 'http_auth',
1489
+ 'Set/clear credentials for HTTP Basic/Digest auth dialogs (browser-wide, in-memory only).',
1490
+ {
1491
+ action: z.enum(['set', 'clear']),
1492
+ username: z.string().optional(),
1493
+ password: z.string().optional(),
1494
+ },
1495
+ async ({ action, username, password }) => {
1496
+ if (action === 'set' && !username) throw new Error('username is required for action=set');
1497
+ const data = await send(MessageType.HTTP_AUTH, { action, username, password });
1498
+ return { content: [{ type: 'text', text: jsonText(data) }] };
1499
+ }
1500
+ );
1501
+
1502
+ // --- security_headers ---
1503
+ server.tool(
1504
+ 'security_headers',
1505
+ 'Audit security headers (CSP, HSTS, XCTO, clickjacking, Referrer/Permissions-Policy, version leaks). Captured from real navigations — reload if unavailable.',
1506
+ {
1507
+ tab_id: z.number().optional(),
1508
+ },
1509
+ async ({ tab_id }) => {
1510
+ const data = await send(MessageType.GET_RESPONSE_HEADERS, { tab_id });
1511
+ if (!data.available) {
1512
+ return { content: [{ type: 'text', text: jsonText(data) }] };
1513
+ }
1514
+ const result = evaluateSecurityHeaders(data.headers, data.url);
1515
+ result.status = data.status;
1516
+ return { content: [{ type: 'text', text: jsonText(result) }] };
1517
+ }
1518
+ );
1519
+
1520
+ // --- session_fixture ---
1521
+ server.tool(
1522
+ 'session_fixture',
1523
+ 'Save/restore localStorage+sessionStorage+cookies as a named fixture (e.g. a logged-in state). Restore requires the tab to be on the origin recorded at save.',
1524
+ {
1525
+ action: z.enum(['save', 'restore', 'list']),
1526
+ name: z.string().optional().describe('Required for save/restore'),
1527
+ tab_id: z.number().optional(),
1528
+ },
1529
+ async ({ action, name, tab_id }) => {
1530
+ if (action === 'list') {
1531
+ const { readdir } = await import('node:fs/promises');
1532
+ let files = [];
1533
+ try { files = (await readdir(SESSIONS_DIR)).filter((f) => f.endsWith('.json')); } catch {}
1534
+ return { content: [{ type: 'text', text: jsonText({ fixtures: files.map((f) => f.replace(/\.json$/, '')) }) }] };
1535
+ }
1536
+ if (!name || !/^[\w-]+$/.test(name)) throw new Error('name is required and must match [\\w-]+');
1537
+ const file = join(SESSIONS_DIR, `${name}.json`);
1538
+
1539
+ // Origin del tab target (match per tab_id, altrimenti tab attivo)
1540
+ const getTabOrigin = async () => {
1541
+ const tabs = await send(MessageType.GET_TABS);
1542
+ const list = Array.isArray(tabs) ? tabs : [];
1543
+ const tab = tab_id != null ? list.find((t) => t.id === tab_id) : list.find((t) => t.active);
1544
+ try { return tab?.url ? new URL(tab.url).origin : null; } catch { return null; }
1545
+ };
1546
+
1547
+ if (action === 'save') {
1548
+ const data = await send(MessageType.GET_STORAGE, { type: 'all', tab_id });
1549
+ const origin = await getTabOrigin();
1550
+ await mkdir(SESSIONS_DIR, { recursive: true });
1551
+ // File su disco per umani: pretty-print qui non costa token
1552
+ await writeFile(file, JSON.stringify({ savedAt: new Date().toISOString(), origin, ...data }, null, 2));
1553
+ return { content: [{ type: 'text', text: jsonText({ saved: name, origin, localStorage: Object.keys(data.localStorage || {}).length, sessionStorage: Object.keys(data.sessionStorage || {}).length, cookies: (data.cookies || []).length }) }] };
1554
+ }
1555
+
1556
+ // restore
1557
+ const fixture = JSON.parse(await readFile(file, 'utf8'));
1558
+ if (fixture.origin) {
1559
+ const currentOrigin = await getTabOrigin();
1560
+ if (currentOrigin && currentOrigin !== fixture.origin) {
1561
+ throw new Error(`Fixture was saved on ${fixture.origin}, current tab is ${currentOrigin} — cookies/storage would attach to the wrong site. Navigate there first.`);
1562
+ }
1563
+ }
1564
+ const restored = { localStorage: 0, sessionStorage: 0, cookies: 0, cookie_errors: [] };
1565
+ for (const storageType of ['localStorage', 'sessionStorage']) {
1566
+ for (const [k, v] of Object.entries(fixture[storageType] || {})) {
1567
+ await send(MessageType.SET_STORAGE, { type: storageType, action: 'set', key: k, value: v, tab_id });
1568
+ restored[storageType]++;
1569
+ }
1570
+ }
1571
+ for (const c of fixture.cookies || []) {
1572
+ try {
1573
+ await send(MessageType.SET_STORAGE, {
1574
+ type: 'cookie', action: 'set', key: c.name, value: c.value,
1575
+ path: c.path, domain: c.domain && c.domain.startsWith('.') ? c.domain : undefined,
1576
+ expires: c.expirationDate ? new Date(c.expirationDate * 1000).toUTCString() : undefined,
1577
+ secure: c.secure, sameSite: c.sameSite === 'no_restriction' ? 'None' : c.sameSite === 'strict' ? 'Strict' : c.sameSite === 'lax' ? 'Lax' : undefined,
1578
+ http_only: c.httpOnly,
1579
+ tab_id,
1580
+ });
1581
+ restored.cookies++;
1582
+ } catch (err) {
1583
+ restored.cookie_errors.push({ name: c.name, error: err.message });
1584
+ }
1585
+ }
1586
+ if (restored.cookie_errors.length === 0) delete restored.cookie_errors;
1587
+ return { content: [{ type: 'text', text: jsonText({ restored: name, ...restored }) }] };
1588
+ }
1589
+ );
1590
+
1591
+ // --- get_interactives ---
1592
+ server.tool(
1593
+ 'get_interactives',
1594
+ 'List actionable elements (buttons, links, inputs, [role], [onclick]) with ready-to-use CSS selector, label, position, flags. Prefer this over dumping HTML to discover selectors.',
1595
+ {
1596
+ scope: z.string().optional().describe('Limit search (CSS selector)'),
1597
+ limit: z.number().optional().default(100),
1598
+ visible_only: z.boolean().optional().default(true),
1599
+ format: z.enum(['lines', 'json']).optional().default('lines'),
1600
+ frame_id: z.number().optional(),
1601
+ tab_id: z.number().optional(),
1602
+ },
1603
+ async ({ scope, limit, visible_only, format, frame_id, tab_id }) => {
1604
+ const data = await send(MessageType.GET_INTERACTIVES, { scope, limit, visible_only, frame_id, tab_id });
1605
+ // Assegna ref n1..nN e memorizza la mappa ref → selector per click/type_text/hover
1606
+ const refMap = new Map();
1607
+ (data?.elements ?? []).forEach((e, i) => {
1608
+ e.ref = `n${i + 1}`;
1609
+ if (e.selector) refMap.set(e.ref, e.selector);
1610
+ });
1611
+ interactivesRefs.set(refsKey(tab_id), refMap);
1612
+ if ((format ?? 'lines') === 'json') {
1613
+ return { content: [{ type: 'text', text: jsonText(data) }] };
1614
+ }
1615
+ return {
1616
+ content: [{
1617
+ type: 'text',
1618
+ text: truncateText(interactivesLines(data), DEFAULT_MAX_OUTPUT),
1619
+ }],
1620
+ };
1621
+ }
1622
+ );
1623
+
1624
+ // --- extract ---
1625
+ server.tool(
1626
+ 'extract',
1627
+ 'Extract repeated structured data (rows, cards, lists) in one call: item_selector matches the items, fields map names to relative selectors. Deterministic, parses the main-document HTML server-side.',
1628
+ {
1629
+ item_selector: z.string().describe('CSS selector matching each item'),
1630
+ fields: z.record(z.string(), z.object({
1631
+ selector: z.string().optional().describe('Relative to the item (default: the item itself)'),
1632
+ attr: z.string().optional().describe('Attribute to read (default: text content)'),
1633
+ })).describe('field name → {selector, attr}'),
1634
+ max_items: z.number().optional().default(50),
1635
+ format: z.enum(['lines', 'json']).optional().default('lines'),
1636
+ tab_id: z.number().optional(),
1637
+ max_length: z.number().optional().default(20000).describe('Max output chars'),
1638
+ },
1639
+ async ({ item_selector, fields, max_items, format, tab_id, max_length }) => {
1640
+ max_length = max_length ?? DEFAULT_MAX_OUTPUT;
1641
+ const html = await send(MessageType.READ_PAGE, { mode: 'html', tab_id });
1642
+ if (typeof html !== 'string') throw new Error('Could not read page HTML');
1643
+ const root = parseHtml(html);
1644
+ const names = Object.keys(fields);
1645
+ const nodes = root.querySelectorAll(item_selector);
1646
+ const items = nodes.slice(0, max_items ?? 50).map((node) => {
1647
+ const row = {};
1648
+ for (const name of names) {
1649
+ const { selector, attr } = fields[name];
1650
+ const el = selector ? node.querySelector(selector) : node;
1651
+ const value = el ? (attr ? el.getAttribute(attr) : el.text.trim().replace(/\s+/g, ' ')) : null;
1652
+ row[name] = value ?? null;
1653
+ }
1654
+ return row;
1655
+ });
1656
+ if ((format ?? 'lines') === 'json') {
1657
+ return { content: [{ type: 'text', text: truncateText(JSON.stringify({ total: nodes.length, shown: items.length, items }), max_length, 'max_items or max_length') }] };
1658
+ }
1659
+ const lines = [`extract total=${nodes.length} shown=${items.length}`, names.join('\t'),
1660
+ ...items.map((row) => names.map((n) => row[n] ?? '').join('\t'))];
1661
+ return { content: [{ type: 'text', text: truncateText(lines.join('\n'), max_length, 'max_items or max_length') }] };
1662
+ }
1663
+ );
1664
+
1665
+ // --- assert ---
1666
+ server.tool(
1667
+ 'assert',
1668
+ 'Assert a page condition, polling until timeout: element exists/visible (optionally with count or containing text), text on page, tab url/title. Patterns: substring, or "/…/" for regex. Recorded flows replay it as a test.',
1669
+ {
1670
+ selector: z.string().optional(),
1671
+ state: z.enum(['attached', 'visible']).optional().default('attached'),
1672
+ text: z.string().optional().describe('In the element (with selector) or anywhere on the page'),
1673
+ count: z.number().optional().describe('Exact match count for selector'),
1674
+ url: z.string().optional().describe('Tab url pattern'),
1675
+ title: z.string().optional().describe('Tab title pattern'),
1676
+ timeout: z.number().optional().default(5000),
1677
+ tab_id: z.number().optional(),
1678
+ },
1679
+ async ({ selector, state, text, count, url, title, timeout, tab_id }) => {
1680
+ const params = { selector, state, text, count, url, title, timeout, tab_id: tab_id ?? sessionTabId ?? undefined };
1681
+ if (recording) {
1682
+ const { tab_id: _tab, ...rest } = params;
1683
+ appendFile(recording.file, JSON.stringify({ command: 'assert', params: rest }) + '\n').catch(() => {});
1684
+ }
1685
+ recordSuppressed++;
1686
+ try {
1687
+ const result = await runAssert(send, params);
1688
+ return { content: [{ type: 'text', text: jsonText(result) }] };
1689
+ } finally {
1690
+ recordSuppressed--;
1691
+ }
1692
+ }
1693
+ );
1694
+
1695
+ // --- session_record ---
1696
+ server.tool(
1697
+ 'session_record',
1698
+ 'Record the commands of this session as a replayable jsonl file (replay with the CLI: chrome-bridge replay --file <path>). tab_id is stripped — replays target the tab they navigate.',
1699
+ {
1700
+ action: z.enum(['start', 'stop', 'status', 'list']),
1701
+ name: z.string().optional().describe('Required for start'),
1702
+ },
1703
+ async ({ action, name }) => {
1704
+ if (action === 'status') {
1705
+ return { content: [{ type: 'text', text: jsonText(recording ? { recording: recording.name, file: recording.file } : { recording: null }) }] };
1706
+ }
1707
+ if (action === 'list') {
1708
+ const { readdir } = await import('node:fs/promises');
1709
+ let files = [];
1710
+ try { files = (await readdir(RECORDINGS_DIR)).filter((f) => f.endsWith('.jsonl')); } catch {}
1711
+ return { content: [{ type: 'text', text: jsonText({ recordings: files.map((f) => join(RECORDINGS_DIR, f)) }) }] };
1712
+ }
1713
+ if (action === 'start') {
1714
+ if (!name || !/^[\w-]+$/.test(name)) throw new Error('name is required and must match [\\w-]+');
1715
+ await mkdir(RECORDINGS_DIR, { recursive: true });
1716
+ const file = join(RECORDINGS_DIR, `${name}.jsonl`);
1717
+ await writeFile(file, '');
1718
+ recording = { name, file };
1719
+ return { content: [{ type: 'text', text: jsonText({ recording: name, file }) }] };
1720
+ }
1721
+ // stop
1722
+ const stopped = recording;
1723
+ recording = null;
1724
+ await recordChain; // il file deve essere completo quando il tool ritorna
1725
+ return { content: [{ type: 'text', text: jsonText(stopped ? { stopped: stopped.name, file: stopped.file } : { stopped: null }) }] };
1726
+ }
1727
+ );
1728
+ }