chrome-bridge-mcp 1.8.0 → 1.9.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,10 +1,10 @@
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-109%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-128%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
 
7
- By using a local WebSocket bridge and a specialized Chrome extension, Chrome Bridge provides 59 web-development tools (navigation, DOM inspection, visual regression, audits, network mocking) and a dedicated headless instance for CI. It is self-hosted, local-only, and requires no paid plan.
7
+ By using a local WebSocket bridge and a specialized Chrome extension, Chrome Bridge provides 60 web-development tools (navigation, DOM inspection, visual regression, audits, network mocking) and a dedicated headless instance for CI. It is self-hosted, local-only, and requires no paid plan.
8
8
 
9
9
  ## Why Chrome Bridge?
10
10
 
@@ -31,12 +31,12 @@ unfavourable ones) and the harness limits are in
31
31
  - **Honest caveat:** per *turn*, Chrome Bridge costs slightly more than Claude in Chrome (41,985 vs 36,613 cache-read tokens; $0.0373 vs $0.0337). The win is in the number of turns.
32
32
 
33
33
  ### 2. Feature Comparison
34
- Chrome Bridge ships 59 specialized tools (30 core by default) compared to ~20 in Claude in Chrome. It is also the only automation that drives the real host Chrome on ChromeOS/Crostini.
34
+ Chrome Bridge ships 60 specialized tools (31 core by default) compared to ~20 in Claude in Chrome. It is also the only automation that drives the real host Chrome on ChromeOS/Crostini.
35
35
 
36
36
  | | Chrome Bridge | Claude in Chrome | Chrome DevTools MCP | Playwright MCP |
37
37
  |---|---|---|---|---|
38
38
  | **ChromeOS / Crostini** | **Yes** (real host) | No | Container only | Container only |
39
- | **Tools** | **59** (30 core) | ~20 | ~50 | 23 core (71 total) |
39
+ | **Tools** | **60** (31 core) | ~20 | ~50 | 23 core (71 total) |
40
40
  | **Requires Paid Plan** | **No** | Yes (Pro+) | No | No |
41
41
  | **Network Mocking** | **Yes** (stub/headers) | No | No | Yes |
42
42
  | **Visual Regression** | **Yes** (`screenshot_diff`) | No | No | No |
