mcp-context-cost 0.4.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.
Files changed (41) hide show
  1. package/README.md +101 -33
  2. package/dist/audit/audit.d.ts +48 -0
  3. package/dist/audit/audit.js +392 -7
  4. package/dist/audit/config.d.ts +46 -0
  5. package/dist/audit/config.js +86 -2
  6. package/dist/audit/deferral.d.ts +366 -0
  7. package/dist/audit/deferral.js +403 -0
  8. package/dist/audit/run.d.ts +22 -0
  9. package/dist/audit/run.js +17 -1
  10. package/dist/cli.js +11 -0
  11. package/dist/core/adoption.d.ts +226 -0
  12. package/dist/core/adoption.js +432 -0
  13. package/dist/core/canonical.d.ts +6 -0
  14. package/dist/core/canonical.js +3 -0
  15. package/dist/core/index.d.ts +1 -0
  16. package/dist/core/index.js +1 -0
  17. package/dist/core/session-start.d.ts +102 -0
  18. package/dist/core/session-start.js +186 -0
  19. package/dist/core/types.d.ts +8 -0
  20. package/dist/sweep/client.d.ts +6 -1
  21. package/dist/sweep/client.js +1 -0
  22. package/dist/sweep/dashboard.d.ts +10 -0
  23. package/dist/sweep/dashboard.js +32 -16
  24. package/dist/sweep/docker.d.ts +23 -0
  25. package/dist/sweep/docker.js +16 -12
  26. package/dist/sweep/harness-guard.d.ts +57 -0
  27. package/dist/sweep/harness-guard.js +144 -0
  28. package/dist/sweep/history.d.ts +33 -1
  29. package/dist/sweep/history.js +60 -5
  30. package/dist/sweep/regen.js +6 -1
  31. package/dist/sweep/report.d.ts +16 -0
  32. package/dist/sweep/report.js +79 -5
  33. package/dist/sweep/run.d.ts +41 -0
  34. package/dist/sweep/run.js +129 -36
  35. package/dist/sweep/server-pages.js +31 -6
  36. package/dist/sweep/session-start.d.ts +3 -0
  37. package/dist/sweep/session-start.js +103 -0
  38. package/dist/sweep/shard.d.ts +41 -0
  39. package/dist/sweep/shard.js +58 -0
  40. package/dist/sweep/sweep-all.js +56 -2
  41. package/package.json +3 -1
