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 +4 -4
- package/extension/manifest.json +2 -2
- package/extension/service-worker.js +45 -1
- package/extension/webmcp-hook.js +306 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -2
- package/server/protocol.js +2 -1
- package/server/tools.js +101 -8
package/README.md
CHANGED
|
@@ -164,12 +164,12 @@ user-script toggle isn't available.
|
|
|
164
164
|
|
|
165
165
|
## Tools
|
|
166
166
|
|
|
167
|
-
|
|
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
|
-

|
|
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
|
|
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`
|
package/extension/manifest.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "Chrome Bridge for Claude Code",
|
|
4
|
-
"version": "1.
|
|
5
|
-
"description": "Bridge your browser to Claude Code:
|
|
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
|
+
})();
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chrome-bridge-mcp",
|
|
3
|
-
"version": "1.
|
|
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.
|
|
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.
|
|
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).
|
|
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",
|
package/server/protocol.js
CHANGED
|
@@ -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.
|
|
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
|
|
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([
|
|
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
|
|
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',
|