chrome-bridge-mcp 1.10.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Chrome Bridge
2
2
 
3
- ![License: MIT](https://img.shields.io/badge/license-MIT-green) ![Node 18+](https://img.shields.io/badge/node-%E2%89%A518-brightgreen) ![Chrome 135+](https://img.shields.io/badge/chrome-%E2%89%A5135-blue) ![Tests](https://img.shields.io/badge/tests-131%20unit%20%2B%20e2e-brightgreen) [![Chrome Web Store](https://img.shields.io/badge/web%20store-published-blue)](https://chromewebstore.google.com/detail/chrome-bridge-for-claude/bioknpaeahidbelaljjohjofiloeodmb)
3
+ ![License: MIT](https://img.shields.io/badge/license-MIT-green) ![Node 18+](https://img.shields.io/badge/node-%E2%89%A518-brightgreen) ![Chrome 135+](https://img.shields.io/badge/chrome-%E2%89%A5135-blue) ![Tests](https://img.shields.io/badge/tests-147%20unit%20%2B%20e2e-brightgreen) [![Chrome Web Store](https://img.shields.io/badge/web%20store-published-blue)](https://chromewebstore.google.com/detail/chrome-bridge-for-claude/bioknpaeahidbelaljjohjofiloeodmb)
4
4
 
5
5
  **Chrome Bridge is an MCP server that connects Claude Code to your real, logged-in Chrome browser — measured 2.75× fewer turns and 2.28× lower cost than the official "Claude in Chrome" extension on a form-filling task, with ~3× the toolset and no paid plan.**
6
6
 
@@ -63,10 +63,10 @@ Claude Code calls `navigate` (returning clickable element refs), `accessibility_
63
63
 
64
64
  ## Token-Efficient Design
65
65
 
66
- - **Opt-in Surface**: 31 core tools cost ≈7.4k tokens of `tools/list`, all 60 cost ≈14.1k; specialized groups (audits, visual, network, storage, dom, files) are opt-in via `--caps`. That core figure was ≈3.8k in 1.8.0 and roughly doubled in 1.10.0: MCP annotations on every tool, rewritten descriptions, and a documented `.describe()` on all 254 parameters (coverage went from 35% to 100%). The trade is deliberate and it is not free — on the form benchmark it adds ≈8% to the cache-read tokens per turn — but the measured advantage was never prefix size, it was round trips: 2.75× fewer turns. Playwright MCP's 23 core tools measured ≈4.6k in July 2026, so on prefix size alone it is the leaner one. Measure ours with `npm run measure`.
66
+ - **Opt-in Surface**: 31 core tools cost ≈7.9k tokens of `tools/list`, all 60 cost ≈14.9k; specialized groups (audits, visual, network, storage, dom, files) are opt-in via `--caps`. That core figure was ≈3.8k in 1.8.0 and roughly doubled in 1.10.0: MCP annotations on every tool, rewritten descriptions, and a documented `.describe()` on all 254 parameters (coverage went from 35% to 100%). The trade is deliberate and it is not free — on the form benchmark it adds ≈8% to the cache-read tokens per turn — but the measured advantage was never prefix size, it was round trips: 2.75× fewer turns. Playwright MCP's 23 core tools measured ≈4.6k in July 2026, so on prefix size alone it is the leaner one. Measure ours with `npm run measure`.
67
67
  - **Act-from-Result**: Tools attach a capped preview of interactive elements with short refs. Actions only report a `page_changed` delta when the URL or title actually changes.
68
68
  - **Optimized Media**: Screenshots are downscaled to ≤1568px. Full-page captures are sliced into readable segments.
69
- - **Zero-Token Escape Hatches**: The [CLI](#cli) skips MCP schemas entirely, and recorded flows [replay](#launch-mode-headless--ci) without any model in the loop.
69
+ - **Payload Escape Hatches**: `save_to` on `read_page`, `extract`, `screenshot` and `http_request` writes the result to a file and returns the path — the bytes never enter the context unless the agent decides to read them. `read_page(mode="markdown")` keeps headings, links and tables at a fraction of the HTML cost. The [CLI](#cli) skips MCP schemas entirely, and recorded flows [replay](#launch-mode-headless--ci) without any model in the loop.
70
70
 
71
71
  ## Highlights
72
72
 
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Rende una pagina in Markdown per il consumo del modello: struttura preservata
3
+ * a una frazione del costo dell'HTML.
4
+ *
5
+ * La funzione viene serializzata e iniettata nella pagina con
6
+ * chrome.scripting.executeScript, quindi deve essere autocontenuta: niente
7
+ * riferimenti a variabili esterne, niente import. Per lo stesso motivo è testata
8
+ * a parte contro un DOM finto (`test/unit/page-markdown.test.js`) invece che dal
9
+ * vivo.
10
+ *
11
+ * Cammina l'albero per blocchi invece di usare un querySelectorAll piatto: un
12
+ * `<li><a>x</a></li>` con il selettore piatto verrebbe emesso due volte, una
13
+ * come voce di lista e una come link.
14
+ */
15
+ export function buildMarkdown(doc) {
16
+ const d = doc || document;
17
+ const out = [];
18
+
19
+ const SKIP = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'SVG', 'CANVAS', 'IFRAME']);
20
+ const HEADINGS = new Set(['H1', 'H2', 'H3', 'H4', 'H5', 'H6']);
21
+ const BLOCKS = new Set(['P', 'LI', 'BLOCKQUOTE', 'PRE', 'DT', 'DD', 'FIGCAPTION']);
22
+ const MAX_TABLE_ROWS = 50;
23
+
24
+ const attr = (el, name) => (el.getAttribute ? el.getAttribute(name) : null);
25
+ const clean = (s) => String(s || '').replace(/\s+/g, ' ').trim();
26
+
27
+ // Dimensioni: il rect è la verità quando c'è un layout, ma su un'immagine non
28
+ // ancora disposta (o in test) restano solo gli attributi.
29
+ const sizeOf = (el) => {
30
+ let w = 0;
31
+ let h = 0;
32
+ if (typeof el.getBoundingClientRect === 'function') {
33
+ const r = el.getBoundingClientRect();
34
+ w = r.width; h = r.height;
35
+ }
36
+ if (!w) w = Number(attr(el, 'width')) || 0;
37
+ if (!h) h = Number(attr(el, 'height')) || 0;
38
+ return { w, h };
39
+ };
40
+
41
+ // Testo inline di un blocco: i link diventano [testo](href) senza che il
42
+ // testo venga emesso una seconda volta dal walker.
43
+ const inline = (node) => {
44
+ if (node.nodeType === 3) return node.textContent || '';
45
+ if (node.nodeType !== 1) return '';
46
+ const tag = node.tagName;
47
+ if (SKIP.has(tag)) return '';
48
+ if (tag === 'BR') return ' ';
49
+ if (tag === 'IMG') {
50
+ const { w, h } = sizeOf(node);
51
+ if (w >= 50 && h >= 50) {
52
+ const alt = clean(attr(node, 'alt')) || 'no alt text';
53
+ return ` ![${alt} ${Math.round(w)}x${Math.round(h)}](${attr(node, 'src') || ''}) `;
54
+ }
55
+ return '';
56
+ }
57
+ const kids = (node.childNodes || []).map(inline).join('');
58
+ if (tag === 'A') {
59
+ const href = attr(node, 'href');
60
+ const label = clean(kids);
61
+ if (!label) return '';
62
+ return href ? `[${label}](${href})` : label;
63
+ }
64
+ if (tag === 'CODE' || tag === 'KBD') {
65
+ const label = clean(kids);
66
+ return label ? '`' + label + '`' : '';
67
+ }
68
+ if (tag === 'STRONG' || tag === 'B') { const l = clean(kids); return l ? `**${l}**` : ''; }
69
+ if (tag === 'EM' || tag === 'I') { const l = clean(kids); return l ? `*${l}*` : ''; }
70
+ return kids;
71
+ };
72
+
73
+ const cellsOf = (row) => (row.childNodes || [])
74
+ .filter((n) => n.nodeType === 1 && (n.tagName === 'TD' || n.tagName === 'TH'))
75
+ .map((c) => clean(c.textContent).replace(/\|/g, '\\|'));
76
+
77
+ const renderTable = (table) => {
78
+ const rows = (table.querySelectorAll ? table.querySelectorAll('tr') : []) || [];
79
+ if (!rows.length) return;
80
+ const all = rows.map(cellsOf).filter((r) => r.length);
81
+ if (!all.length) return;
82
+
83
+ // Gli header veri, non un segnaposto: la prima riga se contiene th,
84
+ // altrimenti colonne numerate.
85
+ const firstHasTh = (rows[0].childNodes || []).some((n) => n.nodeType === 1 && n.tagName === 'TH');
86
+ const header = firstHasTh ? all[0] : all[0].map((_, i) => `col${i + 1}`);
87
+ const body = firstHasTh ? all.slice(1) : all;
88
+ const width = header.length;
89
+
90
+ out.push('');
91
+ out.push(`| ${header.join(' | ')} |`);
92
+ out.push(`|${' --- |'.repeat(width)}`);
93
+ for (const r of body.slice(0, MAX_TABLE_ROWS)) {
94
+ const padded = r.slice(0, width);
95
+ while (padded.length < width) padded.push('');
96
+ out.push(`| ${padded.join(' | ')} |`);
97
+ }
98
+ if (body.length > MAX_TABLE_ROWS) {
99
+ out.push(`_${body.length} rows total, ${body.length - MAX_TABLE_ROWS} omitted — use extract_table with \`where\` to filter server-side instead of reading them here._`);
100
+ }
101
+ out.push('');
102
+ };
103
+
104
+ const walk = (node) => {
105
+ if (!node || node.nodeType !== 1) return;
106
+ const tag = node.tagName;
107
+ if (SKIP.has(tag)) return;
108
+
109
+ if (tag === 'TABLE') { renderTable(node); return; }
110
+
111
+ if (HEADINGS.has(tag)) {
112
+ const text = clean(inline(node));
113
+ if (text) out.push(`${'#'.repeat(Number(tag[1]))} ${text}`, '');
114
+ return;
115
+ }
116
+
117
+ if (BLOCKS.has(tag)) {
118
+ const text = clean(inline(node));
119
+ if (!text) return;
120
+ if (tag === 'LI') out.push(`- ${text}`);
121
+ else if (tag === 'BLOCKQUOTE') out.push(`> ${text}`, '');
122
+ else if (tag === 'PRE') out.push('```', text, '```', '');
123
+ else if (tag === 'FIGCAPTION') out.push(`*${text}*`, '');
124
+ else out.push(text, '');
125
+ return;
126
+ }
127
+
128
+ if (tag === 'IMG') {
129
+ const rendered = clean(inline(node));
130
+ if (rendered) out.push(rendered, '');
131
+ return;
132
+ }
133
+
134
+ for (const child of node.childNodes || []) walk(child);
135
+ };
136
+
137
+ const title = clean(d.title);
138
+ if (title) out.push(`# ${title}`, '');
139
+
140
+ // Sommario delle immagini grandi: dice al modello se vale la pena chiedere
141
+ // uno screenshot, al costo di una riga.
142
+ const imgs = (d.body?.querySelectorAll ? d.body.querySelectorAll('img') : []) || [];
143
+ const significant = imgs.filter((img) => { const { w, h } = sizeOf(img); return w >= 100 && h >= 100; });
144
+ if (significant.length) {
145
+ out.push(`_${significant.length} image(s) at least 100x100 on this page — take a screenshot if the visual matters._`, '');
146
+ }
147
+
148
+ walk(d.body);
149
+
150
+ return out.join('\n').replace(/\n{3,}/g, '\n\n').trim();
151
+ }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Chrome Bridge for Claude Code",
4
- "version": "1.10.0",
4
+ "version": "1.11.0",
5
5
  "description": "Bridge your browser to Claude Code: 60 token-efficient web-dev automation tools over a local WebSocket. ChromeOS included.",