@@ -0,0 +1,403 @@
1
+ /** Share of the context window at which deferral activates under `auto`. */
2
+ export const TOOL_SEARCH_AUTO_SHARE = 0.1;
3
+ /** The variables that decide whether this machine's Claude Code defers. */
4
+ export const TOOL_SEARCH_VARS = [
5
+ 'ENABLE_TOOL_SEARCH',
6
+ 'CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS',
7
+ 'ANTHROPIC_BASE_URL',
8
+ ];
9
+ /** Pick the three variables that matter out of a process environment. */
10
+ export function toolSearchEnv(env) {
11
+ return {
12
+ ENABLE_TOOL_SEARCH: env.ENABLE_TOOL_SEARCH,
13
+ CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS: env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS,
14
+ ANTHROPIC_BASE_URL: env.ANTHROPIC_BASE_URL,
15
+ };
16
+ }
17
+ /** What `source` says for the process environment, which has no path. */
18
+ export const SHELL_SOURCE = '(shell environment)';
19
+ /** A source as it is published: what it sets, by name. */
20
+ export function toolSearchSourceRecord(s) {
21
+ const unreadable = TOOL_SEARCH_VARS.filter((n) => (s.unreadable ?? []).includes(n));
22
+ return {
23
+ scope: s.scope,
24
+ source: s.source,
25
+ state: s.state,
26
+ sets: TOOL_SEARCH_VARS.filter((n) => (s.vars[n] ?? '').trim() !== ''),
27
+ ...(unreadable.length ? { unreadable } : {}),
28
+ };
29
+ }
30
+ /** The one host Claude Code treats as first-party for the tool-search fallback. */
31
+ const FIRST_PARTY_API_HOST = 'api.anthropic.com';
32
+ /** What is printed for a base URL we could not parse — a marker, not the value. */
33
+ const UNREADABLE_BASE_URL = '(unreadable URL)';
34
+ /**
35
+ * The hostname of a base URL, or null if it does not parse.
36
+ *
37
+ * The hostname is the whole of what the mode decision needs, and it is also the
38
+ * whole of what may leave this function: the rest of the value can carry a
39
+ * credential. A value that does not parse is not first-party, which is the
40
+ * reading that says tokens are paid — never the one that says they are free.
41
+ */
42
+ function baseUrlHost(raw) {
43
+ try {
44
+ return new URL(raw).hostname.toLowerCase();
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ }
50
+ /**
51
+ * Read the tool-search setting out of ONE environment. Values are matched
52
+ * exactly as documented: an unrecognized value produces `setting-unrecognized`
53
+ * rather than a guess, because guessing here would print a definite verdict
54
+ * about tokens the reader may or may not be paying.
55
+ *
56
+ * `source` is left null here: this function is given one environment and has no
57
+ * way to say which of the machine's places it came from. `resolveToolSearchSources`,
58
+ * which does, fills it in.
59
+ */
60
+ export function resolveToolSearch(env) {
61
+ const betas = env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS?.trim();
62
+ // Read first: documented as not overridable by ENABLE_TOOL_SEARCH.
63
+ if (betas) {
64
+ return {
65
+ mode: 'loads-upfront',
66
+ thresholdShare: null,
67
+ variable: 'CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS',
68
+ value: betas,
69
+ source: null,
70
+ readFromMachine: true,
71
+ };
72
+ }
73
+ const raw = env.ENABLE_TOOL_SEARCH?.trim();
74
+ const set = (mode, thresholdShare) => ({
75
+ mode,
76
+ thresholdShare,
77
+ variable: 'ENABLE_TOOL_SEARCH',
78
+ value: raw ?? null,
79
+ source: null,
80
+ readFromMachine: true,
81
+ });
82
+ if (raw === undefined || raw === '') {
83
+ const base = env.ANTHROPIC_BASE_URL?.trim();
84
+ if (base) {
85
+ const host = baseUrlHost(base);
86
+ if (host !== FIRST_PARTY_API_HOST) {
87
+ return {
88
+ mode: 'loads-upfront',
89
+ thresholdShare: null,
90
+ variable: 'ANTHROPIC_BASE_URL',
91
+ // The hostname alone. `base` itself is never carried out of here.
92
+ value: host ?? UNREADABLE_BASE_URL,
93
+ source: null,
94
+ readFromMachine: true,
95
+ };
96
+ }
97
+ }
98
+ return {
99
+ mode: 'defers-all',
100
+ thresholdShare: null,
101
+ variable: 'ENABLE_TOOL_SEARCH',
102
+ value: null,
103
+ source: null,
104
+ readFromMachine: false,
105
+ };
106
+ }
107
+ if (raw === 'true')
108
+ return set('defers-all', null);
109
+ if (raw === 'false')
110
+ return set('loads-upfront', null);
111
+ if (raw === 'auto')
112
+ return set('threshold', TOOL_SEARCH_AUTO_SHARE);
113
+ const custom = /^auto:(\d{1,3})$/.exec(raw);
114
+ if (custom) {
115
+ const pct = Number(custom[1]);
116
+ if (pct >= 0 && pct <= 100)
117
+ return set('threshold', pct / 100);
118
+ }
119
+ return set('setting-unrecognized', null);
120
+ }
121
+ /**
122
+ * Read the posture from every place the audited machine can set it.
123
+ *
124
+ * Claude Code takes these variables from the shell it was started in AND from
125
+ * the `env` block of its own settings files, so an audit that reads only the
126
+ * shell answers the machine's question with someone else's environment. The
127
+ * case that made this necessary: `~/.claude/settings.json` sets
128
+ * `ENABLE_TOOL_SEARCH: "false"`, the shell running the audit sets nothing, and
129
+ * every request on that machine pays for every tool definition while the report
130
+ * calls it the documented default and says the tokens are not loaded at all.
131
+ *
132
+ * Among the settings files the order is Claude Code's documented precedence —
133
+ * enterprise managed policy, then project-local, then project, then user
134
+ * (Claude Code settings documentation, §"Settings files", read 2026-08-20) — so
135
+ * the first of them that sets a variable is the one that would win.
136
+ *
137
+ * Between the settings files and the shell there is NO order on record here, so
138
+ * a disagreement is refused rather than resolved: `setting-unresolved` names the
139
+ * variable and every place, and no verdict is given. A place that exists and
140
+ * could not be read is the same refusal for the same reason — what it sets is
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.
145
+ *
146
+ * Not visible from here at all, and so not claimed: a variable set on Claude
147
+ * Code's own command line.
148
+ */
149
+ export function resolveToolSearchSources(sources) {
150
+ const unresolved = (reason, variable) => ({
151
+ mode: 'setting-unresolved',
152
+ thresholdShare: null,
153
+ variable,
154
+ value: null,
155
+ source: null,
156
+ readFromMachine: false,
157
+ unresolved: reason,
158
+ });
159
+ if (sources.some((s) => s.state === 'unreadable'))
160
+ return unresolved('source-unreadable', null);
161
+ const settings = sources.filter((s) => s.scope !== 'shell');
162
+ const shell = sources.find((s) => s.scope === 'shell');
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
+ */
176
+ const read = (name) => {
177
+ const winner = settings.find((s) => holds(s, name));
178
+ const shellValue = (shell?.vars[name] ?? '').trim();
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)
186
+ return 'conflict';
187
+ if (winner)
188
+ return { value: (winner.vars[name] ?? '').trim(), source: winner.source };
189
+ if (shellValue && shell)
190
+ return { value: shellValue, source: shell.source };
191
+ return null;
192
+ };
193
+ // Consulted in the same order `resolveToolSearch` consults them, so a
194
+ // disagreement over a variable that would not have decided anything —
195
+ // ANTHROPIC_BASE_URL behind an explicit ENABLE_TOOL_SEARCH — does not refuse
196
+ // an answer the machine actually gives.
197
+ const betas = read('CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS');
198
+ if (betas === 'conflict')
199
+ return unresolved('sources-disagree', 'CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS');
200
+ if (betas === 'unreadable')
201
+ return unresolved('value-unreadable', 'CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS');
202
+ if (betas) {
203
+ return {
204
+ ...resolveToolSearch({ CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS: betas.value }),
205
+ source: betas.source,
206
+ };
207
+ }
208
+ const enable = read('ENABLE_TOOL_SEARCH');
209
+ if (enable === 'conflict')
210
+ return unresolved('sources-disagree', 'ENABLE_TOOL_SEARCH');
211
+ if (enable === 'unreadable')
212
+ return unresolved('value-unreadable', 'ENABLE_TOOL_SEARCH');
213
+ if (enable) {
214
+ return { ...resolveToolSearch({ ENABLE_TOOL_SEARCH: enable.value }), source: enable.source };
215
+ }
216
+ const base = read('ANTHROPIC_BASE_URL');
217
+ if (base === 'conflict')
218
+ return unresolved('sources-disagree', 'ANTHROPIC_BASE_URL');
219
+ if (base === 'unreadable')
220
+ return unresolved('value-unreadable', 'ANTHROPIC_BASE_URL');
221
+ const resolved = resolveToolSearch(base ? { ANTHROPIC_BASE_URL: base.value } : {});
222
+ return { ...resolved, source: resolved.readFromMachine ? (base?.source ?? null) : null };
223
+ }
224
+ /**
225
+ * The band as published in this repository's own `results/divergence.json`
226
+ * (claude-opus-5, 2026-08-19, 20 servers). Used when no divergence run was
227
+ * supplied; `--claude` recomputes it from the run it fetched.
228
+ */
229
+ export const PUBLISHED_WIRE_TO_CLIENT_RATIO = {
230
+ low: 0.2,
231
+ high: 1.92,
232
+ servers: 20,
233
+ source: 'the published claude-opus-5 divergence run',
234
+ };
235
+ /** Derive the band from a supplied divergence run, falling back to the published one. */
236
+ export function wireToClientRatio(run) {
237
+ if (!run)
238
+ return PUBLISHED_WIRE_TO_CLIENT_RATIO;
239
+ let low = Infinity;
240
+ let high = -Infinity;
241
+ let servers = 0;
242
+ for (const row of Object.values(run.servers)) {
243
+ if (!row || row.error || typeof row.claudeDelta !== 'number' || !(row.o200kFull > 0))
244
+ continue;
245
+ const ratio = row.claudeDelta / row.o200kFull;
246
+ low = Math.min(low, ratio);
247
+ high = Math.max(high, ratio);
248
+ servers++;
249
+ }
250
+ if (servers === 0)
251
+ return PUBLISHED_WIRE_TO_CLIENT_RATIO;
252
+ return { low, high, servers, source: `the ${run.measuredAt} ${run.model} divergence run` };
253
+ }
254
+ /** Clients this tool discovers that have no default deferral on record. */
255
+ const NO_DEFERRAL_ON_RECORD = new Set(['claude-desktop', 'cursor', 'vscode', 'windsurf']);
256
+ /**
257
+ * Where deferral does not apply even when the machine's setting says it should.
258
+ * None of these can be read from the config or the environment, so they are
259
+ * printed as conditions for the reader to check rather than folded into the
260
+ * verdict.
261
+ */
262
+ const EXCEPTIONS = [
263
+ 'a Microsoft Foundry deployment hosted on Azure, which rejects tool search server-side',
264
+ "Google Cloud's Agent Platform on a model earlier than the Claude 4.5 generation",
265
+ 'a model without support for tool_reference blocks (before Sonnet 4.5 / Haiku 4.5 / Opus 4.5)',
266
+ 'a server pinned with "alwaysLoad": true, whose tools load at session start regardless',
267
+ ];
268
+ function estimate(servers, ratio) {
269
+ let low = 0;
270
+ let high = 0;
271
+ let exact = 0;
272
+ let estimated = 0;
273
+ for (const s of servers) {
274
+ if (typeof s.claudeTokens === 'number') {
275
+ // A published Anthropic count for this exact capture. It carries the tool
276
+ // framework overhead the API charges once per request rather than once
277
+ // per server, so a multi-server sum leans high by at most that overhead —
278
+ // far inside the band the converted servers already contribute.
279
+ low += s.claudeTokens;
280
+ high += s.claudeTokens;
281
+ exact++;
282
+ }
283
+ else {
284
+ low += s.tokens * ratio.low;
285
+ high += s.tokens * ratio.high;
286
+ estimated++;
287
+ }
288
+ }
289
+ return { low: Math.round(low), high: Math.round(high), exact, estimated };
290
+ }
291
+ /**
292
+ * Read one session's deferral position. Pure arithmetic over a built scope — no
293
+ * config file is re-read and no server is launched. The environment is passed
294
+ * in rather than read here, so the answer is reproducible from its inputs.
295
+ */
296
+ export function evaluateDeferral(scope, opts) {
297
+ const wireTokens = scope.servers.reduce((a, s) => a + s.tokens, 0);
298
+ const isFloor = scope.skippedCount > 0;
299
+ const sharedMeasurements = scope.sharedMeasurements;
300
+ // Every field a verdict carries, at its "nothing to say" value. Each mode
301
+ // below overrides only what it can actually answer.
302
+ const base = {
303
+ client: scope.client,
304
+ sources: scope.sources,
305
+ wireTokens,
306
+ isFloor,
307
+ // Carried by every mode, not just the one that returns early on it below.
308
+ // That early return withholds `clientTokens` and `crosses`, which only
309
+ // threshold mode ever computes; the other modes derive nothing from the
310
+ // total and so have nothing to withhold. What they do all do is PRINT it,
311
+ // so the caveat belongs in every branch of the report — see
312
+ // `sharedMeasurementLines` in audit.ts, which every mode calls.
313
+ sharedMeasurements,
314
+ mechanism: null,
315
+ setting: null,
316
+ thresholdShare: null,
317
+ thresholdTokens: null,
318
+ clientTokens: null,
319
+ ratio: null,
320
+ distanceTokens: null,
321
+ crosses: null,
322
+ exceptions: [],
323
+ };
324
+ if (scope.client !== 'claude-code') {
325
+ return {
326
+ ...base,
327
+ mode: NO_DEFERRAL_ON_RECORD.has(scope.client) ? 'no-deferral-on-record' : 'client-unknown',
328
+ };
329
+ }
330
+ // The shell is one place among several, not the machine. Everything Claude
331
+ // Code would read is resolved together, and a disagreement between them is
332
+ // refused rather than decided by whichever this happened to open.
333
+ const sources = [
334
+ { scope: 'shell', source: SHELL_SOURCE, state: 'read', vars: opts.env ?? {} },
335
+ ...(opts.settings ?? []),
336
+ ];
337
+ const resolved = resolveToolSearchSources(sources);
338
+ const setting = {
339
+ variable: resolved.variable,
340
+ value: resolved.value,
341
+ source: resolved.source,
342
+ readFromMachine: resolved.readFromMachine,
343
+ sources: sources.map(toolSearchSourceRecord),
344
+ ...(resolved.unresolved ? { unresolved: resolved.unresolved } : {}),
345
+ };
346
+ if (resolved.mode !== 'threshold') {
347
+ return {
348
+ ...base,
349
+ mode: resolved.mode,
350
+ mechanism: 'tool search',
351
+ setting,
352
+ // Nothing is deferred in the other two modes, so the conditions under
353
+ // which deferral fails to apply are not worth printing there.
354
+ exceptions: resolved.mode === 'defers-all' ? EXCEPTIONS : [],
355
+ };
356
+ }
357
+ const thresholdShare = resolved.thresholdShare ?? TOOL_SEARCH_AUTO_SHARE;
358
+ const thresholdTokens = Math.round(opts.contextWindow * thresholdShare);
359
+ if (sharedMeasurements > 0) {
360
+ // There is a threshold, and no total to hold against it. A shared
361
+ // measurement is not a floor: a twin can serve more tools than the one that
362
+ // was launched or fewer, so the sum can be wrong in either direction and
363
+ // neither side can be ruled out. The same machine has already been seen to
364
+ // report 13,834 wire tokens or 392 for one stack depending on which twin
365
+ // the cache happened to hold, and to print a confident — opposite — side
366
+ // each time. This is the rule `evaluateIncreaseGate` states and `crosses`
367
+ // already follows: an answer that could not be established fails rather
368
+ // than resolves. The threshold itself is still reported, because where the
369
+ // line sits is known even when this stack's distance from it is not.
370
+ return {
371
+ ...base,
372
+ mode: 'threshold',
373
+ mechanism: 'tool search',
374
+ setting,
375
+ thresholdShare,
376
+ thresholdTokens,
377
+ exceptions: EXCEPTIONS,
378
+ };
379
+ }
380
+ const ratio = wireToClientRatio(opts.divergence);
381
+ const clientTokens = estimate(scope.servers, ratio);
382
+ // At-or-above, on the documented "defers all of them once the definitions
383
+ // reach 10%". A range that is entirely over is over even if it is a floor:
384
+ // more unmeasured tokens cannot take it back under.
385
+ const crosses = clientTokens.low >= thresholdTokens
386
+ ? true
387
+ : isFloor || clientTokens.high >= thresholdTokens
388
+ ? null
389
+ : false;
390
+ return {
391
+ ...base,
392
+ mode: 'threshold',
393
+ mechanism: 'tool search',
394
+ setting,
395
+ thresholdShare,
396
+ thresholdTokens,
397
+ clientTokens,
398
+ ratio,
399
+ distanceTokens: { low: clientTokens.low - thresholdTokens, high: clientTokens.high - thresholdTokens },
400
+ crosses,
401
+ exceptions: EXCEPTIONS,
402
+ };
403
+ }
@@ -1,6 +1,7 @@
1
1
  import type { Measurement } from '../core/types.js';
