mcp-context-cost 0.11.2 → 0.12.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 +5 -5
- package/dist/audit/audit.js +7 -0
- package/dist/audit/config.js +9 -0
- package/dist/audit/diff.js +9 -1
- package/dist/cli.js +38 -8
- package/dist/core/adoption.d.ts +23 -0
- package/dist/core/adoption.js +26 -2
- package/dist/core/capture-index.js +9 -5
- package/dist/core/cross-check.js +3 -0
- package/dist/core/divergence.js +8 -0
- package/dist/core/regression.d.ts +14 -1
- package/dist/core/regression.js +16 -0
- package/dist/core/tool-shape.js +10 -2
- package/dist/core/types.d.ts +23 -1
- package/dist/sweep/client.d.ts +22 -0
- package/dist/sweep/client.js +93 -2
- package/dist/sweep/dashboard.js +4 -4
- package/dist/sweep/harness-guard.d.ts +9 -1
- package/dist/sweep/harness-guard.js +18 -8
- package/dist/sweep/report.d.ts +16 -0
- package/dist/sweep/report.js +14 -1
- package/dist/sweep/run.d.ts +41 -0
- package/dist/sweep/run.js +60 -7
- package/dist/sweep/session-start.js +31 -9
- package/dist/sweep/sweep-all.js +29 -15
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -184,7 +184,7 @@ Add `--claude` to annotate each server with its Anthropic-request cost from the
|
|
|
184
184
|
[Claude divergence](docs/METHODOLOGY.md#claude-divergence) run — an exact number when the
|
|
185
185
|
published capture hash matches what you have installed, `—` (silence, not a stale guess)
|
|
186
186
|
when it doesn't. The run holds 20 rows — the top 20 measured servers by tokens when it ran —
|
|
187
|
-
and [results/leaderboard.md](results/leaderboard.md) prints a claude number for the
|
|
187
|
+
and [results/leaderboard.md](results/leaderboard.md) prints a claude number for the 12 that
|
|
188
188
|
still match today and silence for the rest. Most installs will show a mix:
|
|
189
189
|
|
|
190
190
|
```
|
|
@@ -235,7 +235,7 @@ Flags: `--json` (full report on stdout, progress on stderr), `--budget N`,
|
|
|
235
235
|
|
|
236
236
|
The number `audit` gives you is the same measurement, run across a curated set of public
|
|
237
237
|
servers — which is how you can tell it is a measurement and not this tool's opinion. It also
|
|
238
|
-
shows what you are choosing between: across the
|
|
238
|
+
shows what you are choosing between: across the 83 servers measured, cost spans **1,700×**,
|
|
239
239
|
from `postgres` at 32 tokens to `github` at 54,622. The table below is a
|
|
240
240
|
sample of that range; the full range is in
|
|
241
241
|
[results/leaderboard.md](results/leaderboard.md).
|
|
@@ -244,13 +244,13 @@ sample of that range; the full range is in
|
|
|
244
244
|
|---|---:|---:|
|
|
245
245
|
| github (official) | **54,622 tokens** | 44 |
|
|
246
246
|
| xcodebuildmcp | 26,594 | 24 |
|
|
247
|
-
| brave-search | 25,
|
|
247
|
+
| brave-search | 25,487 | 8 |
|
|
248
248
|
| notion | 17,500 | 24 |
|
|
249
249
|
| playwright *(4.8M installs/week)* | 4,024 | 24 |
|
|
250
250
|
| filesystem (reference) | 2,823 | 14 |
|
|
251
251
|
| markitdown | 64 | 1 |
|
|
252
252
|
|
|
253
|
-
*(
|
|
253
|
+
*(83 of 106 popular servers measured, each row dated by its own most recent sweep — full table in
|
|
254
254
|
[results/leaderboard.md](results/leaderboard.md); every failure is listed with its reason.
|
|
255
255
|
Each measured server also has a [detail page](https://athakur3.github.io/mcp-context-cost/servers/)
|
|
256
256
|
showing which tools its tokens are in.)*
|
|
@@ -260,7 +260,7 @@ answers a question no client asks: **what did this server cost last month?**
|
|
|
260
260
|
[results/regressions.md](results/regressions.md) reports each server's most recent movement —
|
|
261
261
|
dated to when it happened, separated into *shipped more tools* versus *same tools, rewritten*,
|
|
262
262
|
and compared only within one isolation. The ecosystem ratchets upward: of the servers whose
|
|
263
|
-
cost has moved at all,
|
|
263
|
+
cost has moved at all, 11 moved up against 6 that moved down. Method:
|
|
264
264
|
[cost movement](docs/METHODOLOGY.md#cost-movement).
|
|
265
265
|
|
|
266
266
|
If you publish a server, the same measurement is available as a badge, so your users can see
|
package/dist/audit/audit.js
CHANGED
|
@@ -179,6 +179,13 @@ export function buildReport(configs, measured, opts = {}) {
|
|
|
179
179
|
const contextWindow = opts.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
|
|
180
180
|
const problems = [];
|
|
181
181
|
const emptyConfigs = [];
|
|
182
|
+
// canonicalSha256 → the divergence row computed from it, so a server can be
|
|
183
|
+
// identified by its bytes whatever the local config calls it.
|
|
184
|
+
const divByHash = new Map();
|
|
185
|
+
for (const row of Object.values(opts.divergence?.servers ?? {})) {
|
|
186
|
+
if (row?.capturedSha256)
|
|
187
|
+
divByHash.set(row.capturedSha256, row);
|
|
188
|
+
}
|
|
182
189
|
const built = [];
|
|
183
190
|
// Across every config at once: a twin in one client's file is measured for
|
|
184
191
|
// the other client's entry just the same.
|
package/dist/audit/config.js
CHANGED
|
@@ -182,9 +182,18 @@ export function configCandidates(env) {
|
|
|
182
182
|
/** Read + parse the candidates that exist. Unreadable files are reported, not thrown. */
|
|
183
183
|
export function loadConfigs(candidates, cwd) {
|
|
184
184
|
const out = [];
|
|
185
|
+
// Running from your home directory nominates `~/.cursor/mcp.json` twice —
|
|
186
|
+
// once as the home candidate, once as the cwd one. Loaded twice it is
|
|
187
|
+
// reported twice, doubles that client's deferral scope, and under
|
|
188
|
+
// `--baseline` the second copy pairs with nothing and fails the gate. One
|
|
189
|
+
// path is one config however many ways it was nominated.
|
|
190
|
+
const seen = new Set();
|
|
185
191
|
for (const c of candidates) {
|
|
186
192
|
if (!existsSync(c.path))
|
|
187
193
|
continue;
|
|
194
|
+
if (seen.has(c.path))
|
|
195
|
+
continue;
|
|
196
|
+
seen.add(c.path);
|
|
188
197
|
try {
|
|
189
198
|
const doc = parseJsonc(readFileSync(c.path, 'utf8'));
|
|
190
199
|
const { servers, disabled } = extractDeclaration(doc, { client: c.client, source: c.path, cwd });
|
package/dist/audit/diff.js
CHANGED
|
@@ -181,7 +181,15 @@ export function pairConfigs(before, after) {
|
|
|
181
181
|
pairs.push({ before: null, after: cur, matchedBy: 'unmatched' });
|
|
182
182
|
}
|
|
183
183
|
}
|
|
184
|
-
|
|
184
|
+
// One on each side reads as the same config seen from two machines, where the
|
|
185
|
+
// path legitimately differs (a laptop's baseline against a CI checkout). It
|
|
186
|
+
// does not survive the clients differing: a Claude Desktop baseline against a
|
|
187
|
+
// Claude Code run compares two unrelated stacks, and the gate then rests on
|
|
188
|
+
// that difference. The paths may differ; what they are configs *for* may not.
|
|
189
|
+
if (before.length === 1 &&
|
|
190
|
+
after.length === 1 &&
|
|
191
|
+
pairs[0].before === null &&
|
|
192
|
+
before[0].client === after[0].client) {
|
|
185
193
|
pairs[0] = { before: before[0], after: after[0], matchedBy: 'sole-config' };
|
|
186
194
|
unusedBefore.delete(before[0].source);
|
|
187
195
|
}
|
package/dist/cli.js
CHANGED
|
@@ -45,6 +45,19 @@ export function slugFromUrl(url) {
|
|
|
45
45
|
const host = new URL(url).hostname.replace(/^(www|mcp)\./, '');
|
|
46
46
|
return host.replace(/[^a-z0-9]+/gi, '-').replace(/^-+|-+$/g, '').toLowerCase() || 'remote';
|
|
47
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* Report a `verify` failure and exit 1, in whichever shape the caller asked
|
|
50
|
+
* for. `--json` is documented as putting `{ ok, rederivedTokens, rederivedSha,
|
|
51
|
+
* problems }` on stdout; a script reading that gets nothing from a thrown
|
|
52
|
+
* exception, so every failure path goes through here.
|
|
53
|
+
*/
|
|
54
|
+
function failVerify(json, problem) {
|
|
55
|
+
if (json)
|
|
56
|
+
console.log(JSON.stringify({ ok: false, rederivedTokens: null, rederivedSha: null, problems: [problem] }));
|
|
57
|
+
else
|
|
58
|
+
console.error(problem);
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
48
61
|
/** Installed version, for error messages that need to say which one you are running. */
|
|
49
62
|
export function cliVersion() {
|
|
50
63
|
try {
|
|
@@ -322,6 +335,12 @@ if (cmd === 'audit') {
|
|
|
322
335
|
`${where}\n` +
|
|
323
336
|
`${empty.length === 1 ? 'It was' : 'They were'} read and parsed; there is simply nothing declared to measure.\n` +
|
|
324
337
|
`Declare a server in one of them, or point at a different config: mcp-context-cost audit --config <path/to/mcp.json>`);
|
|
338
|
+
else if (all('config').length)
|
|
339
|
+
// A path the user named is not a discovery miss. Saying "looked in the
|
|
340
|
+
// standard locations" describes something the command did not do, and
|
|
341
|
+
// then advises doing the thing they just did.
|
|
342
|
+
console.error(`no MCP config found at the path(s) given: ${all('config').join(', ')}. ` +
|
|
343
|
+
`Nothing else was searched, because --config was set.`);
|
|
325
344
|
else
|
|
326
345
|
console.error(`no MCP config found. Looked in the standard Claude Desktop / Claude Code / Cursor / VS Code / Windsurf locations.${where}\n` +
|
|
327
346
|
`Point at one explicitly: mcp-context-cost audit --config <path/to/mcp.json>`);
|
|
@@ -355,18 +374,29 @@ else if (cmd === 'verify') {
|
|
|
355
374
|
raw = await res.text();
|
|
356
375
|
}
|
|
357
376
|
catch (e) {
|
|
358
|
-
|
|
359
|
-
if (json)
|
|
360
|
-
console.log(JSON.stringify({ ok: false, rederivedTokens: null, rederivedSha: null, problems: [problem] }));
|
|
361
|
-
else
|
|
362
|
-
console.error(problem);
|
|
363
|
-
process.exit(1);
|
|
377
|
+
failVerify(json, `failed to fetch ${remoteUrl}: ${e.message}`);
|
|
364
378
|
}
|
|
365
379
|
}
|
|
366
380
|
else {
|
|
367
|
-
|
|
381
|
+
try {
|
|
382
|
+
raw = readFileSync(path, 'utf8');
|
|
383
|
+
}
|
|
384
|
+
catch (e) {
|
|
385
|
+
// The remote branch above reports a failed fetch in the documented shape;
|
|
386
|
+
// this one used to throw, so `--json` produced a stack trace on stderr and
|
|
387
|
+
// nothing at all on stdout — the contract a script parses.
|
|
388
|
+
failVerify(json, `cannot read ${path}: ${e.message}`);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
let m;
|
|
392
|
+
try {
|
|
393
|
+
m = JSON.parse(raw);
|
|
394
|
+
}
|
|
395
|
+
catch (e) {
|
|
396
|
+
// Reachable remotely: a proxy, a captive portal or an HTML error page
|
|
397
|
+
// served with status 200 passes the `res.ok` check above and arrives here.
|
|
398
|
+
failVerify(json, `${remoteUrl ?? path} is not valid JSON: ${e.message}`);
|
|
368
399
|
}
|
|
369
|
-
const m = JSON.parse(raw);
|
|
370
400
|
const r = verifyMeasurement(m);
|
|
371
401
|
if (json) {
|
|
372
402
|
console.log(JSON.stringify({ serverName: m.serverName, ...r, badge: r.ok ? toBadge(m) : undefined }));
|
package/dist/core/adoption.d.ts
CHANGED
|
@@ -143,6 +143,14 @@ export declare function decodeLoose(text: string): string;
|
|
|
143
143
|
export interface EndpointBadge {
|
|
144
144
|
url: string;
|
|
145
145
|
linkTarget: string | null;
|
|
146
|
+
/**
|
|
147
|
+
* The badge's own label — markdown alt text or an `<img alt>`. What the badge
|
|
148
|
+
* calls itself is the only thing in a README that distinguishes *this*
|
|
149
|
+
* project's badge from any other shields endpoint badge, since the JSON can
|
|
150
|
+
* be hosted anywhere (the staged action publishes to a gist, with no path
|
|
151
|
+
* shape to recognise).
|
|
152
|
+
*/
|
|
153
|
+
alt: string | null;
|
|
146
154
|
}
|
|
147
155
|
/**
|
|
148
156
|
* Every shields endpoint badge in a file, decoded, each paired with its own
|
|
@@ -172,6 +180,21 @@ export declare function linksBackToProject(target: string, src?: BadgeSource): b
|
|
|
172
180
|
* measurement here. See the header for why both count and why the link is
|
|
173
181
|
* paired with the image rather than looked for anywhere in the file.
|
|
174
182
|
*/
|
|
183
|
+
/**
|
|
184
|
+
* Whether a badge calls itself this project's badge.
|
|
185
|
+
*
|
|
186
|
+
* The self-hosted branch below used to accept the link alone and look at
|
|
187
|
+
* nothing else, so *any* shields endpoint badge — a coverage badge, say —
|
|
188
|
+
* wrapped in a link to this repository counted as displaying ours. That
|
|
189
|
+
* inflates the one number this project keeps about itself, which is the last
|
|
190
|
+
* place it can afford a generous reading.
|
|
191
|
+
*
|
|
192
|
+
* The URL cannot decide it: self-hosted JSON lives wherever its author put it,
|
|
193
|
+
* and the staged action publishes to a gist. What every published snippet does
|
|
194
|
+
* carry is the label — `context cost` — so that is what is read, tolerantly
|
|
195
|
+
* enough to accept `Context-Cost` and strictly enough to reject `coverage`.
|
|
196
|
+
*/
|
|
197
|
+
export declare function namesThisBadge(alt: string | null): boolean;
|
|
175
198
|
export declare function displaysBadge(text: string, src?: BadgeSource): boolean;
|
|
176
199
|
/**
|
|
177
200
|
* Every shields endpoint `url` in a file whose JSON is served from this
|
package/dist/core/adoption.js
CHANGED
|
@@ -137,7 +137,11 @@ export function endpointBadges(text) {
|
|
|
137
137
|
if (last && !before.slice(last.index ?? 0).includes('</a>'))
|
|
138
138
|
linkTarget = last[1];
|
|
139
139
|
}
|
|
140
|
-
|
|
140
|
+
// Markdown puts the alt before the image; HTML puts it in the same tag.
|
|
141
|
+
const beforeAlt = decoded.slice(Math.max(0, start - LINK_WINDOW), start);
|
|
142
|
+
const mdAlt = beforeAlt.match(/!\[([^\]]*)\]\(\s*[^\s)]*$/);
|
|
143
|
+
const tagAlt = after.match(/^[^<>]*?\balt\s*=\s*["']([^"']*)["']/i);
|
|
144
|
+
out.push({ url: m[1], linkTarget, alt: mdAlt ? mdAlt[1] : tagAlt ? tagAlt[1] : null });
|
|
141
145
|
}
|
|
142
146
|
return out;
|
|
143
147
|
}
|
|
@@ -169,8 +173,28 @@ export function linksBackToProject(target, src = BADGE_SOURCE) {
|
|
|
169
173
|
* measurement here. See the header for why both count and why the link is
|
|
170
174
|
* paired with the image rather than looked for anywhere in the file.
|
|
171
175
|
*/
|
|
176
|
+
/**
|
|
177
|
+
* Whether a badge calls itself this project's badge.
|
|
178
|
+
*
|
|
179
|
+
* The self-hosted branch below used to accept the link alone and look at
|
|
180
|
+
* nothing else, so *any* shields endpoint badge — a coverage badge, say —
|
|
181
|
+
* wrapped in a link to this repository counted as displaying ours. That
|
|
182
|
+
* inflates the one number this project keeps about itself, which is the last
|
|
183
|
+
* place it can afford a generous reading.
|
|
184
|
+
*
|
|
185
|
+
* The URL cannot decide it: self-hosted JSON lives wherever its author put it,
|
|
186
|
+
* and the staged action publishes to a gist. What every published snippet does
|
|
187
|
+
* carry is the label — `context cost` — so that is what is read, tolerantly
|
|
188
|
+
* enough to accept `Context-Cost` and strictly enough to reject `coverage`.
|
|
189
|
+
*/
|
|
190
|
+
export function namesThisBadge(alt) {
|
|
191
|
+
if (!alt)
|
|
192
|
+
return false;
|
|
193
|
+
return alt.toLowerCase().replace(/[^a-z]/g, '').includes('contextcost');
|
|
194
|
+
}
|
|
172
195
|
export function displaysBadge(text, src = BADGE_SOURCE) {
|
|
173
|
-
return endpointBadges(text).some((b) => hostedHere(b.url, src) ||
|
|
196
|
+
return endpointBadges(text).some((b) => hostedHere(b.url, src) ||
|
|
197
|
+
(namesThisBadge(b.alt) && b.linkTarget !== null && linksBackToProject(b.linkTarget, src)));
|
|
174
198
|
}
|
|
175
199
|
/**
|
|
176
200
|
* Every shields endpoint `url` in a file whose JSON is served from this
|
|
@@ -72,14 +72,18 @@ export function identify(canonicalSha256, index) {
|
|
|
72
72
|
if (!mine)
|
|
73
73
|
return { kind: 'unknown' };
|
|
74
74
|
const currentSha = index.current[mine.server];
|
|
75
|
-
if (
|
|
75
|
+
if (currentSha === canonicalSha256) {
|
|
76
76
|
return { kind: 'current', server: mine.server, date: mine.date, tokens: mine.totalTokens };
|
|
77
77
|
}
|
|
78
|
-
|
|
79
|
-
//
|
|
80
|
-
//
|
|
78
|
+
// The bytes are identified, but what is current for this server is not: the
|
|
79
|
+
// pointer is missing, or points at a capture the index dropped as ambiguous.
|
|
80
|
+
// `current` would be an affirmative claim that nothing has moved, which the
|
|
81
|
+
// audit prints as "no server here is running a published capture that has
|
|
82
|
+
// since moved" — told to someone who may be far behind. Unknown currency
|
|
83
|
+
// reads as unknown, which is the discipline the rest of this module keeps.
|
|
84
|
+
const current = currentSha ? index.captures[currentSha] : undefined;
|
|
81
85
|
if (!current)
|
|
82
|
-
return { kind: '
|
|
86
|
+
return { kind: 'unknown' };
|
|
83
87
|
return {
|
|
84
88
|
kind: 'behind',
|
|
85
89
|
server: mine.server,
|
package/dist/core/cross-check.js
CHANGED
|
@@ -160,6 +160,9 @@ export function isComparable(row, canonicalSha256) {
|
|
|
160
160
|
canonicalSha256 !== null &&
|
|
161
161
|
row.capturedSha256 === canonicalSha256 &&
|
|
162
162
|
row.ourTokens > 0 &&
|
|
163
|
+
// A report that parsed but carries `total: 0` is not a measurement of
|
|
164
|
+
// anything; published, it renders as a −100% divergence.
|
|
165
|
+
row.cliTokens > 0 &&
|
|
163
166
|
row.ourMappedTokens > 0);
|
|
164
167
|
}
|
|
165
168
|
/**
|
package/dist/core/divergence.js
CHANGED
|
@@ -55,6 +55,14 @@ export function mappedTokens(raw) {
|
|
|
55
55
|
export function fieldSelectionShare(row) {
|
|
56
56
|
if (row.o200kFull <= 0)
|
|
57
57
|
return null;
|
|
58
|
+
// The projection can add bytes rather than remove them — `inputSchema`
|
|
59
|
+
// becomes the longer `input_schema`, and a tool with no description gains
|
|
60
|
+
// `description: ""` — so a server with almost no metadata to drop can map
|
|
61
|
+
// *larger* than it measured. There is no share of the payload removed in that
|
|
62
|
+
// case, and publishing a negative one reads as "−11.1% of the capture is
|
|
63
|
+
// MCP-only metadata", which is not a thing.
|
|
64
|
+
if (row.o200kMapped > row.o200kFull)
|
|
65
|
+
return null;
|
|
58
66
|
return (row.o200kFull - row.o200kMapped) / row.o200kFull;
|
|
59
67
|
}
|
|
60
68
|
/**
|
|
@@ -124,7 +124,20 @@ export interface ToolAttribution {
|
|
|
124
124
|
*/
|
|
125
125
|
unexplainedTokens: number;
|
|
126
126
|
}
|
|
127
|
-
|
|
127
|
+
/**
|
|
128
|
+
* Per-tool attribution, or null when the names cannot carry it.
|
|
129
|
+
*
|
|
130
|
+
* The breakdown matches tools by name, so a name that appears twice on either
|
|
131
|
+
* side makes the maps below lose one of them silently — and the lost tokens
|
|
132
|
+
* resurface as `unexplainedTokens`, which the report explains to the reader as
|
|
133
|
+
* canonical-array framing bytes. That is a confident false explanation. Two
|
|
134
|
+
* ways it happens: a server that ships duplicate or namespaced-collapsed tool
|
|
135
|
+
* names, and `measureTools` recording every nameless tool as the single key
|
|
136
|
+
* `(unnamed)` — an invented name, where `toolNames` and `toAnthropicTools`
|
|
137
|
+
* deliberately drop nameless tools rather than invent one. Where the names
|
|
138
|
+
* cannot identify the tools, there is no attribution to give.
|
|
139
|
+
*/
|
|
140
|
+
export declare function attribute(from: ToolVectorEntry, to: ToolVectorEntry, deltaTokens: number): ToolAttribution | null;
|
|
128
141
|
export interface CostChange {
|
|
129
142
|
server: string;
|
|
130
143
|
fromDate: string;
|
package/dist/core/regression.js
CHANGED
|
@@ -87,7 +87,23 @@ export function mechanismOf(deltaTokens, deltaTools) {
|
|
|
87
87
|
// and rewritten, and the totals cannot separate the two.
|
|
88
88
|
return 'mixed';
|
|
89
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* Per-tool attribution, or null when the names cannot carry it.
|
|
92
|
+
*
|
|
93
|
+
* The breakdown matches tools by name, so a name that appears twice on either
|
|
94
|
+
* side makes the maps below lose one of them silently — and the lost tokens
|
|
95
|
+
* resurface as `unexplainedTokens`, which the report explains to the reader as
|
|
96
|
+
* canonical-array framing bytes. That is a confident false explanation. Two
|
|
97
|
+
* ways it happens: a server that ships duplicate or namespaced-collapsed tool
|
|
98
|
+
* names, and `measureTools` recording every nameless tool as the single key
|
|
99
|
+
* `(unnamed)` — an invented name, where `toolNames` and `toAnthropicTools`
|
|
100
|
+
* deliberately drop nameless tools rather than invent one. Where the names
|
|
101
|
+
* cannot identify the tools, there is no attribution to give.
|
|
102
|
+
*/
|
|
90
103
|
export function attribute(from, to, deltaTokens) {
|
|
104
|
+
const unique = (ts) => new Set(ts.map((t) => t.name)).size === ts.length;
|
|
105
|
+
if (!unique(from.tools) || !unique(to.tools))
|
|
106
|
+
return null;
|
|
91
107
|
const before = new Map(from.tools.map((t) => [t.name, t.tokens]));
|
|
92
108
|
const after = new Map(to.tools.map((t) => [t.name, t.tokens]));
|
|
93
109
|
const added = [];
|
package/dist/core/tool-shape.js
CHANGED
|
@@ -23,10 +23,18 @@ export function quantileTable(values) {
|
|
|
23
23
|
* checkable by anyone holding the same JSON.
|
|
24
24
|
*/
|
|
25
25
|
export function percentileOf(quantiles, value) {
|
|
26
|
+
// The LOWEST percentile whose quantile the value reaches, not the highest.
|
|
27
|
+
// Taking the highest reports a value tied with half the measured set as p100
|
|
28
|
+
// — "heavier than 100% of measured tools" about something exactly average for
|
|
29
|
+
// its tie — because every percentile across the tie carries the same
|
|
30
|
+
// quantile. The lowest names where the tie begins, which is what "heavier
|
|
31
|
+
// than P% of tools" means.
|
|
26
32
|
let p = 0;
|
|
27
33
|
for (let i = 0; i <= 100; i++) {
|
|
28
|
-
if (quantiles[i] <= value)
|
|
29
|
-
|
|
34
|
+
if (quantiles[i] <= value) {
|
|
35
|
+
if (quantiles[i] < value || i === 0 || quantiles[i - 1] < quantiles[i])
|
|
36
|
+
p = i;
|
|
37
|
+
}
|
|
30
38
|
else
|
|
31
39
|
break;
|
|
32
40
|
}
|
package/dist/core/types.d.ts
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
/** Status taxonomy — every swept server gets exactly one; no silent drops. */
|
|
2
|
-
export type MeasurementStatus = 'measured' | 'auth-required' | 'startup-failure' | 'timeout'
|
|
2
|
+
export type MeasurementStatus = 'measured' | 'auth-required' | 'startup-failure' | 'timeout'
|
|
3
|
+
/**
|
|
4
|
+
* This harness cannot run the server, for a reason that is a property of the
|
|
5
|
+
* harness rather than of the software: an OS or architecture the package does
|
|
6
|
+
* not ship for, or a backing service the isolation deliberately does not
|
|
7
|
+
* provide. Distinct from `startup-failure`, which asserts the server did not
|
|
8
|
+
* come up — a claim about someone else's code that these entries do not
|
|
9
|
+
* support. Only ever set when the entry declares the reason AND the failure's
|
|
10
|
+
* own text corroborates it (see `notApplicable` in report.ts).
|
|
11
|
+
*/
|
|
12
|
+
| 'not-applicable' | 'dynamic' | 'remote-auth-wall';
|
|
3
13
|
export interface ToolMeasurement {
|
|
4
14
|
name: string;
|
|
5
15
|
/** Tokens of the whole tool object, canonically serialized. */
|
|
@@ -43,6 +53,18 @@ export interface Measurement {
|
|
|
43
53
|
image?: string;
|
|
44
54
|
network?: string;
|
|
45
55
|
note?: string;
|
|
56
|
+
/**
|
|
57
|
+
* The architecture the measurement ran on, as `<platform>/<arch>` (e.g.
|
|
58
|
+
* `linux/amd64`). Part of the isolation because some packages ship builds
|
|
59
|
+
* for only some of them: `local-mcp` was published as a startup failure
|
|
60
|
+
* for weeks on the strength of a run whose real finding was "this laptop
|
|
61
|
+
* is arm64 and the package has no arm64 runtime" — a fact about the
|
|
62
|
+
* machine that the record gave no way to see.
|
|
63
|
+
*
|
|
64
|
+
* Absent on records written before this was captured, which is why it is
|
|
65
|
+
* optional; absence means unknown, never "the same as yours".
|
|
66
|
+
*/
|
|
67
|
+
arch?: string;
|
|
46
68
|
};
|
|
47
69
|
/** Request timeout in force during this measurement. */
|
|
48
70
|
timeoutMs?: number;
|
package/dist/sweep/client.d.ts
CHANGED
|
@@ -13,6 +13,28 @@ export interface WireCapture {
|
|
|
13
13
|
instructions: string | null;
|
|
14
14
|
stderrTail: string;
|
|
15
15
|
}
|
|
16
|
+
/**
|
|
17
|
+
* The part of a dead server's stderr worth keeping as evidence.
|
|
18
|
+
*
|
|
19
|
+
* A failure record is only useful if it contains the failure, and a plain tail
|
|
20
|
+
* reliably keeps the least useful part. `npx` prints a deprecation warning per
|
|
21
|
+
* transitive dependency and a version notice at the end, and a crashing process
|
|
22
|
+
* prints its message *before* the stack — so the last N characters of stderr
|
|
23
|
+
* are npm warnings and stack frames on exactly the servers whose failure needs
|
|
24
|
+
* explaining. Several published records ended up saying nothing about why the
|
|
25
|
+
* server did not start.
|
|
26
|
+
*
|
|
27
|
+
* This is not cosmetic. `run.ts` classifies a failure by reading these words:
|
|
28
|
+
* a record whose message was cut off is filed as `startup-failure` — the server
|
|
29
|
+
* is broken — when the surviving text would have said `auth-required`. Spending
|
|
30
|
+
* the budget on the message rather than the frames is what keeps the published
|
|
31
|
+
* taxonomy describing the server.
|
|
32
|
+
*
|
|
33
|
+
* Noise is only dropped while something else survives. A package that fails
|
|
34
|
+
* *inside* npm (EBADPLATFORM, a failed postinstall) has npm's own lines as its
|
|
35
|
+
* only evidence, and a server whose whole output is a stack keeps the stack.
|
|
36
|
+
*/
|
|
37
|
+
export declare function evidenceTail(stderr: string, limit?: number): string;
|
|
16
38
|
export declare class McpStdioClient {
|
|
17
39
|
private child;
|
|
18
40
|
private buffer;
|
package/dist/sweep/client.js
CHANGED
|
@@ -6,6 +6,92 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { spawn } from 'node:child_process';
|
|
8
8
|
const PROTOCOL_VERSION = '2025-06-18';
|
|
9
|
+
/**
|
|
10
|
+
* The part of a dead server's stderr worth keeping as evidence.
|
|
11
|
+
*
|
|
12
|
+
* A failure record is only useful if it contains the failure, and a plain tail
|
|
13
|
+
* reliably keeps the least useful part. `npx` prints a deprecation warning per
|
|
14
|
+
* transitive dependency and a version notice at the end, and a crashing process
|
|
15
|
+
* prints its message *before* the stack — so the last N characters of stderr
|
|
16
|
+
* are npm warnings and stack frames on exactly the servers whose failure needs
|
|
17
|
+
* explaining. Several published records ended up saying nothing about why the
|
|
18
|
+
* server did not start.
|
|
19
|
+
*
|
|
20
|
+
* This is not cosmetic. `run.ts` classifies a failure by reading these words:
|
|
21
|
+
* a record whose message was cut off is filed as `startup-failure` — the server
|
|
22
|
+
* is broken — when the surviving text would have said `auth-required`. Spending
|
|
23
|
+
* the budget on the message rather than the frames is what keeps the published
|
|
24
|
+
* taxonomy describing the server.
|
|
25
|
+
*
|
|
26
|
+
* Noise is only dropped while something else survives. A package that fails
|
|
27
|
+
* *inside* npm (EBADPLATFORM, a failed postinstall) has npm's own lines as its
|
|
28
|
+
* only evidence, and a server whose whole output is a stack keeps the stack.
|
|
29
|
+
*/
|
|
30
|
+
export function evidenceTail(stderr, limit = 600) {
|
|
31
|
+
const withoutNoise = drop(stderr, (l) => /^npm (warn|notice)\b/.test(l));
|
|
32
|
+
const withoutFrames = drop(withoutNoise, (l) => /^at\s/.test(l));
|
|
33
|
+
return bothEnds(withoutFrames, limit);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Keep the start and the end, eliding the middle.
|
|
37
|
+
*
|
|
38
|
+
* Dropping npm noise and stack frames is not enough on its own: a CLI that
|
|
39
|
+
* rejects its environment often prints one line saying why and then its entire
|
|
40
|
+
* usage screen, which is neither. kubernetes-mcp-server does exactly that, and
|
|
41
|
+
* a tail-only budget kept forty lines of flag documentation while discarding
|
|
42
|
+
* "no current-context is set and no contexts are defined in kubeconfig" — the
|
|
43
|
+
* only sentence in the output that explained anything.
|
|
44
|
+
*
|
|
45
|
+
* Failures put their explanation at one end or the other — a crash message
|
|
46
|
+
* above its aftermath, or an error at the end of a log — so both ends are kept
|
|
47
|
+
* and the middle is what goes. The split leans towards the head because a
|
|
48
|
+
* message that precedes its own noise is the more common shape here.
|
|
49
|
+
*/
|
|
50
|
+
function bothEnds(text, limit) {
|
|
51
|
+
if (text.length <= limit)
|
|
52
|
+
return text;
|
|
53
|
+
const elision = '\n […] \n';
|
|
54
|
+
const budget = Math.max(0, limit - elision.length);
|
|
55
|
+
const lines = text.split('\n');
|
|
56
|
+
// Whole lines only: a boundary cut mid-word ("ool/prompt change") reads as
|
|
57
|
+
// corruption and loses the token an evidence string would match on.
|
|
58
|
+
const take = (from, to, cap, fromEnd) => {
|
|
59
|
+
const out = [];
|
|
60
|
+
let used = 0;
|
|
61
|
+
for (let i = fromEnd ? to : from; fromEnd ? i >= from : i <= to; i += fromEnd ? -1 : 1) {
|
|
62
|
+
const cost = lines[i].length + 1;
|
|
63
|
+
if (used + cost > cap)
|
|
64
|
+
break;
|
|
65
|
+
fromEnd ? out.unshift(lines[i]) : out.push(lines[i]);
|
|
66
|
+
used += cost;
|
|
67
|
+
}
|
|
68
|
+
return { out, used };
|
|
69
|
+
};
|
|
70
|
+
const headCap = Math.ceil(budget * 0.6);
|
|
71
|
+
const head = take(0, lines.length - 1, headCap, false);
|
|
72
|
+
// Whole lines, except when the first line alone overruns the budget. A server
|
|
73
|
+
// that logs structured JSON puts its entire message on one line, so that line
|
|
74
|
+
// is both the most informative thing in the output and the only one that can
|
|
75
|
+
// never fit — slack-mcp-server's `{"level":"fatal","message":"Authentication
|
|
76
|
+
// required: ..."}` was dropped in full, and the record it left behind said a
|
|
77
|
+
// child process exited. Truncated evidence beats none.
|
|
78
|
+
const headText = head.out.length > 0 ? head.out.join('\n') : lines[0].slice(0, headCap);
|
|
79
|
+
const headUsed = head.out.length > 0 ? head.used : headText.length;
|
|
80
|
+
const tailFrom = head.out.length > 0 ? head.out.length : 1;
|
|
81
|
+
const tail = take(tailFrom, lines.length - 1, budget - headUsed, true);
|
|
82
|
+
if (headText === '' && tail.out.length === 0)
|
|
83
|
+
return text.slice(0, budget) + elision;
|
|
84
|
+
return `${headText}${elision}${tail.out.join('\n')}`;
|
|
85
|
+
}
|
|
86
|
+
/** Drop matching lines, keeping the input whole if that would leave nothing. */
|
|
87
|
+
function drop(text, isNoise) {
|
|
88
|
+
const kept = text
|
|
89
|
+
.split('\n')
|
|
90
|
+
.filter((l) => !isNoise(l.trim()))
|
|
91
|
+
.join('\n')
|
|
92
|
+
.trim();
|
|
93
|
+
return kept || text.trim();
|
|
94
|
+
}
|
|
9
95
|
export class McpStdioClient {
|
|
10
96
|
child;
|
|
11
97
|
buffer = '';
|
|
@@ -37,7 +123,7 @@ export class McpStdioClient {
|
|
|
37
123
|
resolve();
|
|
38
124
|
});
|
|
39
125
|
this.child.on('exit', (code) => {
|
|
40
|
-
const tail = this.stderrTail
|
|
126
|
+
const tail = evidenceTail(this.stderrTail);
|
|
41
127
|
this.deadReason = `server exited (code ${code})${tail ? `; stderr tail: ${tail}` : ''}`;
|
|
42
128
|
for (const p of this.pending.values())
|
|
43
129
|
p.reject(new Error(this.deadReason));
|
|
@@ -95,7 +181,12 @@ export class McpStdioClient {
|
|
|
95
181
|
return new Promise((resolve, reject) => {
|
|
96
182
|
const timer = setTimeout(() => {
|
|
97
183
|
this.pending.delete(id);
|
|
98
|
-
|
|
184
|
+
// A process that is killed mid-hang never reaches the exit handler, so
|
|
185
|
+
// without this a timed-out record carries no evidence at all — it says
|
|
186
|
+
// only that we waited. What the server managed to print before it
|
|
187
|
+
// stopped answering is usually the whole explanation.
|
|
188
|
+
const tail = evidenceTail(this.stderrTail);
|
|
189
|
+
reject(new Error(`timeout after ${timeoutMs}ms waiting for ${method}${tail ? `; stderr tail: ${tail}` : ''}`));
|
|
99
190
|
}, timeoutMs);
|
|
100
191
|
this.pending.set(id, {
|
|
101
192
|
resolve: (v) => {
|
package/dist/sweep/dashboard.js
CHANGED
|
@@ -7,6 +7,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
|
7
7
|
import { dirname, join } from 'node:path';
|
|
8
8
|
import { parse } from 'yaml';
|
|
9
9
|
import { isCurrent } from '../core/divergence.js';
|
|
10
|
+
import { loadRows } from './report.js';
|
|
10
11
|
import { bandColor, BAND_META } from '../core/bands.js';
|
|
11
12
|
import { parseHistory, plottableSeries } from './history.js';
|
|
12
13
|
/** Longest series a sparkline plots — a stat-tile trend, not a full chart. */
|
|
@@ -53,10 +54,9 @@ export function generateDashboard(root = process.cwd()) {
|
|
|
53
54
|
// Only the run of sweeps taken under the same isolation is plotted: a step
|
|
54
55
|
// across an isolation change is the harness moving, not the server.
|
|
55
56
|
const seriesFor = (name) => plottableSeries(history.filter((h) => h.server === name));
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
});
|
|
57
|
+
// Shared with the leaderboard rather than re-read here: one definition of how
|
|
58
|
+
// a measurement is loaded, including its tolerance for a half-written file.
|
|
59
|
+
const rows = loadRows(doc.servers, root);
|
|
60
60
|
const measured = rows
|
|
61
61
|
.filter((r) => r.m && (r.m.status === 'measured' || r.m.status === 'dynamic') && r.m.totalTokens !== null)
|
|
62
62
|
.sort((a, b) => (b.m.totalTokens ?? 0) - (a.m.totalTokens ?? 0));
|
|
@@ -47,7 +47,15 @@ export interface Verdict {
|
|
|
47
47
|
* `current` maps server name to the status it just measured at. Servers absent
|
|
48
48
|
* from it were not swept and are ignored.
|
|
49
49
|
*/
|
|
50
|
-
export declare function verdict(prior: Snapshot[], current: Map<string, MeasurementStatus
|
|
50
|
+
export declare function verdict(prior: Snapshot[], current: Map<string, MeasurementStatus>,
|
|
51
|
+
/**
|
|
52
|
+
* Servers this sweep could not measure because docker itself failed. They
|
|
53
|
+
* never reached a status, so they are absent from `current` and invisible to
|
|
54
|
+
* the comparison below — but they are the same fact it is looking for: a
|
|
55
|
+
* server that could have produced a number and did not. Counted here so the
|
|
56
|
+
* two symptoms share one threshold instead of being each other's blind spot.
|
|
57
|
+
*/
|
|
58
|
+
dockerFaults?: number): Verdict;
|
|
51
59
|
/**
|
|
52
60
|
* Put the snapshotted artifacts back, byte for byte. Only servers named in
|
|
53
61
|
* `names` are touched, and only where a prior file existed — a server whose
|
|
@@ -78,12 +78,21 @@ export function snapshot(names, root = process.cwd()) {
|
|
|
78
78
|
* `current` maps server name to the status it just measured at. Servers absent
|
|
79
79
|
* from it were not swept and are ignored.
|
|
80
80
|
*/
|
|
81
|
-
export function verdict(prior, current
|
|
81
|
+
export function verdict(prior, current,
|
|
82
|
+
/**
|
|
83
|
+
* Servers this sweep could not measure because docker itself failed. They
|
|
84
|
+
* never reached a status, so they are absent from `current` and invisible to
|
|
85
|
+
* the comparison below — but they are the same fact it is looking for: a
|
|
86
|
+
* server that could have produced a number and did not. Counted here so the
|
|
87
|
+
* two symptoms share one threshold instead of being each other's blind spot.
|
|
88
|
+
*/
|
|
89
|
+
dockerFaults = 0) {
|
|
82
90
|
const comparableNames = prior
|
|
83
91
|
.filter((s) => s.status !== null && isGood(s.status) && current.has(s.name))
|
|
84
92
|
.map((s) => s.name);
|
|
85
93
|
const regressed = comparableNames.filter((n) => !isGood(current.get(n)));
|
|
86
|
-
const
|
|
94
|
+
const failed = regressed.length + dockerFaults;
|
|
95
|
+
const comparable = comparableNames.length + dockerFaults;
|
|
87
96
|
if (comparable === 0) {
|
|
88
97
|
return {
|
|
89
98
|
fault: false,
|
|
@@ -94,15 +103,16 @@ export function verdict(prior, current) {
|
|
|
94
103
|
reason: 'no prior measurement to compare against — harness check not performed',
|
|
95
104
|
};
|
|
96
105
|
}
|
|
97
|
-
const ratio =
|
|
106
|
+
const ratio = failed / comparable;
|
|
98
107
|
const pct = (ratio * 100).toFixed(0);
|
|
99
|
-
|
|
108
|
+
const how = dockerFaults > 0 ? ` (${regressed.length} regressed, ${dockerFaults} unmeasurable)` : '';
|
|
109
|
+
if (failed >= MIN_REGRESSIONS && ratio >= FAULT_RATIO) {
|
|
100
110
|
return {
|
|
101
111
|
fault: true,
|
|
102
112
|
regressed,
|
|
103
113
|
comparable,
|
|
104
|
-
reason: `${
|
|
105
|
-
`this sweep — at or above the ${MIN_REGRESSIONS}-server, ` +
|
|
114
|
+
reason: `${failed} of ${comparable} previously-measured servers (${pct}%) produced no number in ` +
|
|
115
|
+
`this sweep${how} — at or above the ${MIN_REGRESSIONS}-server, ` +
|
|
106
116
|
`${(FAULT_RATIO * 100).toFixed(0)}% threshold that reads as a broken harness ` +
|
|
107
117
|
`rather than broken servers`,
|
|
108
118
|
};
|
|
@@ -111,8 +121,8 @@ export function verdict(prior, current) {
|
|
|
111
121
|
fault: false,
|
|
112
122
|
regressed,
|
|
113
123
|
comparable,
|
|
114
|
-
reason: `${
|
|
115
|
-
`this sweep — below the harness-fault threshold, publishing normally`,
|
|
124
|
+
reason: `${failed} of ${comparable} previously-measured servers (${pct}%) produced no number in ` +
|
|
125
|
+
`this sweep${how} — below the harness-fault threshold, publishing normally`,
|
|
116
126
|
};
|
|
117
127
|
}
|
|
118
128
|
/**
|
package/dist/sweep/report.d.ts
CHANGED
|
@@ -23,6 +23,22 @@ export interface ServerEntry {
|
|
|
23
23
|
* before ever reaching tools/list. See docker.ts `dummyEnvValues`.
|
|
24
24
|
*/
|
|
25
25
|
envValues?: Record<string, string>;
|
|
26
|
+
/**
|
|
27
|
+
* Declares that a failure of this entry is this harness's limitation, not the
|
|
28
|
+
* server's — an OS or architecture the package does not ship for, or a
|
|
29
|
+
* backing service the isolation deliberately does not provide.
|
|
30
|
+
*
|
|
31
|
+
* `evidence` is what keeps the declaration honest. The status only becomes
|
|
32
|
+
* `not-applicable` when the failure's own text contains that substring, so an
|
|
33
|
+
* annotation left behind after upstream changes cannot quietly absorb a real
|
|
34
|
+
* breakage: the server fails a different way, the evidence stops matching,
|
|
35
|
+
* and it is published as the failure it actually is. The entry is still
|
|
36
|
+
* attempted every sweep, so the day it starts working it simply measures.
|
|
37
|
+
*/
|
|
38
|
+
notApplicable?: {
|
|
39
|
+
reason: string;
|
|
40
|
+
evidence: string;
|
|
41
|
+
};
|
|
26
42
|
}
|
|
27
43
|
export interface Row {
|
|
28
44
|
entry: ServerEntry;
|
package/dist/sweep/report.js
CHANGED
|
@@ -21,7 +21,20 @@ function csvCell(s) {
|
|
|
21
21
|
export function loadRows(entries, root = process.cwd()) {
|
|
22
22
|
return entries.map((entry) => {
|
|
23
23
|
const p = join(root, 'results', entry.name, 'measurement.json');
|
|
24
|
-
|
|
24
|
+
if (!existsSync(p))
|
|
25
|
+
return { entry, m: null };
|
|
26
|
+
try {
|
|
27
|
+
return { entry, m: JSON.parse(readFileSync(p, 'utf8')) };
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// The same tolerance `appendHistory` states for the same file: a sweep
|
|
31
|
+
// killed mid-write leaves a truncated measurement, and every generator
|
|
32
|
+
// reads through here. Throwing meant one such file broke the leaderboard,
|
|
33
|
+
// the server pages, the dashboard, the tool-shape baseline and the
|
|
34
|
+
// published-stats check at once — weekly, with a SyntaxError that named
|
|
35
|
+
// no file. A server with no readable record reads as one with no record.
|
|
36
|
+
return { entry, m: null };
|
|
37
|
+
}
|
|
25
38
|
});
|
|
26
39
|
}
|
|
27
40
|
/** results/divergence.json if a divergence run has been recorded, else null. */
|
package/dist/sweep/run.d.ts
CHANGED
|
@@ -1,4 +1,40 @@
|
|
|
1
1
|
import type { Measurement } from '../core/types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Which kind of failure a dead server's own words describe.
|
|
4
|
+
*
|
|
5
|
+
* The distinction is the published one: `auth-required` says the server works
|
|
6
|
+
* and this harness has no credentials for it, `startup-failure` says the server
|
|
7
|
+
* did not come up. Only the text decides, so it matters that the text reaching
|
|
8
|
+
* here is the part that explains the failure rather than whatever happened to
|
|
9
|
+
* fall in the last few hundred bytes — see `evidenceTail` in client.ts, which
|
|
10
|
+
* exists because a truncated message was being filed as a broken server.
|
|
11
|
+
*/
|
|
12
|
+
export declare function classifyFailure(msg: string): 'timeout' | 'auth-required' | 'startup-failure';
|
|
13
|
+
/**
|
|
14
|
+
* The architecture a measurement ran on, in Docker's vocabulary (`linux/amd64`).
|
|
15
|
+
*
|
|
16
|
+
* Worth recording because a package can ship builds for some architectures and
|
|
17
|
+
* not others, and then the *machine* decides the result. `local-mcp` sat
|
|
18
|
+
* published as a startup failure on the strength of a run whose actual finding
|
|
19
|
+
* was that the laptop was arm64 and the package ships no arm64 runtime — and
|
|
20
|
+
* the record gave a reader no way to notice.
|
|
21
|
+
*
|
|
22
|
+
* Containers are linux whatever the host is; with no explicit `--platform` they
|
|
23
|
+
* take the host's architecture, so that is the half worth reporting.
|
|
24
|
+
*/
|
|
25
|
+
export declare function measuringArch(docker: boolean): string;
|
|
26
|
+
/**
|
|
27
|
+
* The declared reason, when this failure is the one the entry warned about.
|
|
28
|
+
*
|
|
29
|
+
* Corroboration is the whole point: an entry may declare that this harness
|
|
30
|
+
* cannot run it, but only the failure's own words can confirm that *this*
|
|
31
|
+
* failure is that one. A macOS-only package that starts failing for some new
|
|
32
|
+
* reason stops matching, and is published as the failure it actually is.
|
|
33
|
+
*/
|
|
34
|
+
export declare function notApplicableReason(declared: {
|
|
35
|
+
reason: string;
|
|
36
|
+
evidence: string;
|
|
37
|
+
} | undefined, msg: string): string | null;
|
|
2
38
|
export interface MeasureOptions {
|
|
3
39
|
timeoutMs?: number;
|
|
4
40
|
env?: Record<string, string>;
|
|
@@ -11,6 +47,11 @@ export interface MeasureOptions {
|
|
|
11
47
|
dummyEnvValues?: Record<string, string>;
|
|
12
48
|
/** Install `git` in the container before launch (docker mode) — see docker.ts. */
|
|
13
49
|
needsGit?: boolean;
|
|
50
|
+
/** Declared harness limitation for this entry — see `notApplicable` in report.ts. */
|
|
51
|
+
notApplicable?: {
|
|
52
|
+
reason: string;
|
|
53
|
+
evidence: string;
|
|
54
|
+
};
|
|
14
55
|
/**
|
|
15
56
|
* Exact argv, when the caller already has it (client configs store command and
|
|
16
57
|
* args separately). Avoids re-splitting a joined string on spaces, which would
|
package/dist/sweep/run.js
CHANGED
|
@@ -11,6 +11,56 @@ import { captureTools } from './client.js';
|
|
|
11
11
|
import { DockerHarnessFault, defaultImageFor, dockerize, ensureImage, isDockerRunFailure } from './docker.js';
|
|
12
12
|
import { measureTools, failedMeasurement, canonicalString } from '../core/canonical.js';
|
|
13
13
|
import { toBadge } from '../core/badge.js';
|
|
14
|
+
/**
|
|
15
|
+
* Which kind of failure a dead server's own words describe.
|
|
16
|
+
*
|
|
17
|
+
* The distinction is the published one: `auth-required` says the server works
|
|
18
|
+
* and this harness has no credentials for it, `startup-failure` says the server
|
|
19
|
+
* did not come up. Only the text decides, so it matters that the text reaching
|
|
20
|
+
* here is the part that explains the failure rather than whatever happened to
|
|
21
|
+
* fall in the last few hundred bytes — see `evidenceTail` in client.ts, which
|
|
22
|
+
* exists because a truncated message was being filed as a broken server.
|
|
23
|
+
*/
|
|
24
|
+
export function classifyFailure(msg) {
|
|
25
|
+
// Matched against this harness's own phrasing, not the bare word: these
|
|
26
|
+
// messages carry the server's stderr, and a server that prints "connection
|
|
27
|
+
// timeout" before dying did not time out — it exited, and saying otherwise
|
|
28
|
+
// blames the clock for a breakage.
|
|
29
|
+
if (/timeout after \d+ms waiting for/.test(msg))
|
|
30
|
+
return 'timeout';
|
|
31
|
+
return /auth|unauthorized|401|forbidden|credential|api.?key|token/i.test(msg)
|
|
32
|
+
? 'auth-required'
|
|
33
|
+
: 'startup-failure';
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The architecture a measurement ran on, in Docker's vocabulary (`linux/amd64`).
|
|
37
|
+
*
|
|
38
|
+
* Worth recording because a package can ship builds for some architectures and
|
|
39
|
+
* not others, and then the *machine* decides the result. `local-mcp` sat
|
|
40
|
+
* published as a startup failure on the strength of a run whose actual finding
|
|
41
|
+
* was that the laptop was arm64 and the package ships no arm64 runtime — and
|
|
42
|
+
* the record gave a reader no way to notice.
|
|
43
|
+
*
|
|
44
|
+
* Containers are linux whatever the host is; with no explicit `--platform` they
|
|
45
|
+
* take the host's architecture, so that is the half worth reporting.
|
|
46
|
+
*/
|
|
47
|
+
export function measuringArch(docker) {
|
|
48
|
+
const arch = process.arch === 'x64' ? 'amd64' : process.arch;
|
|
49
|
+
return `${docker ? 'linux' : process.platform}/${arch}`;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The declared reason, when this failure is the one the entry warned about.
|
|
53
|
+
*
|
|
54
|
+
* Corroboration is the whole point: an entry may declare that this harness
|
|
55
|
+
* cannot run it, but only the failure's own words can confirm that *this*
|
|
56
|
+
* failure is that one. A macOS-only package that starts failing for some new
|
|
57
|
+
* reason stops matching, and is published as the failure it actually is.
|
|
58
|
+
*/
|
|
59
|
+
export function notApplicableReason(declared, msg) {
|
|
60
|
+
if (!declared?.evidence)
|
|
61
|
+
return null;
|
|
62
|
+
return msg.toLowerCase().includes(declared.evidence.toLowerCase()) ? declared.reason : null;
|
|
63
|
+
}
|
|
14
64
|
function arg(name) {
|
|
15
65
|
const i = process.argv.indexOf(`--${name}`);
|
|
16
66
|
return i >= 0 ? process.argv[i + 1] : undefined;
|
|
@@ -135,14 +185,17 @@ export async function measureServer(name, command, opts = {}) {
|
|
|
135
185
|
if (dockerWrapped && isDockerRunFailure(msg)) {
|
|
136
186
|
throw new DockerHarnessFault(`docker could not run the container for ${name}: ${msg.slice(0, 400)}`);
|
|
137
187
|
}
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
:
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
188
|
+
const declared = notApplicableReason(opts.notApplicable, msg);
|
|
189
|
+
r = failedMeasurement(declared ? 'not-applicable' : classifyFailure(msg), {
|
|
190
|
+
serverName: name,
|
|
191
|
+
launchCommand: command,
|
|
192
|
+
// The declared reason leads, but the raw failure stays behind it: the
|
|
193
|
+
// record has to remain checkable against the run that produced it.
|
|
194
|
+
notes: (declared ? `${declared} — ${msg}` : msg).slice(0, 700),
|
|
195
|
+
});
|
|
144
196
|
}
|
|
145
|
-
|
|
197
|
+
const iso = isolation ?? { docker: false };
|
|
198
|
+
r.isolation = { ...iso, arch: measuringArch(iso.docker) };
|
|
146
199
|
r.timeoutMs = attemptOpts.timeoutMs ?? 60_000;
|
|
147
200
|
return r;
|
|
148
201
|
}
|
|
@@ -25,6 +25,7 @@ import { join, resolve } from 'node:path';
|
|
|
25
25
|
import { fileURLToPath } from 'node:url';
|
|
26
26
|
import { parse } from 'yaml';
|
|
27
27
|
import { measureServer } from './run.js';
|
|
28
|
+
import { DockerHarnessFault } from './docker.js';
|
|
28
29
|
import { SESSION_START_METHOD, parseSessionStart, toSessionStartRow, } from '../core/session-start.js';
|
|
29
30
|
export function loadSessionStart(root = process.cwd()) {
|
|
30
31
|
const p = join(root, 'results', 'session-start.json');
|
|
@@ -63,15 +64,36 @@ if (isMain) {
|
|
|
63
64
|
const queue = [...entries];
|
|
64
65
|
async function worker() {
|
|
65
66
|
for (let e = queue.shift(); e; e = queue.shift()) {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
67
|
+
let m;
|
|
68
|
+
try {
|
|
69
|
+
m = await measureServer(e.name, e.command, {
|
|
70
|
+
timeoutMs: (e.timeoutSeconds ?? defaultTimeout) * 1000,
|
|
71
|
+
docker,
|
|
72
|
+
dockerImage: e.dockerImage,
|
|
73
|
+
dummyEnv: e.env ?? [],
|
|
74
|
+
dummyEnvValues: e.envValues,
|
|
75
|
+
needsGit: e.needsGit,
|
|
76
|
+
persist: false, // the measurements on disk are not this run's to rewrite
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
// One thrown error used to reject Promise.all and end the process
|
|
81
|
+
// before anything was written, discarding every capture the run had
|
|
82
|
+
// already completed — and skipping the `finally` that force-removes
|
|
83
|
+
// containers, orphaning the in-flight ones. A machine fault on one
|
|
84
|
+
// server is recorded against that server; the rest of the run stands.
|
|
85
|
+
if (!(err instanceof DockerHarnessFault))
|
|
86
|
+
throw err;
|
|
87
|
+
servers[e.name] = {
|
|
88
|
+
instructions: '',
|
|
89
|
+
instructionsTokens: 0,
|
|
90
|
+
instructionsSha256: '',
|
|
91
|
+
capturedSha256: null,
|
|
92
|
+
error: `docker harness fault: ${err.message.slice(0, 200)}`,
|
|
93
|
+
};
|
|
94
|
+
console.log(` ${e.name}: docker harness fault — recorded, run continues`);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
75
97
|
if (m.status !== 'measured' && m.status !== 'dynamic') {
|
|
76
98
|
servers[e.name] = {
|
|
77
99
|
instructions: '',
|
package/dist/sweep/sweep-all.js
CHANGED
|
@@ -18,7 +18,7 @@ import { DockerHarnessFault } from './docker.js';
|
|
|
18
18
|
import { writeLeaderboard } from './report.js';
|
|
19
19
|
import { appendHistory } from './history.js';
|
|
20
20
|
import { appendToolVectors, writeRegressions } from './regressions.js';
|
|
21
|
-
import {
|
|
21
|
+
import { MIN_REGRESSIONS, snapshot, verdict, restore } from './harness-guard.js';
|
|
22
22
|
import { selectShard, shardIndexForDate } from './shard.js';
|
|
23
23
|
function arg(name) {
|
|
24
24
|
const i = process.argv.indexOf(`--${name}`);
|
|
@@ -55,6 +55,18 @@ if (shards !== undefined) {
|
|
|
55
55
|
const index = shardIndexArg ?? shardIndexForDate(new Date(), shards);
|
|
56
56
|
entries = selectShard(sweepable, shards, index);
|
|
57
57
|
shardLabel = `, shard ${index + 1}/${shards}`;
|
|
58
|
+
// The harness guard needs MIN_REGRESSIONS previously-good servers to fail
|
|
59
|
+
// together before it will call a broken runner rather than broken servers.
|
|
60
|
+
// A slice smaller than that floor can never reach it, so a wedged Docker
|
|
61
|
+
// daemon would publish the whole slice as startup failures with nothing to
|
|
62
|
+
// trip. `--shards` is the one knob that can shrink a slice under the floor
|
|
63
|
+
// unattended, so it refuses here instead of measuring through it.
|
|
64
|
+
if (entries.length < MIN_REGRESSIONS) {
|
|
65
|
+
console.error(`--shards ${shards} cuts a ${entries.length}-server slice, below the ` +
|
|
66
|
+
`${MIN_REGRESSIONS}-server floor the harness guard needs to tell a broken runner ` +
|
|
67
|
+
`from broken servers. Use a smaller --shards, or --only to measure a handful by name.`);
|
|
68
|
+
process.exit(2);
|
|
69
|
+
}
|
|
58
70
|
console.log(`shard ${index + 1}/${shards} of ${sweepable.length} sweepable: ${entries.map((e) => e.name).join(', ')}`);
|
|
59
71
|
}
|
|
60
72
|
console.log(`sweeping ${entries.length} servers (docker=${docker}, concurrency=${concurrency}${shardLabel})`);
|
|
@@ -81,6 +93,7 @@ async function worker() {
|
|
|
81
93
|
dummyEnv: e.env ?? [],
|
|
82
94
|
dummyEnvValues: e.envValues,
|
|
83
95
|
needsGit: e.needsGit,
|
|
96
|
+
notApplicable: e.notApplicable,
|
|
84
97
|
});
|
|
85
98
|
}
|
|
86
99
|
catch (err) {
|
|
@@ -101,24 +114,25 @@ async function worker() {
|
|
|
101
114
|
}
|
|
102
115
|
}
|
|
103
116
|
await Promise.all(Array.from({ length: Math.max(1, concurrency) }, () => worker()));
|
|
104
|
-
// A docker fault on most of the slice is the harness-fault story with an
|
|
105
|
-
// earlier symptom — the guard below can't see it (nothing regressed on disk;
|
|
106
|
-
// the throws happened before anything was written), so it is judged here by
|
|
107
|
-
// the guard's own thresholds. Below them, the sweep publishes what it did
|
|
108
|
-
// measure and the faulted servers wait for the next cycle.
|
|
109
|
-
if (dockerFaults.size >= MIN_REGRESSIONS && dockerFaults.size / entries.length >= FAULT_RATIO) {
|
|
110
|
-
console.error(`\nHARNESS FAULT — docker could not run for ${dockerFaults.size} of ${entries.length} servers; ` +
|
|
111
|
-
`refusing to publish this sweep.\n` +
|
|
112
|
-
[...dockerFaults].map(([n, msg]) => ` ${n}: ${msg}`).join('\n') +
|
|
113
|
-
`\n Nothing was overwritten — every previous record stands. ` +
|
|
114
|
-
`Check the Docker daemon and registry path, then re-run.`);
|
|
115
|
-
process.exit(1);
|
|
116
|
-
}
|
|
117
117
|
// Before publishing anything: is this sweep a statement about the servers, or
|
|
118
118
|
// about the machine that measured them?
|
|
119
|
-
|
|
119
|
+
//
|
|
120
|
+
// The two symptoms are counted together, against one denominator. Judged apart
|
|
121
|
+
// they were each other's blind spot: a flaky daemon that throws for 6 of 14
|
|
122
|
+
// servers (6 ≥ 5, but 43% of the slice) and times out 4 more (4 < 5) trips
|
|
123
|
+
// neither threshold, and the sweep publishes with 10 of 14 servers producing no
|
|
124
|
+
// number and four good records overwritten with failures. A server that could
|
|
125
|
+
// have produced a number and didn't is one fact, however it failed.
|
|
126
|
+
const v = verdict(prior, statuses, dockerFaults.size);
|
|
120
127
|
console.log(`harness check: ${v.reason}`);
|
|
128
|
+
if (dockerFaults.size > 0 && !v.fault) {
|
|
129
|
+
console.warn(` (${dockerFaults.size} docker fault(s) counted toward that check; those servers were not measured)`);
|
|
130
|
+
}
|
|
121
131
|
if (v.fault) {
|
|
132
|
+
if (dockerFaults.size) {
|
|
133
|
+
console.error(`\ndocker could not run for ${dockerFaults.size} server(s):\n` +
|
|
134
|
+
[...dockerFaults].map(([n, msg]) => ` ${n}: ${msg}`).join('\n'));
|
|
135
|
+
}
|
|
122
136
|
const restored = restore(prior, v.regressed);
|
|
123
137
|
console.error(`\nHARNESS FAULT — refusing to publish this sweep.\n` +
|
|
124
138
|
` regressed: ${v.regressed.join(', ')}\n` +
|
package/package.json
CHANGED