mcp-context-cost 0.10.0 → 0.11.1

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
@@ -210,9 +210,26 @@ prose every request carries — and only descriptions at or above the 90th perce
210
210
  A config where nothing is out of distribution is told that in those words, and a baseline
211
211
  that cannot be fetched is a named problem, never a silently skipped check.
212
212
 
213
+ Add `--changed` to ask the other question — *did the servers I already have get heavier?*
214
+ Each installed server is identified against the published capture history by its canonical
215
+ hash, never by its name, because the name in your config is a label you chose and the bytes
216
+ are not:
217
+
218
+ ```
219
+ changed — published versions of your servers that have moved since
220
+ (index 2026-09-04, 2 published captures; matched by canonical hash, never by name):
221
+ notes (published as obsidian) — you have the capture published 2026-08-19 at 1,132 tokens;
222
+ the current one is 2,062 (+930, 2026-08-26)
223
+ updating all 1 would add 930 tokens to every request in this client.
224
+ ```
225
+
226
+ A server whose bytes match no published capture — a version never measured here, a fork, a
227
+ pin — is reported as unidentified with nothing claimed about it.
228
+ Method: [capture index](docs/METHODOLOGY.md#capture-index).
229
+
213
230
  Flags: `--json` (full report on stdout, progress on stderr), `--budget N`,
214
231
  `--baseline <report.json>`, `--max-increase N`, `--context N` (default 200,000),
215
- `--timeout ms`, `--concurrency N`, `--docker`, `--claude`, `--suggest`.
232
+ `--timeout ms`, `--concurrency N`, `--docker`, `--claude`, `--suggest`, `--changed`.
216
233
 
217
234
  ## Where the numbers come from
218
235
 
@@ -238,6 +255,14 @@ sample of that range; the full range is in
238
255
  Each measured server also has a [detail page](https://athakur3.github.io/mcp-context-cost/servers/)
239
256
  showing which tools its tokens are in.)*
240
257
 
258
+ Because the set is re-measured on a rotation and most entries launch unpinned, the same data
259
+ answers a question no client asks: **what did this server cost last month?**
260
+ [results/regressions.md](results/regressions.md) reports each server's most recent movement —
261
+ dated to when it happened, separated into *shipped more tools* versus *same tools, rewritten*,
262
+ and compared only within one isolation. The ecosystem ratchets upward: of the servers whose
263
+ cost has moved at all, 9 moved up against 1 that moved down. Method:
264
+ [cost movement](docs/METHODOLOGY.md#cost-movement).
265
+
241
266
  If you publish a server, the same measurement is available as a badge, so your users can see
242
267
  the cost before they install rather than after:
243
268
 
@@ -321,6 +346,58 @@ Then in your README:
321
346
  [![context cost](https://img.shields.io/endpoint?url=<raw URL of badges/my-server.json>)](<link target>)
322
347
  ```
323
348
 
349
+ ### Defend the number, don't just display it
350
+
351
+ A badge says what your server costs today; it does nothing about the release
352
+ that adds 1,200 tokens to every user's context next month. Across the servers
353
+ measured here, that release is the norm rather than the exception — the
354
+ [movement report](results/regressions.md) has nine servers ratcheting upward
355
+ against one that got cheaper, and none of those maintainers had a check that
356
+ would have said so first. `measure` takes the same gate flags `audit` does, so
357
+ your own CI can be that check:
358
+
359
+ ```bash
360
+ # on your default branch, once — commit the result
361
+ npx -y mcp-context-cost measure --name my-server --command "node dist/index.js"
362
+ cp results/my-server/measurement.json .context-cost/baseline.json
363
+
364
+ # on every pull request
365
+ npx -y mcp-context-cost measure --name my-server --command "node dist/index.js" \
366
+ --baseline .context-cost/baseline.json --max-increase 500
367
+ ```
368
+
369
+ ```
370
+ diff vs baseline .context-cost/baseline.json
371
+ my-server: 61 → 182 +121 tokens (2 → 3 tools)
372
+ added: bulk_export (43)
373
+ grew: search 30 → 108 (+78)
374
+
375
+ INCREASE FAIL: +121 tokens, over the 100 allowed — this change adds that to every request of every install.
376
+ ```
377
+
378
+ Both sides are single measurements carrying per-tool counts, so an established
379
+ change is attributed exactly: which tools arrived, which grew, and by how much.
380
+ And `--max-increase` fails on more than the number — a server that stops
381
+ starting on the branch makes the total go *down*, and reporting that as an
382
+ improvement is the one mistake a gate like this must not make, so a change that
383
+ could not be established fails too.
384
+
385
+ As a GitHub Action, that whole workflow is five lines
386
+ ([full example](examples/server-author-ci.yml)):
387
+
388
+ ```yaml
389
+ - uses: athakur3/mcp-context-cost@v1
390
+ with:
391
+ name: my-server
392
+ command: node dist/index.js
393
+ baseline: .context-cost/baseline.json
394
+ max-increase: 500
395
+ ```
396
+
397
+ It exposes `tokens`, `tools`, `status`, `measurement` and `badge` as outputs —
398
+ available whether the gate passed or not — so a later step can comment the
399
+ number on the pull request or publish the badge.
400
+
324
401
  Point the link at the measurement behind the number — for servers in this sweep that is
325
402
  `https://athakur3.github.io/mcp-context-cost/servers/<name>.html`; otherwise the
326
403
  methodology page. A badge nobody can audit is decoration.
@@ -1,5 +1,6 @@
1
1
  import { type DivergenceRun } from '../core/divergence.js';
2
2
  import { type ToolShapeBaseline, type ToolSuggestion } from '../core/tool-shape.js';
3
+ import { type CaptureIndex, type CaptureVerdict } from '../core/capture-index.js';
3
4
  import type { Measurement, MeasurementStatus, ToolMeasurement } from '../core/types.js';
4
5
  import type { ConfiguredServer, LoadedConfig } from './config.js';
5
6
  import { type DeferralVerdict, type ToolSearchEnv, type ToolSearchSource } from './deferral.js';
@@ -57,6 +58,18 @@ export interface ConfigSuggestions {
57
58
  outOfDistribution: ToolSuggestion[];
58
59
  checkedTools: number;
59
60
  }
61
+ /**
62
+ * `--changed`: one locally measured server placed against the published capture
63
+ * history, joined by canonical hash rather than by name. The local label is
64
+ * carried alongside the published server the bytes identify, because they need
65
+ * not agree — a config may call the official GitHub server anything at all, and
66
+ * a name that disagrees with the bytes is information rather than noise.
67
+ */
68
+ export interface ServerCaptureVerdict {
69
+ /** The name this config gave the server. */
70
+ name: string;
71
+ verdict: CaptureVerdict;
72
+ }
60
73
  export interface AuditConfigResult {
61
74
  client: string;
62
75
  source: string;
@@ -70,6 +83,8 @@ export interface AuditConfigResult {
70
83
  trimAdvice: TrimAdvice | null;
71
84
  /** Present only when `--suggest` ran with a usable baseline. */
72
85
  suggestions?: ConfigSuggestions;
86
+ /** Present only when `--changed` ran with a usable capture index. */
87
+ captureVerdicts?: ServerCaptureVerdict[];
73
88
  /**
74
89
  * Whether this client loads the total up front or defers it, and — when the
75
90
  * client decides that by a threshold — which side of it this stack is on.
@@ -150,6 +165,11 @@ export interface AuditReport {
150
165
  toolCount: number;
151
166
  serverCount: number;
152
167
  };
168
+ /** Which published capture index `--changed` joined against. */
169
+ captureIndex?: {
170
+ generatedAt: string;
171
+ captureCount: number;
172
+ };
153
173
  /** Present only when a baseline report was supplied (`--baseline`). */
154
174
  diff?: AuditDiff;
155
175
  /** Present only when `--max-increase` was supplied alongside a baseline. */
@@ -184,6 +204,8 @@ export declare function buildReport(configs: LoadedConfig[], measured: Map<strin
184
204
  divergence?: DivergenceRun | null;
185
205
  /** Published `tool-shape/v1` baseline (`--suggest`); omit to skip suggestions. */
186
206
  toolShape?: ToolShapeBaseline | null;
207
+ /** Published `capture-index/v1` (`--changed`); omit to skip the version join. */
208
+ captureIndex?: CaptureIndex | null;
187
209
  /**
188
210
  * The audited machine's SHELL tool-search variables. Passed in rather than
189
211
  * read here so this stays pure and a report is reproducible from its
@@ -15,6 +15,7 @@
15
15
  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
+ import { identify } from '../core/capture-index.js';
18
19
  import { evaluateDeferral, PUBLISHED_WIRE_TO_CLIENT_RATIO, SHELL_SOURCE, } from './deferral.js';
19
20
  import { formatDiff, formatGate } from './diff.js';
20
21
  export const DEFAULT_CONTEXT_WINDOW = 200_000;
@@ -280,6 +281,9 @@ export function buildReport(configs, measured, opts = {}) {
280
281
  heaviestTools: tools.slice(0, 5),
281
282
  trimAdvice: buildTrimAdvice(tools, totalTokens),
282
283
  suggestions: opts.toolShape ? buildSuggestions(shapePool, opts.toolShape) : undefined,
284
+ captureVerdicts: opts.captureIndex
285
+ ? ok.map((s) => ({ name: s.name, verdict: identify(s.canonicalSha256, opts.captureIndex) }))
286
+ : undefined,
283
287
  };
284
288
  built.push(result);
285
289
  shared.set(result, sharedHere);
@@ -305,6 +309,12 @@ export function buildReport(configs, measured, opts = {}) {
305
309
  serverCount: opts.toolShape.serverCount,
306
310
  };
307
311
  }
312
+ if (opts.captureIndex) {
313
+ report.captureIndex = {
314
+ generatedAt: opts.captureIndex.generatedAt,
315
+ captureCount: Object.keys(opts.captureIndex.captures).length,
316
+ };
317
+ }
308
318
  if (typeof opts.budget === 'number') {
309
319
  // The worst config is the gate: passing because your *lightest* client fits
310
320
  // would be a green check on a session you don't run.
@@ -640,6 +650,39 @@ export function formatReport(report) {
640
650
  `(${names}) would recover ${n(cfg.trimAdvice.recoverableTokens)} tokens ` +
641
651
  `(${pct(cfg.trimAdvice.recoverableShare)} of this config) — if your client supports per-tool filtering.`);
642
652
  }
653
+ if (cfg.captureVerdicts) {
654
+ const behind = cfg.captureVerdicts.filter((v) => v.verdict.kind === 'behind');
655
+ const current = cfg.captureVerdicts.filter((v) => v.verdict.kind === 'current');
656
+ const unknown = cfg.captureVerdicts.length - behind.length - current.length;
657
+ const idx = report.captureIndex
658
+ ? `index ${report.captureIndex.generatedAt}, ${n(report.captureIndex.captureCount)} published captures`
659
+ : 'the published capture index';
660
+ lines.push('');
661
+ if (behind.length === 0) {
662
+ lines.push(` changed: no server here is running a published capture that has since moved ` +
663
+ `(${idx}; matched by canonical hash, never by name).`);
664
+ }
665
+ else {
666
+ const total = behind.reduce((a, v) => a + v.verdict.deltaTokens, 0);
667
+ lines.push(` changed — published versions of your servers that have moved since ` +
668
+ `(${idx}; matched by canonical hash, never by name):`);
669
+ for (const { name, verdict: v } of behind) {
670
+ // The local label and the published server are printed together: the
671
+ // bytes decide which server this is, and a name that disagrees is a
672
+ // fact worth seeing rather than one to smooth over.
673
+ const alias = name === v.server ? name : `${name} (published as ${v.server})`;
674
+ lines.push(` ${alias} — you have the capture published ${v.yourDate} at ${n(v.yourTokens)} tokens; ` +
675
+ `the current one is ${n(v.currentTokens)} (${v.deltaTokens >= 0 ? '+' : '−'}${n(Math.abs(v.deltaTokens))}, ${v.currentDate})`);
676
+ }
677
+ lines.push(` updating all ${behind.length} would ${total >= 0 ? 'add' : 'remove'} ${n(Math.abs(total))} tokens ` +
678
+ `${total >= 0 ? 'to' : 'from'} every request in this client.`);
679
+ }
680
+ if (unknown > 0) {
681
+ lines.push(` ${unknown} server${unknown === 1 ? '' : 's'} could not be identified: the installed bytes match no ` +
682
+ `published capture — a version never measured here, or one published before the index began. ` +
683
+ `Nothing is claimed about ${unknown === 1 ? 'it' : 'them'}.`);
684
+ }
685
+ }
643
686
  if (cfg.suggestions) {
644
687
  const sg = cfg.suggestions;
645
688
  const base = report.toolShape
@@ -1,6 +1,7 @@
1
1
  import type { Measurement } from '../core/types.js';
2
2
  import { type DivergenceRun } from '../core/divergence.js';
3
3
  import { type ToolShapeBaseline } from '../core/tool-shape.js';
4
+ import { type CaptureIndex } from '../core/capture-index.js';
4
5
  import { type AuditReport } from './audit.js';
5
6
  import { type ToolSearchEnv, type ToolSearchSource } from './deferral.js';
6
7
  import { type LoadedConfig } from './config.js';
@@ -8,6 +9,8 @@ import { type LoadedConfig } from './config.js';
8
9
  export declare const DEFAULT_DIVERGENCE_URL = "https://raw.githubusercontent.com/athakur3/mcp-context-cost/main/results/divergence.json";
9
10
  /** Where the published `tool-shape/v1` baseline lives when `--suggest` doesn't override it. */
10
11
  export declare const DEFAULT_TOOL_SHAPE_URL = "https://raw.githubusercontent.com/athakur3/mcp-context-cost/main/results/tool-shape.json";
12
+ /** Where the published `capture-index/v1` lives when `--changed` doesn't override it. */
13
+ export declare const DEFAULT_CAPTURE_INDEX_URL = "https://raw.githubusercontent.com/athakur3/mcp-context-cost/main/results/capture-index.json";
11
14
  export interface AuditOptions {
12
15
  /** Explicit config path(s); when empty, every known client location is tried. */
13
16
  configPaths?: string[];
@@ -26,6 +29,10 @@ export interface AuditOptions {
26
29
  suggest?: boolean;
27
30
  /** Override the tool-shape.json source — mainly for tests and self-hosted mirrors. */
28
31
  toolShapeUrl?: string;
32
+ /** Identify each server against the published capture history, by hash, and report what has moved. */
33
+ changed?: boolean;
34
+ /** Override the capture-index.json source — mainly for tests and self-hosted mirrors. */
35
+ captureIndexUrl?: string;
29
36
  /**
30
37
  * The tool-search variables as this process's SHELL has them. Defaults to
31
38
  * this process's environment. Overridable so a test can state a machine
@@ -40,6 +47,11 @@ export interface AuditOptions {
40
47
  settings?: ToolSearchSource[];
41
48
  onProgress?: (name: string, done: number, total: number) => void;
42
49
  }
50
+ /** Fetch and parse the published capture index. Never throws: a failure is a report problem, not a crash. */
51
+ export declare function fetchCaptureIndex(url: string): Promise<{
52
+ index: CaptureIndex | null;
53
+ problem?: string;
54
+ }>;
43
55
  /** Fetch and parse the published tool-shape baseline. Never throws: a failure is a report problem, not a crash. */
44
56
  export declare function fetchToolShape(url: string): Promise<{
45
57
  baseline: ToolShapeBaseline | null;
package/dist/audit/run.js CHANGED
@@ -8,6 +8,7 @@ import { homedir } from 'node:os';
8
8
  import { measureServer } from '../sweep/run.js';
9
9
  import { parseDivergence } from '../core/divergence.js';
10
10
  import { parseToolShapeBaseline } from '../core/tool-shape.js';
11
+ import { parseCaptureIndex } from '../core/capture-index.js';
11
12
  import { buildReport, serverKey } from './audit.js';
12
13
  import { toolSearchEnv } from './deferral.js';
13
14
  import { configCandidates, loadConfigs, loadSettingsSources, settingsCandidates, } from './config.js';
@@ -15,6 +16,21 @@ import { configCandidates, loadConfigs, loadSettingsSources, settingsCandidates,
15
16
  export const DEFAULT_DIVERGENCE_URL = 'https://raw.githubusercontent.com/athakur3/mcp-context-cost/main/results/divergence.json';
16
17
  /** Where the published `tool-shape/v1` baseline lives when `--suggest` doesn't override it. */
17
18
  export const DEFAULT_TOOL_SHAPE_URL = 'https://raw.githubusercontent.com/athakur3/mcp-context-cost/main/results/tool-shape.json';
19
+ /** Where the published `capture-index/v1` lives when `--changed` doesn't override it. */
20
+ export const DEFAULT_CAPTURE_INDEX_URL = 'https://raw.githubusercontent.com/athakur3/mcp-context-cost/main/results/capture-index.json';
21
+ /** Fetch and parse the published capture index. Never throws: a failure is a report problem, not a crash. */
22
+ export async function fetchCaptureIndex(url) {
23
+ try {
24
+ const res = await fetch(url, { signal: AbortSignal.timeout(15_000) });
25
+ if (!res.ok)
26
+ return { index: null, problem: `capture index: HTTP ${res.status} fetching ${url}` };
27
+ const index = parseCaptureIndex(await res.text());
28
+ return index ? { index } : { index: null, problem: `capture index: malformed data at ${url}` };
29
+ }
30
+ catch (e) {
31
+ return { index: null, problem: `capture index: failed to fetch ${url}: ${e.message}` };
32
+ }
33
+ }
18
34
  /** Fetch and parse the published tool-shape baseline. Never throws: a failure is a report problem, not a crash. */
19
35
  export async function fetchToolShape(url) {
20
36
  try {
@@ -112,11 +128,19 @@ export async function runAudit(opts = {}) {
112
128
  toolShape = fetched.baseline;
113
129
  toolShapeProblem = fetched.problem;
114
130
  }
131
+ let captureIndex = null;
132
+ let captureIndexProblem;
133
+ if (opts.changed) {
134
+ const fetched = await fetchCaptureIndex(opts.captureIndexUrl ?? DEFAULT_CAPTURE_INDEX_URL);
135
+ captureIndex = fetched.index;
136
+ captureIndexProblem = fetched.problem;
137
+ }
115
138
  const report = buildReport(configs, measured, {
116
139
  contextWindow: opts.contextWindow,
117
140
  budget: opts.budget,
118
141
  divergence,
119
142
  toolShape,
143
+ captureIndex,
120
144
  env: opts.env ?? toolSearchEnv(process.env),
121
145
  settings: opts.settings ?? discoverSettings(opts),
122
146
  });
@@ -124,5 +148,7 @@ export async function runAudit(opts = {}) {
124
148
  report.problems.push(divergenceProblem);
125
149
  if (toolShapeProblem)
126
150
  report.problems.push(toolShapeProblem);
151
+ if (captureIndexProblem)
152
+ report.problems.push(captureIndexProblem);
127
153
  return report;
128
154
  }
package/dist/cli.d.ts CHANGED
@@ -27,3 +27,30 @@ export declare function unknownFlags(argv: string[], spec: {
27
27
  value: string[];
28
28
  boolean: string[];
29
29
  }): string[];
30
+ /**
31
+ * Every value a value-taking flag was given, in either accepted spelling:
32
+ * `--flag value` and `--flag=value`.
33
+ *
34
+ * Both forms are read here because reading only one of them is the same bug as
35
+ * ignoring an unknown flag. `--max-increase=100` was accepted by
36
+ * `unknownFlags` (which splits on `=`) and then invisible to a reader that only
37
+ * matched the bare token, so the gate it asked for silently did not run and the
38
+ * command exited 0 — a green check on a check that never happened.
39
+ */
40
+ export declare function flagValues(argv: string[], name: string): string[];
41
+ /** The last value given for a flag, or undefined when the flag is absent. */
42
+ export declare function flagValue(argv: string[], name: string): string | undefined;
43
+ /**
44
+ * Value-taking flags that appear with no usable value.
45
+ *
46
+ * A flag present without its value is a *usage error*, never an absent flag.
47
+ * `--max-increase` as the last argument — what a CI template renders when its
48
+ * variable is empty — otherwise reads as "no gate was asked for", and the run
49
+ * exits 0 on a change that should have failed it. That is the same green-check
50
+ * failure `unknownFlags` exists to prevent, reached through a different door,
51
+ * so it is refused in the same place and with the same severity.
52
+ */
53
+ export declare function valuelessFlags(argv: string[], spec: {
54
+ value: string[];
55
+ boolean: string[];
56
+ }): string[];
package/dist/cli.js CHANGED
@@ -85,28 +85,107 @@ export function unknownFlags(argv, spec) {
85
85
  }
86
86
  return unknown;
87
87
  }
88
+ /**
89
+ * Every value a value-taking flag was given, in either accepted spelling:
90
+ * `--flag value` and `--flag=value`.
91
+ *
92
+ * Both forms are read here because reading only one of them is the same bug as
93
+ * ignoring an unknown flag. `--max-increase=100` was accepted by
94
+ * `unknownFlags` (which splits on `=`) and then invisible to a reader that only
95
+ * matched the bare token, so the gate it asked for silently did not run and the
96
+ * command exited 0 — a green check on a check that never happened.
97
+ */
98
+ export function flagValues(argv, name) {
99
+ const out = [];
100
+ for (let i = 0; i < argv.length; i++) {
101
+ const tok = argv[i];
102
+ if (tok === `--${name}`) {
103
+ const next = argv[i + 1];
104
+ // A following flag is not this flag's value; that case is a usage error,
105
+ // caught by `valuelessFlags`, and must not be read as a value here.
106
+ if (next !== undefined && !next.startsWith('--'))
107
+ out.push(next);
108
+ continue;
109
+ }
110
+ if (tok.startsWith(`--${name}=`))
111
+ out.push(tok.slice(name.length + 3));
112
+ }
113
+ return out;
114
+ }
115
+ /** The last value given for a flag, or undefined when the flag is absent. */
116
+ export function flagValue(argv, name) {
117
+ const values = flagValues(argv, name);
118
+ return values.length ? values[values.length - 1] : undefined;
119
+ }
120
+ /**
121
+ * Value-taking flags that appear with no usable value.
122
+ *
123
+ * A flag present without its value is a *usage error*, never an absent flag.
124
+ * `--max-increase` as the last argument — what a CI template renders when its
125
+ * variable is empty — otherwise reads as "no gate was asked for", and the run
126
+ * exits 0 on a change that should have failed it. That is the same green-check
127
+ * failure `unknownFlags` exists to prevent, reached through a different door,
128
+ * so it is refused in the same place and with the same severity.
129
+ */
130
+ export function valuelessFlags(argv, spec) {
131
+ const bad = [];
132
+ for (let i = 0; i < argv.length; i++) {
133
+ const tok = argv[i];
134
+ if (!tok.startsWith('--'))
135
+ continue;
136
+ const name = tok.slice(2).split('=')[0];
137
+ if (!spec.value.includes(name))
138
+ continue;
139
+ if (tok.includes('=')) {
140
+ if (tok.slice(name.length + 3) === '')
141
+ bad.push(`--${name}`);
142
+ continue;
143
+ }
144
+ const next = argv[i + 1];
145
+ if (next === undefined || next.startsWith('--'))
146
+ bad.push(`--${name}`);
147
+ else
148
+ i++; // consume the value, so `--command "--weird"` is not re-read as a flag
149
+ }
150
+ return bad;
151
+ }
88
152
  function rejectUnknownFlags(cmd, argv, spec) {
89
153
  const bad = unknownFlags(argv, spec);
90
- if (!bad.length)
91
- return;
92
- const all = [...spec.value, ...spec.boolean].sort().map((f) => `--${f}`).join(' ');
93
- console.error(`unknown flag for \`${cmd}\`: ${bad.join(', ')}`);
94
- console.error(`this is mcp-context-cost ${cliVersion()} — if you copied the command from the README,`);
95
- console.error(`your install may be older than the docs. Try: npx -y mcp-context-cost@latest ${cmd} ...`);
96
- console.error(`known flags for ${cmd}: ${all}`);
97
- process.exit(2);
154
+ if (bad.length) {
155
+ const all = [...spec.value, ...spec.boolean].sort().map((f) => `--${f}`).join(' ');
156
+ console.error(`unknown flag for \`${cmd}\`: ${bad.join(', ')}`);
157
+ console.error(`this is mcp-context-cost ${cliVersion()} — if you copied the command from the README,`);
158
+ console.error(`your install may be older than the docs. Try: npx -y mcp-context-cost@latest ${cmd} ...`);
159
+ console.error(`known flags for ${cmd}: ${all}`);
160
+ process.exit(2);
161
+ }
162
+ const empty = valuelessFlags(argv, spec);
163
+ if (empty.length) {
164
+ console.error(`flag with no value for \`${cmd}\`: ${empty.join(', ')}`);
165
+ console.error(`a flag given without its value is refused rather than ignored: ignoring it would run`);
166
+ console.error(`a command that quietly does less than it was asked to — a gate that never gates.`);
167
+ process.exit(2);
168
+ }
98
169
  }
99
170
  const [, , cmd, ...rest] = process.argv;
100
171
  if (cmd === 'audit') {
101
172
  rejectUnknownFlags('audit', rest, {
102
- value: ['config', 'budget', 'baseline', 'max-increase', 'context', 'timeout', 'concurrency', 'divergence-url', 'tool-shape-url'],
103
- boolean: ['json', 'docker', 'claude', 'suggest'],
173
+ value: [
174
+ 'config',
175
+ 'budget',
176
+ 'baseline',
177
+ 'max-increase',
178
+ 'context',
179
+ 'timeout',
180
+ 'concurrency',
181
+ 'divergence-url',
182
+ 'tool-shape-url',
183
+ 'capture-index-url',
184
+ ],
185
+ boolean: ['json', 'docker', 'claude', 'suggest', 'changed'],
104
186
  });
105
- const argOf = (name) => {
106
- const i = rest.indexOf(`--${name}`);
107
- return i >= 0 ? rest[i + 1] : undefined;
108
- };
109
- const all = (name) => rest.flatMap((a, i) => (a === `--${name}` && rest[i + 1] ? [rest[i + 1]] : []));
187
+ const argOf = (name) => flagValue(rest, name);
188
+ const all = (name) => flagValues(rest, name);
110
189
  const json = rest.includes('--json');
111
190
  const numeric = (name) => {
112
191
  const raw = argOf(name);
@@ -173,6 +252,8 @@ if (cmd === 'audit') {
173
252
  divergenceUrl: argOf('divergence-url'),
174
253
  suggest: rest.includes('--suggest'),
175
254
  toolShapeUrl: argOf('tool-shape-url'),
255
+ changed: rest.includes('--changed'),
256
+ captureIndexUrl: argOf('capture-index-url'),
176
257
  // Progress goes to stderr so `--json` stdout stays a single parseable object.
177
258
  onProgress: json ? undefined : (name, done, total) => process.stderr.write(` [${done}/${total}] ${name}\n`),
178
259
  });
@@ -236,8 +317,7 @@ if (cmd === 'audit') {
236
317
  else if (cmd === 'verify') {
237
318
  rejectUnknownFlags('verify', rest, { value: ['remote'], boolean: ['json'] });
238
319
  const json = rest.includes('--json');
239
- const remoteIdx = rest.indexOf('--remote');
240
- const remoteUrl = remoteIdx >= 0 ? rest[remoteIdx + 1] : undefined;
320
+ const remoteUrl = flagValue(rest, 'remote');
241
321
  const path = rest.find((a) => !a.startsWith('--') && a !== remoteUrl);
242
322
  if (!remoteUrl && !path) {
243
323
  console.error('usage: mcp-context-cost verify <measurement.json> [--json]');
@@ -282,13 +362,10 @@ else if (cmd === 'verify') {
282
362
  }
283
363
  else if (cmd === 'measure') {
284
364
  rejectUnknownFlags('measure', rest, {
285
- value: ['name', 'command', 'remote', 'timeout', 'docker-image'],
365
+ value: ['name', 'command', 'remote', 'timeout', 'docker-image', 'baseline', 'max-increase', 'budget'],
286
366
  boolean: ['docker'],
287
367
  });
288
- const argOf = (name) => {
289
- const i = rest.indexOf(`--${name}`);
290
- return i >= 0 ? rest[i + 1] : undefined;
291
- };
368
+ const argOf = (name) => flagValue(rest, name);
292
369
  const command = argOf('command');
293
370
  const remoteUrl = argOf('remote');
294
371
  if (remoteUrl && !/^https?:\/\//i.test(remoteUrl)) {
@@ -305,6 +382,45 @@ else if (cmd === 'measure') {
305
382
  console.error('usage: mcp-context-cost measure --name <slug> --command "npx -y <server>" [--timeout ms] [--docker]');
306
383
  process.exit(2);
307
384
  }
385
+ const { diffServer, evaluateServerGate, formatServerDiff, parseBaselineMeasurement, } = await import('./core/server-diff.js');
386
+ // Gate limits are read before anything is measured: an unusable number is a
387
+ // usage error, and finding that out after a two-minute container launch is
388
+ // the wrong time to find it out.
389
+ const numericFlag = (flag) => {
390
+ const raw = argOf(flag);
391
+ if (raw === undefined)
392
+ return undefined;
393
+ const v = Number(raw);
394
+ if (!Number.isFinite(v) || v < 0) {
395
+ console.error(`--${flag} must be a non-negative number, got '${raw}'`);
396
+ process.exit(2);
397
+ }
398
+ return v;
399
+ };
400
+ const maxIncrease = numericFlag('max-increase');
401
+ const budget = numericFlag('budget');
402
+ const baselinePath = argOf('baseline');
403
+ if (maxIncrease !== undefined && !baselinePath) {
404
+ console.error('--max-increase needs --baseline <measurement.json> to compare against');
405
+ process.exit(2);
406
+ }
407
+ let baseline = null;
408
+ if (baselinePath) {
409
+ let raw;
410
+ try {
411
+ raw = readFileSync(baselinePath, 'utf8');
412
+ }
413
+ catch (e) {
414
+ console.error(`cannot read baseline ${baselinePath}: ${e.message}`);
415
+ process.exit(2);
416
+ }
417
+ const parsed = parseBaselineMeasurement(raw);
418
+ if (!parsed.measurement) {
419
+ console.error(`${baselinePath}: ${parsed.problem}`);
420
+ process.exit(2);
421
+ }
422
+ baseline = parsed.measurement;
423
+ }
308
424
  const { measureServer } = await import('./sweep/run.js');
309
425
  const m = await measureServer(name, remoteUrl ? `npx -y mcp-remote ${remoteUrl}` : command, {
310
426
  timeoutMs: Number(argOf('timeout') ?? 60_000),
@@ -316,6 +432,20 @@ else if (cmd === 'measure') {
316
432
  console.log(ok
317
433
  ? `${name}: ${m.totalTokens} tokens across ${m.toolCount} tools (${m.status}) — results/${name}/measurement.json, badges/${name}.json`
318
434
  : `${name}: ${m.status} — ${m.notes ?? ''}`);
435
+ if (baseline || budget !== undefined || maxIncrease !== undefined) {
436
+ const diff = diffServer(name, baseline, m);
437
+ if (baseline) {
438
+ console.log('');
439
+ console.log(`diff vs baseline ${baselinePath}`);
440
+ console.log(formatServerDiff(diff));
441
+ }
442
+ const gate = evaluateServerGate(diff, { budget, maxIncrease });
443
+ if (!gate.pass) {
444
+ console.log('');
445
+ console.error(gate.failure);
446
+ process.exit(1);
447
+ }
448
+ }
319
449
  process.exit(ok ? 0 : 1);
320
450
  }
321
451
  else if (cmd !== undefined && cmd !== '--help' && cmd !== '-h') {
@@ -333,5 +463,8 @@ else {
333
463
  console.log(' verify --remote <url> [--json] same, fetched from a measurement URL');
334
464
  console.log(' measure --name x --command "npx -y <server>" run a one-off measurement');
335
465
  console.log(' measure --remote <url> [--name x] measure a remote server via mcp-remote');
466
+ console.log(' [--baseline <measurement.json>] [--max-increase N] [--budget N]');
467
+ console.log(' gate your own server in CI: fail the');
468
+ console.log(' build when a change adds too much');
336
469
  console.log('exit codes: 0 ok, 1 verification/measurement/budget failed, 2 usage error');
337
470
  }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Which published version is this, by its bytes?
3
+ *
4
+ * `audit --changed` answers "did the servers in my config get heavier?" — and
5
+ * the whole question turns on joining a machine's installed server to the
6
+ * published history. Matching by *name* would be a lie waiting to happen: a
7
+ * config's keys are arbitrary local labels, so a server a user calls `github`
8
+ * may be a fork, a pin, or something else entirely, and reporting the official
9
+ * server's movement against it would be a confident false statement.
10
+ *
11
+ * So the join is byte identity — the same discipline the Claude column already
12
+ * uses to decide whether it may print. `results/capture-index.json` maps the
13
+ * `canonicalSha256` of every capture the project has ever published to the
14
+ * server and date it belongs to. A local measurement either *is* one of those
15
+ * captures, exactly, or it is not in the published history at all, and there is
16
+ * no third state to be fuzzy about.
17
+ *
18
+ * The index is derivable from the per-server tool vectors, which is why it can
19
+ * only see as far back as those vectors go: a version published before the
20
+ * vectors existed is not in the index and reads as unknown, not as absent from
21
+ * history. Versioned independently, like every published artifact.
22
+ */
23
+ /** Method identifier, versioned independently of METHODOLOGY_VERSION. */
24
+ export declare const CAPTURE_INDEX_METHOD = "capture-index/v1";
25
+ export interface IndexedCapture {
26
+ server: string;
27
+ /** The day this capture was first measured. */
28
+ date: string;
29
+ totalTokens: number;
30
+ toolCount: number;
31
+ }
32
+ export interface CaptureIndex {
33
+ method: string;
34
+ /** UTC day the index was derived (YYYY-MM-DD). */
35
+ generatedAt: string;
36
+ /** canonicalSha256 → the capture it identifies. */
37
+ captures: Record<string, IndexedCapture>;
38
+ /** server → the newest published capture's hash. */
39
+ current: Record<string, string>;
40
+ }
41
+ export declare function parseCaptureIndex(text: string): CaptureIndex | null;
42
+ /**
43
+ * What the index can say about one locally measured server.
44
+ *
45
+ * `unknown` is the honest and common outcome: users install versions this
46
+ * project has never measured, and versions published before the capture index
47
+ * existed are not in it either. It is reported, not hidden — an absence of a
48
+ * record about that version, not a statement that nothing changed.
49
+ */
50
+ export type CaptureVerdict = {
51
+ kind: 'behind';
52
+ /** The published server this capture belongs to — established by bytes, not by name. */
53
+ server: string;
54
+ yourDate: string;
55
+ yourTokens: number;
56
+ currentDate: string;
57
+ currentTokens: number;
58
+ /** What moving to the current published version would add to every request. */
59
+ deltaTokens: number;
60
+ } | {
61
+ kind: 'current';
62
+ server: string;
63
+ date: string;
64
+ tokens: number;
65
+ } | {
66
+ kind: 'unknown';
67
+ };
68
+ /**
69
+ * Identify a local capture against the published index.
70
+ *
71
+ * A hash that matches the server's newest published capture is `current`; one
72
+ * that matches an older capture is `behind`, carrying the exact delta to
73
+ * current. Anything else — including a version newer than anything published —
74
+ * is `unknown`, because the index cannot describe what it has never measured.
75
+ */
76
+ export declare function identify(canonicalSha256: string | null | undefined, index: CaptureIndex): CaptureVerdict;