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 +21 -19
- package/dist/audit/audit.d.ts +15 -0
- package/dist/audit/audit.js +26 -3
- package/dist/audit/config.d.ts +33 -0
- package/dist/audit/config.js +55 -6
- package/dist/audit/deferral.d.ts +22 -2
- package/dist/audit/deferral.js +34 -7
- package/dist/cli.js +28 -0
- package/package.json +1 -1
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,
|
|
66
|
-
|
|
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
|
|
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
|
|
184
|
-
|
|
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
|
|
202
|
-
|
|
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/` |
|
|
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 #
|
|
311
|
+
npm test # TS suite incl. golden fixtures + dispute drills
|
|
312
312
|
npx tsc --noEmit # typecheck
|
|
313
|
-
./upstream/tests/badge-test.sh #
|
|
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 —
|
|
326
|
-
sixth of the list
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
package/dist/audit/audit.d.ts
CHANGED
|
@@ -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;
|
package/dist/audit/audit.js
CHANGED
|
@@ -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
|
-
|
|
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.');
|
package/dist/audit/config.d.ts
CHANGED
|
@@ -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[];
|
package/dist/audit/config.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 {
|
|
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: {} };
|
package/dist/audit/deferral.d.ts
CHANGED
|
@@ -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.
|
package/dist/audit/deferral.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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