chrome-bridge-mcp 1.30.6 → 1.31.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
@@ -2,7 +2,7 @@
2
2
 
3
3
  > By [frsorrentino](https://github.com/frsorrentino) · npm `chrome-bridge-mcp` · not affiliated with other projects named "chrome-bridge".
4
4
 
5
- ![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-388%20unit%20%2B%2041%20e2e-brightgreen) [![Chrome Web Store](https://img.shields.io/badge/web%20store-published-blue)](https://chromewebstore.google.com/detail/chrome-bridge-for-claude/bioknpaeahidbelaljjohjofiloeodmb)
5
+ ![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-388%20unit%20%2B%2041%20e2e-brightgreen) [![Chrome Web Store](https://img.shields.io/badge/web%20store-published-blue)](https://chromewebstore.google.com/detail/chrome-bridge-for-claude/bioknpaeahidbelaljjohjofiloeodmb) [![Chrome Bridge MCP on mcprush](https://mcprush.com/badge/chrome-bridge-mcp/score.svg)](https://mcprush.com/to/chrome-bridge-mcp?ref=badge)
6
6
 
7
7
  **Claude Code borrows a tab from the Chrome you are already using — signed in,
8
8
  with your extensions and your cookies — works in it, and hands it back. What
@@ -164,12 +164,12 @@ user-script toggle isn't available.
164
164
 
165
165
  ## Tools
166
166
 
167
- 67 in total. `core` (44 tools, every tool used in 101 real sessions) loads by
168
- default; the other 23 sit in seven optional caps that the agent switches on
167
+ 69 in total. `core` (46 tools: every tool used in 101 real sessions, plus the
168
+ two WebMCP tools) loads by default; the other 23 sit in seven optional caps that the agent switches on
169
169
  mid-session with `get_status({enable: ["visual"]})`, with no restart
170
170
  (`--caps` still sets them at startup).
171
171
 
172
- ![67 tools in eight groups, from clicking a button to tracing a page](assets/readme/card4-toolbox.png)
172
+ ![69 tools in eight groups, from clicking a button to tracing a page](assets/readme/card4-toolbox.png)
173
173
 
174
174
  | Group | N | What's in it |
175
175
  |---|---|---|
@@ -316,7 +316,7 @@ count.
316
316
 
317
317
  ## Documentation
318
318
 
319
- - [docs/TOOLS.md](docs/TOOLS.md) — all 67 tools, by group
319
+ - [docs/TOOLS.md](docs/TOOLS.md) — all 69 tools, by group
320
320
  - [docs/CAPABILITIES.md](docs/CAPABILITIES.md) — what the bridge gets past and what it does not, one dated state per wall
321
321
  - [docs/EFFICIENCY.md](docs/EFFICIENCY.md) — the benchmark and the design behind it
322
322
  - [docs/PERFORMANCE.md](docs/PERFORMANCE.md) — latency per tool on the real path, `npm run bench:latency`
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Chrome Bridge for Claude Code",
4
- "version": "1.30.5",
5
- "description": "Bridge your browser to Claude Code: 67 token-efficient web-dev automation tools over a local WebSocket. ChromeOS included.",
4
+ "version": "1.31.0",
5
+ "description": "Bridge your browser to Claude Code: 69 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": [
@@ -50,7 +50,7 @@ async function _applyInstrumentation() {
50
50
  const isReg = existing.some((s) => s.id === INSTRUMENTATION_ID);
51
51
  // network-hook.js dalla 1.25.0: la registrazione persiste agli
52
52
  // aggiornamenti, quindi un elenco vecchio va riscritto, non solo creato.
53
- const JS = ['console-capture.js', 'network-hook.js', 'page-instrumentation.js'];
53
+ const JS = ['console-capture.js', 'network-hook.js', 'page-instrumentation.js', 'webmcp-hook.js'];
54
54
  const stale = isReg && JSON.stringify(existing.find((s) => s.id === INSTRUMENTATION_ID)?.js ?? []) !== JSON.stringify(JS);
55
55
  if (instrument && stale) {
56
56
  await chrome.scripting.updateContentScripts([{ id: INSTRUMENTATION_ID, js: JS }]);
@@ -553,6 +553,8 @@ async function executeCommand(msg) {
553
553
  return await cmdGetResponseHeaders(params);
554
554
  case 'get_interactives':
555
555
  return await cmdGetInteractives(params);
556
+ case 'webmcp':
557
+ return await cmdWebmcp(params);
556
558
  case 'wait_for_function':
557
559
  return await cmdWaitForFunction(params);
558
560
  case 'scroll_until':
@@ -5539,6 +5541,48 @@ async function cmdWaitForFunction({ expression, timeout = 10000, polling_ms = 10
5539
5541
  }
5540
5542
  }
5541
5543
 
5544
+ // --- webmcp ---
5545
+ // Gli strumenti WebMCP vivono in window.__cbWebmcp (webmcp-hook.js, MAIN
5546
+ // world). Con la strumentazione spenta il gancio non c'è: si inietta qui, e
5547
+ // vede comunque tutto ciò che getTools elenca.
5548
+
5549
+ async function ensureWebmcpHook(target) {
5550
+ const [{ result } = {}] = await chrome.scripting.executeScript({ target, world: 'MAIN', func: () => Boolean(window.__cbWebmcp) });
5551
+ if (!result) await chrome.scripting.executeScript({ target, files: ['webmcp-hook.js'], world: 'MAIN' });
5552
+ }
5553
+
5554
+ async function cmdWebmcp({ action = 'list', name, input = {}, timeout = 30000, confirm = false, limit = 20, tab_id, frame_id }) {
5555
+ const tabId = await resolveTabId(tab_id);
5556
+ const target = scriptTarget(tabId, frame_id);
5557
+ await ensureWebmcpHook(target);
5558
+ const run = async (func, args = []) => (await chrome.scripting.executeScript({ target, world: 'MAIN', func, args }))[0]?.result;
5559
+ if (action === 'summary') return await run(() => window.__cbWebmcp.summary());
5560
+ if (action === 'list') {
5561
+ const data = await run(() => window.__cbWebmcp.list());
5562
+ return limit > 0 && data ? { ...data, recent_calls: await run((l) => window.__cbWebmcp.calls(l), [limit]) } : data;
5563
+ }
5564
+ if (action !== 'call') throw new Error(`Unknown webmcp action: ${action}`);
5565
+ if (!name) throw new Error('Missing required parameter: name');
5566
+
5567
+ // Una chiamata può portare a un'altra pagina (un form inviato, un redirect):
5568
+ // la risposta dice dove. È il caso aperto dell'explainer (issue #135).
5569
+ const before = (await chrome.tabs.get(tabId)).url;
5570
+ let res;
5571
+ try {
5572
+ res = await run((n, i, o) => window.__cbWebmcp.call(n, i, o), [name, input, { timeout, confirmed: confirm === true }]);
5573
+ } catch (err) {
5574
+ res = { status: 'navigated', note: `the page went away during the call (${err.message})` };
5575
+ }
5576
+ if (res == null) res = { status: 'navigated', note: 'the page went away before the tool answered' };
5577
+ if (['ok', 'error', 'timeout', 'navigated'].includes(res.status)) {
5578
+ await new Promise((r) => setTimeout(r, 300));
5579
+ let tab = await chrome.tabs.get(tabId);
5580
+ if (tab.status === 'loading') { await waitForComplete(tabId, 4000); tab = await chrome.tabs.get(tabId); }
5581
+ if (tab.url !== before) res.navigated_to = tab.url;
5582
+ }
5583
+ return res;
5584
+ }
5585
+
5542
5586
  // --- scroll_until ---
5543
5587
 
5544
5588
  async function cmdScrollUntil({ until = 'no_new_content', selector, container, max_scrolls = 20, step_px, settle_ms = 400, tab_id }) {
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Iniettato a document_start in MAIN world: legge e chiama gli strumenti
3
+ * WebMCP che la pagina dichiara, e ne tiene il registro delle chiamate.
4
+ *
5
+ * WebMCP (W3C Web Machine Learning CG): la pagina registra strumenti con
6
+ * document.modelContext.registerTool (imperativi) o li dichiara con
7
+ * <form toolname> (dichiarativi). Senza il flag
8
+ * chrome://flags/#enable-webmcp-testing o l'origin trial document.modelContext
9
+ * non esiste: i form dichiarativi restano visibili ma inerti.
10
+ *
11
+ * Il gancio non crea mai document.modelContext: un sito che fa feature
12
+ * detection deve vedere il browser com'è. Avvolge registerTool dove esiste
13
+ * (nativo a document_start, o un polyfill alla prima lettura), così anche le
14
+ * chiamate dell'agente del browser finiscono nel registro.
15
+ */
16
+ (() => {
17
+ if (window.__cbWebmcp) return;
18
+
19
+ const MAX_CALLS = 100;
20
+ const CLIP = 2000;
21
+ const calls = [];
22
+ const regs = new Map(); // name → { annotations, execute } dalle registerTool viste
23
+ const patched = new WeakMap(); // modelContext → executeTool originale
24
+ const pending = new Map(); // name → voci di registro di chiamate già aperte e in corso
25
+ let toolchanges = 0;
26
+
27
+ const mcOf = () => document.modelContext ?? navigator.modelContext ?? null;
28
+
29
+ const clip = (v) => {
30
+ if (v === undefined) return null;
31
+ let s;
32
+ try { s = JSON.stringify(v); } catch { s = String(v); }
33
+ if (s === undefined) return null;
34
+ if (s.length <= CLIP) { try { return JSON.parse(s); } catch { return s; } }
35
+ return `${s.slice(0, CLIP)}…[+${s.length - CLIP} chars]`;
36
+ };
37
+
38
+ const record = (entry) => {
39
+ if (calls.length >= MAX_CALLS) calls.shift();
40
+ calls.push(entry);
41
+ return entry;
42
+ };
43
+
44
+ // Una chiamata già aperta (da call o da executeTool) passa anche dal
45
+ // wrapper di execute o dal listener di submit: una sola voce, col chiamante
46
+ // giusto.
47
+ const open = (entry) => {
48
+ if (!pending.has(entry.tool)) pending.set(entry.tool, []);
49
+ pending.get(entry.tool).push(entry);
50
+ };
51
+ const close = (entry) => {
52
+ const q = pending.get(entry.tool);
53
+ const i = q ? q.indexOf(entry) : -1;
54
+ if (i >= 0) q.splice(i, 1);
55
+ };
56
+ const takePending = (name) => pending.get(name)?.shift() ?? null;
57
+
58
+ const settle = (entry, t0, out, err) => Object.assign(entry, err === undefined
59
+ ? { status: 'ok', output: clip(out), ms: Math.round(performance.now() - t0) }
60
+ : { status: err?.timeout ? 'timeout' : 'error', error: String(err?.message ?? err), ms: Math.round(performance.now() - t0) });
61
+
62
+ const patch = (mc) => {
63
+ if (!mc || patched.has(mc) || typeof mc.registerTool !== 'function') return;
64
+ const origExecute = typeof mc.executeTool === 'function' ? mc.executeTool : null;
65
+ patched.set(mc, origExecute);
66
+ try { mc.addEventListener?.('toolchange', () => { toolchanges += 1; }); } catch {}
67
+ // executeTool chiamato dalla pagina o da un altro agente: si registra anche
68
+ // per gli strumenti registrati prima del gancio (un polyfill).
69
+ if (origExecute) {
70
+ mc.executeTool = async function (tool, input, ...rest) {
71
+ const name = String(tool?.name);
72
+ const isForm = declaredForms().some((f) => f.getAttribute('toolname') === name);
73
+ const e = record({ at: Date.now(), tool: name, kind: isForm ? 'declarative' : 'imperative', caller: 'other', input: clip(input), status: 'running' });
74
+ open(e);
75
+ const t0 = performance.now();
76
+ try {
77
+ const out = await origExecute.call(this, tool, input, ...rest);
78
+ settle(e, t0, out);
79
+ return out;
80
+ } catch (err) {
81
+ settle(e, t0, undefined, err);
82
+ throw err;
83
+ } finally { close(e); }
84
+ };
85
+ }
86
+ const orig = mc.registerTool;
87
+ mc.registerTool = function (tool, opts) {
88
+ try {
89
+ if (tool && typeof tool.execute === 'function' && tool.name) {
90
+ const exec = tool.execute;
91
+ const name = String(tool.name);
92
+ const wrapped = async function (input, ...rest) {
93
+ if (takePending(name)) return exec.call(this, input, ...rest);
94
+ const e = record({ at: Date.now(), tool: name, kind: 'imperative', caller: 'other', input: clip(input), status: 'running' });
95
+ const t0 = performance.now();
96
+ try {
97
+ const out = await exec.call(this, input, ...rest);
98
+ settle(e, t0, out);
99
+ return out;
100
+ } catch (err) {
101
+ settle(e, t0, undefined, err);
102
+ throw err;
103
+ }
104
+ };
105
+ regs.set(name, { annotations: tool.annotations ?? null, execute: exec });
106
+ opts?.signal?.addEventListener?.('abort', () => regs.delete(name));
107
+ return orig.call(this, { ...tool, execute: wrapped }, opts);
108
+ }
109
+ } catch {}
110
+ return orig.call(this, tool, opts);
111
+ };
112
+ };
113
+ patch(mcOf());
114
+
115
+ // --- strumenti dichiarativi: <form toolname> ---
116
+ const fieldSchema = (el) => {
117
+ const type = (el.getAttribute('type') || '').toLowerCase();
118
+ const s = {};
119
+ if (el.tagName === 'SELECT') {
120
+ s.type = 'string';
121
+ s.enum = [...el.options].map((o) => o.value);
122
+ } else if (type === 'number' || type === 'range') s.type = 'number';
123
+ else if (type === 'checkbox') s.type = 'boolean';
124
+ else s.type = 'string';
125
+ const d = el.getAttribute('toolparamdescription');
126
+ if (d) s.description = d;
127
+ return s;
128
+ };
129
+
130
+ const formSchema = (form) => {
131
+ const properties = {};
132
+ const required = [];
133
+ for (const el of form.elements) {
134
+ const name = el.name;
135
+ if (!name || el.disabled) continue;
136
+ const type = (el.getAttribute('type') || '').toLowerCase();
137
+ if (['submit', 'button', 'reset', 'image', 'hidden'].includes(type) || el.tagName === 'BUTTON' || el.tagName === 'FIELDSET') continue;
138
+ if (type === 'radio') {
139
+ const p = (properties[name] ??= { type: 'string', enum: [] });
140
+ p.enum.push(el.value);
141
+ } else properties[name] = fieldSchema(el);
142
+ if (el.required && !required.includes(name)) required.push(name);
143
+ }
144
+ return { type: 'object', properties, ...(required.length && { required }) };
145
+ };
146
+
147
+ const declaredForms = () => [...document.querySelectorAll('form[toolname]')];
148
+
149
+ // Invio di un form dichiarativo da parte di un agente: il risultato arriva
150
+ // con respondWith, che si avvolge sull'istanza prima che il gestore della
151
+ // pagina lo chiami (questo listener è in cattura su window).
152
+ window.addEventListener('submit', (e) => {
153
+ const form = e.target;
154
+ if (!e.agentInvoked || !form?.getAttribute?.('toolname')) return;
155
+ const name = form.getAttribute('toolname');
156
+ const input = {};
157
+ try { for (const [k, v] of new FormData(form)) input[k] = typeof v === 'string' ? v : `[file ${v.name}]`; } catch {}
158
+ const entry = takePending(name) ?? record({ at: Date.now(), tool: name, kind: 'declarative', caller: 'other', input: clip(input), status: 'submitted' });
159
+ const t0 = performance.now();
160
+ if (typeof e.respondWith === 'function') {
161
+ const orig = e.respondWith;
162
+ e.respondWith = function (p) {
163
+ Promise.resolve(p).then((out) => settle(entry, t0, out), (err) => settle(entry, t0, undefined, err));
164
+ return orig.call(this, p);
165
+ };
166
+ }
167
+ }, true);
168
+ window.addEventListener('toolcancel', (e) => {
169
+ const name = e.target?.getAttribute?.('toolname');
170
+ const last = [...calls].reverse().find((c) => c.tool === name && c.kind === 'declarative');
171
+ if (last && last.status === 'submitted') last.status = 'cancelled';
172
+ }, true);
173
+
174
+ // --- azioni sensibili ---
175
+ // Acquisti, pagamenti, invii, cancellazioni: partono solo con conferma.
176
+ // Un form dichiarativo è un invio di modulo, tranne il GET (ricerca, filtri),
177
+ // che per HTTP non cambia lo stato del server.
178
+ const SENSITIVE = /check.?out|purchase|buy|pay|order|book|reserv|subscri|send|submit|delete|remove|cancel|transfer|donat|sign.?up|register|acquist|pagament|pagare|ordin|prenot|invi|elimin|cancell|bonific|iscri/i;
179
+ const sensitivity = (t) => {
180
+ if (t.read_only === true) return null;
181
+ if (t.kind === 'declarative') {
182
+ return t.method === 'get' ? null : `declarative form (${t.method.toUpperCase()}): submitting it sends data to the site`;
183
+ }
184
+ const m = `${t.name} ${t.description ?? ''}`.match(SENSITIVE);
185
+ return m ? `"${m[0]}" in name or description: it may buy, send or delete` : null;
186
+ };
187
+
188
+ const schemaOf = (raw) => {
189
+ if (typeof raw !== 'string') return raw ?? null;
190
+ try { return JSON.parse(raw); } catch { return raw; }
191
+ };
192
+
193
+ async function list() {
194
+ const mc = mcOf();
195
+ patch(mc);
196
+ const forms = declaredForms();
197
+ const formByName = new Map(forms.map((f) => [f.getAttribute('toolname'), f]));
198
+ const out = [];
199
+ const seen = new Set();
200
+ const add = (t) => {
201
+ if (seen.has(t.name)) return;
202
+ seen.add(t.name);
203
+ const n = calls.filter((c) => c.tool === t.name).length;
204
+ const tool = { ...t, ...(n && { calls: n }) };
205
+ const why = sensitivity(tool);
206
+ if (why) tool.sensitive = why;
207
+ delete tool.method;
208
+ out.push(tool);
209
+ };
210
+ const formTool = (f, extra = {}) => ({
211
+ name: f.getAttribute('toolname'),
212
+ description: f.getAttribute('tooldescription') ?? '',
213
+ input_schema: formSchema(f),
214
+ kind: 'declarative',
215
+ method: (f.getAttribute('method') || 'get').toLowerCase(),
216
+ autosubmit: f.hasAttribute('toolautosubmit'),
217
+ ...extra,
218
+ });
219
+ if (mc && typeof mc.getTools === 'function') {
220
+ let tools = [];
221
+ try { tools = (await mc.getTools()) ?? []; } catch {}
222
+ for (const t of tools) {
223
+ const name = String(t.name);
224
+ const f = formByName.get(name);
225
+ if (f) {
226
+ // Lo schema del browser vince su quello letto dai campi, se dice qualcosa.
227
+ const given = schemaOf(t.inputSchema);
228
+ const useful = given && typeof given === 'object' && Object.keys(given.properties ?? {}).length;
229
+ add(formTool(f, { origin: t.origin ?? location.origin, ...(useful && { input_schema: given }) }));
230
+ continue;
231
+ }
232
+ const ann = regs.get(name)?.annotations ?? t.annotations ?? null;
233
+ add({
234
+ name,
235
+ description: t.description ?? '',
236
+ input_schema: schemaOf(t.inputSchema),
237
+ kind: 'imperative',
238
+ origin: t.origin ?? location.origin,
239
+ ...(ann?.readOnlyHint === true && { read_only: true }),
240
+ });
241
+ }
242
+ } else if (mc) {
243
+ // Senza getTools: almeno quelli registrati dopo il gancio.
244
+ for (const [name, r] of regs) add({ name, kind: 'imperative', origin: location.origin, ...(r.annotations?.readOnlyHint === true && { read_only: true }) });
245
+ }
246
+ for (const f of forms) add(formTool(f));
247
+ return { active: Boolean(mc), tools: out, ...(toolchanges && { toolchange_events: toolchanges }) };
248
+ }
249
+
250
+ async function summary() {
251
+ const { active, tools } = await list();
252
+ return { active, tools: tools.map((t) => ({ name: t.name, kind: t.kind, ...(t.sensitive && { sensitive: true }) })) };
253
+ }
254
+
255
+ async function call(name, input = {}, { timeout = 30000, confirmed = false } = {}) {
256
+ const mc = mcOf();
257
+ const { tools } = await list();
258
+ const meta = tools.find((t) => t.name === name);
259
+ if (!meta) {
260
+ return { status: 'not_found', error: `No WebMCP tool "${name}" on this page`, available: tools.map((t) => t.name) };
261
+ }
262
+ if (!mc) {
263
+ return { status: 'inactive', error: 'WebMCP is off in this Chrome (document.modelContext missing): the tool is only declared' };
264
+ }
265
+ if (meta.sensitive && !confirmed) {
266
+ return { status: 'needs_confirmation', tool: name, reason: meta.sensitive, input };
267
+ }
268
+ const entry = record({ at: Date.now(), tool: name, kind: meta.kind, caller: 'chrome-bridge', input: clip(input), status: 'running' });
269
+ const t0 = performance.now();
270
+ const ac = new AbortController();
271
+ let timer;
272
+ const timedOut = new Promise((_, rej) => { timer = setTimeout(() => { ac.abort(); rej(Object.assign(new Error(`timed out after ${timeout} ms`), { timeout: true })); }, timeout); });
273
+ open(entry);
274
+ try {
275
+ let run;
276
+ const exec = patched.get(mc);
277
+ if (exec && typeof mc.getTools === 'function') {
278
+ const tool = ((await mc.getTools()) ?? []).find((t) => String(t.name) === name);
279
+ if (!tool) throw new Error(`"${name}" is no longer registered`);
280
+ run = exec.call(mc, tool, input, { signal: ac.signal });
281
+ } else if (regs.has(name)) {
282
+ run = regs.get(name).execute(input);
283
+ } else {
284
+ throw new Error('this WebMCP version has no executeTool and the tool was registered before chrome-bridge could see it');
285
+ }
286
+ const out = await Promise.race([run, timedOut]);
287
+ settle(entry, t0, out);
288
+ return { status: 'ok', result: entry.output, ms: entry.ms };
289
+ } catch (err) {
290
+ settle(entry, t0, undefined, err);
291
+ return { status: entry.status, error: entry.error, ms: entry.ms };
292
+ } finally {
293
+ clearTimeout(timer);
294
+ // Uno strumento che non è passato dal wrapper (registrato prima del
295
+ // gancio) lascerebbe la voce in coda e ruberebbe la prossima chiamata.
296
+ close(entry);
297
+ }
298
+ }
299
+
300
+ window.__cbWebmcp = {
301
+ list,
302
+ summary,
303
+ call,
304
+ calls: (limit = 20) => calls.slice(-Math.max(1, limit)),
305
+ };
306
+ })();