mcp-context-cost 0.15.0 → 0.17.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
@@ -130,6 +130,40 @@ On a machine where none of them is set, the same stack reads:
130
130
  a tool whose _meta carries "anthropic/alwaysLoad": true, which this audit does not read from a capture
131
131
  ```
132
132
 
133
+ **Do not take that table on trust — your own client will tell you.** Everything above is read
134
+ from Anthropic's documentation, and documentation about someone else's product is exactly the
135
+ kind of claim this project refuses to leave unchecked elsewhere. Claude Code writes its own
136
+ decision to a debug log, before it sends anything, so you can check your machine rather than
137
+ believe this page. In a directory with an `.mcp.json`:
138
+
139
+ ```bash
140
+ claude --debug-file /tmp/cc.txt -p "ok"
141
+ grep -E 'ToolSearch|Dynamic tool loading|Auto tool search' /tmp/cc.txt
142
+ ```
143
+
144
+ Three line shapes answer three different questions:
145
+
146
+ | the line | what it tells you |
147
+ |---|---|
148
+ | `[ToolSearch:optimistic] mode=…, ENABLE_TOOL_SEARCH=…, result=…` | which mode it picked at startup, and the value it read. **Optimistic is its own word for a guess** — it can be revised below |
149
+ | `Dynamic tool loading: 0/N deferred tools included` | the one that settles it: how many of the `N` deferrable tools went into the request. `0/N` is deferral actually happening |
150
+ | `[ToolSearch:optimistic] disabled: ANTHROPIC_BASE_URL=… is not a first-party Anthropic host` | the fallback in the table above, firing, in the client's own words |
151
+
152
+ Read the *later* requests, not the first. A stdio server can finish connecting after the first
153
+ request has already gone, so an early low count is a race rather than a finding.
154
+
155
+ **The mode most likely to surprise you is `auto`.** It reads as the careful setting and it is the
156
+ one that loads everything up front at ordinary sizes: below the threshold it does not defer, by
157
+ design, and a line reading `Auto tool search disabled: … (threshold: …)` is that decision being
158
+ made. The threshold is a *percentage of the context window*, so a model with a larger window has
159
+ a proportionally larger one — pass `--context` to `audit` to compare against the window you
160
+ actually run.
161
+
162
+ None of this is free, and it is one trivial request. These are Claude Code's own debug lines
163
+ rather than a documented interface, so they can change; the table above is what this project
164
+ holds to a dated re-read. No other client discovered by `audit` writes anything comparable,
165
+ which is why their rows say an absence of a record rather than a measurement.
166
+
133
167
  Set `ENABLE_TOOL_SEARCH=false` in that shell and the same config reports the opposite —
134
168
  `loads every tool definition up front here`, naming the variable and the place it was read
135
169
  from. Deferring is also not free: what a deferring client *does* load at session start —
@@ -294,7 +328,7 @@ answers a question no client asks: **what did this server cost last month?**
294
328
  [results/regressions.md](results/regressions.md) reports each server's most recent movement —
295
329
  dated to when it happened, separated into *shipped more tools* versus *same tools, rewritten*,
296
330
  and compared only within one isolation. The ecosystem ratchets upward: of the servers whose
297
- cost has moved at all, 11 moved up against 6 that moved down. Method:
331
+ cost has moved at all, 13 moved up against 5 that moved down. Method:
298
332
  [cost movement](docs/METHODOLOGY.md#cost-movement).
299
333
 
300
334
  If you publish a server, the same measurement is available as a badge, so your users can see
@@ -312,7 +346,7 @@ now measured against a pinned model and published beside the badge, and they do
312
346
 
313
347
  | server | badge (o200k) | Claude (`claude-opus-5`) | |
314
348
  |---|---:|---:|---|
315
- | github | 54,622 | **18,728** | most of the capture is `annotations`/`outputSchema` metadata Claude never sees |
349
+ | github | 54,622 | **18,728** | 78% of the capture is `icons` metadata Claude never sees |
316
350
  | notion | 17,500 | **33,560** | almost no metadata to drop, so the tokenizer difference dominates |
317
351
 
318
352
  So the heaviest server on the badge is not the heaviest server on Claude. Per-server
@@ -400,8 +434,8 @@ repository), then in your README:
400
434
  A badge says what your server costs today; it does nothing about the release
401
435
  that adds 1,200 tokens to every user's context next month. Across the servers
402
436
  measured here most costs hold steady from sweep to sweep, but when a cost does
403
- move it usually moves up: the [movement report](results/regressions.md) has 11
404
- servers ratcheting upward against 6 that got cheaper, and none of those
437
+ move it usually moves up: the [movement report](results/regressions.md) has 13
438
+ servers ratcheting upward against 5 that got cheaper, and none of those
405
439
  maintainers had a check that would have said so first. `measure` takes the same
406
440
  gate flags `audit` does, so your own CI can be that check:
407
441
 
@@ -477,7 +511,7 @@ says as a whole is written up, dated, in
477
511
  [The State of MCP Context Cost](https://athakur3.github.io/mcp-context-cost/state-of-mcp-context-cost)
478
512
  (September 2026). Two
479
513
  weekly jobs re-measure the set — the `memory` reference server on Mondays, and a rotating
480
- sixth of the list on Wednesdays, so every row comes round within six weeks. Read each row's
514
+ third of the list on Wednesdays, so every row comes round within three weeks. Read each row's
481
515
  date as the date it means, and don't take the cadence on trust — the build history is
482
516
  public, one click each:
483
517
  [re-sweep runs](https://github.com/athakur3/mcp-context-cost/actions/workflows/resweep.yml)
@@ -16,7 +16,7 @@ import { METHODOLOGY_VERSION } from '../core/canonical.js';
16
16
  import { isCurrent } from '../core/divergence.js';
17
17
  import { SUGGEST_DESCRIPTION_PERCENTILE, suggestFor, } from '../core/tool-shape.js';
18
18
  import { identify } from '../core/capture-index.js';
19
- import { evaluateDeferral, PUBLISHED_WIRE_TO_CLIENT_RATIO, SHELL_SOURCE, } from './deferral.js';
19
+ import { evaluateDeferral, BAND_PRECISION, PUBLISHED_WIRE_TO_CLIENT_RATIO, SHELL_SOURCE, } from './deferral.js';
20
20
  import { formatDiff, formatGate } from './diff.js';
21
21
  export const DEFAULT_CONTEXT_WINDOW = 200_000;
22
22
  const TRIM_TOOL_COUNT = 3;
@@ -493,6 +493,40 @@ function sharedMeasurementLines(d, skippedNames, consequence) {
493
493
  }
494
494
  return lines;
495
495
  }
496
+ /**
497
+ * What the threshold below is a share *of*, said where the threshold is said.
498
+ *
499
+ * The setting is a percentage of the context window, so the token figure it
500
+ * resolves to is only as right as the window this audit assumed. That window is
501
+ * a property of the model the client runs, which no config file states and this
502
+ * audit therefore cannot read: it uses 200,000 unless `--context` overrides it.
503
+ * A model with a larger window puts the threshold proportionally higher, and a
504
+ * stack that clears a 20,000-token threshold does not clear a 100,000-token one.
505
+ *
506
+ * This is stated rather than silently assumed because the error has a direction.
507
+ * A window assumed too small makes the threshold too small, which pushes the
508
+ * verdict toward "at or above" — toward telling a reader their tool definitions
509
+ * are deferred, and therefore not charged, when the client's own larger
510
+ * threshold would have loaded every one of them up front. Of the two ways to be
511
+ * wrong here, that is the one that costs somebody tokens they were told they
512
+ * would not pay, so it does not get to be a footnote.
513
+ *
514
+ * Only threshold mode reaches this. The default defers everything at any size,
515
+ * where no window and no arithmetic enter the answer at all.
516
+ */
517
+ function thresholdAssumptionLines(d) {
518
+ const share = d.thresholdShare ?? 0;
519
+ if (!(share > 0) || d.thresholdTokens === null || d.thresholdTokens === undefined)
520
+ return [];
521
+ const window = Math.round(d.thresholdTokens / share);
522
+ return [
523
+ ` That token figure assumes a ${n(window)}-token context window, which is a`,
524
+ ' property of the model in use and not of your config, so this audit cannot',
525
+ ' read it — pass --context to set it. A model with a larger window has a',
526
+ ' proportionally larger threshold, and the same stack can fall on either',
527
+ ' side of it depending on which model the client runs.',
528
+ ];
529
+ }
496
530
  /** Where a threshold is in play, the unknown size is the whole verdict. */
497
531
  const SIDE_UNKNOWN = [
498
532
  ' either direction — which side of the threshold this stack falls on cannot be',
@@ -608,6 +642,7 @@ function deferralLines(d, skippedNames) {
608
642
  lines.push(` ${d.client} defers tool definitions above a threshold here (${d.mechanism}):`);
609
643
  lines.push(` ${settingPhrase(d)}, so deferral activates once the`);
610
644
  lines.push(` definitions reach ${n(t)} tokens — ${pct(d.thresholdShare ?? 0)} of the context window.`);
645
+ lines.push(...thresholdAssumptionLines(d));
611
646
  lines.push(...postureSourceLines(d));
612
647
  const c = d.clientTokens;
613
648
  if (!c) {
@@ -662,7 +697,7 @@ function deferralLines(d, skippedNames) {
662
697
  /** "0.20×–1.92×", from whichever band the verdict was actually computed against. */
663
698
  function ratioBand(d) {
664
699
  const r = d.ratio ?? PUBLISHED_WIRE_TO_CLIENT_RATIO;
665
- return `${r.low.toFixed(2)}×–${r.high.toFixed(2)}×`;
700
+ return `${r.low.toFixed(BAND_PRECISION)}×–${r.high.toFixed(BAND_PRECISION)}×`;
666
701
  }
667
702
  /** Human output. JSON output is the report object itself. */
668
703
  export function formatReport(report) {
@@ -861,8 +896,8 @@ export function formatReport(report) {
861
896
  }
862
897
  lines.push('');
863
898
  lines.push('These are wire tokens — what the server puts on the wire, counted with o200k_base. What your model is billed');
864
- lines.push(`differs per provider: measured ratios run ${PUBLISHED_WIRE_TO_CLIENT_RATIO.low.toFixed(2)}×–` +
865
- `${PUBLISHED_WIRE_TO_CLIENT_RATIO.high.toFixed(2)}× on Anthropic requests. See docs/METHODOLOGY.md §claude-divergence.`);
899
+ lines.push(`differs per provider: measured ratios run ${PUBLISHED_WIRE_TO_CLIENT_RATIO.low.toFixed(BAND_PRECISION)}×–` +
900
+ `${PUBLISHED_WIRE_TO_CLIENT_RATIO.high.toFixed(BAND_PRECISION)}× on Anthropic requests. See docs/METHODOLOGY.md §claude-divergence.`);
866
901
  return lines.map((l) => l.replace(/\s+$/, '')).join('\n');
867
902
  }
868
903
  /** Top-level tool list across every config — used by nothing yet, handy for --json consumers. */
@@ -286,6 +286,41 @@ export interface WireToClientRatio {
286
286
  * level down. The fields are the record; `source` dates them.
287
287
  */
288
288
  export declare const PUBLISHED_WIRE_TO_CLIENT_RATIO: WireToClientRatio;
289
+ /**
290
+ * The decimal places the band is published at.
291
+ *
292
+ * Not a display choice: it is the precision a snapshot of the band has to stay
293
+ * accurate to, because two numbers that round the same way decide the same
294
+ * above/below verdict against a client threshold, and two that do not, do not.
295
+ * The pages print the band to this many places (`published-stats.ts`) and
296
+ * `bandSnapshotProblem` judges a snapshot at the same width, so "still right"
297
+ * means the same thing in both places.
298
+ */
299
+ export declare const BAND_PRECISION = 2;
300
+ /**
301
+ * Is a snapshot of the band still an honest description of `derived`? The
302
+ * problem in words, or `null` when the snapshot may lag but does not mislead.
303
+ *
304
+ * Two callers, one rule. The suite asks it of `PUBLISHED_WIRE_TO_CLIENT_RATIO`
305
+ * against the run committed beside it. The release readiness gate asks it of
306
+ * the constant the *last release* shipped against the run on trunk — that band
307
+ * is what an installed package states offline, and nothing except cutting a
308
+ * release can move it, so a release being due is exactly what a finding there
309
+ * means.
310
+ *
311
+ * Deliberately not an equality check, and the first version of this rule was
312
+ * one: it demanded agreement and went red on the next bot commit, which had
313
+ * done nothing but measure a server for the first time. The run grows whenever
314
+ * a sweep reaches a new server, and the bot that commits it cannot edit a
315
+ * TypeScript constant. A snapshot is allowed to lag. It is not allowed to be
316
+ * wrong:
317
+ *
318
+ * - the **band** must still be right to the precision it is published at,
319
+ * because it decides an above/below verdict against a client threshold;
320
+ * - the **count** must never exceed the run, because a snapshot claiming more
321
+ * servers than were measured is a fabricated number rather than an old one.
322
+ */
323
+ export declare function bandSnapshotProblem(snapshot: Pick<WireToClientRatio, 'low' | 'high' | 'servers'>, derived: Pick<WireToClientRatio, 'low' | 'high' | 'servers'>): string | null;
289
324
  /** Derive the band from a supplied divergence run, falling back to the published one. */
290
325
  export declare function wireToClientRatio(run?: DivergenceRun | null): WireToClientRatio;
291
326
  /** One measured server, as the deferral arithmetic needs it. */
@@ -258,6 +258,51 @@ export const PUBLISHED_WIRE_TO_CLIENT_RATIO = {
258
258
  servers: 86,
259
259
  source: 'the published claude-opus-5 divergence run',
260
260
  };
261
+ /**
262
+ * The decimal places the band is published at.
263
+ *
264
+ * Not a display choice: it is the precision a snapshot of the band has to stay
265
+ * accurate to, because two numbers that round the same way decide the same
266
+ * above/below verdict against a client threshold, and two that do not, do not.
267
+ * The pages print the band to this many places (`published-stats.ts`) and
268
+ * `bandSnapshotProblem` judges a snapshot at the same width, so "still right"
269
+ * means the same thing in both places.
270
+ */
271
+ export const BAND_PRECISION = 2;
272
+ /**
273
+ * Is a snapshot of the band still an honest description of `derived`? The
274
+ * problem in words, or `null` when the snapshot may lag but does not mislead.
275
+ *
276
+ * Two callers, one rule. The suite asks it of `PUBLISHED_WIRE_TO_CLIENT_RATIO`
277
+ * against the run committed beside it. The release readiness gate asks it of
278
+ * the constant the *last release* shipped against the run on trunk — that band
279
+ * is what an installed package states offline, and nothing except cutting a
280
+ * release can move it, so a release being due is exactly what a finding there
281
+ * means.
282
+ *
283
+ * Deliberately not an equality check, and the first version of this rule was
284
+ * one: it demanded agreement and went red on the next bot commit, which had
285
+ * done nothing but measure a server for the first time. The run grows whenever
286
+ * a sweep reaches a new server, and the bot that commits it cannot edit a
287
+ * TypeScript constant. A snapshot is allowed to lag. It is not allowed to be
288
+ * wrong:
289
+ *
290
+ * - the **band** must still be right to the precision it is published at,
291
+ * because it decides an above/below verdict against a client threshold;
292
+ * - the **count** must never exceed the run, because a snapshot claiming more
293
+ * servers than were measured is a fabricated number rather than an old one.
294
+ */
295
+ export function bandSnapshotProblem(snapshot, derived) {
296
+ const at = (n) => n.toFixed(BAND_PRECISION);
297
+ if (snapshot.servers > derived.servers) {
298
+ return `it is measured across ${snapshot.servers} servers where the run holds ${derived.servers}`;
299
+ }
300
+ if (at(snapshot.low) !== at(derived.low) || at(snapshot.high) !== at(derived.high)) {
301
+ return (`it states ${at(snapshot.low)}×–${at(snapshot.high)}× where the run derives ` +
302
+ `${at(derived.low)}×–${at(derived.high)}×`);
303
+ }
304
+ return null;
305
+ }
261
306
  /** Derive the band from a supplied divergence run, falling back to the published one. */
262
307
  export function wireToClientRatio(run) {
263
308
  if (!run)
@@ -1,4 +1,4 @@
1
- import type { Measurement, MeasurementStatus } from './types.js';
1
+ import type { Measurement, MeasurementStatus, ToolMeasurement } from './types.js';
2
2
  export declare const METHODOLOGY_VERSION = "1.0";
3
3
  /**
4
4
  * Canonical form: JSON.stringify over the PARSED tools array (no whitespace,
@@ -10,6 +10,21 @@ export declare const METHODOLOGY_VERSION = "1.0";
10
10
  export declare function canonicalString(tools: unknown[]): string;
11
11
  export declare function sha256Hex(text: string): string;
12
12
  export declare function countTokens(text: string): number;
13
+ /**
14
+ * One tool's diagnostic breakdown, from the tool object alone.
15
+ *
16
+ * Exported and used by `measureTools` rather than inlined there, because two
17
+ * other callers have to agree with it exactly: the backfill that fills these
18
+ * fields in on records written before they existed, and the test that re-derives
19
+ * every published record's breakdown from that record's own capture. A second
20
+ * implementation of this arithmetic would be a second answer to the same
21
+ * question.
22
+ *
23
+ * A tool that ships no `outputSchema` or `annotations` records `0`. An absent
24
+ * key means something else — a record written before this existed — which is
25
+ * why `ToolMeasurement` makes them optional.
26
+ */
27
+ export declare function measureTool(t: unknown): ToolMeasurement;
13
28
  /**
14
29
  * totalTokens is authoritative (tokens of the whole canonical array — array
15
30
  * punctuation included); per-tool numbers are diagnostic and won't sum to it.
@@ -20,21 +20,38 @@ export function countTokens(text) {
20
20
  // descriptions are counted as ordinary text instead of throwing.
21
21
  return enc.encode(text, undefined, []).length;
22
22
  }
23
+ /**
24
+ * One tool's diagnostic breakdown, from the tool object alone.
25
+ *
26
+ * Exported and used by `measureTools` rather than inlined there, because two
27
+ * other callers have to agree with it exactly: the backfill that fills these
28
+ * fields in on records written before they existed, and the test that re-derives
29
+ * every published record's breakdown from that record's own capture. A second
30
+ * implementation of this arithmetic would be a second answer to the same
31
+ * question.
32
+ *
33
+ * A tool that ships no `outputSchema` or `annotations` records `0`. An absent
34
+ * key means something else — a record written before this existed — which is
35
+ * why `ToolMeasurement` makes them optional.
36
+ */
37
+ export function measureTool(t) {
38
+ const tool = t;
39
+ return {
40
+ name: tool.name ?? '(unnamed)',
41
+ tokens: countTokens(JSON.stringify(t)),
42
+ descriptionTokens: tool.description ? countTokens(tool.description) : 0,
43
+ inputSchemaTokens: tool.inputSchema ? countTokens(JSON.stringify(tool.inputSchema)) : 0,
44
+ outputSchemaTokens: tool.outputSchema ? countTokens(JSON.stringify(tool.outputSchema)) : 0,
45
+ annotationsTokens: tool.annotations ? countTokens(JSON.stringify(tool.annotations)) : 0,
46
+ };
47
+ }
23
48
  /**
24
49
  * totalTokens is authoritative (tokens of the whole canonical array — array
25
50
  * punctuation included); per-tool numbers are diagnostic and won't sum to it.
26
51
  */
27
52
  export function measureTools(tools, meta) {
28
53
  const canonical = canonicalString(tools);
29
- const perTool = tools.map((t) => {
30
- const tool = t;
31
- return {
32
- name: tool.name ?? '(unnamed)',
33
- tokens: countTokens(JSON.stringify(t)),
34
- descriptionTokens: tool.description ? countTokens(tool.description) : 0,
35
- inputSchemaTokens: tool.inputSchema ? countTokens(JSON.stringify(tool.inputSchema)) : 0,
36
- };
37
- });
54
+ const perTool = tools.map(measureTool);
38
55
  return {
39
56
  methodologyVersion: METHODOLOGY_VERSION,
40
57
  provider: 'tiktoken',
@@ -16,6 +16,20 @@ export interface ToolMeasurement {
16
16
  tokens: number;
17
17
  descriptionTokens: number;
18
18
  inputSchemaTokens: number;
19
+ /**
20
+ * The two fields that used to sit inside `tokens` with nothing naming them.
21
+ * Across the measured set output schemas are about a sixth of every published
22
+ * token and annotations another thirtieth, so a reader could see that a tool
23
+ * was expensive without being able to see that its output schema was why.
24
+ *
25
+ * Optional because absent and zero are different claims, the same distinction
26
+ * `serverInstructions` draws below: `0` means the tool ships no such field,
27
+ * absent means the record predates the attribution. Every record written
28
+ * since carries both, and `tools/backfill-tool-attribution.ts` re-derives
29
+ * them for older ones out of the capture stored in the same file.
30
+ */
31
+ outputSchemaTokens?: number;
32
+ annotationsTokens?: number;
19
33
  }
20
34
  /**
21
35
  * The full reproducibility record published next to every badge.
@@ -58,9 +58,51 @@ export declare function evidenceTail(stderr: string, limit?: number, required?:
58
58
  * one character of growth in somebody else's error message.
59
59
  */
60
60
  export declare function clampNotes(text: string, limit: number, required?: string): string;
61
+ /**
62
+ * A client posture: what it declares at `initialize`, and how it answers the
63
+ * requests that declaration invites. The two live in one object because they
64
+ * are one decision — a client that declares `roots` and then returns
65
+ * "method not found" to `roots/list` has told the server something untrue, and
66
+ * a server is entitled to shape its tool list around the answer.
67
+ *
68
+ * This exists because the default posture, `{}`, is not neutral. A server may
69
+ * gate tools on what the client can do: measured 2026-09-06, the reference
70
+ * `everything` server exposes 13 tools to a client declaring nothing and 15 to
71
+ * one declaring roots and elicitation. So the published number for such a
72
+ * server is a floor, and which posture the sweep runs with is a measurement
73
+ * decision rather than a detail.
74
+ *
75
+ * `sampling` is deliberately absent. Declaring it says this client can ask a
76
+ * model for a completion, and it cannot; there is no honest minimal answer to
77
+ * `sampling/createMessage`, unlike an empty root list or a declined
78
+ * elicitation, both of which are ordinary states a real client can be in.
79
+ */
80
+ export interface ClientPosture {
81
+ /** Sent verbatim as `capabilities` in `initialize`. */
82
+ capabilities: Record<string, unknown>;
83
+ /** Answers to server-initiated requests, by method. Every declared capability needs one. */
84
+ answers: Record<string, unknown>;
85
+ }
86
+ /** What the sweep has always declared: nothing, and so nothing to answer. */
87
+ export declare const MINIMAL_POSTURE: ClientPosture;
88
+ /**
89
+ * The two capabilities this harness can answer truthfully.
90
+ *
91
+ * `roots/list` returns an empty list: this client exposes no filesystem roots,
92
+ * which is a true statement about it rather than a refusal. `elicitation/create`
93
+ * declines: the protocol provides for a user declining to answer, and an
94
+ * unattended sweep has no user, so declining is the honest reply and not a
95
+ * failure to implement one.
96
+ */
97
+ export declare const DECLARING_POSTURE: ClientPosture;
61
98
  export declare class McpStdioClient {
62
99
  /** Phrase this entry's declared status depends on — see `evidenceTail`. */
63
100
  private keepEvidence?;
101
+ /**
102
+ * Answers to server-initiated requests, one per declared capability. Empty
103
+ * by default, which is correct only while `initialize` declares nothing.
104
+ */
105
+ private answers;
64
106
  private child;
65
107
  private buffer;
66
108
  private nextId;
@@ -69,7 +111,12 @@ export declare class McpStdioClient {
69
111
  private exited;
70
112
  constructor(command: string, args: string[], env: Record<string, string | undefined>,
71
113
  /** Phrase this entry's declared status depends on — see `evidenceTail`. */
72
- keepEvidence?: string | undefined);
114
+ keepEvidence?: string | undefined,
115
+ /**
116
+ * Answers to server-initiated requests, one per declared capability. Empty
117
+ * by default, which is correct only while `initialize` declares nothing.
118
+ */
119
+ answers?: Record<string, unknown>);
73
120
  private onData;
74
121
  private send;
75
122
  private deadReason;
@@ -89,6 +136,12 @@ export declare function captureTools(spec: string | {
89
136
  timeoutMs?: number;
90
137
  env?: Record<string, string>;
91
138
  keepEvidence?: string;
139
+ /**
140
+ * What to declare at `initialize`, and how to answer what that invites.
141
+ * Defaults to declaring nothing, which is what every published measurement
142
+ * was taken with.
143
+ */
144
+ posture?: ClientPosture;
92
145
  }): Promise<WireCapture>;
93
146
  /** Shell-free command splitting: honors single/double quotes, no expansion. */
94
147
  export declare function splitCommand(line: string): string[];
@@ -212,8 +212,27 @@ function drop(text, isNoise, isProtected = () => false) {
212
212
  .trim();
213
213
  return kept || text.trim();
214
214
  }
215
+ /** What the sweep has always declared: nothing, and so nothing to answer. */
216
+ export const MINIMAL_POSTURE = { capabilities: {}, answers: {} };
217
+ /**
218
+ * The two capabilities this harness can answer truthfully.
219
+ *
220
+ * `roots/list` returns an empty list: this client exposes no filesystem roots,
221
+ * which is a true statement about it rather than a refusal. `elicitation/create`
222
+ * declines: the protocol provides for a user declining to answer, and an
223
+ * unattended sweep has no user, so declining is the honest reply and not a
224
+ * failure to implement one.
225
+ */
226
+ export const DECLARING_POSTURE = {
227
+ capabilities: { roots: { listChanged: false }, elicitation: {} },
228
+ answers: {
229
+ 'roots/list': { roots: [] },
230
+ 'elicitation/create': { action: 'decline' },
231
+ },
232
+ };
215
233
  export class McpStdioClient {
216
234
  keepEvidence;
235
+ answers;
217
236
  child;
218
237
  buffer = '';
219
238
  nextId = 1;
@@ -222,8 +241,14 @@ export class McpStdioClient {
222
241
  exited;
223
242
  constructor(command, args, env,
224
243
  /** Phrase this entry's declared status depends on — see `evidenceTail`. */
225
- keepEvidence) {
244
+ keepEvidence,
245
+ /**
246
+ * Answers to server-initiated requests, one per declared capability. Empty
247
+ * by default, which is correct only while `initialize` declares nothing.
248
+ */
249
+ answers = {}) {
226
250
  this.keepEvidence = keepEvidence;
251
+ this.answers = answers;
227
252
  this.child = spawn(command, args, {
228
253
  env: { ...env },
229
254
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -279,6 +304,8 @@ export class McpStdioClient {
279
304
  if (msg.id !== undefined && msg.id !== null) {
280
305
  if (msg.method === 'ping')
281
306
  this.send({ jsonrpc: '2.0', id: msg.id, result: {} });
307
+ else if (Object.hasOwn(this.answers, msg.method))
308
+ this.send({ jsonrpc: '2.0', id: msg.id, result: this.answers[msg.method] });
282
309
  else
283
310
  this.send({ jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: 'method not found' } });
284
311
  }
@@ -345,12 +372,13 @@ export class McpStdioClient {
345
372
  */
346
373
  export async function captureTools(spec, opts = {}) {
347
374
  const timeoutMs = opts.timeoutMs ?? 60_000;
375
+ const posture = opts.posture ?? MINIMAL_POSTURE;
348
376
  const [cmd, ...args] = typeof spec === 'string' ? splitCommand(spec) : [spec.command, ...spec.argv];
349
- const client = new McpStdioClient(cmd, args, { PATH: process.env.PATH, HOME: process.env.HOME, ...opts.env }, opts.keepEvidence);
377
+ const client = new McpStdioClient(cmd, args, { PATH: process.env.PATH, HOME: process.env.HOME, ...opts.env }, opts.keepEvidence, posture.answers);
350
378
  try {
351
379
  const init = await client.request('initialize', {
352
380
  protocolVersion: PROTOCOL_VERSION,
353
- capabilities: {},
381
+ capabilities: posture.capabilities,
354
382
  clientInfo: { name: 'mcp-context-cost', version: '0.1.0' },
355
383
  }, timeoutMs);
356
384
  client.notify('notifications/initialized');
@@ -32,6 +32,17 @@ export interface PublishedStats {
32
32
  github: {
33
33
  badgeTokens: number;
34
34
  claudeTokens: number | null;
35
+ /**
36
+ * The single heaviest field in github's capture that an Anthropic tools
37
+ * array has nowhere to put, and its share of the capture. Derived rather
38
+ * than described, because the sentence it feeds was wrong for as long as
39
+ * it was hand-written: it named `annotations`/`outputSchema` as "most of
40
+ * the capture" while github ships no `outputSchema` at all and 1.7% of
41
+ * annotations. The dropped weight was `icons`, at 78%. A claim about
42
+ * which bytes are dropped has to come from the bytes.
43
+ */
44
+ dropField: string;
45
+ dropSharePct: number;
35
46
  };
36
47
  notion: {
37
48
  badgeTokens: number;
@@ -77,6 +88,24 @@ export interface PublishedStats {
77
88
  }
78
89
  /** Named in README's sample table — the choice is editorial, the numbers are not. */
79
90
  export declare const SAMPLE_SERVERS: readonly ['github', 'xcodebuildmcp', 'brave-search', 'notion', 'playwright', 'filesystem', 'markitdown'];
91
+ /**
92
+ * The heaviest field in a capture that an Anthropic request has nowhere to put,
93
+ * and what share of the capture it is.
94
+ *
95
+ * This exists because the README sentence it feeds was hand-written and wrong.
96
+ * It said most of github's capture was `annotations`/`outputSchema` metadata;
97
+ * github ships no `outputSchema` at all and 1.7% of annotations, and the weight
98
+ * being dropped was `icons` at 78%. The claim was plausible, unmaintained, and
99
+ * describing a different server's shape — so it is derived now, and it moves
100
+ * when the capture does.
101
+ *
102
+ * Ties break on the field name so the sentence does not flip between two
103
+ * equal-weight fields from one regeneration to the next.
104
+ */
105
+ export declare function heaviestDroppedField(capture: unknown[] | null, totalTokens: number): {
106
+ dropField: string;
107
+ dropSharePct: number;
108
+ };
80
109
  export declare function floorToTwoSignificant(n: number): number;
81
110
  export declare function computePublishedStats(entries: ServerEntry[], root?: string): PublishedStats;
82
111
  export type PageFile = 'README.md' | 'docs/index.md' | 'docs/METHODOLOGY.md';
@@ -28,7 +28,8 @@
28
28
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
29
29
  import { join } from 'node:path';
30
30
  import { DEFAULT_CONTEXT_WINDOW } from '../audit/audit.js';
31
- import { wireToClientRatio } from '../audit/deferral.js';
31
+ import { BAND_PRECISION, wireToClientRatio } from '../audit/deferral.js';
32
+ import { countTokens } from '../core/canonical.js';
32
33
  import { fieldSelectionShare, isCurrent } from '../core/divergence.js';
33
34
  import { sessionStartLoad } from '../core/session-start.js';
34
35
  import { isGood } from './harness-guard.js';
@@ -44,6 +45,40 @@ export const SAMPLE_SERVERS = [
44
45
  'filesystem',
45
46
  'markitdown',
46
47
  ];
48
+ /**
49
+ * The three fields an Anthropic tool definition carries. Everything else a
50
+ * server ships in `tools/list` is dropped before the request — see
51
+ * docs/METHODOLOGY.md#claude-divergence.
52
+ */
53
+ const ANTHROPIC_TOOL_FIELDS = new Set(['name', 'description', 'inputSchema']);
54
+ /**
55
+ * The heaviest field in a capture that an Anthropic request has nowhere to put,
56
+ * and what share of the capture it is.
57
+ *
58
+ * This exists because the README sentence it feeds was hand-written and wrong.
59
+ * It said most of github's capture was `annotations`/`outputSchema` metadata;
60
+ * github ships no `outputSchema` at all and 1.7% of annotations, and the weight
61
+ * being dropped was `icons` at 78%. The claim was plausible, unmaintained, and
62
+ * describing a different server's shape — so it is derived now, and it moves
63
+ * when the capture does.
64
+ *
65
+ * Ties break on the field name so the sentence does not flip between two
66
+ * equal-weight fields from one regeneration to the next.
67
+ */
68
+ export function heaviestDroppedField(capture, totalTokens) {
69
+ const tally = new Map();
70
+ for (const tool of capture ?? []) {
71
+ for (const [k, v] of Object.entries((tool ?? {}))) {
72
+ if (ANTHROPIC_TOOL_FIELDS.has(k))
73
+ continue;
74
+ tally.set(k, (tally.get(k) ?? 0) + countTokens(JSON.stringify(v)));
75
+ }
76
+ }
77
+ const top = [...tally].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0];
78
+ if (!top || totalTokens <= 0)
79
+ return { dropField: 'none', dropSharePct: 0 };
80
+ return { dropField: top[0], dropSharePct: Math.round((100 * top[1]) / totalTokens) };
81
+ }
47
82
  export function floorToTwoSignificant(n) {
48
83
  const whole = Math.floor(n);
49
84
  if (whole < 100)
@@ -153,7 +188,11 @@ export function computePublishedStats(entries, root = process.cwd()) {
153
188
  runSize: Object.keys(div.servers).length,
154
189
  currentCount: withClaude.length,
155
190
  heaviestClaudeName: heaviest?.entry.name ?? null,
156
- github: { badgeTokens: badgeTokensOf('github'), claudeTokens: currentDivRow('github')?.claudeDelta ?? null },
191
+ github: {
192
+ badgeTokens: badgeTokensOf('github'),
193
+ claudeTokens: currentDivRow('github')?.claudeDelta ?? null,
194
+ ...heaviestDroppedField(githubRow.m.rawToolsCapture, githubRow.m.totalTokens),
195
+ },
157
196
  notion: { badgeTokens: badgeTokensOf('notion'), claudeTokens: currentDivRow('notion')?.claudeDelta ?? null },
158
197
  widest: { server: widest[0], full: widest[1].o200kFull, mapped: widest[1].o200kMapped },
159
198
  shareMin: Math.min(...shares),
@@ -240,7 +279,11 @@ export const PAGE_CLAIMS = [
240
279
  // page-number guard: three numbers written by hand beside the two
241
280
  // sentences regen already kept true.
242
281
  template: 'measured at {f}×–{f}× across {n} servers)',
243
- values: (s) => [s.claude.ratioMin.toFixed(2), s.claude.ratioMax.toFixed(2), fmt(s.claude.ratioServers)],
282
+ values: (s) => [
283
+ s.claude.ratioMin.toFixed(BAND_PRECISION),
284
+ s.claude.ratioMax.toFixed(BAND_PRECISION),
285
+ fmt(s.claude.ratioServers),
286
+ ],
244
287
  },
245
288
  {
246
289
  file: 'README.md',
@@ -285,8 +328,13 @@ export const PAGE_CLAIMS = [
285
328
  {
286
329
  file: 'README.md',
287
330
  id: 'claude-table:github',
288
- template: '| github | {n} | **{q}** | most of the capture is `annotations`/`outputSchema` metadata Claude never sees |',
289
- values: (s) => [fmt(s.claude.github.badgeTokens), q(s.claude.github.claudeTokens)],
331
+ template: '| github | {n} | **{q}** | {d}% of the capture is `{w}` metadata Claude never sees |',
332
+ values: (s) => [
333
+ fmt(s.claude.github.badgeTokens),
334
+ q(s.claude.github.claudeTokens),
335
+ String(s.claude.github.dropSharePct),
336
+ s.claude.github.dropField,
337
+ ],
290
338
  },
291
339
  {
292
340
  file: 'README.md',
@@ -358,13 +406,21 @@ export const PAGE_CLAIMS = [
358
406
  // different sources for one number is the drift this file exists to end, so
359
407
  // both pages state the run and the constant is guarded separately.
360
408
  template: 'band ({f}×–{f}×\nacross {n} servers)',
361
- values: (s) => [s.claude.ratioMin.toFixed(2), s.claude.ratioMax.toFixed(2), fmt(s.claude.ratioServers)],
409
+ values: (s) => [
410
+ s.claude.ratioMin.toFixed(BAND_PRECISION),
411
+ s.claude.ratioMax.toFixed(BAND_PRECISION),
412
+ fmt(s.claude.ratioServers),
413
+ ],
362
414
  },
363
415
  {
364
416
  file: 'docs/METHODOLOGY.md',
365
417
  id: 'divergence:ratio-range',
366
418
  template: 'it ranged from {f}× to {f}× across the {n} servers in the run,',
367
- values: (s) => [s.claude.ratioMin.toFixed(2), s.claude.ratioMax.toFixed(2), fmt(s.claude.ratioServers)],
419
+ values: (s) => [
420
+ s.claude.ratioMin.toFixed(BAND_PRECISION),
421
+ s.claude.ratioMax.toFixed(BAND_PRECISION),
422
+ fmt(s.claude.ratioServers),
423
+ ],
368
424
  },
369
425
  {
370
426
  file: 'docs/METHODOLOGY.md',
@@ -60,6 +60,40 @@ export interface ServerEntry {
60
60
  * context cost nobody is watching.
61
61
  */
62
62
  deprecated?: Deprecation;
63
+ /**
64
+ * A different project of the same name that this row does **not** measure.
65
+ *
66
+ * `name` is the only thing the leaderboard shows about an entry — the package
67
+ * id and the repo are on the server page, one click away — so a row whose
68
+ * name is shared with a better-known project reads as that project to anyone
69
+ * who does not click. That is not hypothetical: `octocode` here is the npm
70
+ * package `octocode-mcp` from `bgauryy/octocode`, and someone who works on
71
+ * `Muvon/octocode`, an unrelated Rust project with the same name, read the
72
+ * row as theirs and posted a public correction about how their server had
73
+ * been filed.
74
+ *
75
+ * Declared per entry rather than detected, because there is no way to detect
76
+ * it: two unrelated projects picking one word is a fact about the world, and
77
+ * the only honest source is someone noticing. Renaming the entry is the
78
+ * alternative and a much larger one — `name` keys `results/<name>/`,
79
+ * `badges/<name>.json`, the capture index and every history row, so it is a
80
+ * change to a published identifier rather than to a label.
81
+ */
82
+ nameCollision?: NameCollision;
83
+ }
84
+ /**
85
+ * The other project, as a dated reading — the same shape a deprecation takes,
86
+ * for the same reason: it is a claim about something outside this repository,
87
+ * so it carries where it was read and when. A project can be renamed, archived
88
+ * or absorbed without anything here moving.
89
+ */
90
+ export interface NameCollision {
91
+ /** The other project, as its own owner writes it — e.g. `Muvon/octocode`. */
92
+ project: string;
93
+ /** Where it was read. */
94
+ source: string;
95
+ /** The day it was read. */
96
+ readOn: string;
63
97
  }
64
98
  /**
65
99
  * A deprecation as a dated reading, like every other reading here. `version`
@@ -89,6 +123,8 @@ export declare function mdCell(s: unknown): string;
89
123
  * Returns '' for an entry with no deprecation, so a caller can splice it in
90
124
  * without branching.
91
125
  */
126
+ /** `https://github.com/owner/repo` → `owner/repo`; anything else is left alone. */
127
+ export declare function shortRepo(url: string): string;
92
128
  export declare function deprecationText(entry: ServerEntry): string;
