mcp-context-cost 0.13.1 → 0.14.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 +27 -11
- package/dist/audit/deferral.d.ts +23 -5
- package/dist/audit/deferral.js +30 -5
- package/dist/core/divergence.d.ts +20 -0
- package/dist/core/divergence.js +27 -0
- package/dist/sweep/client.d.ts +30 -2
- package/dist/sweep/client.js +137 -17
- package/dist/sweep/cross-check.js +2 -2
- package/dist/sweep/pr-check.d.ts +62 -0
- package/dist/sweep/pr-check.js +296 -0
- package/dist/sweep/published-stats.d.ts +2 -0
- package/dist/sweep/published-stats.js +18 -9
- package/dist/sweep/registry-scan.d.ts +355 -0
- package/dist/sweep/registry-scan.js +432 -0
- package/dist/sweep/run.d.ts +17 -0
- package/dist/sweep/run.js +57 -13
- package/dist/sweep/servers-schema.d.ts +7 -0
- package/dist/sweep/servers-schema.js +8 -1
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -126,7 +126,7 @@ costs **more** than loading the definitions would.
|
|
|
126
126
|
|
|
127
127
|
Three things the report will not do: it will not convert between units silently (in
|
|
128
128
|
threshold mode the stack is compared as a range, because the audit counts wire bytes and the
|
|
129
|
-
threshold is counted in what the client sends to the API — measured at 0.
|
|
129
|
+
threshold is counted in what the client sends to the API — measured at 0.19×–1.93× across 86
|
|
130
130
|
servers); it will not claim a posture the machine did not state readably, which is four
|
|
131
131
|
refusals and not one — when two places set the same variable to different values, when a
|
|
132
132
|
settings file exists and cannot be read, when the place that would decide sets the variable
|
|
@@ -204,8 +204,8 @@ INCREASE FAIL:
|
|
|
204
204
|
Add `--claude` to annotate each server with its Anthropic-request cost from the published
|
|
205
205
|
[Claude divergence](docs/METHODOLOGY.md#claude-divergence) run — an exact number when the
|
|
206
206
|
published capture hash matches what you have installed, `—` (silence, not a stale guess)
|
|
207
|
-
when it doesn't. The run holds
|
|
208
|
-
and [results/leaderboard.md](results/leaderboard.md) prints a claude number for the
|
|
207
|
+
when it doesn't. The run holds 87 rows — the measured servers it covered when it last ran —
|
|
208
|
+
and [results/leaderboard.md](results/leaderboard.md) prints a claude number for the 86 that
|
|
209
209
|
still match today and silence for the rest. Most installs will show a mix:
|
|
210
210
|
|
|
211
211
|
```
|
|
@@ -271,7 +271,7 @@ sample of that range; the full range is in
|
|
|
271
271
|
| filesystem (reference) | 2,823 | 14 |
|
|
272
272
|
| markitdown | 64 | 1 |
|
|
273
273
|
|
|
274
|
-
*(87 of
|
|
274
|
+
*(87 of 107 popular servers measured, each row dated by its own most recent sweep — full table in
|
|
275
275
|
[results/leaderboard.md](results/leaderboard.md); every failure is listed with its reason.
|
|
276
276
|
Each measured server also has a [detail page](https://athakur3.github.io/mcp-context-cost/servers/)
|
|
277
277
|
showing which tools its tokens are in.)*
|
|
@@ -299,7 +299,7 @@ now measured against a pinned model and published beside the badge, and they do
|
|
|
299
299
|
|
|
300
300
|
| server | badge (o200k) | Claude (`claude-opus-5`) | |
|
|
301
301
|
|---|---:|---:|---|
|
|
302
|
-
| github | 54,622 |
|
|
302
|
+
| github | 54,622 | **18,728** | most of the capture is `annotations`/`outputSchema` metadata Claude never sees |
|
|
303
303
|
| notion | 17,500 | **33,560** | almost no metadata to drop, so the tokenizer difference dominates |
|
|
304
304
|
|
|
305
305
|
So the heaviest server on the badge is not the heaviest server on Claude. Per-server
|
|
@@ -347,9 +347,9 @@ number is *not*, config policy, failure taxonomy, frozen color bands, known dive
|
|
|
347
347
|
| `src/audit/` | client-config discovery (5 clients, JSONC-tolerant), the per-stack report, and the baseline diff |
|
|
348
348
|
| `src/cli.ts` | `audit` (measure your own stack), `verify` (re-derive any published number), `measure` |
|
|
349
349
|
| `spec/fixtures/` | golden vectors shared by the TypeScript and bash implementations |
|
|
350
|
-
| `tools/` | the
|
|
350
|
+
| `tools/` | the scripts that call a network API — the Claude divergence run, the adoption reading, the registry scan — kept out of the package so the library stays offline |
|
|
351
351
|
| `upstream/` | `badge.sh` + composite-action patch + bash tests — the self-serve badge recipe, carried here |
|
|
352
|
-
| `servers.yaml` |
|
|
352
|
+
| `servers.yaml` | 107 curated candidates with live install metrics and provenance |
|
|
353
353
|
| `results/` · `badges/` | measurements, leaderboard, history series, shields endpoint JSONs |
|
|
354
354
|
| `docs/` | methodology, dashboard, and a generated page per measured server |
|
|
355
355
|
|
|
@@ -357,11 +357,26 @@ number is *not*, config policy, failure taxonomy, frozen color bands, known dive
|
|
|
357
357
|
|
|
358
358
|
```bash
|
|
359
359
|
npm ci
|
|
360
|
-
npm run sweep -- --name my-server --command "npx -y my-mcp-server"
|
|
361
|
-
cat badges/my-server.json # strict shields.io endpoint JSON
|
|
360
|
+
npm run sweep -- --no-persist --name my-server --command "npx -y my-mcp-server"
|
|
362
361
|
```
|
|
363
362
|
|
|
364
|
-
|
|
363
|
+
That prints the number and writes nothing. Published records
|
|
364
|
+
(`results/<name>/measurement.json`, `badges/<name>.json`, the `history.csv`
|
|
365
|
+
row) come from CI: a developer machine is a different architecture under
|
|
366
|
+
different load, and a measurement taken there describes it rather than the
|
|
367
|
+
server. `local-mcp`'s failing record was made on an arm64 laptop, and its
|
|
368
|
+
stderr named an architecture the record itself did not — which is why every
|
|
369
|
+
measurement now records `isolation.arch`. The entry turned out to be
|
|
370
|
+
unavailable on both architectures, and a record that says where it was made
|
|
371
|
+
is what lets that be told from a broken server. To get your server into the
|
|
372
|
+
leaderboard, add an entry to `servers.yaml` and open a pull request; the check
|
|
373
|
+
on that PR measures the entry read-only, and the rotation publishes it after
|
|
374
|
+
merge.
|
|
375
|
+
|
|
376
|
+
For a badge on your own README, run the published CLI in your server's own CI
|
|
377
|
+
(the [gate](#defend-the-number-dont-just-display-it) below writes
|
|
378
|
+
`results/my-server/measurement.json` and `badges/my-server.json` into that
|
|
379
|
+
repository), then in your README:
|
|
365
380
|
|
|
366
381
|
```markdown
|
|
367
382
|
[](<link target>)
|
|
@@ -455,6 +470,7 @@ public, one click each:
|
|
|
455
470
|
[re-sweep runs](https://github.com/athakur3/mcp-context-cost/actions/workflows/resweep.yml)
|
|
456
471
|
and [self-badge runs](https://github.com/athakur3/mcp-context-cost/actions/workflows/self-badge.yml).
|
|
457
472
|
See [ROADMAP.md](ROADMAP.md) for what's next — contributions welcome, especially new
|
|
458
|
-
`servers.yaml` entries.
|
|
473
|
+
`servers.yaml` entries; [CONTRIBUTING.md](CONTRIBUTING.md) says what an entry needs, the
|
|
474
|
+
order of steps that leaves a pull request green, and where its number comes from.
|
|
459
475
|
|
|
460
476
|
MIT © 2026
|
package/dist/audit/deferral.d.ts
CHANGED
|
@@ -219,15 +219,33 @@ export declare function resolveToolSearchSources(sources: ToolSearchSource[]): R
|
|
|
219
219
|
* threshold is a share of the context window measured in what the client
|
|
220
220
|
* actually sends to the API — the name/description/input_schema projection,
|
|
221
221
|
* counted by Anthropic's tokenizer, plus the tool framework overhead. Those are
|
|
222
|
-
* not the same number and the gap is not small
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
222
|
+
* not the same number and the gap is not small; the fields below carry it, and
|
|
223
|
+
* the pages state it from the run rather than from here. Comparing the wire
|
|
224
|
+
* number directly against the threshold understates the deferrable side for
|
|
225
|
+
* schema-heavy servers and overstates it for metadata-heavy ones, in one
|
|
226
|
+
* direction each.
|
|
227
|
+
*
|
|
228
|
+
* The band is **marginal**: it converts a server's own bytes and excludes the
|
|
229
|
+
* tool framework overhead, which `fixedOverhead` carries separately because the
|
|
230
|
+
* API charges it once per request however many servers are attached. Keeping
|
|
231
|
+
* them together was wrong in a way that stayed invisible while the divergence
|
|
232
|
+
* run sampled the heavy end — on a 54,000-token server a fixed 328 is noise. On
|
|
233
|
+
* 2026-09-05 the run widened to every measured server and reached `postgres` at
|
|
234
|
+
* 32 tokens on the wire, where 328 of its 348 Claude tokens *are* the overhead:
|
|
235
|
+
* a per-server ratio of 10.88× that says nothing about converting bytes. Folded
|
|
236
|
+
* into the band it took the published upper bound from 1.92× to 10.88× and made
|
|
237
|
+
* the audit refuse threshold questions it had been answering correctly. Held
|
|
238
|
+
* apart, the band across the same 86 rows is 0.19×–1.93× — which is where it
|
|
239
|
+
* already was, from a sample a quarter the size.
|
|
227
240
|
*/
|
|
228
241
|
export interface WireToClientRatio {
|
|
229
242
|
low: number;
|
|
230
243
|
high: number;
|
|
244
|
+
/**
|
|
245
|
+
* Tokens the tool framework costs once per request, whatever is attached.
|
|
246
|
+
* Added to a stack a single time; never multiplied by anything.
|
|
247
|
+
*/
|
|
248
|
+
fixedOverhead: number;
|
|
231
249
|
/** How many servers the band was measured across, for the printed caveat. */
|
|
232
250
|
servers: number;
|
|
233
251
|
/** The run it came from, so a reader can date it. */
|
package/dist/audit/deferral.js
CHANGED
|
@@ -233,8 +233,9 @@ export function resolveToolSearchSources(sources) {
|
|
|
233
233
|
* level down. The fields are the record; `source` dates them.
|
|
234
234
|
*/
|
|
235
235
|
export const PUBLISHED_WIRE_TO_CLIENT_RATIO = {
|
|
236
|
-
low: 0.
|
|
237
|
-
high: 1.
|
|
236
|
+
low: 0.19,
|
|
237
|
+
high: 1.93,
|
|
238
|
+
fixedOverhead: 328,
|
|
238
239
|
// A snapshot of the run this package was cut against, which is what `source`
|
|
239
240
|
// below says it is — the installed package has no `results/` to read, so when
|
|
240
241
|
// a live run is supplied `wireToClientRatio` uses that instead and this is
|
|
@@ -248,27 +249,42 @@ export const PUBLISHED_WIRE_TO_CLIENT_RATIO = {
|
|
|
248
249
|
// every measured server on 2026-09-05, with nothing comparing the two. The
|
|
249
250
|
// band had not moved — the servers added sat inside it — which is exactly how
|
|
250
251
|
// a number like this goes wrong quietly.
|
|
251
|
-
|
|
252
|
+
//
|
|
253
|
+
// The widening then moved it by a hair rather than by the fivefold the first
|
|
254
|
+
// reading suggested: 0.20×–1.92× over 23 servers, 0.19×–1.93× over 86. That a
|
|
255
|
+
// quarter of the set predicted the whole of it is the interesting part, and it
|
|
256
|
+
// is only true of the marginal band — see the interface above for what folding
|
|
257
|
+
// the fixed overhead in did to the same numbers.
|
|
258
|
+
servers: 86,
|
|
252
259
|
source: 'the published claude-opus-5 divergence run',
|
|
253
260
|
};
|
|
254
261
|
/** Derive the band from a supplied divergence run, falling back to the published one. */
|
|
255
262
|
export function wireToClientRatio(run) {
|
|
256
263
|
if (!run)
|
|
257
264
|
return PUBLISHED_WIRE_TO_CLIENT_RATIO;
|
|
265
|
+
// `probeDelta` is the run's own reading of the fixed overhead: one tiny tool
|
|
266
|
+
// attached, minus the same request with none. An upper bound, and the only
|
|
267
|
+
// measurement of it there is. A run that does not carry one converts as it
|
|
268
|
+
// always did rather than guessing at a correction.
|
|
269
|
+
const fixedOverhead = typeof run.probeDelta === 'number' && run.probeDelta > 0 ? run.probeDelta : 0;
|
|
258
270
|
let low = Infinity;
|
|
259
271
|
let high = -Infinity;
|
|
260
272
|
let servers = 0;
|
|
261
273
|
for (const row of Object.values(run.servers)) {
|
|
262
274
|
if (!row || row.error || typeof row.claudeDelta !== 'number' || !(row.o200kFull > 0))
|
|
263
275
|
continue;
|
|
264
|
-
const ratio = row.claudeDelta / row.o200kFull;
|
|
276
|
+
const ratio = (row.claudeDelta - fixedOverhead) / row.o200kFull;
|
|
277
|
+
// A server whose entire Claude cost is the overhead says nothing about
|
|
278
|
+
// converting bytes, and a negative ratio is not a conversion at all.
|
|
279
|
+
if (!(ratio > 0))
|
|
280
|
+
continue;
|
|
265
281
|
low = Math.min(low, ratio);
|
|
266
282
|
high = Math.max(high, ratio);
|
|
267
283
|
servers++;
|
|
268
284
|
}
|
|
269
285
|
if (servers === 0)
|
|
270
286
|
return PUBLISHED_WIRE_TO_CLIENT_RATIO;
|
|
271
|
-
return { low, high, servers, source: `the ${run.measuredAt} ${run.model} divergence run` };
|
|
287
|
+
return { low, high, fixedOverhead, servers, source: `the ${run.measuredAt} ${run.model} divergence run` };
|
|
272
288
|
}
|
|
273
289
|
/** Clients this tool discovers that have no default deferral on record. */
|
|
274
290
|
const NO_DEFERRAL_ON_RECORD = new Set(['claude-desktop', 'cursor', 'vscode', 'windsurf']);
|
|
@@ -305,6 +321,15 @@ function estimate(servers, ratio) {
|
|
|
305
321
|
estimated++;
|
|
306
322
|
}
|
|
307
323
|
}
|
|
324
|
+
// Once per request, not once per server — the band is marginal precisely so
|
|
325
|
+
// that this is added here and exactly one time. A published Anthropic count
|
|
326
|
+
// already carries a copy of it, which is why a stack holding one is left
|
|
327
|
+
// alone: adding another would be the same double count in the other
|
|
328
|
+
// direction.
|
|
329
|
+
if (exact === 0 && estimated > 0) {
|
|
330
|
+
low += ratio.fixedOverhead;
|
|
331
|
+
high += ratio.fixedOverhead;
|
|
332
|
+
}
|
|
308
333
|
return { low: Math.round(low), high: Math.round(high), exact, estimated };
|
|
309
334
|
}
|
|
310
335
|
/**
|
|
@@ -67,5 +67,25 @@ export declare function claudeRatio(row: DivergenceRow): number | null;
|
|
|
67
67
|
* caveat: a wrong number next to a fresh badge is worse than no number.
|
|
68
68
|
*/
|
|
69
69
|
export declare function isCurrent(row: DivergenceRow | undefined, canonicalSha256: string | null): row is DivergenceRow;
|
|
70
|
+
/**
|
|
71
|
+
* Drop rows that have stopped describing the capture they were computed from.
|
|
72
|
+
*
|
|
73
|
+
* The companion to `isCurrent`, one layer earlier. `isCurrent` stops a stale row
|
|
74
|
+
* reaching a page; this stops it being carried forward in the first place. A
|
|
75
|
+
* selection preserves every row it does not measure, and a re-sweep moves the
|
|
76
|
+
* capture of every server it re-measures — so between the two, a row outside the
|
|
77
|
+
* selection can go on being merged forward long after the capture it describes
|
|
78
|
+
* is gone. Eight of twenty-four rows had reached that state by 2026-09-05, and
|
|
79
|
+
* nothing said so: the cells were hidden, so the file looked complete while the
|
|
80
|
+
* count of rows had stopped meaning the count of usable rows.
|
|
81
|
+
*
|
|
82
|
+
* `captureSha` returns the canonical hash of what is on disk today, or undefined
|
|
83
|
+
* when there is no capture at all — which is also a reason to drop, because a
|
|
84
|
+
* row about a server that no longer measures is a row nothing can confirm.
|
|
85
|
+
*/
|
|
86
|
+
export declare function dropStaleRows(servers: Record<string, DivergenceRow>, captureSha: (name: string) => string | undefined): {
|
|
87
|
+
kept: Record<string, DivergenceRow>;
|
|
88
|
+
dropped: string[];
|
|
89
|
+
};
|
|
70
90
|
/** Parse results/divergence.json; anything malformed yields null, never throws. */
|
|
71
91
|
export declare function parseDivergence(text: string): DivergenceRun | null;
|
package/dist/core/divergence.js
CHANGED
|
@@ -86,6 +86,33 @@ export function isCurrent(row, canonicalSha256) {
|
|
|
86
86
|
return false;
|
|
87
87
|
return !!canonicalSha256 && row.capturedSha256 === canonicalSha256;
|
|
88
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* Drop rows that have stopped describing the capture they were computed from.
|
|
91
|
+
*
|
|
92
|
+
* The companion to `isCurrent`, one layer earlier. `isCurrent` stops a stale row
|
|
93
|
+
* reaching a page; this stops it being carried forward in the first place. A
|
|
94
|
+
* selection preserves every row it does not measure, and a re-sweep moves the
|
|
95
|
+
* capture of every server it re-measures — so between the two, a row outside the
|
|
96
|
+
* selection can go on being merged forward long after the capture it describes
|
|
97
|
+
* is gone. Eight of twenty-four rows had reached that state by 2026-09-05, and
|
|
98
|
+
* nothing said so: the cells were hidden, so the file looked complete while the
|
|
99
|
+
* count of rows had stopped meaning the count of usable rows.
|
|
100
|
+
*
|
|
101
|
+
* `captureSha` returns the canonical hash of what is on disk today, or undefined
|
|
102
|
+
* when there is no capture at all — which is also a reason to drop, because a
|
|
103
|
+
* row about a server that no longer measures is a row nothing can confirm.
|
|
104
|
+
*/
|
|
105
|
+
export function dropStaleRows(servers, captureSha) {
|
|
106
|
+
const kept = {};
|
|
107
|
+
const dropped = [];
|
|
108
|
+
for (const [name, row] of Object.entries(servers)) {
|
|
109
|
+
if (row.capturedSha256 && row.capturedSha256 === captureSha(name))
|
|
110
|
+
kept[name] = row;
|
|
111
|
+
else
|
|
112
|
+
dropped.push(name);
|
|
113
|
+
}
|
|
114
|
+
return { kept, dropped };
|
|
115
|
+
}
|
|
89
116
|
/** Parse results/divergence.json; anything malformed yields null, never throws. */
|
|
90
117
|
export function parseDivergence(text) {
|
|
91
118
|
let run;
|
package/dist/sweep/client.d.ts
CHANGED
|
@@ -33,16 +33,43 @@ export interface WireCapture {
|
|
|
33
33
|
* Noise is only dropped while something else survives. A package that fails
|
|
34
34
|
* *inside* npm (EBADPLATFORM, a failed postinstall) has npm's own lines as its
|
|
35
35
|
* only evidence, and a server whose whole output is a stack keeps the stack.
|
|
36
|
+
*
|
|
37
|
+
* `required` is the phrase a declared entry's published status depends on
|
|
38
|
+
* (`notApplicable.evidence` in servers.yaml). Everything else here is a budget
|
|
39
|
+
* decision — how much of a long failure is worth publishing — but this one is
|
|
40
|
+
* not. The entry says "this failure is the harness's, and here is the sentence
|
|
41
|
+
* that proves it", and `notApplicableReason` re-reads that sentence out of what
|
|
42
|
+
* survives: elide it and the declaration turns itself off, the row reverts to
|
|
43
|
+
* `startup-failure`, and this project publishes that someone else's working
|
|
44
|
+
* software is broken. The evidence is therefore kept whatever the budget, and
|
|
45
|
+
* everything else competes for what is left — the rule the old one lacked, and
|
|
46
|
+
* the reason it depended on how much the server happened to print.
|
|
47
|
+
*/
|
|
48
|
+
export declare function evidenceTail(stderr: string, limit?: number, required?: string): string;
|
|
49
|
+
/**
|
|
50
|
+
* Cut a record's notes to `limit` without cutting away the evidence its own
|
|
51
|
+
* status rests on.
|
|
52
|
+
*
|
|
53
|
+
* The same rule as `evidenceTail`, one layer up and for a different reason. The
|
|
54
|
+
* classifier reads the message *before* this cut, so this one cannot change a
|
|
55
|
+
* published status — it can only leave a record asserting `not-applicable` with
|
|
56
|
+
* the sentence that justifies it deleted, which is a claim published without its
|
|
57
|
+
* evidence. `windows-mcp` sits at exactly the cap today, so the margin here is
|
|
58
|
+
* one character of growth in somebody else's error message.
|
|
36
59
|
*/
|
|
37
|
-
export declare function
|
|
60
|
+
export declare function clampNotes(text: string, limit: number, required?: string): string;
|
|
38
61
|
export declare class McpStdioClient {
|
|
62
|
+
/** Phrase this entry's declared status depends on — see `evidenceTail`. */
|
|
63
|
+
private keepEvidence?;
|
|
39
64
|
private child;
|
|
40
65
|
private buffer;
|
|
41
66
|
private nextId;
|
|
42
67
|
private pending;
|
|
43
68
|
private stderrChunks;
|
|
44
69
|
private exited;
|
|
45
|
-
constructor(command: string, args: string[], env: Record<string, string | undefined
|
|
70
|
+
constructor(command: string, args: string[], env: Record<string, string | undefined>,
|
|
71
|
+
/** Phrase this entry's declared status depends on — see `evidenceTail`. */
|
|
72
|
+
keepEvidence?: string | undefined);
|
|
46
73
|
private onData;
|
|
47
74
|
private send;
|
|
48
75
|
private deadReason;
|
|
@@ -61,6 +88,7 @@ export declare function captureTools(spec: string | {
|
|
|
61
88
|
}, opts?: {
|
|
62
89
|
timeoutMs?: number;
|
|
63
90
|
env?: Record<string, string>;
|
|
91
|
+
keepEvidence?: string;
|
|
64
92
|
}): Promise<WireCapture>;
|
|
65
93
|
/** Shell-free command splitting: honors single/double quotes, no expansion. */
|
|
66
94
|
export declare function splitCommand(line: string): string[];
|
package/dist/sweep/client.js
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { spawn } from 'node:child_process';
|
|
8
8
|
const PROTOCOL_VERSION = '2025-06-18';
|
|
9
|
+
/** How an elided middle is marked, in every layout here. */
|
|
10
|
+
const ELISION = ' […] ';
|
|
9
11
|
/**
|
|
10
12
|
* The part of a dead server's stderr worth keeping as evidence.
|
|
11
13
|
*
|
|
@@ -26,11 +28,24 @@ const PROTOCOL_VERSION = '2025-06-18';
|
|
|
26
28
|
* Noise is only dropped while something else survives. A package that fails
|
|
27
29
|
* *inside* npm (EBADPLATFORM, a failed postinstall) has npm's own lines as its
|
|
28
30
|
* only evidence, and a server whose whole output is a stack keeps the stack.
|
|
31
|
+
*
|
|
32
|
+
* `required` is the phrase a declared entry's published status depends on
|
|
33
|
+
* (`notApplicable.evidence` in servers.yaml). Everything else here is a budget
|
|
34
|
+
* decision — how much of a long failure is worth publishing — but this one is
|
|
35
|
+
* not. The entry says "this failure is the harness's, and here is the sentence
|
|
36
|
+
* that proves it", and `notApplicableReason` re-reads that sentence out of what
|
|
37
|
+
* survives: elide it and the declaration turns itself off, the row reverts to
|
|
38
|
+
* `startup-failure`, and this project publishes that someone else's working
|
|
39
|
+
* software is broken. The evidence is therefore kept whatever the budget, and
|
|
40
|
+
* everything else competes for what is left — the rule the old one lacked, and
|
|
41
|
+
* the reason it depended on how much the server happened to print.
|
|
29
42
|
*/
|
|
30
|
-
export function evidenceTail(stderr, limit = 600) {
|
|
31
|
-
const
|
|
32
|
-
const
|
|
33
|
-
|
|
43
|
+
export function evidenceTail(stderr, limit = 600, required) {
|
|
44
|
+
const needle = required ? required.toLowerCase() : undefined;
|
|
45
|
+
const protect = (l) => needle !== undefined && l.toLowerCase().includes(needle);
|
|
46
|
+
const withoutNoise = drop(stderr, (l) => /^npm (warn|notice)\b/.test(l), protect);
|
|
47
|
+
const withoutFrames = drop(withoutNoise, (l) => /^at\s/.test(l), protect);
|
|
48
|
+
return bothEnds(withoutFrames, limit, needle);
|
|
34
49
|
}
|
|
35
50
|
/**
|
|
36
51
|
* Keep the start and the end, eliding the middle.
|
|
@@ -47,10 +62,23 @@ export function evidenceTail(stderr, limit = 600) {
|
|
|
47
62
|
* and the middle is what goes. The split leans towards the head because a
|
|
48
63
|
* message that precedes its own noise is the more common shape here.
|
|
49
64
|
*/
|
|
50
|
-
function bothEnds(text, limit) {
|
|
65
|
+
function bothEnds(text, limit, needle) {
|
|
66
|
+
const kept = bothEndsPlain(text, limit);
|
|
67
|
+
if (needle === undefined)
|
|
68
|
+
return kept;
|
|
69
|
+
// Only pay for the anchored layout when the ordinary one lost the phrase and
|
|
70
|
+
// the raw text actually had it. An entry whose evidence never appeared is a
|
|
71
|
+
// declaration that does not hold, and must keep failing as one.
|
|
72
|
+
if (kept.toLowerCase().includes(needle))
|
|
73
|
+
return kept;
|
|
74
|
+
if (!text.toLowerCase().includes(needle))
|
|
75
|
+
return kept;
|
|
76
|
+
return aroundRequired(text, limit, needle);
|
|
77
|
+
}
|
|
78
|
+
function bothEndsPlain(text, limit) {
|
|
51
79
|
if (text.length <= limit)
|
|
52
80
|
return text;
|
|
53
|
-
const elision =
|
|
81
|
+
const elision = `\n${ELISION}\n`;
|
|
54
82
|
const budget = Math.max(0, limit - elision.length);
|
|
55
83
|
const lines = text.split('\n');
|
|
56
84
|
// Whole lines only: a boundary cut mid-word ("ool/prompt change") reads as
|
|
@@ -83,23 +111,119 @@ function bothEnds(text, limit) {
|
|
|
83
111
|
return text.slice(0, budget) + elision;
|
|
84
112
|
return `${headText}${elision}${tail.out.join('\n')}`;
|
|
85
113
|
}
|
|
86
|
-
/**
|
|
87
|
-
|
|
114
|
+
/**
|
|
115
|
+
* Keep the line the declared evidence is on, then spend what is left on context.
|
|
116
|
+
*
|
|
117
|
+
* Head before tail, the same lean `bothEndsPlain` takes and for the same reason:
|
|
118
|
+
* an explanation usually precedes its own aftermath. The elisions are marked, so
|
|
119
|
+
* a reader of the record can see that something was dropped around the sentence
|
|
120
|
+
* that was not.
|
|
121
|
+
*/
|
|
122
|
+
function aroundRequired(text, limit, needle) {
|
|
123
|
+
const lines = text.split('\n');
|
|
124
|
+
const k = lines.findIndex((l) => l.toLowerCase().includes(needle));
|
|
125
|
+
// Both elisions and the newlines joining at most five chunks, reserved before
|
|
126
|
+
// the anchor is sized: the budget is a published-record limit, and a layout
|
|
127
|
+
// that keeps the evidence by overrunning it has only moved the problem.
|
|
128
|
+
const reserve = 2 * ELISION.length + 4;
|
|
129
|
+
const anchor = windowAround(lines[k], needle, Math.max(needle.length, limit - reserve));
|
|
130
|
+
let budget = limit - anchor.length - reserve;
|
|
131
|
+
const head = [];
|
|
132
|
+
for (let i = 0; i < k; i++) {
|
|
133
|
+
const cost = lines[i].length + 1;
|
|
134
|
+
if (cost > budget)
|
|
135
|
+
break;
|
|
136
|
+
head.push(lines[i]);
|
|
137
|
+
budget -= cost;
|
|
138
|
+
}
|
|
139
|
+
const tail = [];
|
|
140
|
+
for (let i = lines.length - 1; i > k; i--) {
|
|
141
|
+
const cost = lines[i].length + 1;
|
|
142
|
+
if (cost > budget)
|
|
143
|
+
break;
|
|
144
|
+
tail.unshift(lines[i]);
|
|
145
|
+
budget -= cost;
|
|
146
|
+
}
|
|
147
|
+
const chunks = [];
|
|
148
|
+
if (head.length)
|
|
149
|
+
chunks.push(head.join('\n'));
|
|
150
|
+
if (head.length < k)
|
|
151
|
+
chunks.push(ELISION);
|
|
152
|
+
chunks.push(anchor);
|
|
153
|
+
if (tail.length < lines.length - 1 - k)
|
|
154
|
+
chunks.push(ELISION);
|
|
155
|
+
if (tail.length)
|
|
156
|
+
chunks.push(tail.join('\n'));
|
|
157
|
+
return chunks.join('\n');
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* A single line that alone overruns the budget, kept as a window around the
|
|
161
|
+
* match rather than from its start — a structured log puts the whole message on
|
|
162
|
+
* one line, and the phrase that matters can sit anywhere in it.
|
|
163
|
+
*/
|
|
164
|
+
function windowAround(line, needle, limit) {
|
|
165
|
+
if (line.length <= limit)
|
|
166
|
+
return line;
|
|
167
|
+
const at = line.toLowerCase().indexOf(needle);
|
|
168
|
+
const lead = Math.max(0, Math.floor((limit - needle.length) / 2));
|
|
169
|
+
const start = Math.max(0, Math.min(at - lead, line.length - limit));
|
|
170
|
+
return line.slice(start, start + limit);
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Cut a record's notes to `limit` without cutting away the evidence its own
|
|
174
|
+
* status rests on.
|
|
175
|
+
*
|
|
176
|
+
* The same rule as `evidenceTail`, one layer up and for a different reason. The
|
|
177
|
+
* classifier reads the message *before* this cut, so this one cannot change a
|
|
178
|
+
* published status — it can only leave a record asserting `not-applicable` with
|
|
179
|
+
* the sentence that justifies it deleted, which is a claim published without its
|
|
180
|
+
* evidence. `windows-mcp` sits at exactly the cap today, so the margin here is
|
|
181
|
+
* one character of growth in somebody else's error message.
|
|
182
|
+
*/
|
|
183
|
+
export function clampNotes(text, limit, required) {
|
|
184
|
+
if (text.length <= limit)
|
|
185
|
+
return text;
|
|
186
|
+
const plain = text.slice(0, limit);
|
|
187
|
+
if (!required)
|
|
188
|
+
return plain;
|
|
189
|
+
const needle = required.toLowerCase();
|
|
190
|
+
if (plain.toLowerCase().includes(needle))
|
|
191
|
+
return plain;
|
|
192
|
+
const at = text.toLowerCase().indexOf(needle);
|
|
193
|
+
if (at < 0)
|
|
194
|
+
return plain;
|
|
195
|
+
const room = Math.max(0, limit - ELISION.length);
|
|
196
|
+
const width = Math.min(room, Math.max(needle.length, Math.floor(room / 2)));
|
|
197
|
+
const start = Math.max(0, Math.min(at - Math.floor((width - needle.length) / 2), text.length - width));
|
|
198
|
+
return text.slice(0, Math.max(0, room - width)) + ELISION + text.slice(start, start + width);
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Drop matching lines, keeping the input whole if that would leave nothing.
|
|
202
|
+
*
|
|
203
|
+
* `isProtected` outranks `isNoise`: safari-mcp declares `EBADPLATFORM`, which npm
|
|
204
|
+
* prints on a line of its own, and a filter that reaches it first would delete
|
|
205
|
+
* the evidence before any budget was even applied.
|
|
206
|
+
*/
|
|
207
|
+
function drop(text, isNoise, isProtected = () => false) {
|
|
88
208
|
const kept = text
|
|
89
209
|
.split('\n')
|
|
90
|
-
.filter((l) => !isNoise(l.trim()))
|
|
210
|
+
.filter((l) => isProtected(l.trim()) || !isNoise(l.trim()))
|
|
91
211
|
.join('\n')
|
|
92
212
|
.trim();
|
|
93
213
|
return kept || text.trim();
|
|
94
214
|
}
|
|
95
215
|
export class McpStdioClient {
|
|
216
|
+
keepEvidence;
|
|
96
217
|
child;
|
|
97
218
|
buffer = '';
|
|
98
219
|
nextId = 1;
|
|
99
220
|
pending = new Map();
|
|
100
221
|
stderrChunks = [];
|
|
101
222
|
exited;
|
|
102
|
-
constructor(command, args, env
|
|
223
|
+
constructor(command, args, env,
|
|
224
|
+
/** Phrase this entry's declared status depends on — see `evidenceTail`. */
|
|
225
|
+
keepEvidence) {
|
|
226
|
+
this.keepEvidence = keepEvidence;
|
|
103
227
|
this.child = spawn(command, args, {
|
|
104
228
|
env: { ...env },
|
|
105
229
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
@@ -123,7 +247,7 @@ export class McpStdioClient {
|
|
|
123
247
|
resolve();
|
|
124
248
|
});
|
|
125
249
|
this.child.on('exit', (code) => {
|
|
126
|
-
const tail = evidenceTail(this.stderrTail);
|
|
250
|
+
const tail = evidenceTail(this.stderrTail, undefined, this.keepEvidence);
|
|
127
251
|
this.deadReason = `server exited (code ${code})${tail ? `; stderr tail: ${tail}` : ''}`;
|
|
128
252
|
for (const p of this.pending.values())
|
|
129
253
|
p.reject(new Error(this.deadReason));
|
|
@@ -185,7 +309,7 @@ export class McpStdioClient {
|
|
|
185
309
|
// without this a timed-out record carries no evidence at all — it says
|
|
186
310
|
// only that we waited. What the server managed to print before it
|
|
187
311
|
// stopped answering is usually the whole explanation.
|
|
188
|
-
const tail = evidenceTail(this.stderrTail);
|
|
312
|
+
const tail = evidenceTail(this.stderrTail, undefined, this.keepEvidence);
|
|
189
313
|
reject(new Error(`timeout after ${timeoutMs}ms waiting for ${method}${tail ? `; stderr tail: ${tail}` : ''}`));
|
|
190
314
|
}, timeoutMs);
|
|
191
315
|
this.pending.set(id, {
|
|
@@ -222,11 +346,7 @@ export class McpStdioClient {
|
|
|
222
346
|
export async function captureTools(spec, opts = {}) {
|
|
223
347
|
const timeoutMs = opts.timeoutMs ?? 60_000;
|
|
224
348
|
const [cmd, ...args] = typeof spec === 'string' ? splitCommand(spec) : [spec.command, ...spec.argv];
|
|
225
|
-
const client = new McpStdioClient(cmd, args, {
|
|
226
|
-
PATH: process.env.PATH,
|
|
227
|
-
HOME: process.env.HOME,
|
|
228
|
-
...opts.env,
|
|
229
|
-
});
|
|
349
|
+
const client = new McpStdioClient(cmd, args, { PATH: process.env.PATH, HOME: process.env.HOME, ...opts.env }, opts.keepEvidence);
|
|
230
350
|
try {
|
|
231
351
|
const init = await client.request('initialize', {
|
|
232
352
|
protocolVersion: PROTOCOL_VERSION,
|
|
@@ -28,7 +28,7 @@ import { homedir, tmpdir } from 'node:os';
|
|
|
28
28
|
import { join, resolve } from 'node:path';
|
|
29
29
|
import { fileURLToPath } from 'node:url';
|
|
30
30
|
import { parse } from 'yaml';
|
|
31
|
-
import { measureServer } from './run.js';
|
|
31
|
+
import { isSelfContainerised, measureServer } from './run.js';
|
|
32
32
|
import { DockerHarnessFault, defaultImageFor, dockerize } from './docker.js';
|
|
33
33
|
import { splitCommand } from './client.js';
|
|
34
34
|
import { selectShard, shardIndexForDate } from './shard.js';
|
|
@@ -305,7 +305,7 @@ if (isMain) {
|
|
|
305
305
|
console.log(` ${e.name}: our measurement ${m.status} — recorded, nothing to compare`);
|
|
306
306
|
continue;
|
|
307
307
|
}
|
|
308
|
-
const selfDocker = e.command
|
|
308
|
+
const selfDocker = isSelfContainerised(e.command);
|
|
309
309
|
const out = await runCli(docker && selfDocker ? await hostBin() : binPath, e, {
|
|
310
310
|
docker: docker && !selfDocker,
|
|
311
311
|
timeoutMs,
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { type SchemaProblem } from './servers-schema.js';
|
|
2
|
+
import type { ServerEntry } from './report.js';
|
|
3
|
+
import type { Measurement } from '../core/types.js';
|
|
4
|
+
/**
|
|
5
|
+
* The fields that change what a sweep launches — the option object
|
|
6
|
+
* sweep-all.ts hands to `measureServer`, plus the timeout it is given. A change
|
|
7
|
+
* to `metric`, `metricSource`, `category`, `repo`, `package` or `deprecated`
|
|
8
|
+
* changes a row's text, not its process, and is not re-measured here.
|
|
9
|
+
*/
|
|
10
|
+
export declare const LAUNCH_FIELDS: readonly ["command", "dockerImage", "aptPackages", "needsGit", "env", "envValues", "timeoutSeconds", "notApplicable"];
|
|
11
|
+
/**
|
|
12
|
+
* How many entries one pull request may launch, at the default budget. The
|
|
13
|
+
* worst case per entry is a timeout retried on `TIMEOUT_RETRY_FACTOR` times
|
|
14
|
+
* its budget (run.ts:351-352), so at the workflow's `--default-timeout 240`
|
|
15
|
+
* one entry can hold the runner for 240 × (1 + 2) = 720s, and this many of
|
|
16
|
+
* them measured one at a time is what pr-check.yml's `timeout-minutes` is
|
|
17
|
+
* sized to (`launchBudgetSeconds`).
|
|
18
|
+
*
|
|
19
|
+
* The count alone does not hold that bound. An entry's own `timeoutSeconds`
|
|
20
|
+
* replaces the default (the rotation's rule, kept here), and the schema bounds
|
|
21
|
+
* it only below — "a whole number of seconds greater than zero"
|
|
22
|
+
* (servers-schema.ts:166-169) — so one added entry with `timeoutSeconds: 4000`
|
|
23
|
+
* would cost 12000s under the same count and be killed by the job limit with
|
|
24
|
+
* no line printed for it. The bound is held because the script also refuses,
|
|
25
|
+
* before any launch, a selection whose `worstCaseSeconds` exceeds the budget
|
|
26
|
+
* the count was sized to. A PR that trips either is asked to split or shorten,
|
|
27
|
+
* rather than have the runner cut it off with the last entries unmeasured and
|
|
28
|
+
* no line saying so.
|
|
29
|
+
*/
|
|
30
|
+
export declare const DEFAULT_MAX_ENTRIES = 4;
|
|
31
|
+
/** The launched entries among a selection: remote endpoints and self-containerised commands are listed, not spawned. */
|
|
32
|
+
export declare function launchedEntries(entries: ServerEntry[]): ServerEntry[];
|
|
33
|
+
/**
|
|
34
|
+
* The most seconds a selection can hold the runner: each launched entry's own
|
|
35
|
+
* budget (`timeoutSeconds`, or the default) once, then `TIMEOUT_RETRY_FACTOR`
|
|
36
|
+
* times more on the retry. Listed entries cost nothing and are not counted.
|
|
37
|
+
*/
|
|
38
|
+
export declare function worstCaseSeconds(entries: ServerEntry[], defaultTimeout: number): number;
|
|
39
|
+
/** What the job's `timeout-minutes` was sized to: `maxEntries` launches, each at the default budget and its retry. */
|
|
40
|
+
export declare function launchBudgetSeconds(maxEntries: number, defaultTimeout: number): number;
|
|
41
|
+
export interface ServersDiff {
|
|
42
|
+
/** Head fails the shape check; nothing is diffed from a malformed document. */
|
|
43
|
+
problems: SchemaProblem[];
|
|
44
|
+
/** Names present in head and absent from base. */
|
|
45
|
+
added: ServerEntry[];
|
|
46
|
+
/** Names in both whose `LAUNCH_FIELDS` projection differs. */
|
|
47
|
+
relaunched: ServerEntry[];
|
|
48
|
+
}
|
|
49
|
+
/** The part of an entry that decides what gets spawned, as one comparable string. */
|
|
50
|
+
export declare function launchSignature(entry: ServerEntry): string;
|
|
51
|
+
/**
|
|
52
|
+
* What a pull request adds or relaunches, given the parsed base and head
|
|
53
|
+
* documents. Head is shape-checked first and returns only problems when it has
|
|
54
|
+
* any. Base is the committed branch, which `npm test` already holds valid; an
|
|
55
|
+
* unreadable base is treated as empty, which makes every head entry "added"
|
|
56
|
+
* and lets the entry cap refuse the run rather than measuring the whole file.
|
|
57
|
+
*/
|
|
58
|
+
export declare function entriesToMeasure(base: unknown, head: unknown): ServersDiff;
|
|
59
|
+
/** The line printed for a launched entry — sweep-all's summary form, with the evidence behind a failure. */
|
|
60
|
+
export declare function summarise(name: string, m: Measurement, secs: number): string;
|
|
61
|
+
/** Whether an outcome fails the check. */
|
|
62
|
+
export declare function failsCheck(status: Measurement['status']): boolean;
|