mcp-context-cost 0.16.0 → 0.18.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
@@ -27,9 +27,13 @@ gate passed or not, so a later step can comment the number on the PR or publish
27
27
  **What do the MCP servers in your config cost you before you type anything — and what did
28
28
  that last config change add to every session you will ever run?**
29
29
 
30
- Every MCP server you wire into an agent injects its tool schemas into the model's context on
31
- every single request. You pay that whether or not the agent ends up using the tools, and no
32
- client shows you the number. Point `audit` at your own MCP config:
30
+ Every MCP server you wire into an agent has to put its tool schemas somewhere. For a client
31
+ with no deferral on record that is the model's context, on every single request, whether or
32
+ not the agent ends up using the tools; for a client that defers them — Claude Code by default,
33
+ and Cursor, Codex CLI and VS Code by their vendors' own records — it is a smaller session-start
34
+ cost plus whatever the agent reaches for. Which of the two you are paying is a property of your
35
+ client, not of the server, and no client shows you either number. Point `audit` at your own MCP
36
+ config:
33
37
 
34
38
  ```bash
35
39
  npx -y mcp-context-cost audit
@@ -83,13 +87,25 @@ nobody runs.
83
87
  ### Where this cost is paid in full, and where it is deferred away
84
88
 
85
89
  Not every client puts every tool definition in context on every request, so the total above
86
- is not automatically your bill. Which client reads the config, and how that client is
87
- configured **on this machine**, decides it and `audit` reads that rather than assuming it.
88
-
89
- **Clients with no default deferral on record** Claude Desktop, Cursor, VS Code, Windsurf,
90
- Codex CLI, Gemini CLI, Zed, Kiro, Goose. The total is what every request carries, as in the
91
- example above. That sentence is an
92
- absence of a record about those clients, not a measurement of them, and the report says so
90
+ is not automatically your bill. Which client reads the config decides it, and for Claude Code
91
+ so does how that client is configured **on this machine**which `audit` reads rather than
92
+ assumes. No other client's posture is readable from a file this opens, so for those the report
93
+ gives what their vendor is on record with, or says there is nothing on record, and claims
94
+ neither as a measurement.
95
+
96
+ **Clients whose vendor is on record as deferring** Cursor (*dynamic context discovery*),
97
+ Codex CLI (*tool search*), and VS Code, whose record is a pair of conditions rather than a
98
+ default. For these the report prints what the vendor states, what that record leaves open, and
99
+ the address and date of every source behind it. It stops where Claude Code's entry stops:
100
+ nothing here measured any of them, and no config file this reads states their posture — so the
101
+ total is what the definitions weigh, not a bill every request is known to carry, and not a
102
+ saving either. Cursor's record is the reason this changed: its engineering blog described the
103
+ mechanism on 2026-01-06 while its MCP configuration page, then and now, says nothing, and a
104
+ rule that read only that page reported an absence of a record for eight months.
105
+
106
+ **Clients with no default deferral on record** — Claude Desktop, Windsurf, Gemini CLI, Zed,
107
+ Kiro, Goose. The total is what every request carries, as in the example above. That sentence is
108
+ an absence of a record about those clients, not a measurement of them, and the report says so
93
109
  in those words.
94
110
 
95
111
  **Claude Code defers MCP tool definitions by default** (its **tool search**): they are not
@@ -105,7 +121,8 @@ deferral off in a settings file is not a machine running the default:
105
121
  | `ENABLE_TOOL_SEARCH=true` | same: every definition deferred |
106
122
  | `ENABLE_TOOL_SEARCH=false` | deferral off — every request carries the full total. In a settings `env` block that is the **string** `"false"`; the JSON boolean `false` is the last row, not this one |
107
123
  | `ENABLE_TOOL_SEARCH=auto` / `auto:N` | deferred only once definitions reach 10% / N% of the context window |
108
- | `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS` set | tool search off — read first, because `ENABLE_TOOL_SEARCH` cannot override it |
124
+ | `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS` set to `1`, `true`, `yes` or `on` | tool search off — read first, because `ENABLE_TOOL_SEARCH` cannot override it |
125
+ | `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS` set to `0`, `false`, `no` or `off` | it turned nothing off, so the read moves on and the rows above decide. It is a boolean flag in the client, not a marker whose presence is the signal |
109
126
  | `ANTHROPIC_BASE_URL` off `api.anthropic.com` | falls back to loading up front — consulted only while `ENABLE_TOOL_SEARCH` is unset |
110
127
  | anything else in `ENABLE_TOOL_SEARCH` | not a documented value, so nothing is claimed from it |
111
128
  | any of the three set, in a settings `env` block, to something that is not a string — a JSON boolean, a number, `null` | it is set there and what it is set to is unknown, so no posture is claimed: the report says whether these tokens are deferred cannot be said from it |
@@ -130,6 +147,41 @@ On a machine where none of them is set, the same stack reads:
130
147
  a tool whose _meta carries "anthropic/alwaysLoad": true, which this audit does not read from a capture
131
148
  ```