2
2
  import { type DivergenceRun } from '../core/divergence.js';
3
3
  import { type AuditReport } from './audit.js';
4
+ import { type ToolSearchEnv, type ToolSearchSource } from './deferral.js';
4
5
  import { type LoadedConfig } from './config.js';
5
6
  /** Where the published `tools-delta/v1` run lives when `--claude` doesn't override it. */
6
7
  export declare const DEFAULT_DIVERGENCE_URL = "https://raw.githubusercontent.com/athakur3/mcp-context-cost/main/results/divergence.json";
@@ -18,6 +19,18 @@ export interface AuditOptions {
18
19
  claude?: boolean;
19
20
  /** Override the divergence.json source — mainly for tests and self-hosted mirrors. */
20
21
  divergenceUrl?: string;
22
+ /**
23
+ * The tool-search variables as this process's SHELL has them. Defaults to
24
+ * this process's environment. Overridable so a test can state a machine
25
+ * rather than inherit the one it runs on.
26
+ */
27
+ env?: ToolSearchEnv;
28
+ /**
29
+ * The same variables as Claude Code's own settings files set them — the other
30
+ * half of the answer, and the half a shell cannot show. Defaults to reading
31
+ * those files off the machine being audited (`discoverSettings`).
32
+ */
33
+ settings?: ToolSearchSource[];
21
34
  onProgress?: (name: string, done: number, total: number) => void;
22
35
  }
