mcp-context-cost 0.5.0 → 0.6.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
@@ -62,8 +62,8 @@ in those words.
62
62
  **Claude Code defers MCP tool definitions by default** (its **tool search**): they are not
63
63
  in context at session start, and load when the model reaches for one. Three variables move
64
64
  that, and `audit` reads all three — from the shell it runs in *and* from the `env` block of
65
- Claude Code's own settings files (managed, `.claude/settings.local.json`,
66
- `.claude/settings.json`, `~/.claude/settings.json`), because a machine that switched
65
+ Claude Code's own settings files (managed, `<cwd>/.claude/settings.local.json`,
66
+ `<cwd>/.claude/settings.json`, `~/.claude/settings.json`), because a machine that switched
67
67
  deferral off in a settings file is not a machine running the default:
68
68
 
69
69
  | setting | what the audit reports |
@@ -146,7 +146,7 @@ INCREASE FAIL:
146
146
  ```
147
147
 
148
148
  > **Version note.** `--baseline` and `--max-increase` shipped in **0.4.0** (published
149
- > 2026-08-18, and the current `latest`), so the command above gates on
149
+ > 2026-08-18), so the command above gates on
150
150
  > `npx -y mcp-context-cost@latest`. Pinning to **0.3.0 or earlier** does not gate, and fails
151
151
  > quietly: those builds ignore flags they do not recognise, so the same command produces a
152
152
  > plain audit and **exit 0** — a passing CI check on a gate that never ran. 0.4.0 rejects
@@ -180,8 +180,10 @@ INCREASE FAIL:
180
180
  Add `--claude` to annotate each server with its Anthropic-request cost from the published
181
181
  [Claude divergence](docs/METHODOLOGY.md#claude-divergence) run — an exact number when the
182
182
  published capture hash matches what you have installed, `—` (silence, not a stale guess)
183
- when it doesn't (today the run covers the top 20 measured servers, so most installs will
184
- show a mix):
183
+ when it doesn't. The run holds 20 rows — the top 20 measured servers by tokens; 19 of them
184
+ still match the capture on disk here, which is why
185
+ [results/leaderboard.md](results/leaderboard.md) prints a claude number for 19 and leaves the
186
+ twentieth blank rather than stale. Most installs will show a mix:
185
187
 
186
188
  ```
187
189
  server tools tokens share claude
@@ -198,13 +200,14 @@ Flags: `--json` (full report on stdout, progress on stderr), `--budget N`,
198
200
  The number `audit` gives you is the same measurement, run across a curated set of public
199
201
  servers — which is how you can tell it is a measurement and not this tool's opinion. It also
200
202
  shows what you are choosing between: across the 69 servers measured, cost spans **1,700×**,
201
- from the 32-token `postgres` reference server to github's 54,422. The table below starts at
202
- markitdown's 64 tokens, an 850× spread; the full range is in
203
+ from the 32-token `postgres` reference server to github's 54,422. The table below is a
204
+ sample of that range; the full range is in
203
205
  [results/leaderboard.md](results/leaderboard.md).
204
206
 
205
207
  | server | context cost | tools |
206
208
  |---|---:|---:|
207
209
  | github (official) | **54,422 tokens** | 44 |
210
+ | xcodebuildmcp | 26,594 | 24 |
208
211
  | brave-search | 25,456 | 8 |
209
212
  | notion | 17,500 | 24 |
210
213
  | playwright *(4.8M installs/week)* | 4,024 | 24 |
@@ -274,7 +277,7 @@ number is *not*, config policy, failure taxonomy, frozen color bands, known dive
274
277
  | `src/cli.ts` | `audit` (measure your own stack), `verify` (re-derive any published number), `measure` |
275
278
  | `spec/fixtures/` | golden vectors shared by the TypeScript and bash implementations |
276
279
  | `tools/` | the one script that calls a network API (Claude divergence); kept out of the package so the library stays offline |