132
149
 
150
+ **Do not take that table on trust — your own client will tell you.** Everything above is read
151
+ from Anthropic's documentation, and documentation about someone else's product is exactly the
152
+ kind of claim this project refuses to leave unchecked elsewhere. Claude Code writes its own
153
+ decision to a debug log, before it sends anything, so you can check your machine rather than
154
+ believe this page. In a directory with an `.mcp.json`:
155
+
156
+ ```bash
157
+ claude --debug-file /tmp/cc.txt -p "ok"
158
+ grep -E 'ToolSearch|Dynamic tool loading|Auto tool search' /tmp/cc.txt
159
+ ```
160
+
161
+ Three line shapes answer three different questions:
162
+
163
+ | the line | what it tells you |
164
+ |---|---|
165
+ | `[ToolSearch:optimistic] mode=…, ENABLE_TOOL_SEARCH=…, result=…` | which mode it picked at startup, and the value it read. **Optimistic is its own word for a guess** — it can be revised below |
166
+ | `Dynamic tool loading: 0/N deferred tools included` | the one that settles it: how many of the `N` deferrable tools went into the request. `0/N` is deferral actually happening |
167
+ | `[ToolSearch:optimistic] disabled: ANTHROPIC_BASE_URL=… is not a first-party Anthropic host` | the fallback in the table above, firing, in the client's own words |
168
+
169
+ Read the *later* requests, not the first. A stdio server can finish connecting after the first
170
+ request has already gone, so an early low count is a race rather than a finding.
171
+
172
+ **The mode most likely to surprise you is `auto`.** It reads as the careful setting and it is the
173
+ one that loads everything up front at ordinary sizes: below the threshold it does not defer, by
174
+ design, and a line reading `Auto tool search disabled: … (threshold: …)` is that decision being
175
+ made. The threshold is a *percentage of the context window*, so a model with a larger window has
176
+ a proportionally larger one — pass `--context` to `audit` to compare against the window you
177
+ actually run.
178
+
179
+ None of this is free, and it is one trivial request. These are Claude Code's own debug lines
180
+ rather than a documented interface, so they can change; the table above is what this project
181
+ holds to a dated re-read. No other client discovered by `audit` writes anything comparable,
182
+ which is why no other row here is a measurement: three of them get their vendor's own record,
183
+ dated and addressed, and the rest an absence of one.
184
+
133
185
  Set `ENABLE_TOOL_SEARCH=false` in that shell and the same config reports the opposite —
134
186
  `loads every tool definition up front here`, naming the variable and the place it was read
135
187
  from. Deferring is also not free: what a deferring client *does* load at session start —
@@ -144,11 +196,13 @@ servers); it will not claim a posture the machine did not state readably, which
144
196
  refusals and not one — when two places set the same variable to different values, when a
145
197
  settings file exists and cannot be read, when the place that would decide sets the variable
146
198
  to something that is not a string, and when `ENABLE_TOOL_SEARCH` holds a value Claude Code
