webmcp-gauge 0.1.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.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +121 -0
  3. package/action.yml +162 -0
  4. package/bin/webmcp-gauge.mjs +544 -0
  5. package/bin/webmcp-gauge.test.mjs +354 -0
  6. package/browser/launch.mjs +188 -0
  7. package/browser/serve.mjs +78 -0
  8. package/browser/session.mjs +210 -0
  9. package/browser/webmcp.mjs +432 -0
  10. package/browser/webmcp.test.mjs +299 -0
  11. package/core/args.mjs +93 -0
  12. package/core/args.test.mjs +85 -0
  13. package/core/capture-seam.test.mjs +86 -0
  14. package/core/cohort.mjs +432 -0
  15. package/core/cohort.test.mjs +370 -0
  16. package/core/gallery.mjs +145 -0
  17. package/core/gallery.test.mjs +128 -0
  18. package/core/gate.mjs +164 -0
  19. package/core/gate.test.mjs +213 -0
  20. package/core/lint.mjs +381 -0
  21. package/core/lint.test.mjs +346 -0
  22. package/core/orchestrate.mjs +128 -0
  23. package/core/orchestrate.test.mjs +191 -0
  24. package/core/stats.mjs +172 -0
  25. package/core/stats.test.mjs +156 -0
  26. package/core/sweep.mjs +274 -0
  27. package/core/sweep.test.mjs +162 -0
  28. package/core/taxonomy.mjs +175 -0
  29. package/core/taxonomy.test.mjs +198 -0
  30. package/core/trial.mjs +248 -0
  31. package/core/visibility.mjs +163 -0
  32. package/core/visibility.test.mjs +164 -0
  33. package/docs/concept.md +468 -0
  34. package/docs/explainer.md +161 -0
  35. package/docs/getting-started.md +331 -0
  36. package/fixtures/README.md +42 -0
  37. package/fixtures/airlock.utterances.json +284 -0
  38. package/fixtures/broken/compose.mjs +52 -0
  39. package/fixtures/broken/compose.test.mjs +270 -0
  40. package/fixtures/broken/sample-expenses.csv +966 -0
  41. package/fixtures/broken/tools.json +1311 -0
  42. package/fixtures/broken/twin.html +482 -0
  43. package/fixtures/broken/widget.html +62 -0
  44. package/fixtures/gallery/gallery.html +56 -0
  45. package/judges/openai-compatible.mjs +145 -0
  46. package/package.json +53 -0
  47. package/report/badge.mjs +110 -0
  48. package/report/badge.test.mjs +97 -0
  49. package/report/emit.mjs +282 -0
  50. package/report/published-runs.test.mjs +77 -0
  51. package/report/scorecard.mjs +157 -0
  52. package/report/scorecard.test.mjs +130 -0
