chrome-bridge-mcp 1.30.7 → 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
@@ -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
+ })();
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "chrome-bridge-mcp",
3
- "version": "1.30.7",
3
+ "version": "1.31.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "chrome-bridge-mcp",
9
- "version": "1.30.7",
9
+ "version": "1.31.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@jridgewell/trace-mapping": "^0.3.31",
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.30.7",
4
+ "version": "1.31.0",
5
5
  "type": "module",
6
- "description": "Chrome Bridge by frsorrentino: MCP server that lets Claude Code borrow a tab from your own logged-in Chrome and hand it back — logins, 2FA and CAPTCHAs stay with you (handoff). 67 tools; 2.8-4.3× cheaper and up to 9.3× fewer turns than Claude in Chrome, measured; ChromeOS included.",
6
+ "description": "Chrome Bridge by frsorrentino: MCP server that lets Claude Code borrow a tab from your own logged-in Chrome and hand it back — logins, 2FA and CAPTCHAs stay with you (handoff). 69 tools; 2.8-4.3× cheaper and up to 9.3× fewer turns than Claude in Chrome, measured; ChromeOS included.",
7
7
  "main": "server/index.js",
8
8
  "bin": {
9
9
  "chrome-bridge-mcp": "server/index.js",
@@ -75,6 +75,7 @@ export const MessageType = Object.freeze({
75
75
  GET_INTERACTIVES: 'get_interactives',
76
76
  WAIT_FOR_FUNCTION: 'wait_for_function',
77
77
  SCROLL_UNTIL: 'scroll_until',
78
+ WEBMCP: 'webmcp',
78
79
  DISMISS_OVERLAYS: 'dismiss_overlays',
79
80
  KEYBOARD_WALK: 'keyboard_walk',
80
81
  HANDOFF: 'handoff',
@@ -104,7 +105,7 @@ export const MessageType = Object.freeze({
104
105
  });
105
106
 
106
107
  // Versione
107
- export const VERSION = '1.30.7';
108
+ export const VERSION = '1.31.0';
108
109
 
109
110
  // Configurazione
110
111
  export const DEFAULT_PORT = 8765;
package/server/tools.js CHANGED
@@ -315,6 +315,10 @@ const SUBMIT_SETTLE_MS = 2000;
315
315
  const SUBMIT_POLL_MS = 250;
316
316
  const SUBMIT_TEXT_MAX = 800;
317
317
 
318
+ // WebMCP (W3C Web Machine Learning CG): in Chrome dietro flag o origin trial.
319
+ const WEBMCP_ENABLE = 'chrome://flags/#enable-webmcp-testing (Chrome 149+), or the site joins the WebMCP origin trial';
320
+ const WEBMCP_TIMEOUT_MAX = 120000;
321
+
318
322
  const waitAfter = z.enum(['none', 'navigation', 'networkidle']).optional().default('none')
319
323
  .describe('Settle before returning: navigation waits for a page load, networkidle for quiet traffic');
320
324
 
@@ -387,6 +391,7 @@ export const TOOL_ANNOTATIONS = {
387
391
  track_events: ro(),
388
392
  cookie_audit: rw({ idempotent: true }),
389
393
  find_setting: rw({ idempotent: true }),
394
+ webmcp_tools: ro(),
390
395
 
391
396
  // --- interazione con la pagina ---
392
397
  click: rw({ open: true }), // un click può navigare
@@ -420,6 +425,7 @@ export const TOOL_ANNOTATIONS = {
420
425
 
421
426
  // --- codice arbitrario ---
422
427
  execute_js: rw({ destructive: true, open: true }),
428
+ webmcp_call: rw({ destructive: true, open: true }), // esegue codice della pagina: può comprare, inviare, navigare
423
429
 
424
430
  // http_request non è read-only: un POST cambia lo stato del server remoto, e
425
431
  // con save_to scrive un file. destructive perché sovrascrive save_to senza
@@ -835,13 +841,36 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
835
841
  } catch { return null; } finally { recordSuppressed -= 1; }
836
842
  }
837
843
 
844
+ // Strumenti WebMCP della pagina in una riga, solo se ce ne sono: l'agente
845
+ // usa l'azione dichiarata al posto di tanti clic. Best-effort come
846
+ // pageProblems: su errore o pagina senza strumenti, niente.
847
+ async function webmcpSummary(tab_id) {
848
+ recordSuppressed += 1;
849
+ try {
850
+ const d = await send(MessageType.WEBMCP, { action: 'summary', tab_id });
851
+ return typeof d?.active === 'boolean' ? d : null;
852
+ } catch { return null; } finally { recordSuppressed -= 1; }
853
+ }
854
+ async function webmcpHint(tab_id) {
855
+ const d = await webmcpSummary(tab_id);
856
+ if (!d?.tools?.length) return null;
857
+ const shown = d.tools.slice(0, 8).map((t) => {
858
+ const tags = [t.kind === 'declarative' && 'form', t.sensitive && 'sensitive'].filter(Boolean);
859
+ return tags.length ? `${t.name} (${tags.join(', ')})` : t.name;
860
+ }).join(', ') + (d.tools.length > 8 ? `, +${d.tools.length - 8}` : '');
861
+ return d.active
862
+ ? `webmcp: the page declares ${d.tools.length} tool(s): ${shown}. webmcp_call runs one in a single step instead of clicks; webmcp_tools gives the schemas`
863
+ : `webmcp: the page declares ${d.tools.length} form tool(s): ${shown}, but WebMCP is off in this Chrome; fill_form drives the form (enable: ${WEBMCP_ENABLE})`;
864
+ }
865
+
838
866
  // --- get_status ---
839
867
  server.tool(
840
868
  'get_status',
841
869
  'Status of the bridge itself, not of a page: call it when a tool you expect is missing or browser commands fail '
842
870
  + '(connected=false: the extension is not reachable, extension_install gives its Chrome Web Store link). Also reports mode (primary/relay), host, port, server and '
843
871
  + 'extension versions, js_evaluation (false under --no-js), write_root, read_root, session_tab_id (the implicit tab), '
844
- + 'owned_tabs (created by this session), uptime_sec. caps_active and caps_available: a missing tool lives in an inactive cap. '
872
+ + 'owned_tabs (created by this session), uptime_sec, webmcp (whether WebMCP is active in the session tab, and how to enable it). '
873
+ + 'caps_active and caps_available: a missing tool lives in an inactive cap. '
845
874
  + 'enable is the only change it makes: it switches caps on for this session, no restart, and lists the tools added. Optional caps: '
846
875
  + `${Object.entries(TOOL_CAPS).map(([g, t]) => `${g} (${t.join(', ')})`).join('; ')}.`,
847
876
  {
@@ -864,6 +893,11 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
864
893
  if (!activeCaps.includes(g)) activeCaps.push(g);
865
894
  }
866
895
  }
896
+ // Lo stato di WebMCP si legge da una pagina: flag e origin trial
897
+ // valgono per il browser o per il sito. Al più 2 s, poi si omette.
898
+ const webmcp = wsManager.isConnected()
899
+ ? await Promise.race([webmcpSummary(), new Promise((r) => setTimeout(r, 2000, null))])
900
+ : null;
867
901
  return {
868
902
  content: [{
869
903
  type: 'text',
@@ -888,6 +922,7 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
888
922
  session_tab_id: sessionTabId,
889
923
  owned_tabs: [...ownedTabs],
890
924
  uptime_sec: Math.round((Date.now() - startedAt) / 1000),
925
+ ...(webmcp && { webmcp: { active: webmcp.active, ...(!webmcp.active && { enable: WEBMCP_ENABLE }) } }),
891
926
  ...(enabledNow && { enabled: enabledNow }),
892
927
  }),
893
928
  }],
@@ -939,7 +974,8 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
939
974
  'navigate',
940
975
  'Navigate a Chrome tab to a URL and wait for the load; DNS and connection errors fail the call. Without tab_id it reuses the tab '
941
976
  + 'this session last navigated or created, else opens a new one rather than taking over the active tab. Returns a preview of '
942
- + 'interactive elements with refs usable in click/type_text/hover, plus the first console errors and failed requests, if any.',
977
+ + 'interactive elements with refs usable in click/type_text/hover, plus the first console errors and failed requests, if any, '
978
+ + 'and the WebMCP tools the page declares.',
943
979
  {
944
980
  url: z.string().describe('Absolute URL, or a path resolved against the current page'),
945
981
  tab_id: tabId,
@@ -952,11 +988,13 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
952
988
  // Il tab navigato diventa il default di sessione per i comandi successivi
953
989
  if (data?.tabId != null) sessionTabId = data.tabId;
954
990
  resetRefs(data?.tabId ?? tab_id);
955
- const [preview, problems] = await Promise.all([interactivesPreview(data?.tabId ?? tab_id), pageProblems(data?.tabId ?? tab_id)]);
991
+ const [preview, problems, webmcp] = await Promise.all([
992
+ interactivesPreview(data?.tabId ?? tab_id), pageProblems(data?.tabId ?? tab_id), webmcpHint(data?.tabId ?? tab_id),
993
+ ]);
956
994
  return {
957
995
  content: [{
958
996
  type: 'text',
959
- text: jsonText(data) + (problems ? `\n${problems}` : '') + (preview ? `\n${preview}` : ''),
997
+ text: jsonText(data) + (problems ? `\n${problems}` : '') + (webmcp ? `\n${webmcp}` : '') + (preview ? `\n${preview}` : ''),
960
998
  }],
961
999
  };
962
1000
  }
@@ -3148,7 +3186,8 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
3148
3186
  server.tool(
3149
3187
  'get_interactives',
3150
3188
  '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. '
3151
- + 'Document order, cut at limit (count equal to limit: there may be more), each with a ref (n1, n2…) for click, type_text and hover.',
3189
+ + 'Document order, cut at limit (count equal to limit: there may be more), each with a ref (n1, n2…) for click, type_text and hover. '
3190
+ + 'The full list also names the WebMCP tools the page declares, if any.',
3152
3191
  {
3153
3192
  scope: z.string().optional().describe('Limit search (CSS selector)'),
3154
3193
  limit: z.number().optional().default(100).describe('Max elements returned; raise it on a dense page'),
@@ -3160,7 +3199,10 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
3160
3199
  tab_id: tabId,
3161
3200
  },
3162
3201
  async ({ scope, limit, visible_only, format, since, frame_id, tab_id }) => {
3163
- const data = await send(MessageType.GET_INTERACTIVES, { scope, limit, visible_only, frame_id, tab_id });
3202
+ const [data, webmcp] = await Promise.all([
3203
+ send(MessageType.GET_INTERACTIVES, { scope, limit, visible_only, frame_id, tab_id }),
3204
+ since ? null : webmcpHint(tab_id),
3205
+ ]);
3164
3206
  const elements = data?.elements ?? [];
3165
3207
  // Ref stabili per selettore e frame, memorizzati per click/type_text/hover
3166
3208
  assignRefs(tab_id, elements, frame_id);
@@ -3168,7 +3210,7 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
3168
3210
  if ((format ?? 'lines') === 'json') {
3169
3211
  const body = diff
3170
3212
  ? { count: data?.count ?? elements.length, since, added: diff.added, changed: diff.changed, removed: diff.removed }
3171
- : { ...data, ...(note && { since_note: note }) };
3213
+ : { ...data, ...(note && { since_note: note }), ...(webmcp && { webmcp }) };
3172
3214
  return { content: [{ type: 'text', text: jsonText({ ...body, cursor }) }] };
3173
3215
  }
3174
3216
  const cursorLine = `\ncursor=${cursor} (pass as since to get only what changed)`;
@@ -3186,12 +3228,63 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
3186
3228
  return {
3187
3229
  content: [{
3188
3230
  type: 'text',
3189
- text: truncateText(interactivesLines(data) + (note ? `\nnote: ${note}` : ''), DEFAULT_MAX_OUTPUT) + cursorLine,
3231
+ text: truncateText(interactivesLines(data) + (note ? `\nnote: ${note}` : ''), DEFAULT_MAX_OUTPUT) + cursorLine + (webmcp ? `\n${webmcp}` : ''),
3190
3232
  }],
3191
3233
  };
3192
3234
  }
3193
3235
  );
3194
3236
 
3237
+ // --- webmcp_tools ---
3238
+ server.tool(
3239
+ 'webmcp_tools',
3240
+ 'List the WebMCP tools the page declares for agents: name, description, input_schema, origin, kind (imperative = registered '
3241
+ + 'with document.modelContext.registerTool; declarative = a <form toolname>), sensitive (why webmcp_call wants confirm) and how '
3242
+ + 'many times each was called. active=false: WebMCP is off in this Chrome and only form tools are visible, as plain forms. '
3243
+ + 'calls adds the call log of the page, any caller (chrome-bridge or the browser agent): input, output, status, ms.',
3244
+ {
3245
+ calls: z.number().int().min(0).max(100).optional().default(0)
3246
+ .describe('Also return the last N calls from the log; 0 = tools only'),
3247
+ frame_id: frameId,
3248
+ tab_id: tabId,
3249
+ },
3250
+ async ({ calls, frame_id, tab_id }) => {
3251
+ const data = await send(MessageType.WEBMCP, { action: 'list', limit: calls ?? 0, frame_id, tab_id });
3252
+ const body = { ...data, ...(data && !data.active && { enable: WEBMCP_ENABLE }) };
3253
+ return { content: [{ type: 'text', text: jsonText(body) }] };
3254
+ }
3255
+ );
3256
+
3257
+ // --- webmcp_call ---
3258
+ server.tool(
3259
+ 'webmcp_call',
3260
+ 'Run a WebMCP tool the page declares (see webmcp_tools or the webmcp line of navigate): one call instead of the clicks and '
3261
+ + 'fields it replaces. Returns status (ok, error, timeout, not_found, inactive, needs_confirmation), the tool result and ms; '
3262
+ + 'navigated_to when the call led to another page. A sensitive tool (buy, pay, send, delete, a POST form) is not run '
3263
+ + 'without confirm:true: on needs_confirmation ask the user, then repeat with confirm.',
3264
+ {
3265
+ name: z.string().describe('Tool name as listed by webmcp_tools'),
3266
+ input: z.record(z.string(), z.any()).optional().default({})
3267
+ .describe('Arguments matching the tool input_schema'),
3268
+ confirm: z.boolean().optional().default(false)
3269
+ .describe('true only after the user approved this sensitive action'),
3270
+ timeout: z.number().int().min(1000).max(WEBMCP_TIMEOUT_MAX).optional().default(30000)
3271
+ .describe('Abort the tool after this many ms'),
3272
+ frame_id: frameId,
3273
+ tab_id: tabId,
3274
+ },
3275
+ async ({ name, input, confirm, timeout, frame_id, tab_id }) => {
3276
+ const data = await send(MessageType.WEBMCP, { action: 'call', name, input: input ?? {}, confirm: confirm === true, timeout, frame_id, tab_id });
3277
+ if (data?.status === 'needs_confirmation') data.next = 'ask the user; if they approve, repeat the call with confirm:true';
3278
+ if (data?.status === 'inactive') data.enable = WEBMCP_ENABLE;
3279
+ const text = jsonText(data);
3280
+ if (!data?.navigated_to) return { content: [{ type: 'text', text }] };
3281
+ // Pagina nuova: i ref vecchi non valgono più, quelli nuovi li dà la preview.
3282
+ resetRefs(tab_id);
3283
+ const preview = await interactivesPreview(tab_id);
3284
+ return { content: [{ type: 'text', text: preview ? `${text}\n${preview}` : text }] };
3285
+ }
3286
+ );
3287
+
3195
3288
  // --- extract ---
3196
3289
  server.tool(
3197
3290
  'extract',