147
- does not document; and it will not pass an absence of a record off as a measurement. The
148
- first two print as unanswered questions. The third prints as an answer that names
149
- itself: for the nine discovered clients with no default on record — `claude-desktop`, `cursor`, `vscode`, `windsurf`, `codex`, `gemini`, `zed`, `kiro`, `goose` — the tokens are counted as
150
- loaded up front, and the report says so in those words, "an absence of a record about the
151
- client, not a measurement of it".
199
+ does not document; and it will not pass an absence of a record off as a measurement, or a
200
+ vendor's record off as one either. The first two print as unanswered questions. The third
201
+ prints as an answer that names itself: for the six discovered clients with no default on
202
+ record `claude-desktop`, `windsurf`, `gemini`, `zed`, `kiro`, `goose` the tokens are
203
+ counted as loaded up front, and the report says so in those words, "an absence of a record
204
+ about the client, not a measurement of it". For `cursor`, `codex` and `vscode` it prints the
205
+ vendor's record with its dates and everything it leaves open, and claims no side.
152
206
  Full model, sources and dates: [METHODOLOGY §who pays the number](docs/METHODOLOGY.md#who-pays).
153
207
 
154
208
  **In CI**, make it a gate — the bundlesize move for agents:
@@ -235,7 +289,7 @@ prose every request carries — and only descriptions at or above the 90th perce
235
289
 
236
290
  ```
237
291
  suggest — descriptions at or above the 90th percentile of measured tools
238
- (baseline 2026-09-05: 1,430 tools across 87 measured servers):
292
+ (baseline 2026-09-07: 1,430 tools across 87 measured servers):
239
293
  stub · wordy — 345 tokens: description 321 (p92), schema 14
240
294
  rewriting the description toward the measured median (27) would recover ≈294 tokens on every request
241
295
  1 of 2 tools sit inside the distribution — no advice where nothing is measurably unusual.
@@ -269,20 +323,21 @@ Flags: `--json` (full report on stdout, progress on stderr), `--budget N`,
269
323
 
270
324
  The number `audit` gives you is the same measurement, run across a curated set of public
271
325
  servers — which is how you can tell it is a measurement and not this tool's opinion. It also
272
- shows what you are choosing between: across the 87 servers measured, cost spans **1,700×**,
273
- from `postgres` at 32 tokens to `github` at 54,622. The table below is a
274
- sample of that range; the full range is in
326
+ shows what you are choosing between: across the 87 servers measured, cost spans **1,700×** on
327
+ the wire, from `postgres` at 32 tokens to `github` at 54,622 of which a request carries
328
+ 10,735, and Claude counts those at 18,728. The table below is a sample of that range, ranked
329
+ on the wire like every other list here; the full range is in
275
330
  [results/leaderboard.md](results/leaderboard.md).
276
331
 
277
- | server | context cost | tools |
278
- |---|---:|---:|
279
- | github (official) | **54,622 tokens** | 44 |
280
- | xcodebuildmcp | 26,594 | 24 |
281
- | brave-search | 25,487 | 8 |
282
- | notion | 17,500 | 24 |
283
- | playwright *(4.8M installs/week)* | 4,024 | 24 |
284
- | filesystem (reference) | 2,823 | 14 |
285
- | markitdown | 64 | 1 |
332
+ | server | context cost (wire) | mapped | on Claude | tools |
333
+ |---|---:|---:|---:|---:|
334
+ | github (official) | **54,622 tokens** | 10,735 | 18,728 | 44 |
335
+ | xcodebuildmcp | 26,594 | 2,676 | 5,335 | 24 |
336
+ | brave-search | 25,487 | 8,278 | 13,762 | 8 |
337
+ | notion | 17,500 | 17,163 | 33,560 | 24 |
338
+ | playwright *(4.8M installs/week)* | 4,024 | 3,402 | 6,172 | 24 |
339
+ | filesystem (reference) | 2,823 | 1,665 | 3,115 | 14 |
340
+ | markitdown | 64 | 64 | 404 | 1 |
286
341
 
287
342
  *(87 of 107 popular servers measured, each row dated by its own most recent sweep — full table in
288
343
  [results/leaderboard.md](results/leaderboard.md); every failure is listed with its reason.
@@ -308,12 +363,14 @@ the cost before they install rather than after:
308
363
 
309
364
  The badge counts every byte a server returns. An Anthropic request carries only `name`,
310
365
  `description`, and `input_schema` — and counts them with a denser tokenizer. Both effects are
311
- now measured against a pinned model and published beside the badge, and they do not cancel:
366
+ now measured against a pinned model and published beside the badge, and they do not cancel.
367
+ The middle column is what a request actually carries, counted with the badge's own tokenizer,
368
+ so the two effects are separated rather than folded together:
312
369
 
313
- | server | badge (o200k) | Claude (`claude-opus-5`) | |
314
- |---|---:|---:|---|
315
- | github | 54,622 | **18,728** | most of the capture is `annotations`/`outputSchema` metadata Claude never sees |
316
- | notion | 17,500 | **33,560** | almost no metadata to drop, so the tokenizer difference dominates |
370
+ | server | badge (o200k) | mapped | Claude (`claude-opus-5`) | |
371
+ |---|---:|---:|---:|---|
372
+ | github | 54,622 | 10,735 | **18,728** | 78% of the capture is `icons` metadata Claude never sees |
373
+ | notion | 17,500 | 17,163 | **33,560** | almost no metadata to drop, so the tokenizer difference dominates |
317
374
 
318
375
  So the heaviest server on the badge is not the heaviest server on Claude. Per-server
319
376
  breakdowns are on each [detail page](https://athakur3.github.io/mcp-context-cost/servers/);
@@ -472,10 +529,7 @@ color bands are frozen against the observed distribution of the first full sweep
472
529
 
473
530
  ## Status
474
531
 
475
- Active. Every row carries the date of its own most recent measurement, and what the data
476
- says as a whole is written up, dated, in
477
- [The State of MCP Context Cost](https://athakur3.github.io/mcp-context-cost/state-of-mcp-context-cost)
478
- (September 2026). Two
532
+ Active. Every row carries the date of its own most recent measurement. Two
479
533
  weekly jobs re-measure the set — the `memory` reference server on Mondays, and a rotating
480
534
  third of the list on Wednesdays, so every row comes round within three weeks. Read each row's
481
535
  date as the date it means, and don't take the cadence on trust — the build history is
@@ -268,6 +268,20 @@ export function buildReport(configs, measured, opts = {}) {
268
268
  });
269
269
  continue;
270
270
  }
271
+ if (probe.kind === 'protocol-mismatch') {
272
+ // The endpoint works. What it refused is the revision this audit
273
+ // sends, which is a fact about this harness — so the row says that,
274
+ // and not that nothing answered. Wired here explicitly because the
275
+ // block below reads anything it has not tested as open and hands it
276
+ // to the bridge.
277
+ skipped.push({
278
+ ...base,
279
+ ...none,
280
+ status: 'protocol-mismatch',
281
+ notes: `${s.url} answered ${probe.detail} — this audit speaks a revision it does not`,
282
+ });
283
+ continue;
284
+ }
271
285
  if (probe.kind === 'unreachable') {
272
286
  skipped.push({ ...base, ...none, status: 'unreachable', notes: `${s.url}: ${probe.detail}` });
273
287
  continue;
@@ -527,6 +541,31 @@ function thresholdAssumptionLines(d) {
527
541
  ' side of it depending on which model the client runs.',
528
542
  ];
529
543
  }
544
+ /**
545
+ * One indented item, wrapped to a terminal width with a hanging indent.
546
+ *
547
+ * The records these print are prose, not labels, and the alternative was
548
+ * hand-wrapping sentences in the data — which puts the line breaks of a report
549
+ * inside the thing the report is quoting, and moves them every time a word
550
+ * changes.
551
+ */
552
+ function bullet(text, width = 92) {
553
+ const out = [];
554
+ let line = '';
555
+ for (const word of text.split(' ')) {
556
+ const next = line ? `${line} ${word}` : word;
557
+ if (line && next.length + 4 > width) {
558
+ out.push(line);
559
+ line = word;
560
+ }
561
+ else {
562
+ line = next;
563
+ }
564
+ }
565
+ if (line)
566
+ out.push(line);
567
+ return out.map((l, i) => (i === 0 ? ` ${l}` : ` ${l}`));
568
+ }
530
569
  /** Where a threshold is in play, the unknown size is the whole verdict. */
531
570
  const SIDE_UNKNOWN = [
532
571
  ' either direction — which side of the threshold this stack falls on cannot be',
@@ -583,6 +622,38 @@ function deferralLines(d, skippedNames) {
583
622
  lines.push(...sharedMeasurementLines(d, skippedNames, SIZE_UNKNOWN));
584
623
  return lines;
585
624
  }
625
+ // A vendor's record, printed with everything it does not settle attached to
626
+ // it. The shape is Claude Code's, minus the half this cannot have: what is on
627
+ // record, that nothing here measured it, and — because no config file on the
628
+ // audited machine states the posture — no verdict about which way this stack
629
+ // goes. Reporting the total as deferred away would be the same error the
630
+ // absence-of-a-record rule was written against, in the opposite direction.
631
+ if (d.mode === 'deferral-on-record') {
632
+ const r = d.record;
633
+ // The mode without its record is not a milder version of this answer, it is
634
+ // no answer: everything below is quotation. Falling through from here would
635
+ // reach the threshold branch and print Claude Code's arithmetic over a
636
+ // client that has none.
637
+ if (!r) {
638
+ lines.push(` ${d.client} is on record as deferring tool definitions, and the record`);
639
+ lines.push(' itself did not reach this report — so nothing is claimed about who pays.');
640
+ return lines;
641
+ }
642
+ for (const l of r.states)
643
+ lines.push(` ${l}`);
644
+ for (const l of r.notReadable)
645
+ lines.push(` ${l}`);
646
+ lines.push(' So read this total as what the definitions weigh, not as a bill every');
647
+ lines.push(' request is known to carry — and not as a saving either:');
648
+ for (const c of r.conditions)
649
+ lines.push(...bullet(c));
650
+ lines.push(' What the vendor is on record with, and when it was read:');
651
+ for (const src of r.sources)
652
+ lines.push(...bullet(src));
653
+ if (d.sharedMeasurements > 0)
654
+ lines.push(...sharedMeasurementLines(d, skippedNames, SIZE_UNKNOWN));
655
+ return lines;
656
+ }
586
657
  if (d.mode === 'setting-unrecognized') {
587
658
  lines.push(` ${d.setting?.variable} is set to "${d.setting?.value}" on this machine, which is not`);
588
659
  lines.push(' one of the values Claude Code documents (unset, true, false, auto, auto:N).');
@@ -119,13 +119,43 @@ export interface SettingsCandidate {
119
119
  * Order is Claude Code's documented settings precedence (enterprise managed
120
120
  * policy, then project-local, then project, then user), read 2026-08-20. Paths
121
121
  * in, candidates out — nothing here touches a disk.
122
+ *
123
+ * The Windows directory is `C:\Program Files\ClaudeCode`, not `%ProgramData%`.
124
+ * `code.claude.com/docs/en/managed-settings.md`, read 2026-09-08, names the
125
+ * system directory per platform and then says in as many words: "Claude Code
126
+ * doesn't read the legacy Windows path
127
+ * `C:\ProgramData\ClaudeCode\managed-settings.json`". This opened that legacy
128
+ * path and no other, so on a managed Windows machine it read a file that
129
+ * decides nothing and never opened the one that does.
130
+ *
131
+ * Not modelled, and named here so the gap is on record rather than implied:
132
+ * WSL can be told to inherit the Windows policy chain, and whether it does
133
+ * turns on a key in a file this may not have read.
122
134
  */
123
135
  export declare function settingsCandidates(env: {
124
136
  home: string;
125
137
  cwd: string;
126
138
  platform: NodeJS.Platform;
127
- programData?: string;
128
139
  }): SettingsCandidate[];
140
+ /**
141
+ * The `managed-settings.d/*.json` drop-ins beside the managed settings file.
142
+ *
143
+ * `code.claude.com/docs/en/managed-settings.md`, read 2026-09-08: the file
144
+ * source is "`managed-settings.d/*.json` and `managed-settings.json` merged
145
+ * together" in the same system directory — one tier, not two. Reading only
146
+ * `managed-settings.json` is the same shape of miss as reading only the shell
147
+ * was: a place the client takes these variables from, never opened.
148
+ *
149
+ * Unlike `settingsCandidates` this one lists a directory, which is why it is a
150
+ * separate function called from `discoverSettings` rather than folded in there:
151
+ * that one promises paths in, candidates out, and nothing touching a disk.
152
+ *
153
+ * A directory that exists and cannot be listed yields the directory itself as a
154
+ * candidate, so it is read as one `unreadable` source rather than as silence.
155
+ * What it holds is unknown, and an unknown in the tier that outranks every
156
+ * other file is not the same as a tier that sets nothing.
157
+ */
158
+ export declare function managedDropInCandidates(managedSettingsPath: string, list?: (dir: string) => string[]): SettingsCandidate[];
129
159
  /**
130
160
  * Read what each settings file sets, of the variables that decide deferral.
131
161
  *
@@ -30,8 +30,8 @@
30
30
  * name) are held to the same rule: sent with the request, never reported —
31
31
  * only `headerNames` is.
32
32
  */
33
- import { existsSync, readFileSync } from 'node:fs';
34
- import { join } from 'node:path';
33
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
34
+ import { dirname, join } from 'node:path';
35
35
  import { parse as parseToml } from 'smol-toml';
36
36
  import { parse as parseYaml } from 'yaml';
37
37
  import { TOOL_SEARCH_VARS, } from './deferral.js';
@@ -348,13 +348,25 @@ export function loadConfigs(candidates, cwd, processEnv = process.env) {
348
348
  * Order is Claude Code's documented settings precedence (enterprise managed
349
349
  * policy, then project-local, then project, then user), read 2026-08-20. Paths
350
350
  * in, candidates out — nothing here touches a disk.
351
+ *
352
+ * The Windows directory is `C:\Program Files\ClaudeCode`, not `%ProgramData%`.
353
+ * `code.claude.com/docs/en/managed-settings.md`, read 2026-09-08, names the
354
+ * system directory per platform and then says in as many words: "Claude Code
355
+ * doesn't read the legacy Windows path
356
+ * `C:\ProgramData\ClaudeCode\managed-settings.json`". This opened that legacy
357
+ * path and no other, so on a managed Windows machine it read a file that
358
+ * decides nothing and never opened the one that does.
359
+ *
360
+ * Not modelled, and named here so the gap is on record rather than implied:
361
+ * WSL can be told to inherit the Windows policy chain, and whether it does
362
+ * turns on a key in a file this may not have read.
351
363
  */
352
364
  export function settingsCandidates(env) {
353
365
  const { home, cwd, platform } = env;
354
366
  const managed = platform === 'darwin'
355
367
  ? '/Library/Application Support/ClaudeCode/managed-settings.json'
356
368
  : platform === 'win32'
357
- ? join(env.programData ?? 'C:\\ProgramData', 'ClaudeCode', 'managed-settings.json')
369
+ ? 'C:\\Program Files\\ClaudeCode\\managed-settings.json'
358
370
  : '/etc/claude-code/managed-settings.json';
359
371
  return [
360
372
  { scope: 'managed-settings', path: managed },
@@ -363,6 +375,40 @@ export function settingsCandidates(env) {
363
375
  { scope: 'user-settings', path: join(home, '.claude', 'settings.json') },
364
376
  ];
365
377
  }
378
+ /**
379
+ * The `managed-settings.d/*.json` drop-ins beside the managed settings file.
380
+ *
381
+ * `code.claude.com/docs/en/managed-settings.md`, read 2026-09-08: the file
382
+ * source is "`managed-settings.d/*.json` and `managed-settings.json` merged
383
+ * together" in the same system directory — one tier, not two. Reading only
384
+ * `managed-settings.json` is the same shape of miss as reading only the shell
385
+ * was: a place the client takes these variables from, never opened.
386
+ *
387
+ * Unlike `settingsCandidates` this one lists a directory, which is why it is a
388
+ * separate function called from `discoverSettings` rather than folded in there:
389
+ * that one promises paths in, candidates out, and nothing touching a disk.
390
+ *
391
+ * A directory that exists and cannot be listed yields the directory itself as a
392
+ * candidate, so it is read as one `unreadable` source rather than as silence.
393
+ * What it holds is unknown, and an unknown in the tier that outranks every
394
+ * other file is not the same as a tier that sets nothing.
395
+ */
396
+ export function managedDropInCandidates(managedSettingsPath, list = readdirSync) {
397
+ const dir = join(dirname(managedSettingsPath), 'managed-settings.d');
398
+ if (!existsSync(dir))
399
+ return [];
400
+ let names;
401
+ try {
402
+ names = list(dir);
403
+ }
404
+ catch {
405
+ return [{ scope: 'managed-drop-in', path: dir }];
406
+ }
407
+ return names
408
+ .filter((n) => n.endsWith('.json') && !n.startsWith('.'))
409
+ .sort()
410
+ .map((n) => ({ scope: 'managed-drop-in', path: join(dir, n) }));
411
+ }
366
412
  /**
367
413
  * Read what each settings file sets, of the variables that decide deferral.
368
414
  *
@@ -46,10 +46,15 @@
46
46
  * the exception below already names only the earlier models. (2) Under
47
47
  * `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS`, "your organization can keep
48
48
  * tool search on through managed settings, on Claude Code v2.1.227 or
49
- * later" — on a direct connection or a gateway, not on a cloud provider.
50
- * This audit reads the managed settings file for the three variables and
51
- * nothing else, so that override is not read here and the variable is
52
- * still resolved as "off". (3) `alwaysLoad: true` is an entry field on
49
+ * later" — on a direct connection or a gateway set with `ANTHROPIC_BASE_URL`,
50
+ * and with no effect on a cloud provider or a Claude apps gateway sign-in
51
+ * (`code.claude.com/docs/en/llm-gateway-protocol.md`, read 2026-09-08).
52
+ * This IS read as of 2026-09-08: `resolveToolSearchSources` looks at the
53
+ * administrator tier, and where that tier sets `ENABLE_TOOL_SEARCH` to a
54
+ * value the vendor does not document, the disabling variable stops
55
+ * deciding and the posture is refused. The value that arms the override is
56
+ * in no vendor document, so none is named here and nothing is claimed about
57
+ * what it does. (3) `alwaysLoad: true` is an entry field on
53
58
  * every server type, and a tool can carry `"anthropic/alwaysLoad": true`
54
59
  * in its `_meta`. The entry form is read from the config now
55
60
  * (`DeferralServer.alwaysLoad`) and counted rather than listed; the
@@ -58,13 +63,29 @@
58
63
  * a deferring session loads at start — noted beside the session-start
59
64
  * metric in METHODOLOGY, not applied to any number here.
60
65
  *
61
- * No default deferral is on record here for the other clients this tool
62
- * discovers Claude Desktop, Cursor, VS Code, Windsurf, and from 2026-09-06
63
- * Codex CLI, Gemini CLI, Zed, Kiro and Goose, whose configuration pages were
64
- * read that day and say nothing about deferring tool definitions. That is an
65
- * absence of a record, not a measurement of those clients, and it is printed
66
- * as such the same rule the rest of this project follows for a value it has
67
- * not observed.
66
+ * The other clients this tool discovers split in two, and what separates them
67
+ * is what counts as a record. Until 2026-09-07 the rule read one surface — each
68
+ * client's own MCP configuration page and reported an absence of a record for
69
+ * every client whose page did not mention deferring. Three of those clients say
70
+ * the opposite elsewhere, on surfaces their own vendors control, the earliest of
71
+ * them dated eight months before that page was read. So the rule now takes four
72
+ * kinds of first-party statement, in METHODOLOGY §who-pays: the configuration
73
+ * page, the vendor's dated blog or changelog, a named staff account on the
74
+ * vendor's own forum, and the client's public source — a merged pull request or
75
+ * a settings default in the shipping tree. None of the four is a measurement.
76
+ *
77
+ * - `DEFERRAL_ON_RECORD` — Cursor, Codex CLI, VS Code. The vendor states, or
78
+ * the vendor's source shows, that definitions are deferred. What is printed
79
+ * is close to Claude Code's honest shape and stops in the same place: the
80
+ * vendor says so, this audit has not measured it, the conditions it depends
81
+ * on are listed rather than resolved, and for none of the three does a file
82
+ * this audit reads state the posture.
83
+ * - `NO_DEFERRAL_ON_RECORD` — Claude Desktop, Windsurf, Gemini CLI, Zed, Kiro,
84
+ * Goose. Still an absence of a record and printed as such, and now an
85
+ * absence that was looked for: the three of them that are open source were
86
+ * searched for a tool-search or deferral mechanism on 2026-09-07 and none
87
+ * was found, while Claude Desktop, Windsurf and Kiro are closed and only
88
+ * their pages have been read.
68
89
  */
69
90
  import type { DivergenceRun } from '../core/divergence.js';
70
91
  /** Share of the context window at which deferral activates under `auto`. */
@@ -89,7 +110,9 @@ export declare function toolSearchEnv(env: Record<string, string | undefined>):
89
110
  * are NOT loaded up front at any size" — at a machine that had switched
90
111
  * deferral off in `~/.claude/settings.json`.
91
112
  */
92
- export type ToolSearchScope = 'shell' | 'managed-settings' | 'local-settings' | 'project-settings' | 'user-settings';
113
+ export type ToolSearchScope = 'shell' | 'managed-settings'
114
+ /** A `managed-settings.d/*.json` drop-in: the managed file's own tier, not a rank below it. */
115
+ | 'managed-drop-in' | 'local-settings' | 'project-settings' | 'user-settings';
93
116
  /** What `source` says for the process environment, which has no path. */
94
117
  export declare const SHELL_SOURCE = "(shell environment)";
95
118
  export interface ToolSearchSource {
@@ -152,6 +175,11 @@ export type DeferralMode =
152
175
  | 'setting-unresolved'
153
176
  /** A client we know about, with no default deferral on record. */
154
177
  | 'no-deferral-on-record'
178
+ /**
179
+ * A client whose vendor states, or whose source shows, that it defers tool
180
+ * definitions — unmeasured here, and not readable from the config either.
181
+ */
182
+ | 'deferral-on-record'
155
183
  /** `--config <path>`: the file was read, but which client reads it is unknown. */
156
184
  | 'client-unknown';
157
185
  /** How the mode was decided — printed, so a reader can check it against their own shell. */
@@ -321,6 +349,54 @@ export declare const BAND_PRECISION = 2;
321
349
  * servers than were measured is a fabricated number rather than an old one.
322
350
  */
323
351
  export declare function bandSnapshotProblem(snapshot: Pick<WireToClientRatio, 'low' | 'high' | 'servers'>, derived: Pick<WireToClientRatio, 'low' | 'high' | 'servers'>): string | null;
352
+ /**
353
+ * The passages on the vendor's own pages that this file's model of tool search
354
+ * rests on, and the rule for deciding whether they still say it.
355
+ *
356
+ * What rots here is not the client. It is these pages: the model is a reading
357
+ * of them, they have already moved host once under this project, and the whole
358
+ * of `resolveToolSearch` is downstream of four sentences. So the watch checks
359
+ * the sentences rather than a page hash — a hash of a documentation page is red
360
+ * every week for a typo and teaches everyone to ignore it.
361
+ *
362
+ * `tools/watch-tool-search-docs.ts` is the half that fetches. This half is the
363
+ * rule, offline and under test, the same split as `src/core/protocol.ts` and
364
+ * its spec watch.
365
+ */
366
+ export interface ToolSearchDocClaim {
367
+ /** The page it was read from. */
368
+ url: string;
369
+ /** What this file does because of it, so a drift report says what broke. */
370
+ because: string;
371
+ /** Text that must still be on that page. Compared with whitespace collapsed. */
372
+ quote: string;
373
+ }
374
+ /** When every claim below was last read against the live page. */
375
+ export declare const TOOL_SEARCH_DOC_READ_ON = "2026-09-08";
376
+ export declare const ENV_VARS_DOC = "https://code.claude.com/docs/en/env-vars.md";
377
+ export declare const GATEWAY_DOC = "https://code.claude.com/docs/en/llm-gateway-protocol.md";
378
+ export declare const MANAGED_SETTINGS_DOC = "https://code.claude.com/docs/en/managed-settings.md";
379
+ export declare const TOOL_SEARCH_DOC_CLAIMS: ToolSearchDocClaim[];
380
+ /**
381
+ * The variables that page names as reading any non-empty value, read on the date
382
+ * above. The one that matters is the one that is NOT here.
383
+ */
384
+ export declare const ANY_NON_EMPTY_VARS: string[];
385
+ /**
386
+ * The variable names in that exception list, or null when the list could not be
387
+ * found in the shape this knows how to read — which is a reason to look, not a
388
+ * reason to report the list unchanged.
389
+ */
390
+ export declare function anyNonEmptyVars(page: string): string[] | null;
391
+ /**
392
+ * What is wrong with the live pages, in words. Empty means the model still has
393
+ * its sources.
394
+ *
395
+ * A page that could not be fetched is reported, never skipped: a watch that is
396
+ * green when it is blind is worse than no watch, which is the rule the spec
397
+ * watch is built on and the same one here.
398
+ */
399
+ export declare function toolSearchDocProblems(pages: Map<string, string | null>): string[];
324
400
  /** Derive the band from a supplied divergence run, falling back to the published one. */
325
401
  export declare function wireToClientRatio(run?: DivergenceRun | null): WireToClientRatio;
326
402
  /** One measured server, as the deferral arithmetic needs it. */
@@ -434,6 +510,12 @@ export interface DeferralVerdict {
434
510
  sharedMeasurements: number;
435
511
  /** Conditions this cannot read, under which a deferring client pays in full. */
436
512
  exceptions: string[];
513
+ /**
514
+ * What the client's vendor is on record as doing, where that is not Claude
515
+ * Code and a record exists. Null in every other mode, including the absence
516
+ * of a record, which is a different fact and is printed as one.
517
+ */
518
+ record: DeferralRecord | null;
437
519
  /**
438
520
  * Servers in this scope pinned `alwaysLoad: true` in their entry, and their
439
521
  * wire tokens. Read from the config, so it is stated rather than listed as a
@@ -444,6 +526,27 @@ export interface DeferralVerdict {
444
526
  tokens: number;
445
527
  };
446
528
  }
529
+ /**
530
+ * What a vendor is on record as doing, for a client this audit cannot measure.
531
+ *
532
+ * Every line here is printed, so every line is written to be checked: the claim
533
+ * is the vendor's, the conditions are the ones the vendor's own record leaves
534
+ * open, and the sources carry the address and the date they were read at. What
535
+ * this type deliberately cannot express is a verdict. A record establishes what
536
+ * a vendor says or ships; nothing in it says what a session paid.
537
+ */
538
+ export interface DeferralRecord {
539
+ /** The vendor's own name for the mechanism, so a reader can look it up. */
540
+ mechanism: string;
541
+ /** What the record establishes. Lines, wrapped for the report. */
542
+ states: string[];
543
+ /** Why the posture still cannot be read off the audited machine. */
544
+ notReadable: string[];
545
+ /** What the record leaves open — printed as conditions, never resolved. */
546
+ conditions: string[];
547
+ /** First-party sources, each with the date it was read. */
548
+ sources: string[];
549
+ }
447
550
  /**
448
551
  * Read one session's deferral position. Pure arithmetic over a built scope — no
449
552
  * config file is re-read and no server is launched. The environment is passed