mcp-context-cost 0.2.0 → 0.4.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.
@@ -0,0 +1,53 @@
1
+ export interface ConfiguredServer {
2
+ name: string;
3
+ /** Which client's config this came from ('claude-desktop', 'cursor', ...). */
4
+ client: string;
5
+ /** Absolute path of the config file. */
6
+ source: string;
7
+ transport: 'stdio' | 'remote';
8
+ /** Display form of the launch command (argv joined) — stdio only. */
9
+ command?: string;
10
+ /** Exact argv, so paths containing spaces survive round-tripping. */
11
+ argv?: string[];
12
+ envVarNames: string[];
13
+ /** Values, needed to spawn the server. NEVER serialize this. */
14
+ env?: Record<string, string>;
15
+ /** Remote endpoint — recorded so the report can say why it was skipped. */
16
+ url?: string;
17
+ }
18
+ /**
19
+ * JSON with comments and trailing commas — VS Code's mcp.json allows both, and
20
+ * hand-edited Claude/Cursor configs often pick up a trailing comma too. String
21
+ * literals are tracked so a `//` or `,}` inside a tool description survives.
22
+ */
23
+ export declare function parseJsonc(text: string): unknown;
24
+ /**
25
+ * Pull every server out of one parsed config document. `cwd` selects the
26
+ * project scope in Claude Code's `~/.claude.json`, which keys per-project
27
+ * servers by absolute directory.
28
+ */
29
+ export declare function extractServers(doc: unknown, meta: {
30
+ client: string;
31
+ source: string;
32
+ cwd?: string;
33
+ }): ConfiguredServer[];
34
+ export interface ConfigCandidate {
35
+ client: string;
36
+ path: string;
37
+ }
38
+ /** Every place a client config is known to live, whether or not it exists. */
39
+ export declare function configCandidates(env: {
40
+ home: string;
41
+ cwd: string;
42
+ platform: NodeJS.Platform;
43
+ appData?: string;
44
+ }): ConfigCandidate[];
45
+ export interface LoadedConfig {
46
+ client: string;
47
+ source: string;
48
+ servers: ConfiguredServer[];
49
+ /** Set when the file exists but could not be read/parsed. */
50
+ error?: string;
51
+ }
52
+ /** Read + parse the candidates that exist. Unreadable files are reported, not thrown. */
53
+ export declare function loadConfigs(candidates: ConfigCandidate[], cwd: string): LoadedConfig[];
@@ -0,0 +1,179 @@
1
+ /**
2
+ * MCP client config discovery + parsing.
3
+ *
4
+ * The leaderboard measures servers one at a time; `audit` measures the set a
5
+ * person actually has installed. That set lives in a client config file, and
6
+ * every client spells it slightly differently:
7
+ *
8
+ * Claude Desktop / Claude Code / Cursor / Windsurf { "mcpServers": { ... } }
9
+ * VS Code (.vscode/mcp.json) { "servers": { ... } }
10
+ * Claude Code (~/.claude.json) also { "projects": { "<dir>": { "mcpServers": ... } } }
11
+ *
12
+ * Everything here is pure (paths in, servers out) so the discovery rules are
13
+ * testable without touching a real home directory.
14
+ *
15
+ * Env var VALUES are read (a server usually needs its key to start) but are
16
+ * never written to a report: report builders pick fields explicitly and only
17
+ * `envVarNames` is ever serialized.
18
+ */
19
+ import { existsSync, readFileSync } from 'node:fs';
20
+ import { join } from 'node:path';
21
+ /**
22
+ * JSON with comments and trailing commas — VS Code's mcp.json allows both, and
23
+ * hand-edited Claude/Cursor configs often pick up a trailing comma too. String
24
+ * literals are tracked so a `//` or `,}` inside a tool description survives.
25
+ */
26
+ export function parseJsonc(text) {
27
+ let out = '';
28
+ let inString = false;
29
+ let escaped = false;
30
+ let pendingComma = false;
31
+ const flush = (next) => {
32
+ if (!pendingComma)
33
+ return;
34
+ // A comma is only trailing if the next real character closes the container.
35
+ if (next !== '}' && next !== ']')
36
+ out += ',';
37
+ pendingComma = false;
38
+ };
39
+ for (let i = 0; i < text.length; i++) {
40
+ const c = text[i];
41
+ const n = text[i + 1];
42
+ if (inString) {
43
+ out += c;
44
+ if (escaped)
45
+ escaped = false;
46
+ else if (c === '\\')
47
+ escaped = true;
48
+ else if (c === '"')
49
+ inString = false;
50
+ continue;
51
+ }
52
+ if (c === '/' && n === '/') {
53
+ while (i < text.length && text[i] !== '\n')
54
+ i++;
55
+ continue;
56
+ }
57
+ if (c === '/' && n === '*') {
58
+ i += 2;
59
+ while (i < text.length && !(text[i] === '*' && text[i + 1] === '/'))
60
+ i++;
61
+ i++;
62
+ continue;
63
+ }
64
+ if (c === ',') {
65
+ pendingComma = true;
66
+ continue;
67
+ }
68
+ if (/\s/.test(c)) {
69
+ if (!pendingComma)
70
+ out += c;
71
+ continue;
72
+ }
73
+ flush(c);
74
+ out += c;
75
+ if (c === '"')
76
+ inString = true;
77
+ }
78
+ return JSON.parse(out);
79
+ }
80
+ function toServer(name, raw, client, source) {
81
+ if (raw.disabled === true)
82
+ return null;
83
+ const env = {};
84
+ for (const [k, v] of Object.entries(raw.env ?? {})) {
85
+ if (typeof v === 'string')
86
+ env[k] = v;
87
+ }
88
+ const envVarNames = Object.keys(env).sort();
89
+ // Remote entries carry a url (and sometimes type http/sse) instead of a command.
90
+ if (!raw.command && typeof raw.url === 'string') {
91
+ return { name, client, source, transport: 'remote', url: raw.url, envVarNames };
92
+ }
93
+ if (typeof raw.command !== 'string' || raw.command.trim() === '')
94
+ return null;
95
+ const args = Array.isArray(raw.args) ? raw.args.filter((a) => typeof a === 'string') : [];
96
+ const argv = [raw.command, ...args];
97
+ return {
98
+ name,
99
+ client,
100
+ source,
101
+ transport: 'stdio',
102
+ // Quote only the args that need it, so the printed command stays copy-pasteable.
103
+ command: argv.map((a) => (/\s/.test(a) ? JSON.stringify(a) : a)).join(' '),
104
+ argv,
105
+ envVarNames,
106
+ env: envVarNames.length ? env : undefined,
107
+ };
108
+ }
109
+ /**
110
+ * Pull every server out of one parsed config document. `cwd` selects the
111
+ * project scope in Claude Code's `~/.claude.json`, which keys per-project
112
+ * servers by absolute directory.
113
+ */
114
+ export function extractServers(doc, meta) {
115
+ if (!doc || typeof doc !== 'object')
116
+ return [];
117
+ const d = doc;
118
+ const out = [];
119
+ const addBlock = (block) => {
120
+ if (!block || typeof block !== 'object')
121
+ return;
122
+ for (const [name, raw] of Object.entries(block)) {
123
+ if (!raw || typeof raw !== 'object')
124
+ continue;
125
+ const s = toServer(name, raw, meta.client, meta.source);
126
+ if (s)
127
+ out.push(s);
128
+ }
129
+ };
130
+ addBlock(d.mcpServers);
131
+ addBlock(d.servers); // VS Code
132
+ if (meta.cwd && d.projects && typeof d.projects === 'object') {
133
+ const project = d.projects[meta.cwd];
134
+ if (project && typeof project === 'object')
135
+ addBlock(project.mcpServers);
136
+ }
137
+ // A name can legitimately appear in both blocks of the same file; keep the first.
138
+ const seen = new Set();
139
+ return out.filter((s) => (seen.has(s.name) ? false : (seen.add(s.name), true)));
140
+ }
141
+ /** Every place a client config is known to live, whether or not it exists. */
142
+ export function configCandidates(env) {
143
+ const { home, cwd, platform } = env;
144
+ const desktop = platform === 'darwin'
145
+ ? join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json')
146
+ : platform === 'win32'
147
+ ? join(env.appData ?? join(home, 'AppData', 'Roaming'), 'Claude', 'claude_desktop_config.json')
148
+ : join(home, '.config', 'Claude', 'claude_desktop_config.json');
149
+ return [
150
+ { client: 'claude-desktop', path: desktop },
151
+ { client: 'claude-code', path: join(home, '.claude.json') },
152
+ { client: 'claude-code', path: join(cwd, '.mcp.json') },
153
+ { client: 'cursor', path: join(home, '.cursor', 'mcp.json') },
154
+ { client: 'cursor', path: join(cwd, '.cursor', 'mcp.json') },
155
+ { client: 'vscode', path: join(cwd, '.vscode', 'mcp.json') },
156
+ { client: 'windsurf', path: join(home, '.codeium', 'windsurf', 'mcp_config.json') },
157
+ ];
158
+ }
159
+ /** Read + parse the candidates that exist. Unreadable files are reported, not thrown. */
160
+ export function loadConfigs(candidates, cwd) {
161
+ const out = [];
162
+ for (const c of candidates) {
163
+ if (!existsSync(c.path))
164
+ continue;
165
+ try {
166
+ const doc = parseJsonc(readFileSync(c.path, 'utf8'));
167
+ const servers = extractServers(doc, { client: c.client, source: c.path, cwd });
168
+ // A config with no MCP block at all (e.g. a ~/.claude.json holding only
169
+ // session history) is not worth a line in the report.
170
+ if (servers.length === 0)
171
+ continue;
172
+ out.push({ client: c.client, source: c.path, servers });
173
+ }
174
+ catch (e) {
175
+ out.push({ client: c.client, source: c.path, servers: [], error: e.message });
176
+ }
177
+ }
178
+ return out;
179
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * `audit --baseline <report.json>` — what a config change costs every future session.
3
+ *
4
+ * `audit` answers "what do my servers cost right now". That is a number a reader
5
+ * has to have an opinion about. A diff against a stored earlier report answers
6
+ * the question that needs no opinion at all: *this change adds 17,000 tokens to
7
+ * every request you will ever send from this client.* Same measurement path,
8
+ * same per-config discipline — a baseline is just an earlier `audit --json`.
9
+ *
10
+ * The trap this file exists to avoid: a server that measured fine before and
11
+ * fails to start now makes the total go DOWN. Subtracting two totals would
12
+ * report that as an improvement, which is the flattering reading and the true
13
+ * one having the same shape. So a server that changed measured-ness is never
14
+ * given a delta — it is named, its known side is printed, and the direction of
15
+ * the resulting error is stated ("understates by at least 9,246").
16
+ */
17
+ import type { AuditConfigResult, AuditReport } from './audit.js';
18
+ export type ServerDeltaKind = 'added' | 'removed' | 'changed' | 'unchanged'
19
+ /** Measured in the baseline, not measurable now — the total understates. */
20
+ | 'unmeasured-now'
21
+ /** Not measurable in the baseline, measured now — the increase overstates. */
22
+ | 'unmeasured-before'
23
+ /** Present and unmeasured in both runs — contributes 0 to both totals, but hides cost. */
24
+ | 'unmeasured-both';
25
+ export interface ServerDelta {
26
+ name: string;
27
+ kind: ServerDeltaKind;
28
+ /** Baseline tokens; `null` when absent from the baseline or unmeasured in it. */
29
+ before: number | null;
30
+ /** Current tokens; `null` when gone from the config or unmeasured now. */
31
+ after: number | null;
32
+ /** Signed change. `null` whenever the two sides are not the same kind of number. */
33
+ delta: number | null;
34
+ /** Why a delta is missing, in a sentence a reader can act on. */
35
+ note?: string;
36
+ }
37
+ export interface ConfigDiff {
38
+ client: string;
39
+ source: string;
40
+ /** How this config was paired with a baseline config. */
41
+ matchedBy: 'source' | 'sole-config' | 'unmatched';
42
+ beforeTotal: number | null;
43
+ afterTotal: number;
44
+ /** afterTotal - beforeTotal, or `null` when there is no baseline to subtract. */
45
+ delta: number | null;
46
+ beforeShare: number | null;
47
+ afterShare: number;
48
+ /**
49
+ * True when `delta` is the exact change in measured cost. False when a server
50
+ * crossed the measured/unmeasured line, which moves the total for a reason
51
+ * that is not a config change.
52
+ */
53
+ exact: boolean;
54
+ /** Tokens the diff is known to be missing, and which way it leans. */
55
+ understatedBy: number;
56
+ overstatedBy: number;
57
+ servers: ServerDelta[];
58
+ }
59
+ export interface AuditDiff {
60
+ baselineGeneratedAt: string;
61
+ baselineMethodologyVersion: string;
62
+ /** False when something makes the two reports incommensurable at all (methodology bump). */
63
+ comparable: boolean;
64
+ /** Baseline configs that no current config matched — never silently dropped. */
65
+ droppedConfigs: {
66
+ client: string;
67
+ source: string;
68
+ totalTokens: number;
69
+ }[];
70
+ warnings: string[];
71
+ configs: ConfigDiff[];
72
+ /**
73
+ * The largest per-config increase. Per config, never merged: a context window
74
+ * belongs to one client session, so a portfolio-wide "total delta" would
75
+ * describe a session nobody runs. `null` when nothing could be compared.
76
+ */
77
+ worstIncrease: {
78
+ source: string;
79
+ delta: number;
80
+ } | null;
81
+ }
82
+ /** Parse and shape-check a stored report. A baseline that cannot be read is never "no change". */
83
+ export declare function parseBaselineReport(text: string): {
84
+ report: AuditReport | null;
85
+ problem?: string;
86
+ };
87
+ export declare function diffConfig(before: AuditConfigResult | null, after: AuditConfigResult, matchedBy: ConfigDiff['matchedBy']): ConfigDiff;
88
+ /**
89
+ * Pair current configs with baseline configs.
90
+ *
91
+ * Exact source path first. Then one deliberate fallback: if each side has
92
+ * exactly one config, they are the same config seen from two machines — the CI
93
+ * case, where a baseline recorded at /Users/… meets a checkout at /home/runner/….
94
+ * Anything looser would pair two unrelated clients and call the difference a
95
+ * change, so everything else stays unmatched and says so.
96
+ */
97
+ export declare function pairConfigs(before: AuditConfigResult[], after: AuditConfigResult[]): {
98
+ pairs: {
99
+ before: AuditConfigResult | null;
100
+ after: AuditConfigResult;
101
+ matchedBy: ConfigDiff['matchedBy'];
102
+ }[];
103
+ dropped: AuditConfigResult[];
104
+ };
105
+ export declare function buildDiff(baseline: AuditReport, current: AuditReport): AuditDiff;
106
+ export declare function formatDiff(diff: AuditDiff, contextWindow: number): string;
107
+ export interface IncreaseGate {
108
+ limit: number;
109
+ pass: boolean;
110
+ /** The increase the gate measured, when it got far enough to measure one. */
111
+ increase: number | null;
112
+ reasons: string[];
113
+ }
114
+ /**
115
+ * `--max-increase N` — the CI gate. Fails on an increase over the limit, and
116
+ * equally on any reason the increase could not be established.
117
+ *
118
+ * That second half is the point. A gate that passes when a server failed to
119
+ * start, or when the baseline covered a config this run never found, is a green
120
+ * check on a question nobody asked. Everything this portfolio has learned says
121
+ * unchecked must not read as clean, so an inexact diff fails and names why.
122
+ */
123
+ export declare function evaluateIncreaseGate(diff: AuditDiff, limit: number): IncreaseGate;
124
+ export declare function formatGate(gate: IncreaseGate): string;
@@ -0,0 +1,318 @@
1
+ /** Parse and shape-check a stored report. A baseline that cannot be read is never "no change". */
2
+ export function parseBaselineReport(text) {
3
+ let doc;
4
+ try {
5
+ doc = JSON.parse(text);
6
+ }
7
+ catch (e) {
8
+ return { report: null, problem: `baseline is not JSON: ${e.message}` };
9
+ }
10
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc)) {
11
+ return { report: null, problem: 'baseline is not an audit report object' };
12
+ }
13
+ const r = doc;
14
+ if (!Array.isArray(r.configs)) {
15
+ return { report: null, problem: "baseline has no 'configs' array — is it the output of `audit --json`?" };
16
+ }
17
+ if (typeof r.methodologyVersion !== 'string' || typeof r.encoding !== 'string') {
18
+ return { report: null, problem: 'baseline is missing methodologyVersion/encoding — is it the output of `audit --json`?' };
19
+ }
20
+ for (const c of r.configs) {
21
+ if (!c || typeof c !== 'object' || typeof c.source !== 'string') {
22
+ return { report: null, problem: 'baseline has a config entry without a source path' };
23
+ }
24
+ }
25
+ return { report: doc };
26
+ }
27
+ function statesOf(cfg) {
28
+ const out = new Map();
29
+ for (const s of cfg.servers ?? [])
30
+ out.set(s.name, { present: true, tokens: typeof s.tokens === 'number' ? s.tokens : null });
31
+ // A skipped server IS in the config; it just has no number. Keeping it distinct
32
+ // from absent is the whole reason `removed` and `unmeasured-now` are separate kinds.
33
+ for (const s of cfg.skipped ?? [])
34
+ if (!out.has(s.name))
35
+ out.set(s.name, { present: true, tokens: null });
36
+ return out;
37
+ }
38
+ export function diffConfig(before, after, matchedBy) {
39
+ const afterShare = after.contextShare;
40
+ if (!before) {
41
+ return {
42
+ client: after.client,
43
+ source: after.source,
44
+ matchedBy: 'unmatched',
45
+ beforeTotal: null,
46
+ afterTotal: after.totalTokens,
47
+ delta: null,
48
+ beforeShare: null,
49
+ afterShare,
50
+ exact: false,
51
+ understatedBy: 0,
52
+ overstatedBy: 0,
53
+ servers: [],
54
+ };
55
+ }
56
+ const b = statesOf(before);
57
+ const a = statesOf(after);
58
+ const servers = [];
59
+ let understatedBy = 0;
60
+ let overstatedBy = 0;
61
+ let exact = true;
62
+ for (const name of new Set([...b.keys(), ...a.keys()])) {
63
+ const bs = b.get(name);
64
+ const as = a.get(name);
65
+ if (bs && !as) {
66
+ servers.push(bs.tokens === null
67
+ ? { name, kind: 'removed', before: null, after: null, delta: null, note: 'was in the config but never measured — removing it changed no measured cost' }
68
+ : { name, kind: 'removed', before: bs.tokens, after: null, delta: -bs.tokens });
69
+ continue;
70
+ }
71
+ if (!bs && as) {
72
+ servers.push(as.tokens === null
73
+ ? { name, kind: 'added', before: null, after: null, delta: null, note: 'added but not measurable — its cost is unknown, not zero' }
74
+ : { name, kind: 'added', before: null, after: as.tokens, delta: as.tokens });
75
+ continue;
76
+ }
77
+ if (!bs || !as)
78
+ continue;
79
+ if (bs.tokens !== null && as.tokens !== null) {
80
+ const delta = as.tokens - bs.tokens;
81
+ servers.push({ name, kind: delta === 0 ? 'unchanged' : 'changed', before: bs.tokens, after: as.tokens, delta });
82
+ }
83
+ else if (bs.tokens !== null && as.tokens === null) {
84
+ exact = false;
85
+ understatedBy += bs.tokens;
86
+ servers.push({
87
+ name,
88
+ kind: 'unmeasured-now',
89
+ before: bs.tokens,
90
+ after: null,
91
+ delta: null,
92
+ note: `measured ${bs.tokens.toLocaleString('en-US')} in the baseline and could not be measured now — its cost is missing from the total, not gone from your config`,
93
+ });
94
+ }
95
+ else if (bs.tokens === null && as.tokens !== null) {
96
+ exact = false;
97
+ overstatedBy += as.tokens;
98
+ servers.push({
99
+ name,
100
+ kind: 'unmeasured-before',
101
+ before: null,
102
+ after: as.tokens,
103
+ delta: null,
104
+ note: `could not be measured in the baseline and measures ${as.tokens.toLocaleString('en-US')} now — this cost is newly visible, not necessarily new`,
105
+ });
106
+ }
107
+ else {
108
+ servers.push({
109
+ name,
110
+ kind: 'unmeasured-both',
111
+ before: null,
112
+ after: null,
113
+ delta: null,
114
+ note: 'not measurable in either run — contributes 0 to both totals and hides an unknown cost',
115
+ });
116
+ }
117
+ }
118
+ // Biggest movers first; ties and non-deltas fall to the bottom in name order.
119
+ servers.sort((x, y) => Math.abs(y.delta ?? 0) - Math.abs(x.delta ?? 0) || x.name.localeCompare(y.name));
120
+ return {
121
+ client: after.client,
122
+ source: after.source,
123
+ matchedBy,
124
+ beforeTotal: before.totalTokens,
125
+ afterTotal: after.totalTokens,
126
+ delta: after.totalTokens - before.totalTokens,
127
+ beforeShare: before.contextShare ?? null,
128
+ afterShare,
129
+ exact,
130
+ understatedBy,
131
+ overstatedBy,
132
+ servers,
133
+ };
134
+ }
135
+ /**
136
+ * Pair current configs with baseline configs.
137
+ *
138
+ * Exact source path first. Then one deliberate fallback: if each side has
139
+ * exactly one config, they are the same config seen from two machines — the CI
140
+ * case, where a baseline recorded at /Users/… meets a checkout at /home/runner/….
141
+ * Anything looser would pair two unrelated clients and call the difference a
142
+ * change, so everything else stays unmatched and says so.
143
+ */
144
+ export function pairConfigs(before, after) {
145
+ const unusedBefore = new Map(before.map((c) => [c.source, c]));
146
+ const pairs = [];
147
+ for (const cur of after) {
148
+ const hit = unusedBefore.get(cur.source);
149
+ if (hit) {
150
+ unusedBefore.delete(cur.source);
151
+ pairs.push({ before: hit, after: cur, matchedBy: 'source' });
152
+ }
153
+ else {
154
+ pairs.push({ before: null, after: cur, matchedBy: 'unmatched' });
155
+ }
156
+ }
157
+ if (before.length === 1 && after.length === 1 && pairs[0].before === null) {
158
+ pairs[0] = { before: before[0], after: after[0], matchedBy: 'sole-config' };
159
+ unusedBefore.delete(before[0].source);
160
+ }
161
+ return { pairs, dropped: [...unusedBefore.values()] };
162
+ }
163
+ export function buildDiff(baseline, current) {
164
+ const warnings = [];
165
+ let comparable = true;
166
+ if (baseline.methodologyVersion !== current.methodologyVersion) {
167
+ comparable = false;
168
+ warnings.push(`methodology changed (${baseline.methodologyVersion} → ${current.methodologyVersion}) — token counts from the two runs are not the same measurement`);
169
+ }
170
+ if (baseline.encoding !== current.encoding) {
171
+ comparable = false;
172
+ warnings.push(`encoding changed (${baseline.encoding} → ${current.encoding}) — the counts are in different units`);
173
+ }
174
+ if (baseline.contextWindow !== current.contextWindow) {
175
+ // Shares move, token counts do not. Worth saying, not worth invalidating.
176
+ warnings.push(`context window changed (${baseline.contextWindow.toLocaleString('en-US')} → ${current.contextWindow.toLocaleString('en-US')}) — shares are not comparable, token counts still are`);
177
+ }
178
+ const { pairs, dropped } = pairConfigs(baseline.configs, current.configs);
179
+ const configs = pairs.map((p) => diffConfig(p.before, p.after, p.matchedBy));
180
+ for (const c of configs) {
181
+ if (c.matchedBy === 'unmatched') {
182
+ warnings.push(`${c.source}: no matching config in the baseline — its ${c.afterTotal.toLocaleString('en-US')} tokens are shown as a total, not a change`);
183
+ }
184
+ if (c.matchedBy === 'sole-config' && c.source !== baseline.configs[0]?.source) {
185
+ warnings.push(`paired ${c.source} with the baseline's ${baseline.configs[0]?.source} — one config on each side, different paths`);
186
+ }
187
+ }
188
+ for (const d of dropped) {
189
+ warnings.push(`${d.source}: in the baseline (${d.totalTokens.toLocaleString('en-US')} tokens) and not found now — a config that disappeared is not a config that got cheaper`);
190
+ }
191
+ const increases = configs.filter((c) => typeof c.delta === 'number' && c.delta > 0);
192
+ increases.sort((a, b) => b.delta - a.delta);
193
+ return {
194
+ baselineGeneratedAt: baseline.generatedAt,
195
+ baselineMethodologyVersion: baseline.methodologyVersion,
196
+ comparable,
197
+ droppedConfigs: dropped.map((d) => ({ client: d.client, source: d.source, totalTokens: d.totalTokens })),
198
+ warnings,
199
+ configs,
200
+ worstIncrease: increases.length ? { source: increases[0].source, delta: increases[0].delta } : null,
201
+ };
202
+ }
203
+ const n = (x) => x.toLocaleString('en-US');
204
+ const signed = (x) => `${x >= 0 ? '+' : '−'}${n(Math.abs(x))}`;
205
+ const pct = (x) => `${(x * 100).toFixed(1)}%`;
206
+ /** `--config <path>` records the client as 'explicit', which is a parser detail, not a name. */
207
+ const clientLabel = (client) => (client === 'explicit' || !client ? 'this client' : client);
208
+ export function formatDiff(diff, contextWindow) {
209
+ const lines = [];
210
+ lines.push('');
211
+ lines.push(`diff vs baseline measured ${diff.baselineGeneratedAt} (methodology ${diff.baselineMethodologyVersion})`);
212
+ for (const c of diff.configs) {
213
+ lines.push('');
214
+ if (c.matchedBy === 'unmatched' || c.delta === null || c.beforeTotal === null) {
215
+ lines.push(` ${c.source} ${n(c.afterTotal)} tokens — no baseline for this config, so nothing to compare`);
216
+ continue;
217
+ }
218
+ const rows = c.servers.filter((s) => s.kind !== 'unchanged');
219
+ lines.push(` ${c.source}`);
220
+ lines.push(` ${n(c.beforeTotal)} → ${n(c.afterTotal)} ${signed(c.delta)}`);
221
+ if (rows.length) {
222
+ lines.push('');
223
+ const w = Math.max(...rows.map((r) => r.name.length), 6);
224
+ for (const r of rows) {
225
+ const from = r.before === null ? '—' : n(r.before);
226
+ const to = r.after === null ? '—' : n(r.after);
227
+ const d = r.delta === null ? '' : ` ${signed(r.delta)}`;
228
+ lines.push(` ${r.kind.padEnd(17)} ${r.name.padEnd(w)} ${from.padStart(9)} → ${to.padStart(9)}${d}`);
229
+ }
230
+ }
231
+ const unchanged = c.servers.length - rows.length;
232
+ if (unchanged)
233
+ lines.push(` (${unchanged} server${unchanged === 1 ? '' : 's'} unchanged)`);
234
+ lines.push('');
235
+ if (!c.exact) {
236
+ // The headline sentence is where a skimmer stops, so it must not assert a change
237
+ // this run could not establish. A server that died takes its tokens out of the
238
+ // total exactly like a server you uninstalled — printing "removes 2,378 tokens"
239
+ // and correcting it two lines down is the flattering reading getting read.
240
+ lines.push(` Not a clean comparison: a server changed measured-ness between the two runs.`);
241
+ lines.push(` The measured total moved ${signed(c.delta)}, but that is not what your config did.`);
242
+ lines.push('');
243
+ for (const r of c.servers) {
244
+ if (r.kind === 'unmeasured-now' || r.kind === 'unmeasured-before')
245
+ lines.push(` ${r.name}: ${r.note}`);
246
+ }
247
+ if (c.understatedBy)
248
+ lines.push(` → true cost is at least ${n(c.understatedBy)} higher than the ${n(c.afterTotal)} measured now.`);
249
+ if (c.overstatedBy)
250
+ lines.push(` → up to ${n(c.overstatedBy)} of that movement was already being paid, just unmeasured.`);
251
+ }
252
+ else if (c.delta === 0) {
253
+ lines.push(` No change: this config costs the same ${n(c.afterTotal)} tokens per request as the baseline.`);
254
+ }
255
+ else if (c.delta > 0) {
256
+ lines.push(` This change adds ${n(c.delta)} tokens to every request in ${clientLabel(c.client)} — ` +
257
+ `${pct(c.beforeShare ?? 0)} → ${pct(c.afterShare)} of a ${n(contextWindow)}-token context window.`);
258
+ }
259
+ else {
260
+ lines.push(` This change removes ${n(Math.abs(c.delta))} tokens from every request in ${clientLabel(c.client)} — ` +
261
+ `${pct(c.beforeShare ?? 0)} → ${pct(c.afterShare)} of a ${n(contextWindow)}-token context window.`);
262
+ }
263
+ const blind = c.servers.filter((r) => r.kind === 'unmeasured-both' || ((r.kind === 'added' || r.kind === 'removed') && r.delta === null));
264
+ if (blind.length) {
265
+ lines.push('');
266
+ for (const r of blind)
267
+ lines.push(` ${r.name}: ${r.note}`);
268
+ }
269
+ }
270
+ if (diff.warnings.length) {
271
+ lines.push('');
272
+ lines.push(' diff warnings');
273
+ for (const w of diff.warnings)
274
+ lines.push(` ${w}`);
275
+ }
276
+ if (!diff.comparable) {
277
+ lines.push('');
278
+ lines.push(' The two runs are not the same measurement, so the numbers above are not a change.');
279
+ lines.push(' Re-record the baseline with this version: mcp-context-cost audit --json > baseline.json');
280
+ }
281
+ return lines.join('\n');
282
+ }
283
+ /**
284
+ * `--max-increase N` — the CI gate. Fails on an increase over the limit, and
285
+ * equally on any reason the increase could not be established.
286
+ *
287
+ * That second half is the point. A gate that passes when a server failed to
288
+ * start, or when the baseline covered a config this run never found, is a green
289
+ * check on a question nobody asked. Everything this portfolio has learned says
290
+ * unchecked must not read as clean, so an inexact diff fails and names why.
291
+ */
292
+ export function evaluateIncreaseGate(diff, limit) {
293
+ const reasons = [];
294
+ if (!diff.comparable)
295
+ reasons.push('the baseline is not the same measurement as this run — nothing was compared');
296
+ for (const c of diff.configs) {
297
+ if (c.matchedBy === 'unmatched') {
298
+ reasons.push(`${c.source}: no baseline to check its ${n(c.afterTotal)} tokens against`);
299
+ }
300
+ else if (!c.exact) {
301
+ reasons.push(`${c.source}: a server changed measured-ness, so the change could not be established exactly`);
302
+ }
303
+ }
304
+ for (const d of diff.droppedConfigs) {
305
+ reasons.push(`${d.source}: covered by the baseline and not found in this run`);
306
+ }
307
+ const increase = diff.worstIncrease?.delta ?? (diff.configs.some((c) => typeof c.delta === 'number') ? 0 : null);
308
+ if (reasons.length === 0 && increase !== null && increase > limit) {
309
+ reasons.push(`${diff.worstIncrease.source}: +${n(increase)} tokens per request, over the ${n(limit)} allowed`);
310
+ }
311
+ return { limit, pass: reasons.length === 0, increase, reasons };
312
+ }
313
+ export function formatGate(gate) {
314
+ if (gate.pass) {
315
+ return `increase ok: ${gate.increase === null ? 'no change to measure' : `${signed(gate.increase)} tokens`} ≤ ${n(gate.limit)} allowed`;
316
+ }
317
+ return ['INCREASE FAIL:', ...gate.reasons.map((r) => ` ${r}`)].join('\n');
318
+ }