@@ -63,7 +63,7 @@ Claude Code calls `navigate` (returning clickable element refs), `accessibility_
63
63
 
64
64
  ## Token-Efficient Design
65
65
 
66
- - **Compact Schemas**: 30 core tools use ≈3.9k tokens of schemas (vs Playwright MCP's ~4.6k). Specialized groups (audits, visual, network, storage, dom, files) are opt-in via `--caps`.
66
+ - **Opt-in Surface**: 31 core tools cost ≈5.7k tokens of `tools/list`, all 60 cost ≈10.8k; specialized groups (audits, visual, network, storage, dom, files) are opt-in via `--caps`. That core figure was ≈3.8k in 1.8.0 — MCP annotations on every tool and fuller descriptions added ≈1.8k, deliberately: the win here is fewer round trips, not a smaller prefix (Playwright MCP's 23 core tools measured ≈4.6k in July 2026, so on this metric alone it is now the leaner one). Measure it yourself 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
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.
@@ -84,7 +84,7 @@ Claude Code <--stdio--> MCP Server <--WebSocket :8765--> Chrome Extension
84
84
 
85
85
  The **MCP Server** (Node.js) handles the protocol and tool logic. The **Chrome Extension** (MV3) executes commands via Chrome APIs. User scripts (`execute_js`) run via `chrome.userScripts.execute()`, requiring the "Allow user scripts" toggle in extension settings.
86
86
 
87
- ## Tools (59 total)
87
+ ## Tools (60 total)
88
88
 
89
89
  ### Core & Navigation (7)
90
90
  `get_status`, `get_tabs`, `create_tab`, `navigate`, `tab_action`, `get_frames`, `screenshot`.
@@ -95,8 +95,8 @@ The **MCP Server** (Node.js) handles the protocol and tool logic. The **Chrome E
95
95
  ### DOM & Inspection (11)
96
96
  `read_page`, `extract`, `get_page_info`, `query_dom`, `modify_dom`, `find_text`, `get_interactives`, `inject_css`, `highlight_elements`, `watch_dom`, `measure_spacing`.
97
97
 
98
- ### Debugging & Network (8)
99
- `execute_js`, `read_console`, `monitor_network`, `monitor_websocket`, `network_rules` (block/redirect/stub/headers), `get_performance`, `web_vitals`, `list_event_listeners`.
98
+ ### Debugging & Network (9)
99
+ `execute_js`, `read_console`, `monitor_network`, `monitor_websocket`, `network_rules` (block/redirect/stub/headers), `http_request` (sent with the user's session cookies), `get_performance`, `web_vitals`, `list_event_listeners`.
100
100
 
101
101
  ### Visual & Responsive (7)
102
102
  `element_screenshot`, `full_page_screenshot`, `screenshot_diff`, `viewport_resize`, `set_zoom`, `emulate_media`, `set_geolocation`.
@@ -132,7 +132,7 @@ Configure the server via environment variables (or the matching CLI flags):
132
132
  - `CHROME_BRIDGE_PORT`: Default `8765`.
133
133
  - `CHROME_BRIDGE_HOST` / `--host`: Bind address, default `127.0.0.1`. Set to `0.0.0.0` **only** where the browser lives outside the container (ChromeOS/Crostini port-forward) — and pair it with a token.
134
134
  - `CHROME_BRIDGE_TOKEN`: Shared secret required on both `ext_init` and `relay_init`. Strongly recommended whenever the bind is not loopback.
135
- - `CHROME_BRIDGE_CAPS` / `--caps`: Tool groups to load (`core`, `audits`, `visual`, `network`, `storage`, `dom`, `files`, or `all`). `install.sh` registers with `all`; the bare server defaults to `core` (30 tools). `get_status` reports `caps_active` and `caps_available`.
135
+ - `CHROME_BRIDGE_CAPS` / `--caps`: Tool groups to load (`core`, `audits`, `visual`, `network`, `storage`, `dom`, `files`, or `all`). `install.sh` registers with `all`; the bare server defaults to `core` (31 tools). `get_status` reports `caps_active` and `caps_available`.
136
136
 
137
137
  Run `node tools/measure-schema.mjs` to see the exact schema cost of the active set.
138
138
 
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Chrome Bridge for Claude Code",
4
- "version": "1.8.0",
5
- "description": "Bridge your browser to Claude Code: 59 token-efficient web-dev automation tools over a local WebSocket. ChromeOS included.",
4
+ "version": "1.9.0",
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",
8
8
  "permissions": [
@@ -423,6 +423,8 @@ async function executeCommand(msg) {
423
423
  return await cmdManageDownloads(params);
424
424
  case 'save_page':
425
425
  return await cmdSavePage(params);
426
+ case 'http_request':
427
+ return await cmdHttpRequest(params);
426
428
  case 'set_zoom':
427
429
  return await cmdSetZoom(params);
428
430
  case 'http_auth':
@@ -2177,12 +2179,12 @@ async function cmdWatchDom({ selector = 'body', attributes = true, childList = t
2177
2179
 
2178
2180
  // --- emulate_media ---
2179
2181
 
2180
- async function cmdEmulateMedia({ colorScheme, reducedMotion, printMode = false, reset = false, tab_id }) {
2182
+ async function cmdEmulateMedia({ colorScheme, reducedMotion, printMode = false, user_agent = null, reset = false, tab_id }) {
2181
2183
  const tabId = await resolveTabId(tab_id);
2182
2184
 
2183
2185
  const results = await chrome.scripting.executeScript({
2184
2186
  target: { tabId },
2185
- func: (cs, rm, print, doReset) => {
2187
+ func: (cs, rm, print, ua, doReset) => {
2186
2188
  const STYLE_ID = '__chromeBridge_media';
2187
2189
  const META_ID = '__chromeBridge_colorScheme';
2188
2190
 
@@ -2196,6 +2198,15 @@ async function cmdEmulateMedia({ colorScheme, reducedMotion, printMode = false,
2196
2198
  window.matchMedia = window.__chromeBridge_origMatchMedia;
2197
2199
  delete window.__chromeBridge_origMatchMedia;
2198
2200
  }
2201
+ // navigator.userAgent è stato ridefinito con un getter: per tornare
2202
+ // all'originale va rimossa la proprietà own, così riemerge quella del
2203
+ // prototipo.
2204
+ if (window.__chromeBridge_uaPatched) {
2205
+ for (const prop of ['userAgent', 'appVersion', 'platform']) {
2206
+ try { delete navigator[prop]; } catch { /* proprietà non configurabile: si prosegue */ }
2207
+ }
2208
+ delete window.__chromeBridge_uaPatched;
2209
+ }
2199
2210
  return { reset: true };
2200
2211
  }
2201
2212
 
@@ -2261,9 +2272,26 @@ async function cmdEmulateMedia({ colorScheme, reducedMotion, printMode = false,
2261
2272
  meta.remove();
2262
2273
  }
2263
2274
 
2275
+ // navigator.userAgent non è scrivibile: va ridefinita come getter sull'
2276
+ // istanza, che precede quella del prototipo. Vale per il JS della pagina,
2277
+ // non per l'header della richiesta (quello è network_rules).
2278
+ if (ua) {
2279
+ for (const [prop, value] of [
2280
+ ['userAgent', ua],
2281
+ ['appVersion', ua.replace(/^Mozilla\//, '')],
2282
+ ['platform', /iPhone|iPad|Android/i.test(ua) ? 'iPhone' : navigator.platform],
2283
+ ]) {
2284
+ try {
2285
+ Object.defineProperty(navigator, prop, { get: () => value, configurable: true });
2286
+ } catch { /* alcune pagine congelano navigator: si prosegue */ }
2287
+ }
2288
+ window.__chromeBridge_uaPatched = true;
2289
+ overrides.userAgent = ua;
2290
+ }
2291
+
2264
2292
  return { emulated: overrides };
2265
2293
  },
2266
- args: [colorScheme || null, reducedMotion || null, printMode, reset],
2294
+ args: [colorScheme || null, reducedMotion || null, printMode, user_agent, reset],
2267
2295
  world: 'MAIN',
2268
2296
  });
2269
2297
  return results?.[0]?.result ?? {};
@@ -3440,6 +3468,46 @@ async function cmdManageDownloads({ action, timeout = 30000, limit = 10 }) {
3440
3468
 
3441
3469
  // --- save_page (MHTML via pageCapture) ---
3442
3470
 
3471
+ // --- http_request ---
3472
+
3473
+ /**
3474
+ * La fetch parte dal service worker, non dal server Node: con host permission
3475
+ * `<all_urls>` e `credentials: 'include'` Chrome allega i cookie di sessione
3476
+ * dell'utente. È l'unica differenza che conta — la stessa URL richiesta da Node
3477
+ * restituisce la pagina di login.
3478
+ */
3479
+ async function cmdHttpRequest({ url, method = 'GET', headers, body, binary = false }) {
3480
+ if (!/^https?:\/\//i.test(String(url || ''))) {
3481
+ throw new Error(`http_request needs an absolute http(s) URL, got "${url}"`);
3482
+ }
3483
+ const res = await fetch(url, {
3484
+ method,
3485
+ headers: headers && Object.keys(headers).length ? headers : undefined,
3486
+ body: body ?? undefined,
3487
+ credentials: 'include',
3488
+ redirect: 'follow',
3489
+ });
3490
+
3491
+ const outHeaders = {};
3492
+ for (const [k, v] of res.headers.entries()) outHeaders[k] = v;
3493
+ const common = {
3494
+ status: res.status,
3495
+ ok: res.ok,
3496
+ // res.url riflette i redirect seguiti: senza, un 302 verso il login
3497
+ // sembrerebbe una risposta della URL chiesta.
3498
+ url: res.url,
3499
+ content_type: res.headers.get('content-type') || '',
3500
+ headers: outHeaders,
3501
+ };
3502
+
3503
+ if (binary) {
3504
+ const blob = await res.blob();
3505
+ return { ...common, body_b64: await blobToBase64(blob), size: blob.size };
3506
+ }
3507
+ const text = await res.text();
3508
+ return { ...common, body: text, size: text.length };
3509
+ }
3510
+
3443
3511
  async function cmdSavePage({ tab_id }) {
3444
3512
  const tabId = await resolveTabId(tab_id);
3445
3513
  const blob = await new Promise((resolve, reject) => {
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "chrome-bridge-mcp",
3
3
  "mcpName": "io.github.frsorrentino/chrome-bridge",
4
- "version": "1.8.0",
4
+ "version": "1.9.0",
5
5
  "type": "module",
6
- "description": "MCP server with 59 tools for browser automation via Chrome extension. Works on ChromeOS/Crostini.",
6
+ "description": "MCP server with 60 tools for browser automation via Chrome extension. Works on ChromeOS/Crostini.",
7
7
  "main": "server/index.js",
8
8
  "bin": {
9
9
  "chrome-bridge-mcp": "server/index.js",
@@ -58,6 +58,7 @@ export const MessageType = Object.freeze({
58
58
  SET_GEOLOCATION: 'set_geolocation',
59
59
  MANAGE_DOWNLOADS: 'manage_downloads',
60
60
  SAVE_PAGE: 'save_page',
61
+ HTTP_REQUEST: 'http_request',
61
62
  SET_ZOOM: 'set_zoom',
62
63
  HTTP_AUTH: 'http_auth',
63
64
  GET_RESPONSE_HEADERS: 'get_response_headers',
@@ -80,7 +81,7 @@ export const MessageType = Object.freeze({
80
81
  });
81
82
 
82
83
  // Versione
83
- export const VERSION = '1.8.0';
84
+ export const VERSION = '1.9.0';
84
85
 
85
86
  // Configurazione
86
87
  export const DEFAULT_PORT = 8765;
@@ -129,6 +130,7 @@ export function getTimeout(type) {
129
130
  || type === MessageType.UPLOAD_FILE
130
131
  || type === MessageType.MANAGE_DOWNLOADS
131
132
  || type === MessageType.SAVE_PAGE
133
+ || type === MessageType.HTTP_REQUEST
132
134
  || type === MessageType.WAIT_FOR_FUNCTION
133
135
  || type === MessageType.SCROLL_UNTIL) return 60000;
134
136
  return COMMAND_TIMEOUT_MS;
package/server/tools.js CHANGED
@@ -190,6 +190,106 @@ for (const [group, names] of Object.entries(TOOL_CAPS)) {
190
190
  for (const n of names) TOOL_TO_CAP.set(n, group);
191
191
  }
192
192
 
193
+ /**
194
+ * Annotations MCP, una voce per tool.
195
+ *
196
+ * Sono l'unico modo che l'agente ha di sapere cosa fa un tool al mondo PRIMA di
197
+ * chiamarlo: la descrizione in prosa la legge, ma non la può confrontare. Senza
198
+ * `readOnlyHint`, `click` e `read_page` si equivalgono al momento della scelta.
199
+ *
200
+ * Stanno qui e non sui singoli `server.tool(...)` perché il wrapper in
201
+ * registerTools le applica tutte da un punto solo, e
202
+ * `test/unit/tool-annotations.test.js` fallisce se un tool nuovo non ha la sua
203
+ * voce — impossibile aggiungere un tool e dimenticarle.
204
+ *
205
+ * openWorldHint = il tool può raggiungere o farsi raggiungere da server remoti
206
+ * (navigazione, fetch, mock di rete), non solo leggere lo stato già caricato.
207
+ */
208
+ const ro = (open = false) => ({
209
+ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: open,
210
+ });
211
+ const rw = ({ destructive = false, idempotent = false, open = false } = {}) => ({
212
+ readOnlyHint: false, destructiveHint: destructive, idempotentHint: idempotent, openWorldHint: open,
213
+ });
214
+
215
+ export const TOOL_ANNOTATIONS = {
216
+ // --- osservazione pura ---
217
+ accessibility_audit: ro(),
218
+ assert: ro(),
219
+ check_links: ro(true),
220
+ element_screenshot: ro(),
221
+ extract: ro(),
222
+ extract_table: ro(),
223
+ find_text: ro(),
224
+ full_page_screenshot: ro(),
225
+ get_frames: ro(),
226
+ get_interactives: ro(),
227
+ get_page_info: ro(),
228
+ get_performance: ro(),
229
+ get_status: ro(),
230
+ get_storage: ro(),
231
+ get_tabs: ro(),
232
+ list_event_listeners: ro(),
233
+ manage_downloads: ro(),
234
+ measure_spacing: ro(),
235
+ monitor_network: ro(true),
236
+ monitor_websocket: ro(true),
237
+ query_dom: ro(),
238
+ read_page: ro(),
239
+ screenshot: ro(),
240
+ security_headers: ro(true),
241
+ seo_audit: ro(),
242
+ unused_css: ro(),
243
+ wait_for: ro(),
244
+ watch_dom: ro(),
245
+ web_vitals: ro(),
246
+
247
+ // --- interazione con la pagina ---
248
+ click: rw({ open: true }), // un click può navigare
249
+ drag_and_drop: rw(),
250
+ fill_form: rw({ idempotent: true }),
251
+ hover: rw({ idempotent: true }),
252
+ press_key: rw(),
253
+ scroll: rw(),
254
+ type_text: rw({ idempotent: true }),
255
+ upload_file: rw(),
256
+
257
+ // --- modifica di pagina, tab o resa ---
258
+ dismiss_overlays: rw({ idempotent: true }),
259
+ emulate_media: rw({ idempotent: true }),
260
+ handle_dialogs: rw({ idempotent: true }),
261
+ highlight_elements: rw({ idempotent: true }),
262
+ inject_css: rw({ idempotent: true }),
263
+ modify_dom: rw({ idempotent: true }),
264
+ screenshot_diff: rw({ idempotent: true }),
265
+ set_zoom: rw({ idempotent: true }),
266
+ viewport_resize: rw({ idempotent: true }),
267
+ create_tab: rw({ open: true }),
268
+ navigate: rw({ idempotent: true, open: true }),
269
+ tab_action: rw({ destructive: true, open: true }), // close chiude una tab dell'utente
270
+
271
+ // --- rete, identità, posizione ---
272
+ http_auth: rw({ idempotent: true, open: true }),
273
+ network_rules: rw({ idempotent: true, open: true }),
274
+ set_geolocation: rw({ idempotent: true }),
275
+
276
+ // --- codice arbitrario ---
277
+ execute_js: rw({ destructive: true, open: true }),
278
+
279
+ // http_request non è read-only: un POST cambia lo stato del server remoto, e
280
+ // con save_to scrive un file. destructive perché sovrascrive save_to senza
281
+ // chiedere.
282
+ http_request: rw({ destructive: true, open: true }),
283
+
284
+ // --- stato che può essere distrutto o sovrascritto ---
285
+ clipboard: rw({ idempotent: true }),
286
+ read_console: rw({ destructive: true }), // clear:true cancella il buffer
287
+ save_page: rw({ destructive: true, idempotent: true }), // sovrascrive output_path
288
+ session_fixture: rw({ destructive: true, idempotent: true }), // restore sovrascrive cookie e storage
289
+ session_record: rw(),
290
+ set_storage: rw({ destructive: true, idempotent: true }),
291
+ };
292
+
193
293
  /**
194
294
  * Registra tutti i tool MCP.
195
295
  *
@@ -203,15 +303,26 @@ export function registerTools(server, wsManager, caps = 'all') {
203
303
  ? ['core', ...Object.keys(TOOL_CAPS)]
204
304
  : ['core', ...String(caps).split(',').map((s) => s.trim()).filter((s) => s && s !== 'core')];
205
305
 
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));
306
+ // Ogni registrazione passa da qui: il filtro capability scarta i tool opt-in
307
+ // fuori dai gruppi attivi, e le annotations vengono applicate da TOOL_ANNOTATIONS.
308
+ // Un solo wrapper per entrambe le cose, altrimenti con caps != 'all' le
309
+ // annotations sparivano insieme al filtro.
310
+ {
209
311
  const target = server;
312
+ const enabled = caps !== 'all'
313
+ ? new Set(String(caps).split(',').map((s) => s.trim()).filter(Boolean))
314
+ : null;
210
315
  server = {
211
316
  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);
317
+ if (enabled) {
318
+ const group = TOOL_TO_CAP.get(name);
319
+ if (group && !enabled.has(group) && !enabled.has('all')) return;
320
+ }
321
+ const annotations = TOOL_ANNOTATIONS[name];
322
+ // Un tool senza voce resta registrato (meglio di un crash all'avvio):
323
+ // è il test tool-annotations a segnalarlo.
324
+ if (annotations) target.tool(name, desc, schema, annotations, handler);
325
+ else target.tool(name, desc, schema, handler);
215
326
  },
216
327
  };
217
328
  }
@@ -342,7 +453,8 @@ export function registerTools(server, wsManager, caps = 'all') {
342
453
  // --- get_tabs ---
343
454
  server.tool(
344
455
  'get_tabs',
345
- 'List all open Chrome tabs',
456
+ 'List every open tab with id, url, title and active flag. Read-only. Use it to find a tab_id when the '
457
+ + 'implicit target (last navigated tab, else the active one) is not the tab you mean.',
346
458
  {},
347
459
  async () => {
348
460
  const data = await send(MessageType.GET_TABS);
@@ -429,7 +541,9 @@ export function registerTools(server, wsManager, caps = 'all') {
429
541
  // --- click ---
430
542
  server.tool(
431
543
  'click',
432
- 'Click an element by CSS selector or by ref from get_interactives.',
544
+ 'Click an element, by CSS selector or by a ref (n1, n2…) from get_interactives or navigate. Fires a real '
545
+ + 'pointer sequence, so it can submit, open a dialog or navigate away — use wait_after to let that settle. '
546
+ + 'Not idempotent, and a click that triggers a native confirm() blocks the bridge: install handle_dialogs first.',
433
547
  {
434
548
  selector: z.string().optional(),
435
549
  ref: z.string().optional().describe('From get_interactives, e.g. "n3"'),
@@ -485,7 +599,9 @@ export function registerTools(server, wsManager, caps = 'all') {
485
599
  // --- read_page ---
486
600
  server.tool(
487
601
  'read_page',
488
- 'Read the content of a Chrome tab page',
602
+ 'Read the page as text (default), raw HTML, or accessibility tree. Read-only. Expensive on large pages: '
603
+ + 'HTML on a big table costs tens of thousands of tokens for data you then filter anyway — prefer '
604
+ + 'extract_table or extract for tabular and repeated content, and get_interactives to find click targets.',
489
605
  {
490
606
  mode: z.enum(['text', 'html', 'accessibility']).default('text'),
491
607
  tab_id: z.number().optional(),
@@ -525,7 +641,9 @@ export function registerTools(server, wsManager, caps = 'all') {
525
641
  // --- get_storage ---
526
642
  server.tool(
527
643
  'get_storage',
528
- 'Get page storage: localStorage, sessionStorage, and cookies',
644
+ 'Read localStorage, sessionStorage or cookies of the current origin (type=all for every one). Read-only. '
645
+ + 'Cookies come with domain, path, expiry and the httpOnly/secure flags, so this also answers whether a '
646
+ + 'session cookie is present — write them back with set_storage, or snapshot a logged-in state with session_fixture.',
529
647
  {
530
648
  type: z.enum(['all', 'localStorage', 'sessionStorage', 'cookies']).default('all'),
531
649
  tab_id: z.number().optional(),
@@ -544,7 +662,9 @@ export function registerTools(server, wsManager, caps = 'all') {
544
662
  // --- get_performance ---
545
663
  server.tool(
546
664
  'get_performance',
547
- 'Get page performance metrics: navigation timing, paint metrics, memory, and resource loading',
665
+ 'Navigation timing, paint metrics, JS heap size and per-resource load times, as measured since the current '
666
+ + 'document loaded. Read-only, no reload triggered — numbers are only meaningful once loading has settled, '
667
+ + 'so on a page still fetching call wait_for(network_idle) first. For CLS/LCP/INP use web_vitals.',
548
668
  {
549
669
  tab_id: z.number().optional(),
550
670
  },
@@ -584,7 +704,9 @@ export function registerTools(server, wsManager, caps = 'all') {
584
704
  // --- modify_dom ---
585
705
  server.tool(
586
706
  'modify_dom',
587
- 'Modify DOM elements: setAttribute, removeAttribute, addClass, removeClass, setStyle, setTextContent',
707
+ 'Change an element in the live DOM: setAttribute, removeAttribute, addClass, removeClass, setStyle, '
708
+ + 'setTextContent. Nothing is persisted — the next reload restores the page as the server sends it. '
709
+ + 'For styling many elements at once inject_css is one call instead of N.',
588
710
  {
589
711
  selector: z.string(),
590
712
  action: z.enum(['setAttribute', 'removeAttribute', 'addClass', 'removeClass', 'setStyle', 'setTextContent']),
@@ -608,7 +730,8 @@ export function registerTools(server, wsManager, caps = 'all') {
608
730
  // --- inject_css ---
609
731
  server.tool(
610
732
  'inject_css',
611
- 'Inject CSS rules into a Chrome tab page',
733
+ 'Inject a CSS rule into the page. Stays until the next navigation or reload, and re-injecting the same id '
734
+ + 'replaces it rather than stacking. Affects only what is rendered — the stylesheet of the site is untouched.',
612
735
  {
613
736
  css: z.string(),
614
737
  tab_id: z.number().optional(),
@@ -700,7 +823,8 @@ export function registerTools(server, wsManager, caps = 'all') {
700
823
  // --- create_tab ---
701
824
  server.tool(
702
825
  'create_tab',
703
- 'Create a new Chrome tab, optionally navigating to a URL',
826
+ 'Open a new tab, optionally at a URL, and make it the implicit target of later commands in this session. '
827
+ + 'Each call creates another tab: reuse a tab_id or navigate() to move an existing one instead of piling up tabs.',
704
828
  {
705
829
  url: z.string().optional().describe('URL to open (default: new tab page)'),
706
830
  active: z.boolean().optional().default(true),
@@ -785,7 +909,9 @@ export function registerTools(server, wsManager, caps = 'all') {
785
909
  // --- set_storage ---
786
910
  server.tool(
787
911
  'set_storage',
788
- 'Write, delete, or clear localStorage, sessionStorage, or cookies',
912
+ 'Write, delete or clear a localStorage/sessionStorage key, or a cookie with its path, domain and expiry. '
913
+ + 'action=clear wipes every entry of that type for the origin and cannot be undone — on a site the user is '
914
+ + 'logged into, clearing cookies logs them out. Read the current values first with get_storage.',
789
915
  {
790
916
  type: z.enum(['localStorage', 'sessionStorage', 'cookie']),
791
917
  action: z.enum(['set', 'delete', 'clear']),
@@ -880,7 +1006,10 @@ export function registerTools(server, wsManager, caps = 'all') {
880
1006
  // --- full_page_screenshot ---
881
1007
  server.tool(
882
1008
  '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.',
1009
+ 'Full-page capture by scrolling: stitched segments of ~2 viewports (≤1568px, top→bottom), or one image per '
1010
+ + 'viewport with stitch=false. Read-only, but expensive: each segment costs ~2.7k image tokens, so only the first '
1011
+ + 'max_segments are returned and the rest need segment_offset. On a long page, reading the text you actually need '
1012
+ + 'is cheaper by an order of magnitude — prefer read_page, extract or find_text unless the layout itself is the question.',
884
1013
  {
885
1014
  max_scrolls: z.number().optional().default(20),
886
1015
  delay: z.number().optional().default(500).describe('ms between captures (min 500, Chrome quota)'),
@@ -1003,7 +1132,9 @@ export function registerTools(server, wsManager, caps = 'all') {
1003
1132
  // --- measure_spacing ---
1004
1133
  server.tool(
1005
1134
  'measure_spacing',
1006
- 'Measure pixel distance, gap, overlap, and margin/padding between two elements',
1135
+ 'Measure the gap, overlap and distance in CSS pixels between two elements, with their margins and paddings. '
1136
+ + 'Read-only. Values come from the current layout, so zoom and viewport size change them: set_zoom(1) and a '
1137
+ + 'fixed viewport_resize make results comparable across runs.',
1007
1138
  {
1008
1139
  selector1: z.string(),
1009
1140
  selector2: z.string(),
@@ -1049,16 +1180,20 @@ export function registerTools(server, wsManager, caps = 'all') {
1049
1180
  // --- emulate_media ---
1050
1181
  server.tool(
1051
1182
  'emulate_media',
1052
- 'Emulate prefers-color-scheme, prefers-reduced-motion, print mode (matchMedia override + CSS).',
1183
+ 'Make the page believe it runs in a different environment, until reset or reload: prefers-color-scheme, '
1184
+ + 'prefers-reduced-motion, print mode (matchMedia override + CSS) and navigator.userAgent/platform. '
1185
+ + 'user_agent only changes what page JS reads — the request header is a separate thing, set it with '
1186
+ + 'network_rules(action=modify_header, header="User-Agent"). Pair with viewport_resize to emulate a device.',
1053
1187
  {
1054
1188
  colorScheme: z.enum(['dark', 'light', 'no-preference']).optional(),
1055
1189
  reducedMotion: z.enum(['reduce', 'no-preference']).optional(),
1056
1190
  printMode: z.boolean().optional().default(false),
1191
+ user_agent: z.string().optional().describe('Overrides navigator.userAgent and appVersion in the page (not the HTTP header)'),
1057
1192
  reset: z.boolean().optional().default(false).describe('Remove all emulations'),
1058
1193
  tab_id: z.number().optional(),
1059
1194
  },
1060
- async ({ colorScheme, reducedMotion, printMode, reset, tab_id }) => {
1061
- const data = await send(MessageType.EMULATE_MEDIA, { colorScheme, reducedMotion, printMode, reset, tab_id });
1195
+ async ({ colorScheme, reducedMotion, printMode, user_agent, reset, tab_id }) => {
1196
+ const data = await send(MessageType.EMULATE_MEDIA, { colorScheme, reducedMotion, printMode, user_agent, reset, tab_id });
1062
1197
  return {
1063
1198
  content: [{
1064
1199
  type: 'text',
@@ -1092,7 +1227,9 @@ export function registerTools(server, wsManager, caps = 'all') {
1092
1227
  // --- press_key ---
1093
1228
  server.tool(
1094
1229
  'press_key',
1095
- 'Press a keyboard key with optional modifiers (keydown/keypress/keyup).',
1230
+ 'Send a key to the focused element (or to selector, focusing it first) as a real keydown/keypress/keyup '
1231
+ + 'sequence, so framework handlers fire. For typing a value use type_text; this is for Enter, Tab, Escape, '
1232
+ + 'arrows and shortcuts. Not idempotent: two calls send the key twice.',
1096
1233
  {
1097
1234
  key: z.string().describe('e.g. "Enter", "Escape", "Tab", "ArrowDown"'),
1098
1235
  selector: z.string().optional().describe('Target (default: activeElement)'),
@@ -1130,7 +1267,9 @@ export function registerTools(server, wsManager, caps = 'all') {
1130
1267
  // --- tab_action ---
1131
1268
  server.tool(
1132
1269
  'tab_action',
1133
- 'Tab lifecycle actions: close, activate (focus), reload (optional cache bypass), back, forward',
1270
+ 'Tab lifecycle: close, activate (focus), reload (optional cache bypass), back, forward. close discards the '
1271
+ + 'tab and anything unsaved in it, and cannot be undone — it may be a tab the user is working in. '
1272
+ + 'reload and navigation drop injected CSS, emulations and page hooks.',
1134
1273
  {
1135
1274
  action: z.enum(['close', 'activate', 'reload', 'back', 'forward']),
1136
1275
  bypass_cache: z.boolean().optional().default(false).describe('reload only'),
@@ -1302,7 +1441,9 @@ export function registerTools(server, wsManager, caps = 'all') {
1302
1441
  // --- web_vitals ---
1303
1442
  server.tool(
1304
1443
  'web_vitals',
1305
- 'Core Web Vitals since page load: CLS, LCP, FCP, TTFB, long tasks, INP approximation.',
1444
+ 'Core Web Vitals accumulated since the current document loaded: CLS, LCP, FCP, TTFB, long tasks and an INP '
1445
+ + 'approximation. Read-only. Needs the page instrumentation active from before load, so it reports whether it '
1446
+ + 'was hooked instead of silently returning zeros — navigate() installs it. For timing and resources use get_performance.',
1306
1447
  {
1307
1448
  tab_id: z.number().optional(),
1308
1449
  },
@@ -1468,6 +1609,64 @@ export function registerTools(server, wsManager, caps = 'all') {
1468
1609
  }
1469
1610
  );
1470
1611
 
1612
+ // --- http_request ---
1613
+ server.tool(
1614
+ 'http_request',
1615
+ 'HTTP request sent from the browser, so it carries the session cookies of the logged-in user: '
1616
+ + 'fetches URLs a plain server-side request would get a login page for (invoices, authenticated JSON, CSV exports). '
1617
+ + 'Text bodies are returned inline (capped by max_length); with save_to the bytes are written to that path instead, '
1618
+ + 'which is how you read a PDF — Chrome renders PDFs in a viewer no content script can reach, so read_page returns nothing on a PDF tab.',
1619
+ {
1620
+ url: z.string().describe('Absolute URL. Cookies are sent for its origin'),
1621
+ method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD']).optional().default('GET'),
1622
+ headers: z.record(z.string(), z.string()).optional().describe('Extra request headers'),
1623
+ body: z.string().optional().describe('Request body (POST/PUT/PATCH)'),
1624
+ save_to: z.string().optional().describe('Absolute path: write the response bytes here instead of returning them (PDF, images, archives)'),
1625
+ max_length: z.number().optional().default(DEFAULT_MAX_OUTPUT).describe('Max chars of body returned inline'),
1626
+ },
1627
+ async ({ url, method, headers, body, save_to, max_length }) => {
1628
+ const data = await send(MessageType.HTTP_REQUEST, { url, method, headers, body, binary: Boolean(save_to) });
1629
+
1630
+ if (save_to) {
1631
+ // Il base64 non deve mai raggiungere il modello: un PDF di 300 kB sono
1632
+ // ~100k token di rumore per un contenuto che va letto da file.
1633
+ await writeFile(save_to, Buffer.from(data.body_b64 ?? '', 'base64'));
1634
+ return {
1635
+ content: [{
1636
+ type: 'text',
1637
+ text: jsonText({
1638
+ saved: save_to,
1639
+ status: data.status,
1640
+ ok: data.ok,
1641
+ content_type: data.content_type,
1642
+ size: data.size,
1643
+ url: data.url,
1644
+ }),
1645
+ }],
1646
+ };
1647
+ }
1648
+
1649
+ return {
1650
+ content: [{
1651
+ type: 'text',
1652
+ text: truncateText(
1653
+ jsonText({
1654
+ status: data.status,
1655
+ ok: data.ok,
1656
+ content_type: data.content_type,
1657
+ size: data.size,
1658
+ url: data.url,
1659
+ headers: data.headers,
1660
+ body: data.body,
1661
+ }, max_length ?? DEFAULT_MAX_OUTPUT, 'max_length'),
1662
+ max_length ?? DEFAULT_MAX_OUTPUT,
1663
+ 'max_length',
1664
+ ),
1665
+ }],
1666
+ };
1667
+ }
1668
+ );
1669
+
1471
1670
  // --- set_zoom ---
1472
1671
  server.tool(
1473
1672
  'set_zoom',