23
36
  /** Fetch and parse the published divergence run. Never throws: a failure is a report problem, not a crash. */
@@ -26,6 +39,15 @@ export declare function fetchDivergence(url: string): Promise<{
26
39
  problem?: string;
27
40
  }>;
28
41
  export declare function discover(opts?: AuditOptions): LoadedConfig[];
42
+ /**
43
+ * Read every settings file Claude Code would take its deferral setting from.
44
+ *
45
+ * A sibling of `discover` above and deliberately separate from it: that one
46
+ * finds which servers exist, this one finds how the client treats them. The
47
+ * setting does not live in a client config, and reading only the shell that
48
+ * launched this audit answers for the wrong machine.
49
+ */
50
+ export declare function discoverSettings(opts?: AuditOptions): ToolSearchSource[];
29
51
  /** Measure every distinct stdio server across the given configs, once each. */
30
52
  export declare function measureAll(configs: LoadedConfig[], opts?: AuditOptions): Promise<Map<string, Measurement>>;
31
53
  export declare function runAudit(opts?: AuditOptions): Promise<AuditReport>;
package/dist/audit/run.js CHANGED
@@ -8,7 +8,8 @@ import { homedir } from 'node:os';
8
8
  import { measureServer } from '../sweep/run.js';
9
9
  import { parseDivergence } from '../core/divergence.js';
