mcp-context-cost 0.5.0 → 0.7.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,21 @@ export interface AuditReport {
100
100
  generatedAt: string;
101
101
  contextWindow: number;
102
102
  configs: AuditConfigResult[];
103
+ /**
104
+ * Client configs that were read and parsed and have nothing to total. They
105
+ * get no report line, but they are the record that a client is installed
106
+ * here, which is not the same machine as one with no client at all.
107
+ *
108
+ * `allDisabled`, when present, is why there is nothing to total: the file
109
+ * declares those servers and every one of them is switched off. A config
110
+ * without it declares no servers at all. The two are different facts about a
111
+ * file this opened, so they are not published as one.
112
+ */
113
+ emptyConfigs: {
114
+ client: string;
115
+ source: string;
116
+ allDisabled?: string[];
117
+ }[];
103
118
  budget?: {
104
119
  limit: number;
105
120
  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,15 @@ export function buildReport(configs, measured, opts = {}) {
176
177
  problems.push(`${cfg.source}: ${cfg.error}`);
177
178
  continue;
178
179
  }
180
+ // Parsed, and nothing to total. 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
+ // including, where that is the case, that what it has is servers turned off.
183
+ if (cfg.declaresNothing || cfg.servers.length === 0) {
184
+ emptyConfigs.push(cfg.allDisabled?.length
185
+ ? { client: cfg.client, source: cfg.source, allDisabled: cfg.allDisabled }
186
+ : { client: cfg.client, source: cfg.source });
187
+ continue;
188
+ }
179
189
  const ok = [];
180
190
  const skipped = [];
181
191
  const tools = [];
@@ -266,6 +276,7 @@ export function buildReport(configs, measured, opts = {}) {
266
276
  generatedAt: opts.generatedAt ?? new Date().toISOString(),
267
277
  contextWindow,
268
278
  configs: results,
279
+ emptyConfigs,
269
280
  problems,
270
281
  };
271
282
  if (opts.divergence) {
@@ -338,11 +349,16 @@ function postureSourceLines(d) {
338
349
  absent++;
339
350
  continue;
340
351
  }
352
+ // A place that sets one of them to something unreadable is neither a place
353
+ // that sets it nor a place that sets none of them, and printing it as the
354
+ // second is the silence this report would then argue from.
355
+ const unreadableVars = r.unreadable?.length
356
+ ? `sets ${r.unreadable.join(', ')} to a value this cannot read`
357
+ : '';
341
358
  const held = r.state === 'unreadable'
342
359
  ? 'could not be read — what it sets is unknown'
343
- : r.sets.length
344
- ? `sets ${r.sets.join(', ')}`
345
- : 'sets none of them';
360
+ : [r.sets.length ? `sets ${r.sets.join(', ')}` : '', unreadableVars].filter(Boolean).join(', and ') ||
361
+ 'sets none of them';
346
362
  // Which place the verdict came out of, said once rather than left to a
347
363
  // reader to work out from two lists.
348
364
  const decided = d.setting?.source === r.source ? ', which decided this' : '';
@@ -445,6 +461,13 @@ function deferralLines(d, skippedNames) {
445
461
  lines.push(' machine reads it from, and which one Claude Code takes is not on record');
446
462
  lines.push(' here. Whether these tokens are deferred cannot be said from them.');
447
463
  }
464
+ else if (d.setting?.unresolved === 'value-unreadable') {
465
+ lines.push(` ${d.setting.variable} is set by a settings file Claude Code reads, to`);
466
+ lines.push(' something that is not a value this can read — an env block holds it as a');
467
+ lines.push(' JSON boolean, a number or null rather than a string. It is set there, and');
468
+ lines.push(' what it is set to is unknown, so whether these tokens are deferred cannot');
469
+ lines.push(' be said from it.');
470
+ }
448
471
  else {
449
472
  lines.push(' A settings file Claude Code reads exists here and could not be read, so');
450
473
  lines.push(' what it sets is unknown — and it can set the variable that decides this.');
@@ -32,6 +32,23 @@ export declare function extractServers(doc: unknown, meta: {
32
32
  source: string;
33
33
  cwd?: string;
34
34
  }): ConfiguredServer[];
35
+ /**
36
+ * What one config document declares, servers and switched-off entries both.
37
+ *
38
+ * `extractServers` above answers "what is there to measure", which is the
39
+ * question almost every caller has. This answers the wider one, because a file
40
+ * that declares three servers and switches all three off has nothing to measure
41
+ * and is still not a file that declares nothing — and the only place that
42
+ * difference is still visible is here, before the off ones are dropped.
43
+ */
44
+ export declare function extractDeclaration(doc: unknown, meta: {
45
+ client: string;
46
+ source: string;
47
+ cwd?: string;
48
+ }): {
49
+ servers: ConfiguredServer[];
50
+ disabled: string[];
51
+ };
35
52
  export interface ConfigCandidate {
36
53
  client: string;
37
54
  path: string;
@@ -49,6 +66,22 @@ export interface LoadedConfig {
49
66
  servers: ConfiguredServer[];
50
67
  /** Set when the file exists but could not be read/parsed. */
51
68
  error?: string;
69
+ /**
70
+ * Set when the file was read and parsed cleanly and declares no servers at
71
+ * all. Such a config has nothing to total, but "this client declares nothing"
72
+ * and "no client is installed here" are different facts about a machine and
73
+ * only one of them is about the client, so the second is not reported as the
74
+ * first.
75
+ */
76
+ declaresNothing?: true;
77
+ /**
78
+ * Set instead of `declaresNothing` when the file was read and parsed cleanly,
79
+ * declares servers, and every one of them is switched off — the names, in the
80
+ * file's own words. There is nothing to total either way, but a person who
81
+ * turned their servers off is not a person who declared none, and telling
82
+ * them the file declares nothing is a false statement about a file this read.
83
+ */
84
+ allDisabled?: string[];
52
85
  }
53
86
  /** Read + parse the candidates that exist. Unreadable files are reported, not thrown. */
54
87
  export declare function loadConfigs(candidates: ConfigCandidate[], cwd: string): LoadedConfig[];
@@ -113,16 +113,34 @@ function toServer(name, raw, client, source) {
113
113
  * servers by absolute directory.
114
114
  */
115
115
  export function extractServers(doc, meta) {
116
+ return extractDeclaration(doc, meta).servers;
117
+ }
118
+ /**
119
+ * What one config document declares, servers and switched-off entries both.
120
+ *
121
+ * `extractServers` above answers "what is there to measure", which is the
122
+ * question almost every caller has. This answers the wider one, because a file
123
+ * that declares three servers and switches all three off has nothing to measure
124
+ * and is still not a file that declares nothing — and the only place that
125
+ * difference is still visible is here, before the off ones are dropped.
126
+ */
127
+ export function extractDeclaration(doc, meta) {
116
128
  if (!doc || typeof doc !== 'object')
117
- return [];
129
+ return { servers: [], disabled: [] };
118
130
  const d = doc;
119
131
  const out = [];
132
+ const off = [];
120
133
  const addBlock = (block) => {
121
134
  if (!block || typeof block !== 'object')
122
135
  return;
123
136
  for (const [name, raw] of Object.entries(block)) {
124
137
  if (!raw || typeof raw !== 'object')
125
138
  continue;
139
+ // Recorded before `toServer` drops it, which is the only difference this
140
+ // can still see: an entry it returns null for because the person turned
141
+ // it off, rather than because it is malformed or absent.
142
+ if (raw.disabled === true)
143
+ off.push(name);
126
144
  const s = toServer(name, raw, meta.client, meta.source);
127
145
  if (s)
128
146
  out.push(s);
@@ -137,7 +155,11 @@ export function extractServers(doc, meta) {
137
155
  }
138
156
  // A name can legitimately appear in both blocks of the same file; keep the first.
139
157
  const seen = new Set();
140
- return out.filter((s) => (seen.has(s.name) ? false : (seen.add(s.name), true)));
158
+ const servers = out.filter((s) => (seen.has(s.name) ? false : (seen.add(s.name), true)));
159
+ // A name that is off in one block and live in another is a live server, not a
160
+ // switched-off one, so it is not reported as both.
161
+ const disabled = [...new Set(off.filter((n) => !seen.has(n)))].sort();
162
+ return { servers, disabled };
141
163
  }
142
164
  /** Every place a client config is known to live, whether or not it exists. */
143
165
  export function configCandidates(env) {
@@ -165,11 +187,22 @@ export function loadConfigs(candidates, cwd) {
165
187
  continue;
166
188
  try {
167
189
  const doc = parseJsonc(readFileSync(c.path, 'utf8'));
168
- const servers = extractServers(doc, { client: c.client, source: c.path, cwd });
190
+ const { servers, disabled } = extractDeclaration(doc, { client: c.client, source: c.path, cwd });
169
191
  // 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)
192
+ // session history) is not worth a line in the report — it has no total.
193
+ // It is still worth carrying: it is the evidence that a client is on this
194
+ // machine, which is what tells an empty client apart from no client.
195
+ if (servers.length === 0) {
196
+ // ...and one whose every declared server is switched off is carried as
197
+ // that, not as one declaring nothing: the second is a claim about the
198
+ // file that the file itself contradicts.
199
+ if (disabled.length) {
200
+ out.push({ client: c.client, source: c.path, servers: [], allDisabled: disabled });
201
+ continue;
202
+ }
203
+ out.push({ client: c.client, source: c.path, servers: [], declaresNothing: true });
172
204
  continue;
205
+ }
173
206
  out.push({ client: c.client, source: c.path, servers });
174
207
  }
175
208
  catch (e) {
@@ -227,14 +260,30 @@ export function loadSettingsSources(candidates) {
227
260
  throw new Error('not a settings object');
228
261
  const block = doc.env;
229
262
  const vars = {};
263
+ const unreadable = [];
230
264
  if (block && typeof block === 'object' && !Array.isArray(block)) {
231
265
  for (const name of TOOL_SEARCH_VARS) {
266
+ if (!(name in block))
267
+ continue;
232
268
  const v = block[name];
233
269
  if (typeof v === 'string')
234
270
  vars[name] = v;
271
+ // The variable is set in this file, to something that is not a value
272
+ // this can read — `"ENABLE_TOOL_SEARCH": false`, the JSON boolean, is
273
+ // the one people write. Dropping it here made the file look like a
274
+ // file that sets nothing, and the report then stated the documented
275
+ // default at a machine whose settings say otherwise.
276
+ else
277
+ unreadable.push(name);
235
278
  }
236
279
  }
237
- return { scope: c.scope, source: c.path, state: 'read', vars };
280
+ return {
281
+ scope: c.scope,
282
+ source: c.path,
283
+ state: 'read',
284
+ vars,
285
+ ...(unreadable.length ? { unreadable } : {}),
286
+ };
238
287
  }
239
288
  catch {
240
289
  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,36 @@ 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 has
180
+ // nothing to total, is told that — being told no client was found anywhere
181
+ // would send a reader looking for an install they already have. Which of
182
+ // the two reasons it is gets said, because "declares nothing" is a false
183
+ // statement about a file that declares servers and switches them off.
184
+ else if (empty.some((c) => c.allDisabled?.length))
185
+ console.error(`${empty.length === 1 ? 'an MCP client config was found' : `${empty.length} MCP client configs were found`}, ` +
186
+ `and ${empty.length === 1 ? 'it has no server' : 'none of them has a server'} to measure:\n` +
187
+ empty
188
+ .map((c) => {
189
+ const off = c.allDisabled ?? [];
190
+ if (!off.length)
191
+ return ` ${c.client}: ${c.source} — declares no servers at all`;
192
+ return (` ${c.client}: ${c.source} — declares ${off.length} server${off.length === 1 ? '' : 's'}, ` +
193
+ `and ${off.length === 1 ? 'it is' : 'every one of them is'} switched off: ${off.join(', ')}`);
194
+ })
195
+ .join('\n') +
196
+ `${where}\n` +
197
+ `${empty.length === 1 ? 'It was' : 'They were'} read and parsed; a switched-off server is not started, so it costs nothing to measure.\n` +
198
+ `Switch one back on, declare one, or point at a different config: mcp-context-cost audit --config <path/to/mcp.json>`);
199
+ else if (empty.length)
200
+ console.error(`${empty.length === 1 ? 'an MCP client config was found' : `${empty.length} MCP client configs were found`}, ` +
201
+ `and ${empty.length === 1 ? 'it declares' : 'they declare'} no servers:\n` +
202
+ empty.map((c) => ` ${c.client}: ${c.source}`).join('\n') +
203
+ `${where}\n` +
204
+ `${empty.length === 1 ? 'It was' : 'They were'} read and parsed; there is simply nothing declared to measure.\n` +
205
+ `Declare a server in one of them, or point at a different config: mcp-context-cost audit --config <path/to/mcp.json>`);
178
206
  else
179
207
  console.error(`no MCP config found. Looked in the standard Claude Desktop / Claude Code / Cursor / VS Code / Windsurf locations.${where}\n` +
180
208
  `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.7.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",