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 +92 -38
- package/dist/audit/audit.js +71 -0
- package/dist/audit/config.d.ts +31 -1
- package/dist/audit/config.js +49 -3
- package/dist/audit/deferral.d.ts +115 -12
- package/dist/audit/deferral.js +348 -22
- package/dist/audit/remote.d.ts +17 -32
- package/dist/audit/remote.js +99 -15
- package/dist/audit/run.js +14 -2
- package/dist/core/adoption.d.ts +162 -8
- package/dist/core/adoption.js +251 -12
- package/dist/core/canonical.d.ts +23 -1
- package/dist/core/canonical.js +30 -9
- package/dist/core/protocol.d.ts +116 -0
- package/dist/core/protocol.js +119 -0
- package/dist/core/types.d.ts +61 -1
- package/dist/sweep/client.d.ts +76 -1
- package/dist/sweep/client.js +69 -5
- package/dist/sweep/dashboard.js +24 -5
- package/dist/sweep/harness-guard.d.ts +13 -1
- package/dist/sweep/harness-guard.js +13 -1
- package/dist/sweep/page-prose.d.ts +25 -0
- package/dist/sweep/page-prose.js +45 -0
- package/dist/sweep/pr-check.d.ts +16 -1
- package/dist/sweep/pr-check.js +16 -1
- package/dist/sweep/published-stats.d.ts +63 -2
- package/dist/sweep/published-stats.js +153 -49
- package/dist/sweep/report.d.ts +36 -0
- package/dist/sweep/report.js +39 -3
- package/dist/sweep/run.d.ts +53 -1
- package/dist/sweep/run.js +57 -1
- package/dist/sweep/server-pages.d.ts +8 -2
- package/dist/sweep/server-pages.js +78 -12
- package/dist/sweep/servers-schema.d.ts +1 -0
- package/dist/sweep/servers-schema.js +30 -0
- package/package.json +1 -1
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
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
87
|
-
configured **on this machine
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
148
|
-
|
|
149
|
-
itself: for the
|
|
150
|
-
|
|
151
|
-
|
|
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-
|
|
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
|
|
274
|
-
|
|
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** |
|
|
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
|
|
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
|
package/dist/audit/audit.js
CHANGED
|
@@ -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).');
|
package/dist/audit/config.d.ts
CHANGED
|
@@ -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
|
*
|
package/dist/audit/config.js
CHANGED
|
@@ -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
|
-
?
|
|
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
|
*
|
package/dist/audit/deferral.d.ts
CHANGED
|
@@ -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
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
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'
|
|
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
|