mcp-context-cost 0.19.0 → 0.20.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 +20 -28
- package/dist/audit/audit.d.ts +54 -1
- package/dist/audit/audit.js +211 -28
- package/dist/audit/config.d.ts +21 -0
- package/dist/audit/config.js +65 -4
- package/dist/audit/deferral.d.ts +8 -1
- package/dist/audit/deferral.js +8 -5
- package/dist/audit/mcp-policy.d.ts +134 -0
- package/dist/audit/mcp-policy.js +229 -0
- package/dist/audit/run.js +2 -2
- package/dist/cli.d.ts +0 -49
- package/dist/cli.js +3 -111
- package/dist/core/protocol.d.ts +22 -0
- package/dist/core/protocol.js +11 -0
- package/dist/core/types.d.ts +9 -5
- package/dist/flags.d.ts +61 -0
- package/dist/flags.js +123 -0
- package/dist/sweep/client.d.ts +8 -0
- package/dist/sweep/client.js +3 -0
- package/dist/sweep/cross-check.js +20 -10
- package/dist/sweep/dashboard.js +2 -1
- package/dist/sweep/harness-guard.d.ts +27 -3
- package/dist/sweep/harness-guard.js +30 -6
- package/dist/sweep/pr-check.js +20 -12
- package/dist/sweep/registry-scan.d.ts +1 -2
- package/dist/sweep/registry-scan.js +1 -2
- package/dist/sweep/run.d.ts +15 -0
- package/dist/sweep/run.js +53 -10
- package/dist/sweep/sweep-all.js +22 -12
- package/package.json +3 -4
- package/dist/core/adoption.d.ts +0 -403
- package/dist/core/adoption.js +0 -709
package/README.md
CHANGED
|
@@ -85,8 +85,9 @@ project, and no **value** an entry carries — an env var, a header — is ever
|
|
|
85
85
|
output. Only their names are.
|
|
86
86
|
|
|
87
87
|
A remote entry — `url`, or the client's own spelling of it — is probed before anything is
|
|
88
|
-
launched, and reported as measured, **auth-walled
|
|
89
|
-
answers; an auth-walled row makes
|
|
88
|
+
launched, and reported as measured, **auth-walled**, **protocol-mismatch** or
|
|
89
|
+
**unreachable** depending on what it answers; an auth-walled or protocol-mismatch row makes
|
|
90
|
+
the total above it a floor rather than a number. The probe,
|
|
90
91
|
what each verdict quotes, and why it exists at all are in
|
|
91
92
|
[METHODOLOGY §who pays the number](docs/METHODOLOGY.md#who-pays). Nothing here opens a
|
|
92
93
|
browser.
|
|
@@ -101,7 +102,10 @@ Not every client puts every tool definition in context on every request, so the
|
|
|
101
102
|
is not automatically your bill. Which client reads the config decides it, and for Claude Code
|
|
102
103
|
so does how that client is configured **on this machine** — three environment variables and a
|
|
103
104
|
per-server `alwaysLoad` pin, which `audit` reads rather than assumes, from the shell it runs
|
|
104
|
-
in *and* from the `env` block of Claude Code's own settings files.
|
|
105
|
+
in *and* from the `env` block of Claude Code's own settings files. It also reads what an
|
|
106
|
+
organisation deploys: the managed MCP file that takes exclusive control of the server list
|
|
107
|
+
where it exists, and the allow/deny lists that filter what loads — a clean deny is applied to
|
|
108
|
+
the session's claims, an allowlist is reported and never subtracted. No other client's posture
|
|
105
109
|
is readable from a file this opens, so for those the report gives what the vendor is on record
|
|
106
110
|
with, or says there is nothing on record, and claims neither as a measurement.
|
|
107
111
|
|
|
@@ -180,6 +184,13 @@ npx -y mcp-context-cost audit --config .mcp.json --budget 20000
|
|
|
180
184
|
# exits 1 when the stack exceeds the budget, so a PR adding a 25K-token server fails
|
|
181
185
|
```
|
|
182
186
|
|
|
187
|
+
The budget is denominated in **sessions**, because a context window belongs to one session:
|
|
188
|
+
for claude-code that is the files one session loads together — both its configs, or the
|
|
189
|
+
managed file alone where one is deployed, minus anything `deniedMcpServers` removes — and for
|
|
190
|
+
every other client one file is one session. Per-file totals are still reported as file facts;
|
|
191
|
+
the gate reads the costliest session. The baseline diff below stays per file, because a
|
|
192
|
+
baseline pins a file artifact.
|
|
193
|
+
|
|
183
194
|
The budget is an absolute ceiling. What a reviewer actually wants to know is what *this pull
|
|
184
195
|
request* did, so record a baseline and diff against it:
|
|
185
196
|
|
|
@@ -256,7 +267,7 @@ does, and the percentile that decides are in
|
|
|
256
267
|
|
|
257
268
|
```
|
|
258
269
|
suggest — descriptions at or above the 90th percentile of measured tools
|
|
259
|
-
(baseline 2026-09-
|
|
270
|
+
(baseline 2026-09-09: 1,433 tools across 87 measured servers):
|
|
260
271
|
stub · wordy — 345 tokens: description 321 (p92), schema 14
|
|
261
272
|
rewriting the description toward the measured median (27) would recover ≈294 tokens on every request
|
|
262
273
|
1 of 2 tools sit inside the distribution — no advice where nothing is measurably unusual.
|
|
@@ -316,7 +327,7 @@ answers a question no client asks: **what did this server cost last month?**
|
|
|
316
327
|
[results/regressions.md](results/regressions.md) reports each server's most recent movement —
|
|
317
328
|
dated to when it happened, separated into *shipped more tools* versus *same tools, rewritten*,
|
|
318
329
|
and compared only within one isolation. The ecosystem ratchets upward: of the servers whose
|
|
319
|
-
cost has moved at all,
|
|
330
|
+
cost has moved at all, 15 moved up against 5 that moved down. Method:
|
|
320
331
|
[cost movement](docs/METHODOLOGY.md#cost-movement).
|
|
321
332
|
|
|
322
333
|
If you publish a server, the same measurement is available as a badge, so your users can see
|
|
@@ -385,7 +396,7 @@ number is *not*, config policy, failure taxonomy, frozen color bands, known dive
|
|
|
385
396
|
| `src/audit/` | client-config discovery (10 clients; JSON with comments, TOML, YAML), the remote probe, the per-stack report, and the baseline diff |
|
|
386
397
|
| `src/cli.ts` | `audit` (measure your own stack), `verify` (re-derive any published number), `measure` |
|
|
387
398
|
| `spec/fixtures/` | golden vectors shared by the TypeScript and bash implementations |
|
|
388
|
-
| `tools/` | the scripts that call a network API — the Claude divergence run, the
|
|
399
|
+
| `tools/` | the scripts that call a network API — the Claude divergence run, the registry scan — kept out of the package so the library stays offline |
|
|
389
400
|
| `upstream/` | `badge.sh` + bash tests — the self-serve badge recipe, carried here |
|
|
390
401
|
| `servers.yaml` | 107 curated candidates with live install metrics and provenance |
|
|
391
402
|
| `results/` · `badges/` | measurements, leaderboard, history series, shields endpoint JSONs |
|
|
@@ -423,7 +434,7 @@ repository), then in your README:
|
|
|
423
434
|
A badge says what your server costs today; it does nothing about the release
|
|
424
435
|
that adds 1,200 tokens to every user's context next month. Across the servers
|
|
425
436
|
measured here most costs hold steady from sweep to sweep, but when a cost does
|
|
426
|
-
move it usually moves up: the [movement report](results/regressions.md) has
|
|
437
|
+
move it usually moves up: the [movement report](results/regressions.md) has 15
|
|
427
438
|
servers ratcheting upward against 5 that got cheaper, and none of those
|
|
428
439
|
maintainers had a check that would have said so first. `measure` takes the same
|
|
429
440
|
gate flags `audit` does, so your own CI can be that check:
|
|
@@ -461,25 +472,6 @@ Point the link at the measurement behind the number — for servers in this swee
|
|
|
461
472
|
`https://athakur3.github.io/mcp-context-cost/servers/<name>.html`; otherwise the
|
|
462
473
|
methodology page. A badge nobody can audit is decoration.
|
|
463
474
|
|
|
464
|
-
How many projects outside this repository actually display it is a dated reading rather
|
|
465
|
-
than a guess — [docs/adoption.md](docs/adoption.md), regenerated by `npm run adoption`,
|
|
466
|
-
which publishes the queries it ran and every file it examined. A zero there means the
|
|
467
|
-
search ran and found none; if it could not run, it says that instead of publishing a zero.
|
|
468
|
-
|
|
469
|
-
## Development
|
|
470
|
-
|
|
471
|
-
```bash
|
|
472
|
-
npm test # TS suite incl. golden fixtures + dispute drills
|
|
473
|
-
npx tsc --noEmit # typecheck
|
|
474
|
-
./upstream/tests/badge-test.sh # bash suite — byte-identical to the TS reference
|
|
475
|
-
npm run sweep:all -- --docker # full curated sweep (Docker isolation)
|
|
476
|
-
```
|
|
477
|
-
|
|
478
|
-
Notable engineering choices: the MCP client is a deliberate raw-wire implementation
|
|
479
|
-
rather than the SDK (schema-parsing can reorder keys, which would corrupt canonical bytes);
|
|
480
|
-
sweep servers run in credential-free Docker containers with recorded isolation; the badge
|
|
481
|
-
color bands are frozen against the observed distribution of the first full sweep.
|
|
482
|
-
|
|
483
475
|
## Status
|
|
484
476
|
|
|
485
477
|
Active. Every row carries the date of its own most recent measurement. Two
|
|
@@ -489,8 +481,8 @@ date as the date it means, and don't take the cadence on trust — the build his
|
|
|
489
481
|
public, one click each:
|
|
490
482
|
[re-sweep runs](https://github.com/athakur3/mcp-context-cost/actions/workflows/resweep.yml)
|
|
491
483
|
and [self-badge runs](https://github.com/athakur3/mcp-context-cost/actions/workflows/self-badge.yml).
|
|
492
|
-
|
|
493
|
-
`servers.yaml` entries
|
|
484
|
+
What changed, and why, is in [CHANGELOG.md](CHANGELOG.md); contributions welcome, especially
|
|
485
|
+
new `servers.yaml` entries — [CONTRIBUTING.md](CONTRIBUTING.md) says what an entry needs, the
|
|
494
486
|
order of steps that leaves a pull request green, and where its number comes from.
|
|
495
487
|
|
|
496
488
|
MIT © 2026
|
package/dist/audit/audit.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { ConfiguredServer, LoadedConfig } from './config.js';
|
|
|
6
6
|
import type { RemoteProbe } from './remote.js';
|
|
7
7
|
import { type DeferralVerdict, type ToolSearchEnv, type ToolSearchSource } from './deferral.js';
|
|
8
8
|
import { type AuditDiff, type IncreaseGate } from './diff.js';
|
|
9
|
+
import { type DenyVerdict, type McpPolicyEvaluation, type UnevaluatedEntry } from './mcp-policy.js';
|
|
9
10
|
export declare const DEFAULT_CONTEXT_WINDOW = 200000;
|
|
10
11
|
/**
|
|
11
12
|
* A server's status in a report: the measurement statuses, plus two that only
|
|
@@ -99,6 +100,15 @@ export interface AuditConfigResult {
|
|
|
99
100
|
suggestions?: ConfigSuggestions | undefined;
|
|
100
101
|
/** Present only when `--changed` ran with a usable capture index. */
|
|
101
102
|
captureVerdicts?: ServerCaptureVerdict[] | undefined;
|
|
103
|
+
/** Set on the managed MCP file's own row (see managedMcpPath in config.ts). */
|
|
104
|
+
managed?: true | undefined;
|
|
105
|
+
/**
|
|
106
|
+
* Set on a claude-code config that is measured here but does not load in a
|
|
107
|
+
* session, because a managed MCP file has exclusive control. The value is
|
|
108
|
+
* that file's path. The measurement stands — it is a fact about this file —
|
|
109
|
+
* and every session-level claim belongs to the managed file instead.
|
|
110
|
+
*/
|
|
111
|
+
suppressedByManagedMcp?: string | undefined;
|
|
102
112
|
/**
|
|
103
113
|
* Whether this client loads the total up front or defers it, and — when the
|
|
104
114
|
* client decides that by a threshold — which side of it this stack is on.
|
|
@@ -138,7 +148,7 @@ export interface BudgetFit {
|
|
|
138
148
|
* need, and dropping by weight will sometimes name the one you cannot live without. That
|
|
139
149
|
* caveat is printed with the result rather than left implied.
|
|
140
150
|
*/
|
|
141
|
-
export declare function planBudgetFit(config: AuditConfigResult, limit: number): BudgetFit;
|
|
151
|
+
export declare function planBudgetFit(config: Pick<AuditConfigResult, 'servers' | 'totalTokens'>, limit: number): BudgetFit;
|
|
142
152
|
export interface AuditReport {
|
|
143
153
|
methodologyVersion: string;
|
|
144
154
|
encoding: 'o200k_base';
|
|
@@ -175,6 +185,49 @@ export interface AuditReport {
|
|
|
175
185
|
/** Present only when over budget: the arithmetic of getting back under it. */
|
|
176
186
|
fit?: BudgetFit | undefined;
|
|
177
187
|
};
|
|
188
|
+
/**
|
|
189
|
+
* What each session loads, one row per session scope — for claude-code the
|
|
190
|
+
* files one session reads together (the managed file alone where deployed),
|
|
191
|
+
* with denylist removals already applied and named. Config rows above stay
|
|
192
|
+
* file facts; this is the session fact, and it is what `--budget` gates.
|
|
193
|
+
*/
|
|
194
|
+
sessions?: {
|
|
195
|
+
client: string;
|
|
196
|
+
sources: string[];
|
|
197
|
+
totalTokens: number;
|
|
198
|
+
contextShare: number;
|
|
199
|
+
deniedTokens?: number | undefined;
|
|
200
|
+
}[] | undefined;
|
|
201
|
+
/**
|
|
202
|
+
* Present when the managed MCP file exists on this machine. `exclusive`
|
|
203
|
+
* means a claude-code session loads only its servers; `disabled` that it is
|
|
204
|
+
* deployed with an empty server map, so no MCP server loads at all;
|
|
205
|
+
* `unreadable` that it exists and could not be read, so which servers a
|
|
206
|
+
* session loads cannot be said. `suppressed` names the claude-code configs
|
|
207
|
+
* measured here that do not load under it. Source:
|
|
208
|
+
* code.claude.com/docs/en/managed-mcp.md, read 2026-09-09.
|
|
209
|
+
*/
|
|
210
|
+
managedMcp?: {
|
|
211
|
+
path: string;
|
|
212
|
+
state: 'exclusive' | 'disabled' | 'unreadable';
|
|
213
|
+
error?: string | undefined;
|
|
214
|
+
suppressed: string[];
|
|
215
|
+
} | undefined;
|
|
216
|
+
/**
|
|
217
|
+
* Claude Code's MCP allowlist/denylist, as read from the settings files this
|
|
218
|
+
* audit opens, evaluated over the claude-code session's servers. Deny
|
|
219
|
+
* matches are applied — those servers are left out of the session-level
|
|
220
|
+
* sums, never out of a file's own total; allow verdicts are reported and
|
|
221
|
+
* never applied (mcp-policy.ts says why the two differ). Absent when no read
|
|
222
|
+
* settings file sets either list.
|
|
223
|
+
*/
|
|
224
|
+
mcpPolicy?: {
|
|
225
|
+
denied: (DenyVerdict & {
|
|
226
|
+
tokens: number | null;
|
|
227
|
+
})[];
|
|
228
|
+
denyUnevaluated: UnevaluatedEntry[];
|
|
229
|
+
allow?: McpPolicyEvaluation['allow'];
|
|
230
|
+
} | undefined;
|
|
178
231
|
/** Present only when a divergence run was supplied (`--claude`). */
|
|
179
232
|
claudeDivergence?: {
|
|
180
233
|
model: string;
|
package/dist/audit/audit.js
CHANGED
|
@@ -19,6 +19,7 @@ import { identify } from '../core/capture-index.js';
|
|
|
19
19
|
import { evaluateDeferral, BAND_PRECISION, PUBLISHED_WIRE_TO_CLIENT_RATIO, SHELL_SOURCE, } from './deferral.js';
|
|
20
20
|
import { formatDiff, formatGate } from './diff.js';
|
|
21
21
|
import { signed } from '../core/format.js';
|
|
22
|
+
import { evaluateMcpPolicy, } from './mcp-policy.js';
|
|
22
23
|
export const DEFAULT_CONTEXT_WINDOW = 200_000;
|
|
23
24
|
const TRIM_TOOL_COUNT = 3;
|
|
24
25
|
function buildSuggestions(pool, baseline) {
|
|
@@ -159,6 +160,39 @@ function measuredOk(m) {
|
|
|
159
160
|
* together — not for adding one client's servers to another's.
|
|
160
161
|
*/
|
|
161
162
|
const ONE_SESSION_PER_CLIENT = new Set(['claude-code']);
|
|
163
|
+
function composeSessions(built, opts) {
|
|
164
|
+
const scopes = new Map();
|
|
165
|
+
for (const cfg of built) {
|
|
166
|
+
const key = deferralScopeKey(cfg.client, cfg.source);
|
|
167
|
+
const group = scopes.get(key);
|
|
168
|
+
if (group)
|
|
169
|
+
group.push(cfg);
|
|
170
|
+
else
|
|
171
|
+
scopes.set(key, [cfg]);
|
|
172
|
+
}
|
|
173
|
+
const out = new Map();
|
|
174
|
+
for (const [key, group] of scopes) {
|
|
175
|
+
const isCc = ONE_SESSION_PER_CLIENT.has(group[0].client);
|
|
176
|
+
const sessionCfgs = isCc && opts.managedActive ? group.filter((c) => c.managed) : group;
|
|
177
|
+
const denied = isCc ? (opts.deniedNames ?? new Set()) : new Set();
|
|
178
|
+
const all = sessionCfgs.flatMap((c) => c.servers);
|
|
179
|
+
const servers = all.filter((s) => !denied.has(s.name));
|
|
180
|
+
out.set(key, {
|
|
181
|
+
client: group[0].client,
|
|
182
|
+
group,
|
|
183
|
+
sessionCfgs,
|
|
184
|
+
sources: sessionCfgs.map((c) => c.source),
|
|
185
|
+
servers,
|
|
186
|
+
totalTokens: servers.reduce((a, s) => a + (s.tokens ?? 0), 0),
|
|
187
|
+
deniedTokens: all.filter((s) => denied.has(s.name)).reduce((a, s) => a + (s.tokens ?? 0), 0),
|
|
188
|
+
skippedRows: sessionCfgs.flatMap((c) => c.skipped
|
|
189
|
+
.filter((s) => !denied.has(s.name))
|
|
190
|
+
.map((s) => ({ source: c.source, name: s.name, status: s.status }))),
|
|
191
|
+
sharedSum: sessionCfgs.reduce((a, c) => a + (opts.shared.get(c) ?? 0), 0),
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
162
196
|
/** Which configs share a deferral verdict. */
|
|
163
197
|
function deferralScopeKey(client, source) {
|
|
164
198
|
return ONE_SESSION_PER_CLIENT.has(client) ? client : `${client}\0${source}`;
|
|
@@ -169,31 +203,26 @@ function deferralScopeKey(client, source) {
|
|
|
169
203
|
* print it once and a `--json` consumer can see which files it spans.
|
|
170
204
|
*/
|
|
171
205
|
function attachDeferral(configs, contextWindow, opts) {
|
|
172
|
-
const scopes = new Map();
|
|
173
|
-
for (const cfg of configs) {
|
|
174
|
-
const key = deferralScopeKey(cfg.client, cfg.source);
|
|
175
|
-
const group = scopes.get(key);
|
|
176
|
-
if (group)
|
|
177
|
-
group.push(cfg);
|
|
178
|
-
else
|
|
179
|
-
scopes.set(key, [cfg]);
|
|
180
|
-
}
|
|
181
206
|
const verdicts = new Map();
|
|
182
|
-
for (const [key,
|
|
207
|
+
for (const [key, comp] of opts.sessions) {
|
|
208
|
+
// The composition already speaks the managed file's exclusivity and the
|
|
209
|
+
// denylist: what reaches evaluateDeferral is what the session loads, and
|
|
210
|
+
// suppressed configs still share the verdict object so their rows can
|
|
211
|
+
// point at it.
|
|
183
212
|
verdicts.set(key,
|
|
184
213
|
// Computed against the same context window the share uses, so any
|
|
185
214
|
// threshold moves with `--context` instead of being pinned to 200,000.
|
|
186
215
|
evaluateDeferral({
|
|
187
|
-
client:
|
|
188
|
-
sources:
|
|
189
|
-
servers:
|
|
216
|
+
client: comp.client,
|
|
217
|
+
sources: comp.sources,
|
|
218
|
+
servers: comp.servers.map((s) => ({
|
|
190
219
|
name: s.name,
|
|
191
220
|
tokens: s.tokens ?? 0,
|
|
192
221
|
claudeTokens: s.claudeTokens,
|
|
193
222
|
...(s.alwaysLoad ? { alwaysLoad: true } : {}),
|
|
194
|
-
}))
|
|
195
|
-
skippedCount:
|
|
196
|
-
sharedMeasurements:
|
|
223
|
+
})),
|
|
224
|
+
skippedCount: comp.skippedRows.length,
|
|
225
|
+
sharedMeasurements: comp.sharedSum,
|
|
197
226
|
}, { contextWindow, env: opts.env, settings: opts.settings, divergence: opts.divergence }));
|
|
198
227
|
}
|
|
199
228
|
return configs.map((cfg) => ({
|
|
@@ -222,6 +251,38 @@ export function buildReport(configs, measured, opts = {}) {
|
|
|
222
251
|
// the other client's entry just the same.
|
|
223
252
|
const collapsed = collapsedKeys(configs);
|
|
224
253
|
const shared = new Map();
|
|
254
|
+
// The managed MCP file, when present, decides what a claude-code session
|
|
255
|
+
// loads (config.ts: managedMcpPath). Detected before the build, so every
|
|
256
|
+
// claude-code row can say which side of that fact it is on.
|
|
257
|
+
const managedCfg = configs.find((c) => c.client === 'claude-code' && c.managed);
|
|
258
|
+
const otherCc = configs.filter((c) => c.client === 'claude-code' && !c.managed);
|
|
259
|
+
const managedState = managedCfg
|
|
260
|
+
? managedCfg.error
|
|
261
|
+
? 'unreadable'
|
|
262
|
+
: managedCfg.servers.length
|
|
263
|
+
? 'exclusive'
|
|
264
|
+
: 'disabled'
|
|
265
|
+
: undefined;
|
|
266
|
+
const managedActive = managedState === 'exclusive' || managedState === 'disabled';
|
|
267
|
+
// The claude-code session's servers, managed-aware — taken at the config
|
|
268
|
+
// level because policy entries match argv and URL, which built rows drop.
|
|
269
|
+
const ccSessionServers = managedActive
|
|
270
|
+
? managedState === 'exclusive'
|
|
271
|
+
? managedCfg.servers
|
|
272
|
+
: []
|
|
273
|
+
: otherCc.flatMap((c) => c.servers);
|
|
274
|
+
const policy = evaluateMcpPolicy(ccSessionServers.map((s) => ({
|
|
275
|
+
name: s.name,
|
|
276
|
+
transport: s.transport,
|
|
277
|
+
argv: s.argv,
|
|
278
|
+
url: s.url,
|
|
279
|
+
})), (opts.settings ?? []).map((s) => ({
|
|
280
|
+
scope: s.scope,
|
|
281
|
+
source: s.source,
|
|
282
|
+
state: s.state,
|
|
283
|
+
policy: s.mcpPolicy,
|
|
284
|
+
})));
|
|
285
|
+
const deniedNames = new Set(policy.denied.map((d) => d.server));
|
|
225
286
|
for (const cfg of configs) {
|
|
226
287
|
if (cfg.error) {
|
|
227
288
|
problems.push(`${cfg.source}: ${cfg.error}`);
|
|
@@ -377,12 +438,21 @@ export function buildReport(configs, measured, opts = {}) {
|
|
|
377
438
|
verdict: identify(s.canonicalSha256, opts.captureIndex),
|
|
378
439
|
}))
|
|
379
440
|
: undefined,
|
|
441
|
+
...(cfg.managed ? { managed: true } : {}),
|
|
442
|
+
...(managedActive && cfg.client === 'claude-code' && !cfg.managed
|
|
443
|
+
? { suppressedByManagedMcp: managedCfg.source }
|
|
444
|
+
: {}),
|
|
380
445
|
};
|
|
381
446
|
built.push(result);
|
|
382
447
|
shared.set(result, sharedHere);
|
|
383
448
|
}
|
|
384
449
|
built.sort((a, b) => b.totalTokens - a.totalTokens);
|
|
385
|
-
const
|
|
450
|
+
const sessions = composeSessions(built, {
|
|
451
|
+
shared,
|
|
452
|
+
managedActive: managedActive ? managedState : undefined,
|
|
453
|
+
deniedNames,
|
|
454
|
+
});
|
|
455
|
+
const results = attachDeferral(built, contextWindow, { ...opts, shared, sessions });
|
|
386
456
|
const report = {
|
|
387
457
|
methodologyVersion: METHODOLOGY_VERSION,
|
|
388
458
|
encoding: 'o200k_base',
|
|
@@ -392,6 +462,32 @@ export function buildReport(configs, measured, opts = {}) {
|
|
|
392
462
|
emptyConfigs,
|
|
393
463
|
problems,
|
|
394
464
|
};
|
|
465
|
+
report.sessions = [...sessions.values()].map((s) => ({
|
|
466
|
+
client: s.client,
|
|
467
|
+
sources: s.sources,
|
|
468
|
+
totalTokens: s.totalTokens,
|
|
469
|
+
contextShare: s.totalTokens / contextWindow,
|
|
470
|
+
...(s.deniedTokens > 0 ? { deniedTokens: s.deniedTokens } : {}),
|
|
471
|
+
}));
|
|
472
|
+
if (managedCfg && managedState) {
|
|
473
|
+
report.managedMcp = {
|
|
474
|
+
path: managedCfg.source,
|
|
475
|
+
state: managedState,
|
|
476
|
+
...(managedCfg.error ? { error: managedCfg.error } : {}),
|
|
477
|
+
suppressed: managedActive ? otherCc.map((c) => c.source) : [],
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
if (policy.sources.length) {
|
|
481
|
+
const ccServers = built.filter((b) => b.client === 'claude-code').flatMap((b) => b.servers);
|
|
482
|
+
report.mcpPolicy = {
|
|
483
|
+
denied: policy.denied.map((d) => ({
|
|
484
|
+
...d,
|
|
485
|
+
tokens: ccServers.find((s) => s.name === d.server)?.tokens ?? null,
|
|
486
|
+
})),
|
|
487
|
+
denyUnevaluated: policy.denyUnevaluated,
|
|
488
|
+
...(policy.allow ? { allow: policy.allow } : {}),
|
|
489
|
+
};
|
|
490
|
+
}
|
|
395
491
|
if (opts.divergence) {
|
|
396
492
|
report.claudeDivergence = {
|
|
397
493
|
model: opts.divergence.model,
|
|
@@ -412,23 +508,28 @@ export function buildReport(configs, measured, opts = {}) {
|
|
|
412
508
|
};
|
|
413
509
|
}
|
|
414
510
|
if (typeof opts.budget === 'number') {
|
|
415
|
-
// The worst
|
|
416
|
-
//
|
|
417
|
-
|
|
511
|
+
// The worst SESSION is the gate — decided 2026-09-09. A context window
|
|
512
|
+
// belongs to one session, and for claude-code a session loads two files
|
|
513
|
+
// together (or the managed file alone, minus what the denylist removed);
|
|
514
|
+
// gating the worst file passed a budget the session it belongs to blows.
|
|
515
|
+
// Passing because your *lightest* client fits would still be a green check
|
|
516
|
+
// on a session you don't run, so the costliest one is the gate.
|
|
517
|
+
const worst = [...sessions.values()].toSorted((a, b) => b.totalTokens - a.totalTokens)[0];
|
|
418
518
|
// A total is only a ceiling to compare against if it is the whole cost. A
|
|
419
|
-
// server that failed to start contributes 0, so the
|
|
519
|
+
// server that failed to start contributes 0, so the session reads lighter
|
|
420
520
|
// than it is and the budget passes on a number that is missing a server —
|
|
421
|
-
// exactly the PR the README says this gate catches.
|
|
422
|
-
//
|
|
423
|
-
// skipped row counts: an auth-walled endpoint is a working server the
|
|
521
|
+
// exactly the PR the README says this gate catches. Every skipped row of
|
|
522
|
+
// every session counts: an auth-walled endpoint is a working server the
|
|
424
523
|
// session pays for with its credential, and an unreachable one is a cost
|
|
425
|
-
// this could not establish, not a cost of zero.
|
|
426
|
-
|
|
524
|
+
// this could not establish, not a cost of zero. What no session loads — a
|
|
525
|
+
// config the managed file suppresses, a server the denylist removed — is
|
|
526
|
+
// nobody's bill, so it does not count.
|
|
527
|
+
const unestablished = [...sessions.values()].flatMap((s) => s.skippedRows.map((r) => `${r.source}: ${r.name} (${r.status})`));
|
|
427
528
|
const over = (worst?.totalTokens ?? 0) > opts.budget;
|
|
428
529
|
report.budget = {
|
|
429
530
|
limit: opts.budget,
|
|
430
531
|
worstTotal: worst?.totalTokens ?? 0,
|
|
431
|
-
worstSource: worst
|
|
532
|
+
worstSource: worst ? worst.sources.join(' + ') || '(none)' : '(none)',
|
|
432
533
|
over: over || unestablished.length > 0,
|
|
433
534
|
// Named so the reader knows which way the number is wrong: the total
|
|
434
535
|
// understates by however much these cost, which nobody knows.
|
|
@@ -808,12 +909,36 @@ export function formatReport(report) {
|
|
|
808
909
|
const lines = [];
|
|
809
910
|
lines.push(`mcp-context-cost audit · methodology ${report.methodologyVersion} · ${report.encoding} · context window ${n(report.contextWindow)}`);
|
|
810
911
|
const showClaude = !!report.claudeDivergence;
|
|
912
|
+
// The managed MCP file with no server rows of its own still owns the session:
|
|
913
|
+
// deployed empty it disables MCP, unreadable it makes the session unsayable.
|
|
914
|
+
// Either way that is the first fact about claude-code on this machine.
|
|
915
|
+
if (report.managedMcp && report.managedMcp.state !== 'exclusive') {
|
|
916
|
+
const m = report.managedMcp;
|
|
917
|
+
lines.push('');
|
|
918
|
+
lines.push(`claude-code ${m.path}`);
|
|
919
|
+
if (m.state === 'disabled') {
|
|
920
|
+
lines.push(' managed: deployed with an empty server map — MCP is disabled by policy. No MCP', ' server loads in a claude-code session on this machine, whatever the configs below', ' declare; they are measured as files.');
|
|
921
|
+
}
|
|
922
|
+
else {
|
|
923
|
+
lines.push(' managed: this file exists and could not be read — which servers a claude-code', ' session loads cannot be said. The configs below are measured as files, and no', ' session-level claim is made for this client.');
|
|
924
|
+
if (m.error)
|
|
925
|
+
lines.push(` ${m.error}`);
|
|
926
|
+
}
|
|
927
|
+
}
|
|
811
928
|
// Configs one session loads together share a verdict object; it answers for
|
|
812
929
|
// all of them at once, so it is printed under the first one and not repeated.
|
|
813
930
|
const verdictPrinted = new Set();
|
|
931
|
+
const sessionPrinted = new Set();
|
|
932
|
+
let policyPrinted = false;
|
|
814
933
|
for (const cfg of report.configs) {
|
|
815
934
|
lines.push('');
|
|
816
935
|
lines.push(`${cfg.client} ${cfg.source}`);
|
|
936
|
+
if (cfg.managed) {
|
|
937
|
+
lines.push(' managed-mcp.json — exclusive control: a claude-code session loads only the servers', ' in this file (plus in-process servers the launching app registers, which no config', ' file describes). Other claude-code configs here are measured and do not load.');
|
|
938
|
+
}
|
|
939
|
+
if (cfg.suppressedByManagedMcp) {
|
|
940
|
+
lines.push(` does not load: ${cfg.suppressedByManagedMcp} has exclusive control of`, ' claude-code sessions. Measured all the same — the numbers below are facts about', ' this file, not about any session.');
|
|
941
|
+
}
|
|
817
942
|
const rows = cfg.servers.map((s) => ({
|
|
818
943
|
name: s.name,
|
|
819
944
|
tools: s.toolCount === null ? '—' : String(s.toolCount),
|
|
@@ -844,6 +969,64 @@ export function formatReport(report) {
|
|
|
844
969
|
for (const deferralLine of deferralLines(cfg.deferral, skippedInScope))
|
|
845
970
|
lines.push(deferralLine);
|
|
846
971
|
}
|
|
972
|
+
{
|
|
973
|
+
const session = report.sessions?.find((s) => ONE_SESSION_PER_CLIENT.has(cfg.client)
|
|
974
|
+
? s.client === cfg.client
|
|
975
|
+
: s.client === cfg.client && s.sources.length === 1 && s.sources[0] === cfg.source);
|
|
976
|
+
if (session &&
|
|
977
|
+
!sessionPrinted.has(session) &&
|
|
978
|
+
(session.sources.length !== 1 || (session.deniedTokens ?? 0) > 0)) {
|
|
979
|
+
sessionPrinted.add(session);
|
|
980
|
+
const denied = (session.deniedTokens ?? 0) > 0
|
|
981
|
+
? ` (after ${n(session.deniedTokens)} tokens removed by deniedMcpServers)`
|
|
982
|
+
: '';
|
|
983
|
+
const what = session.sources.length === 0
|
|
984
|
+
? 'no MCP servers'
|
|
985
|
+
: `${session.sources.length} config files together`;
|
|
986
|
+
lines.push('');
|
|
987
|
+
lines.push(` one ${cfg.client} session loads ${what}: ${n(session.totalTokens)} tokens — ` +
|
|
988
|
+
`${pct(session.contextShare)} of a ${n(report.contextWindow)}-token context window${denied}.`);
|
|
989
|
+
lines.push(' That session figure is what --budget gates; per-file totals stay above.');
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
if (cfg.client === 'claude-code' && report.mcpPolicy && !policyPrinted) {
|
|
993
|
+
policyPrinted = true;
|
|
994
|
+
const p = report.mcpPolicy;
|
|
995
|
+
if (p.denied.length) {
|
|
996
|
+
lines.push('');
|
|
997
|
+
lines.push(' blocked by deniedMcpServers — a denied server does not load, whatever else is', " set, so these are left out of the session claims above (never out of a file's", ' own total):');
|
|
998
|
+
for (const d of p.denied) {
|
|
999
|
+
const tok = d.tokens != null ? ` — ${n(d.tokens)} tokens` : '';
|
|
1000
|
+
lines.push(` ${d.server}${tok} — matched ${d.entry} (${d.source})`);
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
if (p.denyUnevaluated.length) {
|
|
1004
|
+
lines.push('');
|
|
1005
|
+
lines.push(' deny entries set but not evaluated here — a match would only remove more:');
|
|
1006
|
+
for (const u of p.denyUnevaluated)
|
|
1007
|
+
lines.push(` ${u.entry} (${u.source}) — ${u.reason}`);
|
|
1008
|
+
}
|
|
1009
|
+
if (p.allow) {
|
|
1010
|
+
lines.push('');
|
|
1011
|
+
lines.push(` an MCP allowlist is set in ${p.allow.sources.join(', ')}.`);
|
|
1012
|
+
if (p.allow.managedOnly) {
|
|
1013
|
+
lines.push(` allowManagedMcpServersOnly is true in ${p.allow.managedOnly.source}, so only`, ' managed-tier entries counted below.');
|
|
1014
|
+
}
|
|
1015
|
+
const fails = p.allow.rows.filter((r) => r.verdict === 'fails').map((r) => r.server);
|
|
1016
|
+
const unev = p.allow.rows.filter((r) => r.verdict === 'unevaluated').map((r) => r.server);
|
|
1017
|
+
const hit = p.allow.rows.length - fails.length - unev.length;
|
|
1018
|
+
lines.push(` ${hit} of ${p.allow.rows.length} session server(s) match an entry read here.`);
|
|
1019
|
+
if (fails.length) {
|
|
1020
|
+
lines.push(` matched by nothing read here: ${fails.join(', ')} — reported, not removed. An`, ' entry in a tier this audit does not read (server-managed settings, an MDM', ' profile, a registry key) can only broaden an allowlist, so this is a condition', ' to check, never a subtraction.');
|
|
1021
|
+
}
|
|
1022
|
+
if (unev.length) {
|
|
1023
|
+
lines.push(` not evaluated: ${unev.join(', ')} — an entry below decides them.`);
|
|
1024
|
+
}
|
|
1025
|
+
for (const u of p.allow.unevaluated) {
|
|
1026
|
+
lines.push(` entry not evaluated: ${u.entry} (${u.source}) — ${u.reason}`);
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
847
1030
|
if (cfg.heaviestTools.length) {
|
|
848
1031
|
lines.push('');
|
|
849
1032
|
lines.push(' heaviest tools');
|
|
@@ -946,7 +1129,7 @@ export function formatReport(report) {
|
|
|
946
1129
|
lines.push('');
|
|
947
1130
|
if (!b.over) {
|
|
948
1131
|
const headroom = b.limit - b.worstTotal;
|
|
949
|
-
lines.push(`budget ok: ${n(b.worstTotal)} ≤ ${n(b.limit)} — ${n(headroom)} to spare`);
|
|
1132
|
+
lines.push(`budget ok: ${n(b.worstTotal)} ≤ ${n(b.limit)} — ${n(headroom)} to spare (costliest session: ${b.worstSource})`);
|
|
950
1133
|
}
|
|
951
1134
|
else if (b.unestablished && b.worstTotal <= b.limit) {
|
|
952
1135
|
// Under the line on what was measured, but not everything was: say which
|
package/dist/audit/config.d.ts
CHANGED
|
@@ -67,6 +67,8 @@ export interface ConfigCandidate {
|
|
|
67
67
|
path: string;
|
|
68
68
|
/** How the file is written. Absent means JSON, with comments and trailing commas tolerated. */
|
|
69
69
|
format?: 'json' | 'toml' | 'yaml';
|
|
70
|
+
/** Set on the managed MCP file, whose presence changes what a session loads. */
|
|
71
|
+
managed?: true;
|
|
70
72
|
}
|
|
71
73
|
/** Every place a client config is known to live, whether or not it exists. */
|
|
72
74
|
export declare function configCandidates(env: {
|
|
@@ -80,6 +82,8 @@ export declare function parseConfigText(text: string, format?: ConfigCandidate['
|
|
|
80
82
|
export interface LoadedConfig {
|
|
81
83
|
client: string;
|
|
82
84
|
source: string;
|
|
85
|
+
/** Carried from the candidate: this is the managed MCP file (see managedMcpPath). */
|
|
86
|
+
managed?: true;
|
|
83
87
|
servers: ConfiguredServer[];
|
|
84
88
|
/** Set when the file exists but could not be read/parsed. */
|
|
85
89
|
error?: string;
|
|
@@ -102,6 +106,23 @@ export interface LoadedConfig {
|
|
|
102
106
|
}
|
|
103
107
|
/** Read + parse the candidates that exist. Unreadable files are reported, not thrown. */
|
|
104
108
|
export declare function loadConfigs(candidates: ConfigCandidate[], cwd: string, processEnv?: Record<string, string | undefined>): LoadedConfig[];
|
|
109
|
+
/**
|
|
110
|
+
* Where Claude Code looks for the managed MCP file: one fixed path per
|
|
111
|
+
* platform, in the same system directory as `managed-settings.json` — and,
|
|
112
|
+
* unlike that file, with no drop-in directory: the vendor documents a single
|
|
113
|
+
* standalone file that "cannot be delivered through server-managed settings".
|
|
114
|
+
* When it exists, a claude-code session loads only the servers it defines
|
|
115
|
+
* (plus in-process servers the launching app registers, which no config file
|
|
116
|
+
* describes). code.claude.com/docs/en/managed-mcp.md, §Exclusive control with
|
|
117
|
+
* managed-mcp.json, read **2026-09-09**.
|
|
118
|
+
*/
|
|
119
|
+
export declare function managedMcpPath(platform: NodeJS.Platform): string;
|
|
120
|
+
/**
|
|
121
|
+
* The candidate list with the managed MCP file spliced in ahead of the
|
|
122
|
+
* claude-code user and project configs, so the file that decides what a
|
|
123
|
+
* session loads is read — and reported — before the files it can suppress.
|
|
124
|
+
*/
|
|
125
|
+
export declare function withManagedMcpCandidate(candidates: ConfigCandidate[], platform: NodeJS.Platform): ConfigCandidate[];
|
|
105
126
|
/** One file Claude Code reads its `env` block from. */
|
|
106
127
|
export interface SettingsCandidate {
|
|
107
128
|
scope: ToolSearchScope;
|