277
- | `upstream/` | staged contribution to [sd2k/mcp-tokens-action](https://github.com/sd2k/mcp-tokens-action): `badge.sh` + action patch + tests |
280
+ | `upstream/` | `badge.sh` + composite-action patch + bash tests — the self-serve badge recipe, carried here |
278
281
  | `servers.yaml` | 82 curated candidates with live install metrics and provenance |
279
282
  | `results/` · `badges/` | measurements, leaderboard, history series, shields endpoint JSONs |
280
283
  | `docs/` | methodology, dashboard, and a generated page per measured server |
@@ -302,15 +305,12 @@ than a guess — [docs/adoption.md](docs/adoption.md), regenerated by `npm run a
302
305
  which publishes the queries it ran and every file it examined. A zero there means the
303
306
  search ran and found none; if it could not run, it says that instead of publishing a zero.
304
307
 
305
- Or self-serve from CI via the (staged) mcp-tokens-action badge inputs — see
306
- [upstream/action-patch.md](upstream/action-patch.md).
307
-
308
308
  ## Development
309
309
 
310
310
  ```bash
311
- npm test # 403 TS tests incl. golden fixtures + dispute drills
311
+ npm test # TS suite incl. golden fixtures + dispute drills
312
312
  npx tsc --noEmit # typecheck
313
- ./upstream/tests/badge-test.sh # 21 bash tests — byte-identical to the TS reference
313
+ ./upstream/tests/badge-test.sh # bash suite — byte-identical to the TS reference
314
314
  npm run sweep:all -- --docker # full curated sweep (Docker isolation)
315
315
  ```
316
316
 
@@ -322,11 +322,13 @@ color bands are frozen against the observed distribution of the first full sweep
322
322
  ## Status
323
323
 
324
324
  Active. 46 of the 69 numbers come from the sweep of 2026-08-19 and 23 from 2026-08-18. Two
325
- weekly jobs re-measure the set — one reference server (`memory`) every Monday, and a rotating
326
- sixth of the list every Wednesday, so the full set turns over in six weeks but treat the
327
- leaderboard as a dated snapshot rather than a live feed. Badge PRs are open across the
328
- ecosystem and [sd2k/mcp-tokens-action#5](https://github.com/sd2k/mcp-tokens-action/pull/5)
329
- proposes the self-serve badge path upstream. See [ROADMAP.md](ROADMAP.md) for what's next —
330
- contributions welcome, especially new `servers.yaml` entries.
325
+ weekly jobs re-measure the set — the `memory` reference server on Mondays, and a rotating
326
+ sixth of the list on Wednesdays, so every row comes round within six weeks. Read each row's
327
+ date as the date it means, and don't take the cadence on trust the build history is
328
+ public, one click each:
329
+ [re-sweep runs](https://github.com/athakur3/mcp-context-cost/actions/workflows/resweep.yml)
330
+ and [self-badge runs](https://github.com/athakur3/mcp-context-cost/actions/workflows/self-badge.yml).
331
+ See [ROADMAP.md](ROADMAP.md) for what's next — contributions welcome, especially new
332
+ `servers.yaml` entries.
331
333
 
332
334
  MIT © 2026
@@ -100,6 +100,16 @@ export interface AuditReport {
100
100
  generatedAt: string;
101
101
  contextWindow: number;
102
102
  configs: AuditConfigResult[];
103
+ /**
104
+ * Client configs that were read and parsed but declare no servers. They get
105
+ * no report line — there is nothing to total — but they are the record that a
106
+ * client is installed here, which is not the same machine as one with no
107
+ * client at all.
108
+ */
109
+ emptyConfigs: {
110
+ client: string;
111
+ source: string;
112
+ }[];
103
113
  budget?: {
104
114
  limit: number;
105
115
  worstTotal: number;
@@ -166,6 +166,7 @@ function attachDeferral(configs, contextWindow, opts) {
166
166
  export function buildReport(configs, measured, opts = {}) {
167
167
  const contextWindow = opts.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
168
168
  const problems = [];
169
+ const emptyConfigs = [];
169
170
  const built = [];
170
171
  // Across every config at once: a twin in one client's file is measured for
171
172
  // the other client's entry just the same.
@@ -176,6 +177,12 @@ export function buildReport(configs, measured, opts = {}) {
176
177
  problems.push(`${cfg.source}: ${cfg.error}`);
177
178
  continue;
178
179
  }
180
+ // Parsed, and declares nothing. Not a problem and not a report line: it is
181
+ // recorded as itself, so a reader is told what this machine actually has.
182
+ if (cfg.declaresNothing || cfg.servers.length === 0) {
183
+ emptyConfigs.push({ client: cfg.client, source: cfg.source });
184
+ continue;
185
+ }
179
186
  const ok = [];
180
187
  const skipped = [];
181
188
  const tools = [];
@@ -266,6 +273,7 @@ export function buildReport(configs, measured, opts = {}) {
266
273
  generatedAt: opts.generatedAt ?? new Date().toISOString(),
267
274
  contextWindow,
268
275
  configs: results,
276
+ emptyConfigs,
269
277
  problems,
270
278
  };
271
279
  if (opts.divergence) {
@@ -338,11 +346,16 @@ function postureSourceLines(d) {
338
346
  absent++;
339
347
  continue;
340
348
  }
349
+ // A place that sets one of them to something unreadable is neither a place
350
+ // that sets it nor a place that sets none of them, and printing it as the
351
+ // second is the silence this report would then argue from.
352
+ const unreadableVars = r.unreadable?.length
353
+ ? `sets ${r.unreadable.join(', ')} to a value this cannot read`
354
+ : '';
341
355
  const held = r.state === 'unreadable'
342
356
  ? 'could not be read — what it sets is unknown'
343
- : r.sets.length
344
- ? `sets ${r.sets.join(', ')}`
345
- : 'sets none of them';
357
+ : [r.sets.length ? `sets ${r.sets.join(', ')}` : '', unreadableVars].filter(Boolean).join(', and ') ||
358
+ 'sets none of them';
346
359
  // Which place the verdict came out of, said once rather than left to a
347
360
  // reader to work out from two lists.
348
361
  const decided = d.setting?.source === r.source ? ', which decided this' : '';
@@ -445,6 +458,13 @@ function deferralLines(d, skippedNames) {
445
458
  lines.push(' machine reads it from, and which one Claude Code takes is not on record');
446
459
  lines.push(' here. Whether these tokens are deferred cannot be said from them.');
447
460
  }
461
+ else if (d.setting?.unresolved === 'value-unreadable') {
462
+ lines.push(` ${d.setting.variable} is set by a settings file Claude Code reads, to`);
463
+ lines.push(' something that is not a value this can read — an env block holds it as a');
464
+ lines.push(' JSON boolean, a number or null rather than a string. It is set there, and');
465
+ lines.push(' what it is set to is unknown, so whether these tokens are deferred cannot');
466
+ lines.push(' be said from it.');
467
+ }
448
468
  else {
449
469
  lines.push(' A settings file Claude Code reads exists here and could not be read, so');
450
470
  lines.push(' what it sets is unknown — and it can set the variable that decides this.');
@@ -49,6 +49,14 @@ export interface LoadedConfig {
49
49
  servers: ConfiguredServer[];
50
50
  /** Set when the file exists but could not be read/parsed. */
51
51
  error?: string;
52
+ /**
53
+ * Set when the file was read and parsed cleanly and declares no servers at
54
+ * all. Such a config has nothing to total, but "this client declares nothing"
55
+ * and "no client is installed here" are different facts about a machine and
56
+ * only one of them is about the client, so the second is not reported as the
57
+ * first.
58
+ */
59
+ declaresNothing?: true;
52
60
  }
53
61
  /** Read + parse the candidates that exist. Unreadable files are reported, not thrown. */
54
62
  export declare function loadConfigs(candidates: ConfigCandidate[], cwd: string): LoadedConfig[];
@@ -167,9 +167,13 @@ export function loadConfigs(candidates, cwd) {
167
167
  const doc = parseJsonc(readFileSync(c.path, 'utf8'));
168
168
  const servers = extractServers(doc, { client: c.client, source: c.path, cwd });
169
169
  // A config with no MCP block at all (e.g. a ~/.claude.json holding only
170
- // session history) is not worth a line in the report.
171
- if (servers.length === 0)
170
+ // session history) is not worth a line in the report — it has no total.
171
+ // It is still worth carrying: it is the evidence that a client is on this
172
+ // machine, which is what tells an empty client apart from no client.
173
+ if (servers.length === 0) {
174
+ out.push({ client: c.client, source: c.path, servers: [], declaresNothing: true });
172
175
  continue;
176
+ }
173
177
  out.push({ client: c.client, source: c.path, servers });
174
178
  }
175
179
  catch (e) {
@@ -227,14 +231,30 @@ export function loadSettingsSources(candidates) {
227
231
  throw new Error('not a settings object');
228
232
  const block = doc.env;
229
233
  const vars = {};
234
+ const unreadable = [];
230
235
  if (block && typeof block === 'object' && !Array.isArray(block)) {
231
236
  for (const name of TOOL_SEARCH_VARS) {
237
+ if (!(name in block))
238
+ continue;
232
239
  const v = block[name];
233
240
  if (typeof v === 'string')
234
241
  vars[name] = v;
242
+ // The variable is set in this file, to something that is not a value
243
+ // this can read — `"ENABLE_TOOL_SEARCH": false`, the JSON boolean, is
244
+ // the one people write. Dropping it here made the file look like a
245
+ // file that sets nothing, and the report then stated the documented
246
+ // default at a machine whose settings say otherwise.
247
+ else
248
+ unreadable.push(name);
235
249
  }
236
250
  }
237
- return { scope: c.scope, source: c.path, state: 'read', vars };
251
+ return {
252
+ scope: c.scope,
253
+ source: c.path,
254
+ state: 'read',
255
+ vars,
256
+ ...(unreadable.length ? { unreadable } : {}),
257
+ };
238
258
  }
239
259
  catch {
240
260
  return { scope: c.scope, source: c.path, state: 'unreadable', vars: {} };
@@ -82,6 +82,17 @@ export interface ToolSearchSource {
82
82
  state: 'read' | 'absent' | 'unreadable';
83
83
  /** What this place sets, of the three. Values are read here, never reported. */
84
84
  vars: ToolSearchEnv;
85
+ /**
86
+ * Variables this place sets to something that is not a value this audit can
87
+ * read — an env block holding a JSON boolean, a number, or null.
88
+ *
89
+ * The file parsed and the variable IS set in it; what it is set to is
90
+ * unknown. That is not the same as unset, and reading it as unset is how a
91
+ * machine whose `~/.claude/settings.json` held `"ENABLE_TOOL_SEARCH": false`
92
+ * — the boolean, not the string — was told the documented default stands and
93
+ * these tokens are never loaded up front.
94
+ */
95
+ unreadable?: ToolSearchVar[];
85
96
  }
86
97
  /**
87
98
  * A source as it appears in a report: names of what it sets, never values.
@@ -96,6 +107,12 @@ export interface ToolSearchSourceRecord {
96
107
  state: ToolSearchSource['state'];
97
108
  /** Variable NAMES set here. A value can be a base URL carrying a credential. */
98
109
  sets: ToolSearchVar[];
110
+ /**
111
+ * Variable NAMES this place sets to something unreadable, when it does.
112
+ * Omitted otherwise, so the common source record keeps its shape — a reader
113
+ * meets this field only where there is an unknown to meet.
114
+ */
115
+ unreadable?: ToolSearchVar[];
99
116
  }
100
117
  /** A source as it is published: what it sets, by name. */
101
118
  export declare function toolSearchSourceRecord(s: ToolSearchSource): ToolSearchSourceRecord;
@@ -148,7 +165,7 @@ export interface ToolSearchSetting {
148
165
  */
149
166
  sources: ToolSearchSourceRecord[];
150
167
  /** Set only in `setting-unresolved`: why no mode could be read off them. */
151
- unresolved?: 'sources-disagree' | 'source-unreadable';
168
+ unresolved?: 'sources-disagree' | 'source-unreadable' | 'value-unreadable';
152
169
  }
153
170
  interface ResolvedToolSearch extends Omit<ToolSearchSetting, 'sources'> {
154
171
  mode: Extract<DeferralMode, 'defers-all' | 'threshold' | 'loads-upfront' | 'setting-unrecognized' | 'setting-unresolved'>;
@@ -185,7 +202,10 @@ export declare function resolveToolSearch(env: ToolSearchEnv): ResolvedToolSearc
185
202
  * a disagreement is refused rather than resolved: `setting-unresolved` names the
186
203
  * variable and every place, and no verdict is given. A place that exists and
187
204
  * could not be read is the same refusal for the same reason — what it sets is
188
- * unknown, and an unknown that could flip the answer is not a default.
205
+ * unknown, and an unknown that could flip the answer is not a default. So is a
206
+ * place that parsed and sets the deciding variable to something that is not a
207
+ * readable value: the variable is set there, and dropping it leaves the report
208
+ * arguing from a silence that is not silent.
189
209
  *
190
210
  * Not visible from here at all, and so not claimed: a variable set on Claude
191
211
  * Code's own command line.
@@ -18,11 +18,13 @@ export function toolSearchEnv(env) {
18
18
  export const SHELL_SOURCE = '(shell environment)';
19
19
  /** A source as it is published: what it sets, by name. */
20
20
  export function toolSearchSourceRecord(s) {
21
+ const unreadable = TOOL_SEARCH_VARS.filter((n) => (s.unreadable ?? []).includes(n));
21
22
  return {
22
23
  scope: s.scope,
23
24
  source: s.source,
24
25
  state: s.state,
25
26
  sets: TOOL_SEARCH_VARS.filter((n) => (s.vars[n] ?? '').trim() !== ''),
27
+ ...(unreadable.length ? { unreadable } : {}),
26
28
  };
27
29
  }
28
30
  /** The one host Claude Code treats as first-party for the tool-search fallback. */
@@ -136,7 +138,10 @@ export function resolveToolSearch(env) {
136
138
  * a disagreement is refused rather than resolved: `setting-unresolved` names the
137
139
  * variable and every place, and no verdict is given. A place that exists and
138
140
  * could not be read is the same refusal for the same reason — what it sets is
139
- * unknown, and an unknown that could flip the answer is not a default.
141
+ * unknown, and an unknown that could flip the answer is not a default. So is a
142
+ * place that parsed and sets the deciding variable to something that is not a
143
+ * readable value: the variable is set there, and dropping it leaves the report
144
+ * arguing from a silence that is not silent.
140
145
  *
141
146
  * Not visible from here at all, and so not claimed: a variable set on Claude
142
147
  * Code's own command line.
@@ -155,16 +160,32 @@ export function resolveToolSearchSources(sources) {
155
160
  return unresolved('source-unreadable', null);
156
161
  const settings = sources.filter((s) => s.scope !== 'shell');
157
162
  const shell = sources.find((s) => s.scope === 'shell');
158
- /** The value that would win for one variable, or the fact that two places disagree. */
163
+ /** Whether a place sets this variable at all readably or not. */
164
+ const holds = (s, name) => (s.vars[name] ?? '').trim() !== '' || (s.unreadable ?? []).includes(name);
165
+ /**
166
+ * The value that would win for one variable, or the fact that it cannot be
167
+ * had: two places disagree, or the place that would win sets it to something
168
+ * unreadable.
169
+ *
170
+ * Precedence is what makes the unreadable case worth separating from a blanket
171
+ * refusal. A higher-precedence file setting a value Claude Code documents is
172
+ * the value in force, and an unreadable one underneath it decides nothing —
173
+ * the same reasoning that keeps a disagreement over `ANTHROPIC_BASE_URL` from
174
+ * refusing an answer an explicit `ENABLE_TOOL_SEARCH` already gave.
175
+ */
159
176
  const read = (name) => {
160
- const winner = settings
161
- .map((s) => ({ value: (s.vars[name] ?? '').trim(), source: s.source }))
162
- .find((v) => v.value !== '');
177
+ const winner = settings.find((s) => holds(s, name));
163
178
  const shellValue = (shell?.vars[name] ?? '').trim();
164
- if (winner && shellValue && winner.value !== shellValue)
179
+ // Whichever place would decide holds an unknown: it could be any of the
180
+ // documented values or none of them, and the ones it could be do not agree.
181
+ if (winner && (winner.vars[name] ?? '').trim() === '')
182
+ return 'unreadable';
183
+ if (shell && holds(shell, name) && shellValue === '')
184
+ return 'unreadable';
185
+ if (winner && shellValue && (winner.vars[name] ?? '').trim() !== shellValue)
165
186
  return 'conflict';
166
187
  if (winner)
167
- return winner;
188
+ return { value: (winner.vars[name] ?? '').trim(), source: winner.source };
168
189
  if (shellValue && shell)
169
190
  return { value: shellValue, source: shell.source };
170
191
  return null;
@@ -176,6 +197,8 @@ export function resolveToolSearchSources(sources) {
176
197
  const betas = read('CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS');
177
198
  if (betas === 'conflict')
178
199
  return unresolved('sources-disagree', 'CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS');
200
+ if (betas === 'unreadable')
201
+ return unresolved('value-unreadable', 'CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS');
179
202
  if (betas) {
180
203
  return {
181
204
  ...resolveToolSearch({ CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS: betas.value }),
@@ -185,12 +208,16 @@ export function resolveToolSearchSources(sources) {
185
208
  const enable = read('ENABLE_TOOL_SEARCH');
186
209
  if (enable === 'conflict')
187
210
  return unresolved('sources-disagree', 'ENABLE_TOOL_SEARCH');
211
+ if (enable === 'unreadable')
212
+ return unresolved('value-unreadable', 'ENABLE_TOOL_SEARCH');
188
213
  if (enable) {
189
214
  return { ...resolveToolSearch({ ENABLE_TOOL_SEARCH: enable.value }), source: enable.source };
190
215
  }
191
216
  const base = read('ANTHROPIC_BASE_URL');
192
217
  if (base === 'conflict')
193
218
  return unresolved('sources-disagree', 'ANTHROPIC_BASE_URL');
219
+ if (base === 'unreadable')
220
+ return unresolved('value-unreadable', 'ANTHROPIC_BASE_URL');
194
221
  const resolved = resolveToolSearch(base ? { ANTHROPIC_BASE_URL: base.value } : {});
195
222
  return { ...resolved, source: resolved.readFromMachine ? (base?.source ?? null) : null };
196
223
  }
package/dist/cli.js CHANGED
@@ -173,8 +173,19 @@ if (cmd === 'audit') {
173
173
  });
174
174
  if (report.configs.length === 0) {
175
175
  const where = report.problems.length ? `\n${report.problems.map((p) => ` ${p}`).join('\n')}` : '';
176
+ const empty = report.emptyConfigs ?? [];
176
177
  if (json)
177
178
  console.log(JSON.stringify(report));
179
+ // A machine whose client config was found, opened and parsed, and simply
180
+ // declares nothing, is told that — being told no client was found anywhere
181
+ // would send a reader looking for an install they already have.
182
+ else if (empty.length)
183
+ console.error(`${empty.length === 1 ? 'an MCP client config was found' : `${empty.length} MCP client configs were found`}, ` +
184
+ `and ${empty.length === 1 ? 'it declares' : 'they declare'} no servers:\n` +
185
+ empty.map((c) => ` ${c.client}: ${c.source}`).join('\n') +
186
+ `${where}\n` +
187
+ `${empty.length === 1 ? 'It was' : 'They were'} read and parsed; there is simply nothing declared to measure.\n` +
188
+ `Declare a server in one of them, or point at a different config: mcp-context-cost audit --config <path/to/mcp.json>`);
178
189
  else
179
190
  console.error(`no MCP config found. Looked in the standard Claude Desktop / Claude Code / Cursor / VS Code / Windsurf locations.${where}\n` +
180
191
  `Point at one explicitly: mcp-context-cost audit --config <path/to/mcp.json>`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-context-cost",
3
- "version": "0.5.0",
3
+ "version": "0.6.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",