6
6
  "homepage_url": "https://github.com/frsorrentino/chrome-bridge",
7
7
  "minimum_chrome_version": "135",
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import './telemetry.js';
9
+ import { buildMarkdown } from './lib/page-markdown.js';
9
10
  const { pushError } = globalThis.__cbTelemetry;
10
11
 
11
12
  const DEFAULT_PORT = 8765;
@@ -423,6 +424,8 @@ async function executeCommand(msg) {
423
424
  return await cmdManageDownloads(params);
424
425
  case 'save_page':
425
426
  return await cmdSavePage(params);
427
+ case 'wait_for_text':
428
+ return await cmdWaitForText(params);
426
429
  case 'http_request':
427
430
  return await cmdHttpRequest(params);
428
431
  case 'set_zoom':
@@ -722,13 +725,13 @@ async function cmdExecuteJs({ code, tab_id, frame_id }) {
722
725
  }
723
726
  }
724
727
 
725
- async function cmdClick({ selector, tab_id, frame_id, force = false }) {
728
+ async function cmdClick({ selector, tab_id, frame_id, force = false, button = 'left', count = 1 }) {
726
729
  if (!selector) throw new Error('Missing required parameter: selector');
727
730
  const tabId = await resolveTabId(tab_id);
728
731
 
729
732
  const results = await chrome.scripting.executeScript({
730
733
  target: scriptTarget(tabId, frame_id),
731
- func: (sel, forceClick) => {
734
+ func: (sel, forceClick, btn, times) => {
732
735
  function deepQuery(sel) {
733
736
  if (!sel.includes('>>>')) return document.querySelector(sel);
734
737
  const parts = sel.split('>>>').map((s) => s.trim());
@@ -755,15 +758,29 @@ async function cmdClick({ selector, tab_id, frame_id, force = false }) {
755
758
  return { clicked: false, occluded: true, occluder: { selector: occluderSel, tag: top.tagName.toLowerCase(), text: (top.textContent || '').trim().substring(0, 80) } };
756
759
  }
757
760
  }
758
- const opts = { bubbles: true, cancelable: true, clientX: cx, clientY: cy };
759
- el.dispatchEvent(new PointerEvent('pointerdown', opts));
760
- el.dispatchEvent(new MouseEvent('mousedown', opts));
761
- el.dispatchEvent(new PointerEvent('pointerup', opts));
762
- el.dispatchEvent(new MouseEvent('mouseup', opts));
763
- el.click();
764
- return { clicked: true, tagName: el.tagName, text: el.textContent?.substring(0, 100) };
761
+ // button 2 = destro: il browser non apre il suo menu nativo da un evento
762
+ // sintetico, ma le pagine che ne implementano uno proprio ascoltano
763
+ // 'contextmenu', ed è quello il caso d'uso reale.
764
+ const right = btn === 'right';
765
+ const clickCount = Math.max(1, Number(times) || 1);
766
+ const base = { bubbles: true, cancelable: true, clientX: cx, clientY: cy, button: right ? 2 : 0, buttons: right ? 2 : 1 };
767
+
768
+ for (let i = 0; i < clickCount; i++) {
769
+ const opts = { ...base, detail: i + 1 };
770
+ el.dispatchEvent(new PointerEvent('pointerdown', opts));
771
+ el.dispatchEvent(new MouseEvent('mousedown', opts));
772
+ el.dispatchEvent(new PointerEvent('pointerup', opts));
773
+ el.dispatchEvent(new MouseEvent('mouseup', opts));
774
+ if (right) el.dispatchEvent(new MouseEvent('contextmenu', opts));
775
+ else el.click();
776
+ }
777
+ // dblclick non è implicito nei due click: va emesso a parte, ed è quello
778
+ // che seleziona una parola o apre un editor inline.
779
+ if (!right && clickCount >= 2) el.dispatchEvent(new MouseEvent('dblclick', { ...base, detail: 2 }));
780
+
781
+ return { clicked: true, button: right ? 'right' : 'left', count: clickCount, tagName: el.tagName, text: el.textContent?.substring(0, 100) };
765
782
  },
766
- args: [selector, force],
783
+ args: [selector, force, button, count],
767
784
  world: 'MAIN',
768
785
  });
769
786
 
@@ -853,6 +870,18 @@ async function cmdReadPage({ mode = 'text', tab_id, frame_id }) {
853
870
  return results?.[0]?.result ?? '';
854
871
  }
855
872
 
873
+ if (mode === 'markdown') {
874
+ // buildMarkdown è autocontenuta apposta: chrome.scripting ne serializza il
875
+ // sorgente e la esegue nella pagina, dove non esiste il modulo.
876
+ const res = await chrome.scripting.executeScript({
877
+ target: scriptTarget(tabId, frame_id),
878
+ func: buildMarkdown,
879
+ args: [null],
880
+ world: 'MAIN',
881
+ });
882
+ return res?.[0]?.result ?? '';
883
+ }
884
+
856
885
  if (mode === 'accessibility') {
857
886
  // Ritorna una struttura semplificata dell'a11y tree
858
887
  const results = await chrome.scripting.executeScript({
@@ -1639,7 +1668,7 @@ async function cmdFillForm({ fields, submit_selector, tab_id, frame_id }) {
1639
1668
 
1640
1669
  // --- viewport_resize ---
1641
1670
 
1642
- async function cmdViewportResize({ preset, width, height, tab_id }) {
1671
+ async function cmdViewportResize({ preset, width, height, read_only = false, tab_id }) {
1643
1672
  const tabId = await resolveTabId(tab_id);
1644
1673
  const tab = await chrome.tabs.get(tabId);
1645
1674
 
@@ -1656,10 +1685,13 @@ async function cmdViewportResize({ preset, width, height, tab_id }) {
1656
1685
  if (targetW) updateOpts.width = targetW;
1657
1686
  if (targetH) updateOpts.height = targetH;
1658
1687
 
1659
- if (Object.keys(updateOpts).length === 0) throw new Error('Provide preset, width, or height');
1660
-
1661
- await chrome.windows.update(tab.windowId, updateOpts);
1662
- await new Promise((r) => setTimeout(r, 200));
1688
+ if (!read_only) {
1689
+ if (Object.keys(updateOpts).length === 0) throw new Error('Provide preset, width, or height');
1690
+ await chrome.windows.update(tab.windowId, updateOpts);
1691
+ // La finestra si ridisegna in modo asincrono: senza la pausa si leggono le
1692
+ // dimensioni vecchie e si riporta un risultato falso.
1693
+ await new Promise((r) => setTimeout(r, 200));
1694
+ }
1663
1695
 
1664
1696
  const results = await chrome.scripting.executeScript({
1665
1697
  target: { tabId },
@@ -1671,6 +1703,7 @@ async function cmdViewportResize({ preset, width, height, tab_id }) {
1671
1703
  world: 'MAIN',
1672
1704
  });
1673
1705
  const actual = results?.[0]?.result ?? {};
1706
+ if (read_only) return { actual };
1674
1707
  return { requested: { width: targetW, height: targetH, preset: preset || null }, actual };
1675
1708
  }
1676
1709
 
@@ -3508,6 +3541,51 @@ async function cmdHttpRequest({ url, method = 'GET', headers, body, binary = fal
3508
3541
  return { ...common, body: text, size: text.length };
3509
3542
  }
3510
3543
 
3544
+ // --- wait_for_text ---
3545
+
3546
+ /**
3547
+ * Attende che un testo compaia. Si poteva già fare con wait_for_function, ma
3548
+ * quella strada richiede il toggle "Allow user scripts" e un'espressione scritta
3549
+ * a mano: questa no.
3550
+ */
3551
+ async function cmdWaitForText({ text, selector, timeout = 10000, interval = 200, tab_id, frame_id }) {
3552
+ if (!text) throw new Error('Missing required parameter: text');
3553
+ const tabId = await resolveTabId(tab_id);
3554
+
3555
+ const results = await chrome.scripting.executeScript({
3556
+ target: scriptTarget(tabId, frame_id),
3557
+ func: (needle, scopeSel, tout, intv) => new Promise((resolve) => {
3558
+ const started = Date.now();
3559
+ const wanted = String(needle).toLowerCase();
3560
+ const check = () => {
3561
+ const root = scopeSel ? document.querySelector(scopeSel) : document.body;
3562
+ const haystack = (root?.innerText || '').toLowerCase();
3563
+ const at = haystack.indexOf(wanted);
3564
+ if (at !== -1) {
3565
+ resolve({
3566
+ found: true,
3567
+ waited_ms: Date.now() - started,
3568
+ // Contesto attorno alla prima occorrenza: conferma di aver trovato
3569
+ // la cosa giusta senza restituire l'intera pagina.
3570
+ context: (root?.innerText || '').slice(Math.max(0, at - 60), at + wanted.length + 60).trim(),
3571
+ });
3572
+ return;
3573
+ }
3574
+ if (Date.now() - started >= tout) {
3575
+ resolve({ found: false, error: `Text not found within ${tout}ms: ${needle}` });
3576
+ return;
3577
+ }
3578
+ setTimeout(check, intv);
3579
+ };
3580
+ check();
3581
+ }),
3582
+ args: [text, selector || null, timeout, Math.max(50, interval)],
3583
+ world: 'MAIN',
3584
+ });
3585
+
3586
+ return results?.[0]?.result ?? { found: false, error: 'No result' };
3587
+ }
3588
+
3511
3589
  async function cmdSavePage({ tab_id }) {
3512
3590
  const tabId = await resolveTabId(tab_id);
3513
3591
  const blob = await new Promise((resolve, reject) => {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "chrome-bridge-mcp",
3
3
  "mcpName": "io.github.frsorrentino/chrome-bridge",
4
- "version": "1.10.0",
4
+ "version": "1.11.0",
5
5
  "type": "module",
6
6
  "description": "MCP server with 60 tools for browser automation via Chrome extension. Works on ChromeOS/Crostini.",
7
7
  "main": "server/index.js",
@@ -43,6 +43,7 @@ export const MessageType = Object.freeze({
43
43
  UPLOAD_FILE: 'upload_file',
44
44
  WAIT_FOR_NAVIGATION: 'wait_for_navigation',
45
45
  WAIT_FOR_NETWORK_IDLE: 'wait_for_network_idle',
46
+ WAIT_FOR_TEXT: 'wait_for_text',
46
47
  HANDLE_DIALOGS: 'handle_dialogs',
47
48
  FIND_TEXT: 'find_text',
48
49
  NETWORK_RULES: 'network_rules',
@@ -81,7 +82,7 @@ export const MessageType = Object.freeze({
81
82
  });
82
83
 
83
84
  // Versione
84
- export const VERSION = '1.10.0';
85
+ export const VERSION = '1.11.0';
85
86
 
86
87
  // Configurazione
87
88
  export const DEFAULT_PORT = 8765;
@@ -127,6 +128,7 @@ export function getTimeout(type) {
127
128
  if (type === MessageType.WAIT_FOR_ELEMENT
128
129
  || type === MessageType.WAIT_FOR_NAVIGATION
129
130
  || type === MessageType.WAIT_FOR_NETWORK_IDLE
131
+ || type === MessageType.WAIT_FOR_TEXT
130
132
  || type === MessageType.UPLOAD_FILE
131
133
  || type === MessageType.MANAGE_DOWNLOADS
132
134
  || type === MessageType.SAVE_PAGE
package/server/tools.js CHANGED
@@ -187,6 +187,17 @@ export const TOOL_CAPS = {
187
187
 
188
188
  // Parametri ubiqui: un solo testo, così `tab_id` non significa una cosa in un
189
189
  // tool e un'altra nel gemello. test/unit/tool-parameters.test.js lo verifica.
190
+ // save_to: il payload va su disco e nel contesto resta solo il percorso più un
191
+ // sommario. L'idea è di superpowers-chrome, che scrive automaticamente a ogni
192
+ // azione; qui è opt-in, perché scrivere file per chi non li leggerà è una tassa.
193
+ const saveToField = (what) => z.string().optional()
194
+ .describe(`Absolute path: write ${what} there and return the path instead of the content`);
195
+
196
+ async function savedSummary(path, bytes, extra = {}) {
197
+ await writeFile(path, bytes);
198
+ return { content: [{ type: 'text', text: jsonText({ saved: path, bytes: bytes.length, ...extra }) }] };
199
+ }
200
+
190
201
  const selectorField = (extra = '') => z.string()
191
202
  .describe(('CSS selector; ">>>" pierces shadow DOM. ' + extra).trim());
192
203
  const waitAfter = z.enum(['none', 'navigation', 'networkidle']).optional().default('none')
@@ -509,9 +520,12 @@ export function registerTools(server, wsManager, caps = 'all') {
509
520
  + 'Downscaled to ≤1568px, so fine print may not survive.',
510
521
  {
511
522
  tab_id: tabId,
523
+ save_to: saveToField('the PNG'),
512
524
  },
513
- async ({ tab_id }) => {
525
+ async ({ tab_id, save_to }) => {
514
526
  const data = await send(MessageType.SCREENSHOT, { tab_id });
527
+ const b64 = data?.image ?? data?.data;
528
+ if (save_to && b64) return savedSummary(save_to, Buffer.from(b64, 'base64'), { mimeType: 'image/png' });
515
529
  // data.image è base64 PNG
516
530
  if (data && data.image) {
517
531
  return {
@@ -562,14 +576,16 @@ export function registerTools(server, wsManager, caps = 'all') {
562
576
  selector: z.string().optional().describe('CSS selector; ">>>" pierces shadow DOM. Ignored when ref is given'),
563
577
  ref: z.string().optional().describe('From get_interactives, e.g. "n3"'),
564
578
  force: z.boolean().optional().default(false).describe('Click even if occluded'),
579
+ button: z.enum(['left', 'right']).optional().default('left').describe('right opens the page context menu instead of activating the element'),
580
+ count: z.number().optional().default(1).describe('2 emits dblclick after the two clicks, which is what selects a word or opens an editor'),
565
581
  wait_after: waitAfter,
566
582
  tab_id: tabId,
567
583
  frame_id: frameId,
568
584
  },
569
- async ({ selector, ref, force, wait_after, tab_id, frame_id }) => {
585
+ async ({ selector, ref, force, button, count, wait_after, tab_id, frame_id }) => {
570
586
  const target = resolveTarget(selector, ref, tab_id);
571
587
  const before = await tabSnapshot(tab_id);
572
- const data = await send(MessageType.CLICK, { selector: target, force, frame_id, tab_id });
588
+ const data = await send(MessageType.CLICK, { selector: target, force, button: button ?? 'left', count: count ?? 1, frame_id, tab_id });
573
589
  // Niente attesa se il click non è andato a buon fine (es. elemento occluso)
574
590
  const waited = data?.occluded ? null : await applyWaitAfter(send, wait_after, tab_id);
575
591
  const changed = data?.occluded ? null : pageDelta(before, await tabSnapshot(tab_id));
@@ -586,7 +602,11 @@ export function registerTools(server, wsManager, caps = 'all') {
586
602
  // --- type_text ---
587
603
  server.tool(
588
604
  'type_text',
589
- 'Type text into an input, by CSS selector or by ref from get_interactives.',
605
+ 'Put text into an input, textarea or contenteditable, by CSS selector or by a ref from get_interactives. '
606
+ + 'Replaces the whole value rather than appending, and assigns through the native setter so React and '
607
+ + 'Vue controlled inputs register the change, then fires input and change. mode=keys instead emits '
608
+ + 'keydown/input/keyup per character, which is what autocomplete and masked fields need — slower, so '
609
+ + 'reach for it only when mode=set leaves the field empty or the dropdown never opens.',
590
610
  {
591
611
  selector: z.string().optional().describe('CSS selector; ">>>" pierces shadow DOM. Ignored when ref is given'),
592
612
  ref: z.string().optional().describe('From get_interactives, e.g. "n3"'),
@@ -613,21 +633,26 @@ export function registerTools(server, wsManager, caps = 'all') {
613
633
  // --- read_page ---
614
634
  server.tool(
615
635
  'read_page',
616
- 'Read the page as text (default), raw HTML, or accessibility tree. Read-only. Expensive on large pages: '
636
+ 'Read the page as text (default), markdown, raw HTML, or accessibility tree. Read-only. markdown keeps '
637
+ + 'headings, links and tables at a fraction of the HTML cost, and is usually the right middle ground. '
638
+ + 'Expensive on large pages: '
617
639
  + 'HTML on a big table costs tens of thousands of tokens for data you then filter anyway — prefer '
618
640
  + 'extract_table or extract for tabular and repeated content, and get_interactives to find click targets.',
619
641
  {
620
- mode: z.enum(['text', 'html', 'accessibility']).default('text').describe('text strips markup, html keeps it (costly), accessibility returns the a11y tree'),
642
+ mode: z.enum(['text', 'markdown', 'html', 'accessibility']).default('text').describe('text strips markup, markdown keeps headings/links/tables far cheaper than html, accessibility returns the a11y tree'),
621
643
  tab_id: tabId,
622
644
  frame_id: frameId,
623
645
  max_length: z.number().optional().default(50000).describe('Max output chars'),
646
+ save_to: saveToField('the page'),
624
647
  },
625
- async ({ mode, tab_id, frame_id, max_length }) => {
648
+ async ({ mode, tab_id, frame_id, max_length, save_to }) => {
626
649
  const data = await send(MessageType.READ_PAGE, { mode, tab_id, frame_id });
650
+ const text = typeof data === 'string' ? data : JSON.stringify(data);
651
+ if (save_to) return savedSummary(save_to, Buffer.from(text, 'utf8'), { mode: mode ?? 'text' });
627
652
  return {
628
653
  content: [{
629
654
  type: 'text',
630
- text: truncateText(typeof data === 'string' ? data : JSON.stringify(data), max_length ?? 50000, 'max_length'),
655
+ text: truncateText(text, max_length ?? 50000, 'max_length'),
631
656
  }],
632
657
  };
633
658
  }
@@ -859,10 +884,15 @@ export function registerTools(server, wsManager, caps = 'all') {
859
884
  // --- wait_for ---
860
885
  server.tool(
861
886
  'wait_for',
862
- 'Wait for: element (selector in DOM), function (JS expression truthy; needs "Allow user scripts"), navigation (mode=spa for route changes), network_idle.',
887
+ 'Block until a condition holds: element (selector in the DOM), function (JS expression turns truthy; needs '
888
+ + 'the "Allow user scripts" toggle), navigation (mode=spa for client-side route changes), network_idle. '
889
+ + 'Polls until timeout — 10s for element and function, 15s for navigation and network_idle — then '
890
+ + '**returns `found: false` with a reason instead of raising**, so a caller that ignores the result '
891
+ + 'silently proceeds as if the wait had succeeded. Read-only: waiting changes nothing on the page.',
863
892
  {
864
- condition: z.enum(['element', 'function', 'navigation', 'network_idle']).describe('function needs expression; element needs selector'),
865
- selector: z.string().optional().describe('condition=element'),
893
+ condition: z.enum(['element', 'text', 'function', 'navigation', 'network_idle']).describe('element and text need selector or text; function needs expression'),
894
+ selector: z.string().optional().describe('condition=element; with condition=text it narrows the search to that subtree'),
895
+ text: z.string().optional().describe('Literal text to wait for (condition=text), matched case-insensitively'),
866
896
  expression: z.string().optional().describe('JS expression (condition=function)'),
867
897
  visible: z.boolean().optional().default(false).describe('Element must also be visible'),
868
898
  mode: z.enum(['load', 'spa']).optional().default('load').describe('spa = pushState/popstate/hashchange'),
@@ -872,12 +902,16 @@ export function registerTools(server, wsManager, caps = 'all') {
872
902
  tab_id: tabId,
873
903
  frame_id: frameId,
874
904
  },
875
- async ({ condition, selector, expression, visible, mode, idle_ms, timeout, interval, tab_id, frame_id }) => {
905
+ async ({ condition, selector, text, expression, visible, mode, idle_ms, timeout, interval, tab_id, frame_id }) => {
876
906
  let data;
877
907
  if (condition === 'element') {
878
908
  data = await send(MessageType.WAIT_FOR_ELEMENT, {
879
909
  selector, timeout: timeout ?? 10000, interval: interval ?? 200, visible: visible ?? false, tab_id, frame_id,
880
910
  });
911
+ } else if (condition === 'text') {
912
+ data = await send(MessageType.WAIT_FOR_TEXT, {
913
+ text, selector, timeout: timeout ?? 10000, interval: interval ?? 200, tab_id, frame_id,
914
+ });
881
915
  } else if (condition === 'function') {
882
916
  data = await send(MessageType.WAIT_FOR_FUNCTION, {
883
917
  expression, timeout: timeout ?? 10000, polling_ms: interval ?? 100, tab_id, frame_id,
@@ -983,15 +1017,20 @@ export function registerTools(server, wsManager, caps = 'all') {
983
1017
  // --- viewport_resize ---
984
1018
  server.tool(
985
1019
  'viewport_resize',
986
- 'Resize Chrome window to preset (mobile/tablet/desktop) or custom dimensions',
1020
+ 'Resize the Chrome **window** to a preset (mobile 375x812, tablet 768x1024, desktop 1440x900) or to explicit '
1021
+ + 'dimensions. The rendered viewport ends up smaller than what you ask for, by the height of the browser '
1022
+ + 'chrome — measure it with execute_js if the exact number matters. width and height each override the '
1023
+ + 'corresponding half of the preset, so preset plus width gives a custom width at the preset height. '
1024
+ + 'A maximized window on ChromeOS ignores the request.',
987
1025
  {
1026
+ action: z.enum(['set', 'get']).optional().default('set').describe('get reports the current viewport without resizing anything'),
988
1027
  preset: z.enum(['mobile', 'tablet', 'desktop']).optional().describe('375x812, 768x1024, 1440x900'),
989
1028
  width: z.number().optional().describe('Overrides preset'),
990
1029
  height: z.number().optional().describe('Overrides preset'),
991
1030
  tab_id: tabId,
992
1031
  },
993
- async ({ preset, width, height, tab_id }) => {
994
- const data = await send(MessageType.VIEWPORT_RESIZE, { preset, width, height, tab_id });
1032
+ async ({ action, preset, width, height, tab_id }) => {
1033
+ const data = await send(MessageType.VIEWPORT_RESIZE, { preset, width, height, read_only: (action ?? 'set') === 'get', tab_id });
995
1034
  return {
996
1035
  content: [{
997
1036
  type: 'text',
@@ -1072,7 +1111,10 @@ export function registerTools(server, wsManager, caps = 'all') {
1072
1111
  // --- highlight_elements ---
1073
1112
  server.tool(
1074
1113
  'highlight_elements',
1075
- 'Add colored overlay on elements matching a CSS selector. remove=true clears all highlights.',
1114
+ 'Outline every element matching a selector with a coloured overlay, to see on a screenshot what a selector '
1115
+ + 'actually caught. Each call clears the overlays left by the previous one instead of stacking them, and '
1116
+ + 'remove=true clears without adding. The overlays are injected DOM nodes: a reload or a navigation drops '
1117
+ + 'them, and they sit above the page without altering its layout or its own styles.',
1076
1118
  {
1077
1119
  selector: z.string().optional().describe('CSS selector; ">>>" pierces shadow DOM. Every match is outlined'),
1078
1120
  color: z.string().optional().default('rgba(255,0,0,0.3)').describe('Any CSS color for the overlay label'),
@@ -1740,7 +1782,11 @@ export function registerTools(server, wsManager, caps = 'all') {
1740
1782
  // --- session_fixture ---
1741
1783
  server.tool(
1742
1784
  'session_fixture',
1743
- '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.',
1785
+ 'Snapshot localStorage, sessionStorage and cookies of the current origin into a named fixture on the server, '
1786
+ + 'restore one, or list what has been saved. A logged-in state is the usual reason. save overwrites a '
1787
+ + 'fixture of the same name without asking; restore writes entries on top of what is already there '
1788
+ + 'rather than clearing first, and refuses outright when the tab sits on a different origin than the '
1789
+ + 'one recorded — cookies would otherwise attach to the wrong site. name is required except for list.',
1744
1790
  {
1745
1791
  action: z.enum(['save', 'restore', 'list']).describe('save snapshots the current origin; restore writes it back'),
1746
1792
  name: z.string().optional().describe('Required for save/restore'),
@@ -1858,8 +1904,9 @@ export function registerTools(server, wsManager, caps = 'all') {
1858
1904
  format: z.enum(['lines', 'json']).optional().default('lines').describe('lines is compact; json keeps one object per record'),
1859
1905
  tab_id: tabId,
1860
1906
  max_length: z.number().optional().default(20000).describe('Max output chars'),
1907
+ save_to: saveToField('the records as JSON'),
1861
1908
  },
1862
- async ({ item_selector, fields, max_items, format, tab_id, max_length }) => {
1909
+ async ({ item_selector, fields, max_items, format, tab_id, max_length , save_to }) => {
1863
1910
  max_length = max_length ?? DEFAULT_MAX_OUTPUT;
1864
1911
  const html = await send(MessageType.READ_PAGE, { mode: 'html', tab_id });
1865
1912
  if (typeof html !== 'string') throw new Error('Could not read page HTML');
@@ -1876,6 +1923,7 @@ export function registerTools(server, wsManager, caps = 'all') {
1876
1923
  }
1877
1924
  return row;
1878
1925
  });
1926
+ if (save_to) return savedSummary(save_to, Buffer.from(JSON.stringify({ total: nodes.length, shown: items.length, items }), 'utf8'), { total: nodes.length, shown: items.length });
1879
1927
  if ((format ?? 'lines') === 'json') {
1880
1928
  return { content: [{ type: 'text', text: truncateText(JSON.stringify({ total: nodes.length, shown: items.length, items }), max_length, 'max_items or max_length') }] };
1881
1929
  }