93
129
  export declare function loadRows(entries: ServerEntry[], root?: string): Row[];
94
130
  /** results/divergence.json if a divergence run has been recorded, else null. */
@@ -22,6 +22,11 @@ const mdLink = (url) => encodeURI(String(url ?? '')).replace(/\)/g, '%29');
22
22
  * Returns '' for an entry with no deprecation, so a caller can splice it in
23
23
  * without branching.
24
24
  */
25
+ /** `https://github.com/owner/repo` → `owner/repo`; anything else is left alone. */
26
+ export function shortRepo(url) {
27
+ const m = /^https?:\/\/(?:www\.)?github\.com\/([^/]+\/[^/#?]+)/.exec(url);
28
+ return m ? m[1].replace(/\.git$/, '') : url;
29
+ }
25
30
  export function deprecationText(entry) {
26
31
  const d = entry.deprecated;
27
32
  if (!d)
@@ -240,6 +245,31 @@ export function writeLeaderboard(entries, root = process.cwd(), regressions) {
240
245
  }
241
246
  md.push('');
242
247
  }
248
+ // Same rule as the deprecation section above: derived, and gone when no entry
249
+ // declares one. It is a section rather than a column because the fact belongs
250
+ // to a handful of rows and a column would put an empty cell on the other
251
+ // hundred — and because what a reader needs here is a sentence, not a cell.
252
+ const collided = rows.filter((r) => r.entry.nameCollision);
253
+ if (collided.length > 0) {
254
+ md.push('## Same name, different project');
255
+ md.push('');
256
+ md.push(`A row here is named for the package it launches, and ${collided.length === 1 ? 'one name is' : 'these names are'} ` +
257
+ `shared with an unrelated project. The table above shows only the name, so ` +
258
+ `${collided.length === 1 ? 'that row' : 'those rows'} can be read as the wrong software by anyone who does not ` +
259
+ `open the page — which has already happened once, publicly. What each row actually measures is its launch ` +
260
+ `command and its source repository, both on its detail page.`);
261
+ md.push('');
262
+ md.push('| server | measures | not to be confused with |');
263
+ md.push('|---|---|---|');
264
+ for (const r of collided) {
265
+ const c = r.entry.nameCollision;
266
+ const measures = r.entry.repo
267
+ ? `\`${mdCell(r.entry.package ?? r.entry.command)}\` — [${mdCell(shortRepo(r.entry.repo))}](${mdLink(r.entry.repo)})`
268
+ : `\`${mdCell(r.entry.package ?? r.entry.command)}\``;
269
+ md.push(`| ${mdCell(r.entry.name)} | ${measures} | [${mdCell(c.project)}](${mdLink(c.source)}), read ${mdCell(c.readOn)} |`);
270
+ }
271
+ md.push('');
272
+ }
243
273
  if (unmeasured.length > 0) {
244
274
  md.push('## Not measured (and why)');
245
275
  md.push('');
@@ -1,3 +1,4 @@
1
+ import { type ClientPosture } from './client.js';
1
2
  import type { Measurement } from '../core/types.js';
2
3
  /**
3
4
  * Which kind of failure a dead server's own words describe.
@@ -105,6 +106,14 @@ export interface MeasureOptions {
105
106
  * `audit` runs in the user's own directory and must not litter it.
106
107
  */
107
108
  persist?: boolean;
109
+ /**
110
+ * What the client declares at `initialize`, and how it answers what that
111
+ * invites. Every published measurement was taken declaring nothing, which is
112
+ * the default; a probe passes a different posture to find out whether a
113
+ * server gates tools on it. Changing the default would change published
114
+ * numbers, so it is a measurement decision and not a flag to flip lightly.
115
+ */
116
+ posture?: ClientPosture;
108
117
  }
109
118
  /**
110
119
  * Whether a launch command is already its own `docker run`.
package/dist/sweep/run.js CHANGED
@@ -231,7 +231,7 @@ export async function measureServer(name, command, opts = {}) {
231
231
  // The declared evidence travels with the launch, because the truncation
232
232
  // that could lose it happens inside the client, before anything here sees
233
233
  // the message it will be classified from.
234
- const captureOpts = { ...attemptOpts, keepEvidence: opts.notApplicable?.evidence };
234
+ const captureOpts = { ...attemptOpts, keepEvidence: opts.notApplicable?.evidence, posture: opts.posture };
235
235
  const first = await captureTools(buildSpec(noSharedCache), captureOpts);
236
236
  const second = await captureTools(buildSpec(noSharedCache), captureOpts);
237
237
  r = measureTools(first.tools, {
@@ -27,13 +27,28 @@ const fmt = (n) => n.toLocaleString('en-US');
27
27
  function isMeasured(m) {
28
28
  return !!m && (m.status === 'measured' || m.status === 'dynamic') && typeof m.totalTokens === 'number';
29
29
  }
30
+ /**
31
+ * The conditions a number was made under, including the machine.
32
+ *
33
+ * `isolation.arch` has been recorded since 0.12.0 and was rendered nowhere, so
34
+ * a reader could see the image and the network a measurement ran under but not
35
+ * the architecture — the one condition that decides whether a package could run
36
+ * at all. `local-mcp` is why the field exists: it was published as a broken
37
+ * server on the strength of a run whose real finding was the machine.
38
+ *
39
+ * Absent is printed as "architecture not on record" rather than omitted,
40
+ * because the record's own rule is that absent means unknown and never "the
41
+ * same as yours". Thirty-two of the published records predate the field and
42
+ * will say so until the rotation re-measures them.
43
+ */
30
44
  function isolationText(m) {
31
45
  const iso = m.isolation;
32
46
  if (!iso)
33
47
  return 'not recorded';
48
+ const arch = iso.arch ?? 'architecture not on record';
34
49
  if (!iso.docker)
35
- return 'host process (no container)';
36
- return ['docker', iso.image, iso.network ? `network ${iso.network}` : '', iso.note]
50
+ return `host process (no container) · ${arch}`;
51
+ return ['docker', iso.image, iso.network ? `network ${iso.network}` : '', arch, iso.note]
37
52
  .filter(Boolean)
38
53
  .join(' · ');
39
54
  }
@@ -99,6 +114,15 @@ export function renderServerPage(entry, m, history = [], divergence = null) {
99
114
  md.push(`| category | ${mdCell(entry.category)} |`);
100
115
  if (entry.repo)
101
116
  md.push(`| source | ${mdCell(entry.repo)} |`);
117
+ // The page already names the package, the launch command and the repo, so a
118
+ // reader who reads it can tell which project this is. This row is for the
119
+ // reader who arrives from a badge, sees a familiar name in the heading, and
120
+ // has no reason to suspect there are two — which is the person the collision
121
+ // actually caught.
122
+ if (entry.nameCollision) {
123
+ md.push(`| not to be confused with | ${mdCell(entry.nameCollision.project)} ` +
124
+ `(${mdCell(entry.nameCollision.source)}, read ${mdCell(entry.nameCollision.readOn)}) — an unrelated project of the same name, not measured here |`);
125
+ }
102
126
  md.push('');
103
127
  if (m.status === 'dynamic') {
104
128
  md.push('> This server\'s `tools/list` differed between two consecutive captures, so the number ' +
@@ -107,10 +131,20 @@ export function renderServerPage(entry, m, history = [], divergence = null) {
107
131
  }
108
132
  md.push('## Where the tokens are');
109
133
  md.push('');
110
- md.push('| tool | tokens | share | description | schema |');
111
- md.push('|---|---:|---:|---:|---:|');
134
+ // The output-schema column appears only on pages that have one. It is the
135
+ // field most likely to explain an expensive tool — about a sixth of every
136
+ // published token across the set, and the largest thing the breakdown used to
137
+ // leave unnamed — but only a third of measured servers ship one, and a column
138
+ // of zeroes on the rest would cost every reader something to tell them
139
+ // nothing. `annotations` is recorded per tool for the same reason and
140
+ // deliberately gets no column: at roughly 3% of the set it almost never
141
+ // explains a row, and it is in the measurement file for anyone who looks.
142
+ const hasOutput = shown.some((t) => (t.outputSchemaTokens ?? 0) > 0);
143
+ md.push(`| tool | tokens | share | description | input schema |${hasOutput ? ' output schema |' : ''}`);
144
+ md.push(`|---|---:|---:|---:|---:|${hasOutput ? '---:|' : ''}`);
112
145
  for (const t of shown) {
113
- md.push(`| ${mdCell(t.name)} | ${fmt(t.tokens)} | ${pct(t.tokens)} | ${fmt(t.descriptionTokens)} | ${fmt(t.inputSchemaTokens)} |`);
146
+ md.push(`| ${mdCell(t.name)} | ${fmt(t.tokens)} | ${pct(t.tokens)} | ${fmt(t.descriptionTokens)} | ${fmt(t.inputSchemaTokens)} |` +
147
+ (hasOutput ? ` ${fmt(t.outputSchemaTokens ?? 0)} |` : ''));
114
148
  }
115
149
  md.push('');
116
150
  if (tools.length > shown.length) {
@@ -29,6 +29,7 @@ declare const FIELDS: {
29
29
  readonly envValues: 'optional';
30
30
  readonly notApplicable: 'optional';
31
31
  readonly deprecated: 'optional';
32
+ readonly nameCollision: 'optional';
32
33
  };
33
34
  export declare const knownFields: (keyof typeof FIELDS)[];
34
35
  /**
@@ -51,6 +51,7 @@ const FIELDS = {
51
51
  envValues: 'optional',
52
52
  notApplicable: 'optional',
53
53
  deprecated: 'optional',
54
+ nameCollision: 'optional',
54
55
  };
55
56
  export const knownFields = Object.keys(FIELDS);
56
57
  /**
@@ -218,6 +219,35 @@ export function validateEntry(raw, index) {
218
219
  }
219
220
  }
220
221
  }
222
+ const coll = e.nameCollision;
223
+ if (coll !== undefined) {
224
+ if (!isPlainObject(coll))
225
+ bad('must be a mapping with project, source and readOn', 'nameCollision');
226
+ else {
227
+ for (const key of ['project', 'source', 'readOn']) {
228
+ const v = coll[key];
229
+ if (typeof v !== 'string' || v.trim() === '')
230
+ bad(`${key} is required and must be non-empty`, 'nameCollision');
231
+ }
232
+ // Same rule as a deprecation, and for the same reason: this is a claim
233
+ // about a project outside this repository, so it names where it was read.
234
+ if (typeof coll.source === 'string' && !/^https?:\/\//.test(coll.source)) {
235
+ bad('source must be a URL — a published claim carries its evidence', 'nameCollision');
236
+ }
237
+ if (typeof coll.readOn === 'string' && !/^\d{4}-\d{2}-\d{2}$/.test(coll.readOn)) {
238
+ bad('readOn must be YYYY-MM-DD', 'nameCollision');
239
+ }
240
+ // The whole point of the field is to name something this row is *not*.
241
+ // A declaration pointing at the entry's own repository says nothing.
242
+ if (typeof coll.source === 'string' && typeof e.repo === 'string' && coll.source === e.repo) {
243
+ bad('source is this entry\'s own repo — the field names a different project', 'nameCollision');
244
+ }
245
+ for (const key of Object.keys(coll)) {
246
+ if (!['project', 'source', 'readOn'].includes(key))
247
+ bad(`unknown key ${key}`, 'nameCollision');
248
+ }
249
+ }
250
+ }
221
251
  return problems;
222
252
  }
223
253
  /** Shape-check the whole document, including the invariants that span entries. */
@@ -4,7 +4,7 @@
4
4
  * `history.csv` only grows for servers that get measured again, and the only
5
5
  * scheduled measurement this project runs is the weekly self-badge job — one
6
6
  * server, `memory`. Every other row's trend line therefore stops at whatever
7
- * date a maintenance run last swept by hand. Re-measuring all 82 on a schedule
7
+ * date a maintenance run last swept by hand. Re-measuring every one of them on a schedule
8
8
  * is not the fix: a cold runner pays a full image pull and package install per
9
9
  * server with no cache volume to carry over, so the whole set does not fit in
10
10
  * one job's budget comfortably or cheaply.
@@ -4,7 +4,7 @@
4
4
  * `history.csv` only grows for servers that get measured again, and the only
5
5
  * scheduled measurement this project runs is the weekly self-badge job — one
6
6
  * server, `memory`. Every other row's trend line therefore stops at whatever
7
- * date a maintenance run last swept by hand. Re-measuring all 82 on a schedule
7
+ * date a maintenance run last swept by hand. Re-measuring every one of them on a schedule
8
8
  * is not the fix: a cold runner pays a full image pull and package install per
9
9
  * server with no cache volume to carry over, so the whole set does not fit in
10
10
  * one job's budget comfortably or cheaply.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-context-cost",
3
- "version": "0.15.0",
3
+ "version": "0.17.0",
4
4
  "description": "Measure what your MCP servers cost in context tokens — audit your own config, or badge the server you publish",
5
5
  "type": "module",
6
6
  "license": "MIT",