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 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.20×–1.92× across 24
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 24 rows — the measured servers it covered when it last ran —
208
- and [results/leaderboard.md](results/leaderboard.md) prints a claude number for the 16 that
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 106 popular servers measured, each row dated by its own most recent sweep — full table in
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 | **—** | most of the capture is `annotations`/`outputSchema` metadata Claude never sees |
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 one script that calls a network API (Claude divergence); kept out of the package so the library stays offline |
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` | 106 curated candidates with live install metrics and provenance |
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
- Then in your README:
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
  [![context cost](https://img.shields.io/endpoint?url=<raw URL of badges/my-server.json>)](<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
@@ -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: across the published
223
- * divergence run it runs from 0.20× to 1.92×, so a single stack total maps to a
224
- * range roughly ten times as wide as itself. Comparing the wire number directly
225
- * against the threshold understates the deferrable side for schema-heavy
226
- * servers and overstates it for metadata-heavy ones, in one direction each.
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. */
@@ -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.2,
237
- high: 1.92,
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
- servers: 23,
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;
@@ -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;
@@ -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 evidenceTail(stderr: string, limit?: number): string;
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[];
@@ -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 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);
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 = '\n […] \n';
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
- /** Drop matching lines, keeping the input whole if that would leave nothing. */
87
- function drop(text, isNoise) {
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.trimStart().startsWith('docker ');
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;