10
10
  import { buildReport, serverKey } from './audit.js';
11
- import { configCandidates, loadConfigs } from './config.js';
11
+ import { toolSearchEnv } from './deferral.js';
12
+ import { configCandidates, loadConfigs, loadSettingsSources, settingsCandidates, } from './config.js';
12
13
  /** Where the published `tools-delta/v1` run lives when `--claude` doesn't override it. */
13
14
  export const DEFAULT_DIVERGENCE_URL = 'https://raw.githubusercontent.com/athakur3/mcp-context-cost/main/results/divergence.json';
14
15
  /** Fetch and parse the published divergence run. Never throws: a failure is a report problem, not a crash. */
@@ -32,6 +33,19 @@ export function discover(opts = {}) {
32
33
  : configCandidates({ home, cwd, platform: process.platform, appData: process.env.APPDATA });
33
34
  return loadConfigs(candidates, cwd);
34
35
  }
36
+ /**
37
+ * Read every settings file Claude Code would take its deferral setting from.
38
+ *
39
+ * A sibling of `discover` above and deliberately separate from it: that one
40
+ * finds which servers exist, this one finds how the client treats them. The
41
+ * setting does not live in a client config, and reading only the shell that
42
+ * launched this audit answers for the wrong machine.
43
+ */
44
+ export function discoverSettings(opts = {}) {
45
+ const cwd = opts.cwd ?? process.cwd();
46
+ const home = opts.home ?? homedir();
47
+ return loadSettingsSources(settingsCandidates({ home, cwd, platform: process.platform, programData: process.env.ProgramData }));
48
+ }
35
49
  /** Measure every distinct stdio server across the given configs, once each. */
36
50
  export async function measureAll(configs, opts = {}) {
37
51
  const unique = new Map();
@@ -79,6 +93,8 @@ export async function runAudit(opts = {}) {
79
93
  contextWindow: opts.contextWindow,
80
94
  budget: opts.budget,
81
95
  divergence,
96
+ env: opts.env ?? toolSearchEnv(process.env),
97
+ settings: opts.settings ?? discoverSettings(opts),
82
98
  });
83
99
  if (divergenceProblem)
84
100
  report.problems.push(divergenceProblem);
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>`);