@@ -0,0 +1,210 @@
1
+ /**
2
+ * One CDP session against one fresh tab of an already-running flagged Chrome.
3
+ *
4
+ * Grown from _spike/cdp-eval.mjs, with the parts a trial needs that the spike
5
+ * lacked: a clean close path (the spike prints valid JSON and then dies with a
6
+ * libuv assertion on Windows, which is fine for a manual probe and unacceptable
7
+ * for anything a CI gate depends on), and no process.exit anywhere.
8
+ *
9
+ * Every wait in here is bounded. Nothing was, until a sweep hung for 90 minutes on
10
+ * trial 160 of 160 with no error and no progress: a CDP command that never gets a
11
+ * reply used to leave its promise pending forever, and `fetch` against the browser's
12
+ * own HTTP endpoint had no timeout either. A hang is indistinguishable from work in
13
+ * progress, which makes it the worst failure mode a long run can have - so a
14
+ * silent stall is now an error, and the sweep records it as a non-measurement.
15
+ */
16
+
17
+ const DEFAULT_PORT = '9333';
18
+
19
+ export const openSession = async ({
20
+ port = process.env.CDP_PORT ?? DEFAULT_PORT,
21
+ timeoutMs = 30000,
22
+ } = {}) => {
23
+ const base = `http://127.0.0.1:${port}`;
24
+
25
+ const target = await fetch(`${base}/json/new?about:blank`, {
26
+ method: 'PUT',
27
+ signal: AbortSignal.timeout(timeoutMs),
28
+ })
29
+ .then((response) => {
30
+ if (!response.ok) throw new Error(`CDP ${response.status} from ${base}/json/new`);
31
+ return response.json();
32
+ })
33
+ .catch((error) => {
34
+ throw new Error(
35
+ `No debuggable Chrome on ${base} (${error.message}). Launch it per docs/getting-started.md 1.1, and check /json/version answers.`
36
+ );
37
+ });
38
+
39
+ const socket = new WebSocket(target.webSocketDebuggerUrl);
40
+ const pending = new Map();
41
+ const eventWaiters = [];
42
+ /**
43
+ * Persistent event subscribers, as distinct from the one-shot waiters above.
44
+ * The browser's WebMCP domain has no "list the tools" command in Chrome 152 - it
45
+ * announces them through `toolsAdded`, one event per registration - so a browser-
46
+ * side view of a page's tools can only be *accumulated*, and something has to stay
47
+ * subscribed for the whole trial to do it.
48
+ */
49
+ const subscribers = new Map();
50
+ let nextId = 1;
51
+ let closed = false;
52
+
53
+ socket.addEventListener('message', (event) => {
54
+ const message = JSON.parse(event.data);
55
+
56
+ if (message.method) {
57
+ for (const handler of subscribers.get(message.method) ?? []) {
58
+ try {
59
+ handler(message.params ?? {});
60
+ } catch {
61
+ // A subscriber that throws must not take the socket's reader with it.
62
+ }
63
+ }
64
+ for (const waiter of eventWaiters.splice(0)) {
65
+ if (waiter.method === message.method) waiter.resolve(message.params ?? {});
66
+ else eventWaiters.push(waiter);
67
+ }
68
+ return;
69
+ }
70
+
71
+ const entry = pending.get(message.id);
72
+ if (!entry) return;
73
+ pending.delete(message.id);
74
+ clearTimeout(entry.timer);
75
+ if (message.error) entry.reject(new Error(`${message.error.message} (${message.error.code})`));
76
+ else entry.resolve(message.result);
77
+ });
78
+
79
+ await new Promise((resolve, reject) => {
80
+ const timer = setTimeout(
81
+ () => reject(new Error(`CDP websocket did not open within ${timeoutMs}ms`)),
82
+ timeoutMs
83
+ );
84
+ socket.addEventListener(
85
+ 'open',
86
+ () => {
87
+ clearTimeout(timer);
88
+ resolve();
89
+ },
90
+ { once: true }
91
+ );
92
+ socket.addEventListener(
93
+ 'error',
94
+ () => {
95
+ clearTimeout(timer);
96
+ reject(new Error('CDP websocket failed to open'));
97
+ },
98
+ { once: true }
99
+ );
100
+ });
101
+
102
+ const send = (method, params = {}) =>
103
+ new Promise((resolve, reject) => {
104
+ if (closed) {
105
+ reject(new Error(`session closed, cannot send ${method}`));
106
+ return;
107
+ }
108
+ const id = nextId++;
109
+ // A command with no reply is a dead session, not a slow one. Without this the
110
+ // promise stays pending and the caller waits forever.
111
+ const timer = setTimeout(() => {
112
+ pending.delete(id);
113
+ reject(new Error(`CDP ${method} did not answer within ${timeoutMs}ms`));
114
+ }, timeoutMs);
115
+ pending.set(id, { resolve, reject, timer });
116
+ socket.send(JSON.stringify({ id, method, params }));
117
+ });
118
+
119
+ const waitForEvent = (method, waitMs = timeoutMs) =>
120
+ new Promise((resolve, reject) => {
121
+ const timer = setTimeout(() => reject(new Error(`timed out waiting for ${method}`)), waitMs);
122
+ eventWaiters.push({
123
+ method,
124
+ resolve: (params) => {
125
+ clearTimeout(timer);
126
+ resolve(params);
127
+ },
128
+ });
129
+ });
130
+
131
+ /** Returns the evaluated value, or throws with the page-side exception text. */
132
+ const evaluate = async (expression, { awaitPromise = true } = {}) => {
133
+ const { result, exceptionDetails } = await send('Runtime.evaluate', {
134
+ expression,
135
+ awaitPromise,
136
+ returnByValue: true,
137
+ });
138
+ if (exceptionDetails) {
139
+ throw new Error(
140
+ `page threw: ${exceptionDetails.text} ${exceptionDetails.exception?.description ?? ''}`.trim()
141
+ );
142
+ }
143
+ return result.value ?? null;
144
+ };
145
+
146
+ const close = async () => {
147
+ if (closed) return;
148
+ closed = true;
149
+ for (const entry of pending.values()) {
150
+ clearTimeout(entry.timer);
151
+ entry.reject(new Error('session closed'));
152
+ }
153
+ pending.clear();
154
+ try {
155
+ socket.close();
156
+ } catch {
157
+ // A socket that is already gone is the state we wanted.
158
+ }
159
+ // Bounded, because closing the tab is cleanup: a browser that will not answer
160
+ // must not be able to hold a whole sweep open.
161
+ await fetch(`${base}/json/close/${target.id}`, { signal: AbortSignal.timeout(timeoutMs) }).catch(
162
+ () => {}
163
+ );
164
+ };
165
+
166
+ await send('Page.enable');
167
+ await send('Runtime.enable');
168
+
169
+ return {
170
+ targetId: target.id,
171
+ port: String(port),
172
+ send,
173
+ evaluate,
174
+ waitForEvent,
175
+ close,
176
+
177
+ /** Stays subscribed until the returned function is called. Returns an unsubscribe. */
178
+ subscribe(method, handler) {
179
+ const handlers = subscribers.get(method) ?? new Set();
180
+ handlers.add(handler);
181
+ subscribers.set(method, handlers);
182
+ return () => handlers.delete(handler);
183
+ },
184
+
185
+ /** Navigates and waits for the load event. Registration lands later; see captureManifest. */
186
+ async navigate(url) {
187
+ const loaded = waitForEvent('Page.loadEventFired');
188
+ // If the navigate command itself fails, nothing will ever fire the waiter,
189
+ // and its timeout would surface later as an unhandled rejection that kills
190
+ // the process instead of the caller's error.
191
+ loaded.catch(() => {});
192
+ await send('Page.navigate', { url });
193
+ await loaded;
194
+ },
195
+
196
+ /**
197
+ * Enables the browser-side WebMCP domain when the build has one. Chrome 152
198
+ * does; a build that does not is a compatibility-matrix row, not a failure,
199
+ * so the caller gets a flag rather than an exception.
200
+ */
201
+ async enableWebMcpDomain() {
202
+ try {
203
+ await send('WebMCP.enable');
204
+ return { available: true };
205
+ } catch (error) {
206
+ return { available: false, reason: String(error.message ?? error) };
207
+ }
208
+ },
209
+ };
210
+ };
@@ -0,0 +1,432 @@
1
+ /**
2
+ * The WebMCP surface of a page, read through a CDP session.
3
+ *
4
+ * Every read here is deliberately defensive about build differences, because the
5
+ * differences are the data: on Chrome 152 `getTools()` resolves a Promise and
6
+ * `navigator.modelContext` no longer exists, while the draft and older builds
7
+ * disagree on both, and `executeTool` takes an object in the draft but a JSON
8
+ * string in the type surface verified against Chrome 151.
9
+ */
10
+
11
+ /**
12
+ * Waits for the tool set to stop changing rather than to be non-empty. A
13
+ * mid-registration read of the reference page returned 3, then 4, of its 7 tools
14
+ * with no error at all - a partial set reported as the whole one.
15
+ */
16
+ const MANIFEST_EXPRESSION = `(async () => {
17
+ const mc = document.modelContext ?? navigator.modelContext ?? null;
18
+ if (!mc) {
19
+ return { present: false, inNavigator: 'modelContext' in navigator };
20
+ }
21
+
22
+ const readTools = async () => {
23
+ if (typeof mc.getTools !== 'function') return null;
24
+ const raw = mc.getTools();
25
+ return raw != null && typeof raw.then === 'function' ? await raw : raw;
26
+ };
27
+
28
+ const deadline = Date.now() + 8000;
29
+ const stableReadsRequired = 4;
30
+ const startedAt = Date.now();
31
+ let tools = null;
32
+ let lastKey = null;
33
+ let stableReads = 0;
34
+ let settledAtMs = null;
35
+
36
+ while (Date.now() < deadline) {
37
+ tools = await readTools();
38
+ const key = Array.isArray(tools) ? JSON.stringify(tools.map((t) => t?.name ?? null)) : null;
39
+ if (key !== null && key === lastKey) {
40
+ stableReads += 1;
41
+ if (Array.isArray(tools) && tools.length > 0 && stableReads >= stableReadsRequired) {
42
+ settledAtMs = Date.now() - startedAt;
43
+ break;
44
+ }
45
+ } else {
46
+ stableReads = 0;
47
+ lastKey = key;
48
+ }
49
+ await new Promise((resolve) => setTimeout(resolve, 200));
50
+ }
51
+
52
+ return {
53
+ present: true,
54
+ inNavigator: 'modelContext' in navigator,
55
+ surface: Object.getOwnPropertyNames(Object.getPrototypeOf(mc)),
56
+ frozen: Object.isFrozen(mc),
57
+ settled: settledAtMs !== null,
58
+ settledAtMs,
59
+ tools: Array.isArray(tools)
60
+ ? tools.map((tool) => {
61
+ // Measured on Chrome 152.0.7977.65 (2026-08-30): getTools() hands
62
+ // inputSchema back as a JSON *string*, even for a tool registered with a
63
+ // real object - the #241 DOMString-to-object move has not landed in this
64
+ // build's read-back path. Parse it here so every consumer sees a schema,
65
+ // and record which wire form the build used, because that is a
66
+ // compatibility-matrix row rather than a detail to paper over. Leaving it
67
+ // unparsed silently disabled the harness's own unknown-argument check and
68
+ // showed the judge an escaped blob where a schema should be.
69
+ const raw = tool?.inputSchema ?? null;
70
+ let inputSchema = raw;
71
+ let wire = raw === null || raw === undefined ? 'absent' : typeof raw;
72
+ if (typeof raw === 'string') {
73
+ try {
74
+ inputSchema = JSON.parse(raw);
75
+ } catch {
76
+ wire = 'unparseable-string';
77
+ }
78
+ }
79
+ return {
80
+ name: tool?.name ?? null,
81
+ title: tool?.title ?? null,
82
+ description: tool?.description ?? null,
83
+ inputSchema,
84
+ inputSchemaWire: wire,
85
+ annotations: tool?.annotations ?? null,
86
+ };
87
+ })
88
+ : null,
89
+ };
90
+ })()`;
91
+
92
+ /**
93
+ * Airlock renders highlighting as a class on the row body plus a note element.
94
+ * Both are read-only DOM facts, which is what separates a real effect from a
95
+ * tool that returned cheerfully and changed nothing (`silent_fail`).
96
+ */
97
+ const OBSERVATION_EXPRESSION = `(() => {
98
+ const body = document.querySelector('tbody');
99
+ const note = document.querySelector('#highlight-note');
100
+ return {
101
+ highlightClass: body ? body.className : null,
102
+ highlightNote: note ? note.textContent : null,
103
+ dimmedRows: document.querySelectorAll('tbody.has-highlight tr:not(.hit)').length,
104
+ title: document.title,
105
+ };
106
+ })()`;
107
+
108
+ export const captureManifest = (session) => session.evaluate(MANIFEST_EXPRESSION);
109
+
110
+ export const observe = (session) => session.evaluate(OBSERVATION_EXPRESSION);
111
+
112
+ /**
113
+ * The browser's own view of a page's tools, accumulated from CDP events.
114
+ *
115
+ * This is the second, independent view the `not_discovered` outcome has always
116
+ * needed: the page's `getTools()` says what the page believes it registered, this
117
+ * says what the browser is prepared to offer an agent, and a disagreement between
118
+ * them is invisible from inside the page - exactly the silent failure this project
119
+ * exists to catch.
120
+ *
121
+ * Measured on Chrome 152.0.7977.65 (2026-08-31): the `WebMCP` domain is experimental
122
+ * and has **no command that lists tools** — only `enable`, `disable`, `invokeTool`
123
+ * and `cancelInvocation`. The set arrives as `toolsAdded` events, so it can only be
124
+ * accumulated, and the watch must start *before* navigation or the events are already
125
+ * gone. On a build without the domain this returns `available: false` and `names()`
126
+ * returns null rather than an empty array: a view you do not have is not evidence of
127
+ * absence, and the classifier must not read it as one.
128
+ *
129
+ * Two entry points share one accumulator:
130
+ *
131
+ * - `watchBrowserTools(session)` attaches to one page target. Enough when the page
132
+ * and its embeds are same-site: every registration arrives at that session. This
133
+ * is what trials use, and what a same-site capture should use.
134
+ * - `watchBrowserToolsAtBrowser(webSocketDebuggerUrl)` attaches at the browser
135
+ * endpoint instead, which is what a capture over an unknown cohort must use,
136
+ * because a cross-site delegating embed is invisible to the host-attached view.
137
+ */
138
+ export const watchBrowserTools = async (session) => {
139
+ const present = new Map();
140
+ const removed = [];
141
+
142
+ const stopAdded = session.subscribe('WebMCP.toolsAdded', (params) => {
143
+ for (const tool of params.tools ?? []) {
144
+ if (tool?.name) present.set(tool.name, tool);
145
+ }
146
+ });
147
+ const stopRemoved = session.subscribe('WebMCP.toolsRemoved', (params) => {
148
+ for (const tool of params.tools ?? []) {
149
+ if (!tool?.name) continue;
150
+ present.delete(tool.name);
151
+ removed.push(tool.name);
152
+ }
153
+ });
154
+
155
+ const enabled = await session.enableWebMcpDomain();
156
+ if (!enabled.available) {
157
+ stopAdded();
158
+ stopRemoved();
159
+ }
160
+
161
+ return {
162
+ available: enabled.available,
163
+ reason: enabled.reason ?? null,
164
+ names: () => (enabled.available ? [...present.keys()] : null),
165
+ tools: () => (enabled.available ? [...present.values()] : null),
166
+ removedNames: () => (enabled.available ? [...removed] : null),
167
+ stop: () => {
168
+ stopAdded();
169
+ stopRemoved();
170
+ },
171
+ };
172
+ };
173
+
174
+ /**
175
+ * The same accumulation, done at the browser endpoint.
176
+ *
177
+ * `webSocketDebuggerUrl` is the browser's own endpoint (`/json/version`), not a
178
+ * page target's. Everything arrives on one socket, tagged with the `sessionId` of
179
+ * the target each event came from — the pattern `probes/browser-scope.mjs`
180
+ * measured on 2026-09-05 against the `spec-227` cross-site fixture: the host
181
+ * page's `getTools()` sees 3 tools and a watch attached to the host target sees
182
+ * the same 3, while a browser-endpoint watch sees 4 across 2 target sessions,
183
+ * including the cross-site embed's `widget_ping`. A capture that can meet an
184
+ * unknown cohort must read this view, or every page that delegates tools to a
185
+ * different-site embed publishes an `agentVisibleToolCount` that undercounts it —
186
+ * in precisely the case the report cites as the reason for publishing that number
187
+ * beside the page-registered one (PROJECT-LOG item 23).
188
+ *
189
+ * Recursion is the whole game, and it is not automatic. Browser-level auto-attach
190
+ * armed **once** attached six targets with no iframe among them and saw 3 tools —
191
+ * the near-miss that run caught in itself. Every attached session arms
192
+ * `Target.setAutoAttach` again as it attaches; that is what walks down to the
193
+ * out-of-process iframe. Each new session also gets `WebMCP.enable`, tolerated
194
+ * when a target type refuses it: enabling is best-effort per target, and the
195
+ * verdict is about the union, not about any one target's cooperation.
196
+ *
197
+ * Availability cannot be decided at setup the way the host-attached watch decides
198
+ * it, because the browser endpoint itself has no WebMCP domain to enable — the
199
+ * question answers itself one target at a time. So `available` and `reason` are
200
+ * read-time getters, and they keep the classifier-safe rule: a build whose every
201
+ * enable refused reports `available: false` with `names()` null (no view is not
202
+ * evidence of absence), never an empty union.
203
+ *
204
+ * Returns the same view shape as `watchBrowserTools`, plus three read-time
205
+ * counters so a caller can tell "auto-attach reached nothing" apart from "the
206
+ * browser cannot see it" — only one of those is about WebMCP:
207
+ *
208
+ * - `oopiFrames` — attached `iframe` targets. An out-of-process iframe's tool can
209
+ * only arrive through one, so a run where this is 0 has measured its own
210
+ * plumbing rather than the browser's view, and the number must travel with any
211
+ * verdict drawn from the union (the exit-2 guard browser-scope.mjs added after
212
+ * its own first run would have published the opposite answer).
213
+ * - `sessionCount` — targets attached; `toolSessions` — sessions that produced
214
+ * tool events, which is how wide the union actually is.
215
+ *
216
+ * The optional `WebSocket` override exists for the same reason the host-attached
217
+ * tests fake the session: the accumulation contract is this repo's logic, and it
218
+ * must be testable without a browser.
219
+ */
220
+ export const watchBrowserToolsAtBrowser = async (
221
+ webSocketDebuggerUrl,
222
+ { WebSocket: makeSocket = globalThis.WebSocket } = {}
223
+ ) => {
224
+ const present = new Map();
225
+ const removed = [];
226
+ const toolSessions = new Set();
227
+ const attached = new Map(); // sessionId -> { targetId, type, url }
228
+ const enableResults = new Map(); // sessionId -> true | refusal message
229
+
230
+ const socket = new makeSocket(webSocketDebuggerUrl);
231
+ await new Promise((open, failed) => {
232
+ socket.addEventListener('open', open, { once: true });
233
+ socket.addEventListener(
234
+ 'error',
235
+ () => failed(new Error(`could not connect to ${webSocketDebuggerUrl}`)),
236
+ { once: true }
237
+ );
238
+ });
239
+
240
+ let nextId = 1;
241
+ const pending = new Map();
242
+ const listeners = new Map();
243
+
244
+ socket.addEventListener('message', (event) => {
245
+ let message;
246
+ try {
247
+ message = JSON.parse(event.data);
248
+ } catch {
249
+ return;
250
+ }
251
+ if (message.id !== undefined && pending.has(message.id)) {
252
+ const entry = pending.get(message.id);
253
+ pending.delete(message.id);
254
+ clearTimeout(entry.timer);
255
+ if (message.error) entry.reject(new Error(`${message.error.message} (${message.error.code})`));
256
+ else entry.resolve(message.result ?? {});
257
+ return;
258
+ }
259
+ if (!message.method) return;
260
+ for (const handler of listeners.get(message.method) ?? []) {
261
+ handler(message.params ?? {}, message.sessionId ?? null);
262
+ }
263
+ });
264
+
265
+ const send = (method, params = {}, sessionId = undefined) =>
266
+ new Promise((resolve, reject) => {
267
+ const id = nextId++;
268
+ // A command with no reply is a dead endpoint, not a slow one — the same
269
+ // bound every wait in browser/session.mjs carries, for the same reason: a
270
+ // silent stall is indistinguishable from work in progress.
271
+ const timer = setTimeout(() => {
272
+ pending.delete(id);
273
+ reject(new Error(`CDP ${method} did not answer within 15000ms`));
274
+ }, 15000);
275
+ pending.set(id, { resolve, reject, timer });
276
+ socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
277
+ });
278
+
279
+ const on = (method, handler) => {
280
+ const list = listeners.get(method) ?? [];
281
+ list.push(handler);
282
+ listeners.set(method, list);
283
+ };
284
+
285
+ on('Target.attachedToTarget', (params) => {
286
+ const { sessionId } = params;
287
+ const info = params.targetInfo ?? {};
288
+ attached.set(sessionId, {
289
+ targetId: info.targetId ?? null,
290
+ type: info.type ?? null,
291
+ url: info.url ?? null,
292
+ });
293
+ // Arming auto-attach again on each attached session is what makes the walk
294
+ // recursive; a target type that refuses either command is recorded by
295
+ // absence, and an enable refusal decides `available` — a build without the
296
+ // domain must look unavailable, not empty.
297
+ send(
298
+ 'Target.setAutoAttach',
299
+ { autoAttach: true, waitForDebuggerOnStart: false, flatten: true },
300
+ sessionId
301
+ ).catch(() => {});
302
+ send('WebMCP.enable', {}, sessionId)
303
+ .then(() => enableResults.set(sessionId, true))
304
+ .catch((error) => enableResults.set(sessionId, String(error.message ?? error)));
305
+ });
306
+
307
+ on('WebMCP.toolsAdded', (params, sessionId) => {
308
+ for (const tool of params.tools ?? []) {
309
+ if (!tool?.name) continue;
310
+ present.set(tool.name, tool);
311
+ if (sessionId) toolSessions.add(sessionId);
312
+ }
313
+ });
314
+
315
+ on('WebMCP.toolsRemoved', (params, sessionId) => {
316
+ for (const tool of params.tools ?? []) {
317
+ if (!tool?.name) continue;
318
+ present.delete(tool.name);
319
+ removed.push(tool.name);
320
+ if (sessionId) toolSessions.add(sessionId);
321
+ }
322
+ });
323
+
324
+ await send('Target.setDiscoverTargets', { discover: true });
325
+ await send('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: false, flatten: true });
326
+
327
+ const anySessionEnabled = () => {
328
+ for (const succeeded of enableResults.values()) if (succeeded === true) return true;
329
+ return false;
330
+ };
331
+ const lastRefusal = () => {
332
+ let reason = null;
333
+ for (const value of enableResults.values()) if (value !== true) reason = value;
334
+ return reason;
335
+ };
336
+
337
+ return {
338
+ get available() {
339
+ return anySessionEnabled();
340
+ },
341
+ get reason() {
342
+ if (attached.size === 0) return 'no target session attached to the browser endpoint';
343
+ return anySessionEnabled() ? null : (lastRefusal() ?? 'no session enabled the WebMCP domain');
344
+ },
345
+ names: () => (anySessionEnabled() ? [...present.keys()] : null),
346
+ tools: () => (anySessionEnabled() ? [...present.values()] : null),
347
+ removedNames: () => (anySessionEnabled() ? [...removed] : null),
348
+ get oopiFrames() {
349
+ let count = 0;
350
+ for (const target of attached.values()) if (target.type === 'iframe') count += 1;
351
+ return count;
352
+ },
353
+ get sessionCount() {
354
+ return attached.size;
355
+ },
356
+ get toolSessionCount() {
357
+ return toolSessions.size;
358
+ },
359
+ stop: () => {
360
+ for (const entry of pending.values()) {
361
+ clearTimeout(entry.timer);
362
+ entry.reject(new Error('watch stopped'));
363
+ }
364
+ pending.clear();
365
+ listeners.clear();
366
+ try {
367
+ socket.close();
368
+ } catch {
369
+ // A socket that is already gone is the state we wanted.
370
+ }
371
+ },
372
+ };
373
+ };
374
+
375
+ /**
376
+ * Calls a tool through the page API, trying every signature this ecosystem is
377
+ * known to use and reporting which one the build accepted - that is a
378
+ * compatibility-matrix row, not an implementation detail.
379
+ *
380
+ * Measured on Chrome 152.0.7977.65 (2026-08-30): the accepted form is
381
+ * `executeTool(registeredTool, args)`, where the first argument is the object
382
+ * handed back by `getTools()`. Passing the draft's `{name, arguments}` fails with
383
+ * "2 arguments required, but only 1 present", and passing the name as a string
384
+ * fails with "The provided value is not of type 'RegisteredTool'". Neither the
385
+ * draft (#246) nor the type surface verified against Chrome 151 describes this,
386
+ * so the cascade stays until more builds are measured.
387
+ */
388
+ export const executeTool = async (session, name, args = {}) => {
389
+ const expression = `(async () => {
390
+ const mc = document.modelContext ?? navigator.modelContext ?? null;
391
+ if (!mc || typeof mc.executeTool !== 'function') {
392
+ return { ok: false, callShape: null, error: 'executeTool is not available on this build' };
393
+ }
394
+
395
+ const name = ${JSON.stringify(name)};
396
+ const args = ${JSON.stringify(args)};
397
+ const attempts = [];
398
+
399
+ const raw = mc.getTools();
400
+ const tools = raw != null && typeof raw.then === 'function' ? await raw : raw;
401
+ const registered = Array.isArray(tools) ? tools.find((tool) => tool && tool.name === name) : null;
402
+
403
+ const shapes = [
404
+ ['tool-object+object', () => registered && mc.executeTool(registered, args)],
405
+ ['tool-object+string', () => registered && mc.executeTool(registered, JSON.stringify(args))],
406
+ ['draft-object', () => mc.executeTool({ name, arguments: args })],
407
+ ['name+string', () => mc.executeTool(name, JSON.stringify(args))],
408
+ ];
409
+
410
+ for (const [callShape, invoke] of shapes) {
411
+ if (callShape.startsWith('tool-object') && !registered) {
412
+ attempts.push({ callShape, error: 'tool not present in getTools()' });
413
+ continue;
414
+ }
415
+ try {
416
+ const result = await invoke();
417
+ return { ok: true, callShape, result, attempts };
418
+ } catch (error) {
419
+ attempts.push({ callShape, error: String(error && error.message ? error.message : error) });
420
+ }
421
+ }
422
+
423
+ return {
424
+ ok: false,
425
+ callShape: null,
426
+ error: attempts.map((attempt) => attempt.callShape + ': ' + attempt.error).join(' | '),
427
+ attempts,
428
+ };
429
+ })()`;
430
+
431
+ return session.evaluate(expression);
432
+ };