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.
- package/LICENSE +21 -0
- package/README.md +121 -0
- package/action.yml +162 -0
- package/bin/webmcp-gauge.mjs +544 -0
- package/bin/webmcp-gauge.test.mjs +354 -0
- package/browser/launch.mjs +188 -0
- package/browser/serve.mjs +78 -0
- package/browser/session.mjs +210 -0
- package/browser/webmcp.mjs +432 -0
- package/browser/webmcp.test.mjs +299 -0
- package/core/args.mjs +93 -0
- package/core/args.test.mjs +85 -0
- package/core/capture-seam.test.mjs +86 -0
- package/core/cohort.mjs +432 -0
- package/core/cohort.test.mjs +370 -0
- package/core/gallery.mjs +145 -0
- package/core/gallery.test.mjs +128 -0
- package/core/gate.mjs +164 -0
- package/core/gate.test.mjs +213 -0
- package/core/lint.mjs +381 -0
- package/core/lint.test.mjs +346 -0
- package/core/orchestrate.mjs +128 -0
- package/core/orchestrate.test.mjs +191 -0
- package/core/stats.mjs +172 -0
- package/core/stats.test.mjs +156 -0
- package/core/sweep.mjs +274 -0
- package/core/sweep.test.mjs +162 -0
- package/core/taxonomy.mjs +175 -0
- package/core/taxonomy.test.mjs +198 -0
- package/core/trial.mjs +248 -0
- package/core/visibility.mjs +163 -0
- package/core/visibility.test.mjs +164 -0
- package/docs/concept.md +468 -0
- package/docs/explainer.md +161 -0
- package/docs/getting-started.md +331 -0
- package/fixtures/README.md +42 -0
- package/fixtures/airlock.utterances.json +284 -0
- package/fixtures/broken/compose.mjs +52 -0
- package/fixtures/broken/compose.test.mjs +270 -0
- package/fixtures/broken/sample-expenses.csv +966 -0
- package/fixtures/broken/tools.json +1311 -0
- package/fixtures/broken/twin.html +482 -0
- package/fixtures/broken/widget.html +62 -0
- package/fixtures/gallery/gallery.html +56 -0
- package/judges/openai-compatible.mjs +145 -0
- package/package.json +53 -0
- package/report/badge.mjs +110 -0
- package/report/badge.test.mjs +97 -0
- package/report/emit.mjs +282 -0
- package/report/published-runs.test.mjs +77 -0
- package/report/scorecard.mjs +157 -0
- package/report/scorecard.test.mjs +130 -0
package/core/cohort.mjs
ADDED
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cohort snapshot: what gets captured, what gets published, and what is
|
|
3
|
+
* refused. Deliberately browser-free so it can be tested without one — the
|
|
4
|
+
* runner in probes/ owns the CDP work, this owns the rules.
|
|
5
|
+
*
|
|
6
|
+
* This module exists because the capture is a one-day, non-repeatable event
|
|
7
|
+
* (docs/concept.md milestone 4). A bug found on the day is a bug that costs the
|
|
8
|
+
* whole dataset, so the parts that can be decided in advance are decided here,
|
|
9
|
+
* under test, before the gallery opens.
|
|
10
|
+
*
|
|
11
|
+
* Publishing policy is enforced in code rather than remembered, per concept §12:
|
|
12
|
+
* derived metrics and links only, never another project's source or assets.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** The UA suffix every request in a cohort capture must carry, per §12. */
|
|
16
|
+
export const HARNESS_UA_SUFFIX = 'webmcp-gauge/0.1 (+https://github.com/Svishwa2004/webmcp-gauge; measurement, contact via repo issues)';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The date a capture is filed under, in the operator's own timezone.
|
|
20
|
+
*
|
|
21
|
+
* `toISOString().slice(0, 10)` is UTC, and this machine runs at UTC+5:30: a
|
|
22
|
+
* capture started at 00:30 local on gallery-publish day would be filed under the
|
|
23
|
+
* *previous* date. For the one artifact whose entire claim is "taken on the day
|
|
24
|
+
* those URLs were simultaneously live", a date off by one is not cosmetic.
|
|
25
|
+
*/
|
|
26
|
+
export const localDateStamp = (date = new Date()) => {
|
|
27
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
28
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Accepts the messy shapes a URL list arrives in and returns one canonical row
|
|
33
|
+
* per project, or throws with the offending entry. A list assembled by hand on
|
|
34
|
+
* the day will contain duplicates and bare hostnames; both are cheaper to handle
|
|
35
|
+
* here than at 2 a.m.
|
|
36
|
+
*/
|
|
37
|
+
export const normalizeTargets = (raw) => {
|
|
38
|
+
if (!Array.isArray(raw)) throw new TypeError('cohort targets must be an array');
|
|
39
|
+
|
|
40
|
+
const seen = new Map();
|
|
41
|
+
const rows = [];
|
|
42
|
+
|
|
43
|
+
for (const [index, entry] of raw.entries()) {
|
|
44
|
+
const source = typeof entry === 'string' ? { url: entry } : entry ?? {};
|
|
45
|
+
const rawUrl = String(source.url ?? '').trim();
|
|
46
|
+
if (!rawUrl) throw new TypeError(`target ${index} has no url`);
|
|
47
|
+
|
|
48
|
+
// A scheme we do not speak must be refused, not repaired. Prepending https
|
|
49
|
+
// to "ftp://host/x" yields a URL that parses, with host "ftp" — a target
|
|
50
|
+
// that would be captured as dead instead of reported as unusable.
|
|
51
|
+
const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(rawUrl)?.[1]?.toLowerCase() ?? null;
|
|
52
|
+
if (scheme && scheme !== 'http' && scheme !== 'https') {
|
|
53
|
+
throw new TypeError(`target ${index} is not http(s): ${rawUrl}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const withScheme = scheme ? rawUrl : `https://${rawUrl}`;
|
|
57
|
+
let parsed;
|
|
58
|
+
try {
|
|
59
|
+
parsed = new URL(withScheme);
|
|
60
|
+
} catch {
|
|
61
|
+
throw new TypeError(`target ${index} is not a URL: ${rawUrl}`);
|
|
62
|
+
}
|
|
63
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
64
|
+
throw new TypeError(`target ${index} is not http(s): ${rawUrl}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Same page reached by two spellings is one capture, not two visits to
|
|
68
|
+
// someone else's free hosting. Fragment and trailing slash are not identity.
|
|
69
|
+
parsed.hash = '';
|
|
70
|
+
const key = `${parsed.host}${parsed.pathname.replace(/\/$/, '')}${parsed.search}`;
|
|
71
|
+
if (seen.has(key)) {
|
|
72
|
+
rows[seen.get(key)].aliases.push(parsed.href);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
seen.set(key, rows.length);
|
|
77
|
+
rows.push({
|
|
78
|
+
project: source.project ? String(source.project) : parsed.host,
|
|
79
|
+
url: parsed.href,
|
|
80
|
+
repo: source.repo ? String(source.repo) : null,
|
|
81
|
+
aliases: [],
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return rows;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The URLs already captured in a snapshot file — the resume half of a runner
|
|
90
|
+
* whose capture cannot be repeated. Built the night of 2026-09-25/26, when a
|
|
91
|
+
* browser death and a machine sleep each stopped a run mid-corpus and the
|
|
92
|
+
* URL subtraction ran twice by hand. A torn or unparseable line is skipped
|
|
93
|
+
* rather than fatal: the file is append-only, so the worst a torn final line
|
|
94
|
+
* can hide is the one record it belongs to. The runner never writes a record
|
|
95
|
+
* for the target a browser death stopped on, so everything in the file is a
|
|
96
|
+
* completed capture and resuming skips exactly what is done.
|
|
97
|
+
*/
|
|
98
|
+
export const capturedUrlsFrom = (snapshotText) => {
|
|
99
|
+
const urls = new Set();
|
|
100
|
+
if (typeof snapshotText !== 'string') return urls;
|
|
101
|
+
for (const line of snapshotText.split(/\r?\n/)) {
|
|
102
|
+
if (!line.trim()) continue;
|
|
103
|
+
try {
|
|
104
|
+
const record = JSON.parse(line);
|
|
105
|
+
if (typeof record?.url === 'string' && record.url !== '') urls.add(record.url);
|
|
106
|
+
} catch {
|
|
107
|
+
// Skipped, not fatal — see above.
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return urls;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The targets a resumed run still owes, given the set already captured.
|
|
115
|
+
* Matching is by the canonical URL `toRecord` stored, which is exactly what
|
|
116
|
+
* `normalizeTargets` produced — no re-normalization, so a fragment alias
|
|
117
|
+
* cannot come back as a second visit.
|
|
118
|
+
*/
|
|
119
|
+
export const remainingTargets = (targets, captured) => {
|
|
120
|
+
if (!Array.isArray(targets)) throw new TypeError('targets must be an array');
|
|
121
|
+
if (!(captured instanceof Set)) throw new TypeError('captured must be a Set of urls');
|
|
122
|
+
return targets.filter((target) => !captured.has(target.url));
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Minimal robots.txt evaluation for one path and our own user-agent token.
|
|
127
|
+
*
|
|
128
|
+
* Honouring robots is a §12 commitment, and a snapshot that quietly ignored it
|
|
129
|
+
* would poison the dataset's provenance rather than just its manners. Scope is
|
|
130
|
+
* stated so nobody mistakes it for a full implementation: `User-agent`,
|
|
131
|
+
* `Disallow`, `Allow`, longest-match wins, `*` wildcards and `$` anchors. No
|
|
132
|
+
* crawl-delay (the runner rate-limits unconditionally instead), no sitemaps.
|
|
133
|
+
* An unfetchable or unparseable robots.txt is treated as permission, which is
|
|
134
|
+
* what the standard says and is worth saying out loud.
|
|
135
|
+
*/
|
|
136
|
+
export const robotsAllows = (robotsTxt, path, agent = 'webmcp-gauge') => {
|
|
137
|
+
if (typeof robotsTxt !== 'string' || robotsTxt.trim() === '') return true;
|
|
138
|
+
|
|
139
|
+
const groups = [];
|
|
140
|
+
let current = null;
|
|
141
|
+
for (const line of robotsTxt.split(/\r?\n/)) {
|
|
142
|
+
const text = line.replace(/#.*$/, '').trim();
|
|
143
|
+
if (!text) continue;
|
|
144
|
+
const [rawField, ...rest] = text.split(':');
|
|
145
|
+
const field = rawField.trim().toLowerCase();
|
|
146
|
+
const value = rest.join(':').trim();
|
|
147
|
+
|
|
148
|
+
if (field === 'user-agent') {
|
|
149
|
+
// Consecutive User-agent lines share one rule block.
|
|
150
|
+
if (!current || current.rules.length > 0) {
|
|
151
|
+
current = { agents: [], rules: [] };
|
|
152
|
+
groups.push(current);
|
|
153
|
+
}
|
|
154
|
+
current.agents.push(value.toLowerCase());
|
|
155
|
+
} else if ((field === 'disallow' || field === 'allow') && current) {
|
|
156
|
+
current.rules.push({ allow: field === 'allow', pattern: value });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const lower = agent.toLowerCase();
|
|
161
|
+
const specific = groups.filter((g) => g.agents.some((a) => a !== '*' && lower.includes(a)));
|
|
162
|
+
const wildcard = groups.filter((g) => g.agents.includes('*'));
|
|
163
|
+
const applicable = specific.length > 0 ? specific : wildcard;
|
|
164
|
+
if (applicable.length === 0) return true;
|
|
165
|
+
|
|
166
|
+
const toRegExp = (pattern) => {
|
|
167
|
+
const anchored = pattern.endsWith('$');
|
|
168
|
+
const body = anchored ? pattern.slice(0, -1) : pattern;
|
|
169
|
+
const escaped = body.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
|
|
170
|
+
return new RegExp(`^${escaped}${anchored ? '$' : ''}`);
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
let verdict = true;
|
|
174
|
+
let strongest = -1;
|
|
175
|
+
for (const group of applicable) {
|
|
176
|
+
for (const rule of group.rules) {
|
|
177
|
+
// An empty Disallow means "allow everything" and matches nothing.
|
|
178
|
+
if (rule.pattern === '' && !rule.allow) continue;
|
|
179
|
+
if (!toRegExp(rule.pattern).test(path)) continue;
|
|
180
|
+
// Longest match wins; Allow beats Disallow at equal length.
|
|
181
|
+
if (rule.pattern.length > strongest || (rule.pattern.length === strongest && rule.allow)) {
|
|
182
|
+
strongest = rule.pattern.length;
|
|
183
|
+
verdict = rule.allow;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return verdict;
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Attribute each browser-visible tool to the document that registered it.
|
|
192
|
+
*
|
|
193
|
+
* Decided 2026-09-02, before the capture, because the alternative is unrecoverable.
|
|
194
|
+
* A cross-origin embed with `allow="tools"` registers tools that reach the
|
|
195
|
+
* **browser** — and therefore an agent — while appearing in **nobody's**
|
|
196
|
+
* `getTools()`: not the host's, not the union of any script-visible surface. Two
|
|
197
|
+
* different claims then need two different denominators:
|
|
198
|
+
*
|
|
199
|
+
* - "this project shipped tools" is an **attribution** claim, and counting an
|
|
200
|
+
* embedded third party's tools would credit a builder with someone else's
|
|
201
|
+
* work. It uses the page's own view, restricted to the page's origin.
|
|
202
|
+
* - "an agent can call these tools here" is a **reality** claim, and the
|
|
203
|
+
* browser's view is the only one that answers it.
|
|
204
|
+
*
|
|
205
|
+
* So both are captured and each published number says which it used. Capturing
|
|
206
|
+
* one view is a permanent loss on a one-day capture; capturing both is not.
|
|
207
|
+
*
|
|
208
|
+
* `frameId` on each browser-view tool maps to a frame's origin; the top call frame
|
|
209
|
+
* of its `stackTrace` names the registering script, which is the fallback when a
|
|
210
|
+
* frame has gone by the time the tree is read.
|
|
211
|
+
*/
|
|
212
|
+
export const attributeTools = (browserTools, frames = [], pageOrigin = null) => {
|
|
213
|
+
if (!Array.isArray(browserTools)) return null;
|
|
214
|
+
|
|
215
|
+
const originByFrame = new Map((frames ?? []).map((f) => [f.id, f.origin]));
|
|
216
|
+
const originOf = (url) => {
|
|
217
|
+
try {
|
|
218
|
+
return new URL(url).origin;
|
|
219
|
+
} catch {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
return browserTools.map((tool) => {
|
|
225
|
+
const scriptUrl = tool?.stackTrace?.callFrames?.[0]?.url ?? null;
|
|
226
|
+
const origin = originByFrame.get(tool?.frameId) ?? originOf(scriptUrl) ?? null;
|
|
227
|
+
return {
|
|
228
|
+
name: tool?.name ?? null,
|
|
229
|
+
frameId: tool?.frameId ?? null,
|
|
230
|
+
origin,
|
|
231
|
+
scriptUrl,
|
|
232
|
+
// Unknown provenance is not "same origin". A tool whose origin could not be
|
|
233
|
+
// established must not be silently credited to the page.
|
|
234
|
+
sameOrigin: origin && pageOrigin ? origin === pageOrigin : null,
|
|
235
|
+
};
|
|
236
|
+
});
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* One captured project as it is stored locally: the full manifest, because the
|
|
241
|
+
* linter and every later analysis need the real text.
|
|
242
|
+
*
|
|
243
|
+
* The distinction between `apiPresent` and `registered` is load-bearing, and the
|
|
244
|
+
* dry run on 2026-09-01 is why it exists. `document.modelContext` is present on
|
|
245
|
+
* **every** page in a WebMCP-enabled browser — it is a browser API, not a page
|
|
246
|
+
* opt-in — so `example.com` and a 404 page both reported "WebMCP present". Had
|
|
247
|
+
* the census counted that, the dataset's headline claim would have been that
|
|
248
|
+
* most of the cohort uses WebMCP, when what was measured was that Chrome does.
|
|
249
|
+
* A project *uses* WebMCP when it registers at least one tool, and nothing else
|
|
250
|
+
* counts.
|
|
251
|
+
*/
|
|
252
|
+
export const toRecord = ({
|
|
253
|
+
target,
|
|
254
|
+
capturedAt,
|
|
255
|
+
status,
|
|
256
|
+
finalUrl,
|
|
257
|
+
title,
|
|
258
|
+
manifest,
|
|
259
|
+
browserTools = null,
|
|
260
|
+
frames = [],
|
|
261
|
+
browserView = null,
|
|
262
|
+
error = null,
|
|
263
|
+
}) => {
|
|
264
|
+
const reachable = typeof status === 'number' && status < 400;
|
|
265
|
+
const tools =
|
|
266
|
+
manifest?.tools?.map((tool) => ({
|
|
267
|
+
name: tool.name ?? null,
|
|
268
|
+
description: tool.description ?? null,
|
|
269
|
+
inputSchema: tool.inputSchema ?? null,
|
|
270
|
+
inputSchemaWire: tool.inputSchemaWire ?? null,
|
|
271
|
+
annotations: tool.annotations ?? null,
|
|
272
|
+
})) ?? [];
|
|
273
|
+
|
|
274
|
+
const pageOrigin = (() => {
|
|
275
|
+
try {
|
|
276
|
+
return new URL(finalUrl ?? target.url).origin;
|
|
277
|
+
} catch {
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
})();
|
|
281
|
+
|
|
282
|
+
const attributed = reachable ? attributeTools(browserTools, frames, pageOrigin) : null;
|
|
283
|
+
const pageNames = tools.map((t) => t.name);
|
|
284
|
+
const agentNames = attributed ? attributed.map((t) => t.name) : null;
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
project: target.project,
|
|
288
|
+
url: target.url,
|
|
289
|
+
repo: target.repo ?? null,
|
|
290
|
+
aliases: target.aliases ?? [],
|
|
291
|
+
capturedAt,
|
|
292
|
+
liveness: {
|
|
293
|
+
status: status ?? null,
|
|
294
|
+
finalUrl: finalUrl ?? null,
|
|
295
|
+
redirected: Boolean(finalUrl && finalUrl !== target.url),
|
|
296
|
+
reachable,
|
|
297
|
+
},
|
|
298
|
+
title: title ?? null,
|
|
299
|
+
webmcp: {
|
|
300
|
+
// A property of the browser this capture ran in, kept for the compatibility
|
|
301
|
+
// record and never counted as adoption.
|
|
302
|
+
apiPresent: Boolean(manifest?.present),
|
|
303
|
+
// A property of the page, and the only adoption signal. A page that could
|
|
304
|
+
// not be reached registered nothing, whatever its error document did.
|
|
305
|
+
registered: reachable && tools.length > 0,
|
|
306
|
+
settled: manifest?.settled ?? null,
|
|
307
|
+
inNavigator: manifest?.inNavigator ?? null,
|
|
308
|
+
toolCount: reachable ? tools.length : 0,
|
|
309
|
+
tools: reachable ? tools : [],
|
|
310
|
+
// The agent's own view. `null`, never `[]`, when the browser domain was
|
|
311
|
+
// unavailable — a view you do not have is not evidence of absence.
|
|
312
|
+
agentTools: agentNames,
|
|
313
|
+
agentToolCount: agentNames?.length ?? null,
|
|
314
|
+
attribution: attributed,
|
|
315
|
+
thirdPartyToolCount: attributed ? attributed.filter((t) => t.sameOrigin === false).length : null,
|
|
316
|
+
unattributedToolCount: attributed ? attributed.filter((t) => t.sameOrigin === null).length : null,
|
|
317
|
+
// Divergence in both directions, which is what makes the two views worth
|
|
318
|
+
// keeping separately. `onlyInBrowser` is the delegated-embed case measured
|
|
319
|
+
// on 2026-09-02; `onlyInPage` would mean the browser dropped a tool.
|
|
320
|
+
divergence: agentNames
|
|
321
|
+
? {
|
|
322
|
+
onlyInBrowser: agentNames.filter((n) => !pageNames.includes(n)),
|
|
323
|
+
onlyInPage: pageNames.filter((n) => !agentNames.includes(n)),
|
|
324
|
+
}
|
|
325
|
+
: null,
|
|
326
|
+
// How the agent view was taken and what it reached, or null when the
|
|
327
|
+
// runner did not say. Item 23 (2026-09-05): a host-attached watch
|
|
328
|
+
// undercounts a cross-site delegating page, so the capture reads the
|
|
329
|
+
// union at the browser endpoint — and `oopiFrames` travels with the
|
|
330
|
+
// number, because a watch no out-of-process iframe ever attached to has
|
|
331
|
+
// measured auto-attach rather than the browser's view. The two findings
|
|
332
|
+
// look identical from `agentToolCount` alone and are not the same claim.
|
|
333
|
+
browserView,
|
|
334
|
+
},
|
|
335
|
+
error,
|
|
336
|
+
};
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* What may leave this machine. The local record keeps descriptions verbatim
|
|
341
|
+
* because they are the measured object; the published row keeps only shape.
|
|
342
|
+
*
|
|
343
|
+
* The decision this encodes, recorded 2026-09-01: a tool *name* is published
|
|
344
|
+
* (it is an interface, like a function name in an API doc), a tool
|
|
345
|
+
* *description* is not (it is someone's prose, and §12 forbids republishing
|
|
346
|
+
* another project's source). Descriptions survive as lengths and as whatever
|
|
347
|
+
* the linter derives from them, which is what every aggregate claim needs.
|
|
348
|
+
*/
|
|
349
|
+
export const toPublishable = (record) => ({
|
|
350
|
+
project: record.project,
|
|
351
|
+
url: record.url,
|
|
352
|
+
repo: record.repo,
|
|
353
|
+
capturedAt: record.capturedAt,
|
|
354
|
+
reachable: record.liveness.reachable,
|
|
355
|
+
status: record.liveness.status,
|
|
356
|
+
redirected: record.liveness.redirected,
|
|
357
|
+
// "Uses WebMCP" means "registered at least one tool". The browser-API flag is
|
|
358
|
+
// published beside it so the two can never be conflated by a later reader.
|
|
359
|
+
usesWebmcp: record.webmcp.registered,
|
|
360
|
+
browserApiPresent: record.webmcp.apiPresent,
|
|
361
|
+
toolCount: record.webmcp.toolCount,
|
|
362
|
+
// The agent-visible count travels with the page-visible one, because they can
|
|
363
|
+
// differ and the difference is the interesting part. Third-party *origins* stay
|
|
364
|
+
// local: publishing "this project embeds tools from x.example" would put a
|
|
365
|
+
// fourth party's identity into somebody else's row, so only the count and a
|
|
366
|
+
// per-tool boolean go out.
|
|
367
|
+
agentVisibleToolCount: record.webmcp.agentToolCount,
|
|
368
|
+
thirdPartyToolCount: record.webmcp.thirdPartyToolCount,
|
|
369
|
+
viewsDiverge: record.webmcp.divergence
|
|
370
|
+
? record.webmcp.divergence.onlyInBrowser.length > 0 || record.webmcp.divergence.onlyInPage.length > 0
|
|
371
|
+
: null,
|
|
372
|
+
tools: record.webmcp.tools.map((tool) => ({
|
|
373
|
+
name: tool.name,
|
|
374
|
+
descriptionLength: typeof tool.description === 'string' ? tool.description.length : null,
|
|
375
|
+
propertyCount:
|
|
376
|
+
tool.inputSchema && typeof tool.inputSchema === 'object' && tool.inputSchema.properties
|
|
377
|
+
? Object.keys(tool.inputSchema.properties).length
|
|
378
|
+
: null,
|
|
379
|
+
requiredCount: Array.isArray(tool.inputSchema?.required) ? tool.inputSchema.required.length : null,
|
|
380
|
+
hasAnnotations: Boolean(tool.annotations && Object.keys(tool.annotations).length > 0),
|
|
381
|
+
inputSchemaWire: tool.inputSchemaWire,
|
|
382
|
+
})),
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* The one-line-per-project census a snapshot is judged by.
|
|
387
|
+
*
|
|
388
|
+
* `usingWebmcp` counts pages that registered a tool. There is deliberately no
|
|
389
|
+
* count of pages where the API merely existed, because that number describes the
|
|
390
|
+
* browser and would be misread as adoption the moment it appeared in a table.
|
|
391
|
+
*
|
|
392
|
+
* The agent-side totals are reported separately rather than folded in: they
|
|
393
|
+
* answer "what could an agent call across this cohort", which is a different
|
|
394
|
+
* question from "how many builders shipped tools", and 2026-09-02's measurement
|
|
395
|
+
* showed the two can disagree on a single page.
|
|
396
|
+
*/
|
|
397
|
+
export const summarize = (records) => {
|
|
398
|
+
const reachable = records.filter((r) => r.liveness.reachable);
|
|
399
|
+
const using = reachable.filter((r) => r.webmcp.registered);
|
|
400
|
+
const toolCounts = using.map((r) => r.webmcp.toolCount).sort((a, b) => a - b);
|
|
401
|
+
const withAgentView = reachable.filter((r) => Array.isArray(r.webmcp.agentTools));
|
|
402
|
+
|
|
403
|
+
return {
|
|
404
|
+
projects: records.length,
|
|
405
|
+
reachable: reachable.length,
|
|
406
|
+
dead: records.length - reachable.length,
|
|
407
|
+
usingWebmcp: using.length,
|
|
408
|
+
reachableWithoutTools: reachable.length - using.length,
|
|
409
|
+
totalTools: toolCounts.reduce((sum, n) => sum + n, 0),
|
|
410
|
+
medianToolCount: toolCounts.length
|
|
411
|
+
? toolCounts[Math.floor((toolCounts.length - 1) / 2)]
|
|
412
|
+
: null,
|
|
413
|
+
maxToolCount: toolCounts.length ? toolCounts.at(-1) : null,
|
|
414
|
+
// Agent-side, and null-safe: a cohort captured without the browser domain
|
|
415
|
+
// reports nulls rather than zeroes.
|
|
416
|
+
pagesWithAgentView: withAgentView.length,
|
|
417
|
+
totalAgentVisibleTools: withAgentView.length
|
|
418
|
+
? withAgentView.reduce((sum, r) => sum + (r.webmcp.agentToolCount ?? 0), 0)
|
|
419
|
+
: null,
|
|
420
|
+
pagesWithThirdPartyTools: withAgentView.length
|
|
421
|
+
? withAgentView.filter((r) => (r.webmcp.thirdPartyToolCount ?? 0) > 0).length
|
|
422
|
+
: null,
|
|
423
|
+
pagesWhereViewsDiverge: withAgentView.length
|
|
424
|
+
? withAgentView.filter(
|
|
425
|
+
(r) =>
|
|
426
|
+
(r.webmcp.divergence?.onlyInBrowser.length ?? 0) > 0 ||
|
|
427
|
+
(r.webmcp.divergence?.onlyInPage.length ?? 0) > 0
|
|
428
|
+
).length
|
|
429
|
+
: null,
|
|
430
|
+
errors: records.filter((r) => r.error).length,
|
|
431
|
+
};
|
|
432
|
+
};
|