mcp-context-cost 0.13.2 → 0.15.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 +43 -14
- package/dist/audit/audit.d.ts +21 -1
- package/dist/audit/audit.js +71 -17
- package/dist/audit/config.d.ts +19 -2
- package/dist/audit/config.js +140 -24
- package/dist/audit/deferral.d.ts +45 -4
- package/dist/audit/deferral.js +27 -4
- package/dist/audit/remote.d.ts +56 -0
- package/dist/audit/remote.js +126 -0
- package/dist/audit/run.d.ts +32 -2
- package/dist/audit/run.js +60 -5
- package/dist/cli.js +2 -1
- package/dist/sweep/cross-check.js +2 -2
- package/dist/sweep/pr-check.d.ts +62 -0
- package/dist/sweep/pr-check.js +296 -0
- package/dist/sweep/registry-scan.d.ts +355 -0
- package/dist/sweep/registry-scan.js +432 -0
- package/dist/sweep/run.d.ts +17 -0
- package/dist/sweep/run.js +49 -9
- package/dist/sweep/servers-schema.d.ts +7 -0
- package/dist/sweep/servers-schema.js +8 -1
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -60,11 +60,22 @@ claude-desktop ~/Library/Application Support/Claude/claude_desktop_config.json
|
|
|
60
60
|
```
|
|
61
61
|
|
|
62
62
|
It finds configs for Claude Desktop, Claude Code (`~/.claude.json`, `.mcp.json`), Cursor,
|
|
63
|
-
VS Code (`.vscode/mcp.json`),
|
|
64
|
-
|
|
63
|
+
VS Code (`.vscode/mcp.json`), Windsurf, Codex CLI (`~/.codex/config.toml`), Gemini CLI
|
|
64
|
+
(`~/.gemini/settings.json`), Zed (`context_servers`), Kiro (`~/.kiro/settings/mcp.json`) and
|
|
65
|
+
Goose (`~/.config/goose/config.yaml`) — or pass `--config <path>`. Servers are measured by
|
|
66
|
+
the same path as the published leaderboard (dual `tools/list` capture, `o200k_base` over
|
|
65
67
|
canonical JSON), so a server in both places gets the same number. Nothing is written to your
|
|
66
68
|
project, and env var **values** are never read into the output — only their names.
|
|
67
69
|
|
|
70
|
+
A remote entry — `url`, or the client's own spelling of it — is first asked what it says to
|
|
71
|
+
an unauthenticated `initialize`. An endpoint that answers is measured through the
|
|
72
|
+
`mcp-remote` bridge, the path the leaderboard's remote rows already take. One that answers
|
|
73
|
+
`401` or `403` is reported **auth-walled**, quoting the status and the `WWW-Authenticate`
|
|
74
|
+
header it sent, with the URL: a working server this audit holds no credential for, so the
|
|
75
|
+
total above it is a floor. One that answers nothing usable is **unreachable**, with the
|
|
76
|
+
reason. Header values an entry carries are sent and never printed — only their names are —
|
|
77
|
+
and nothing here ever opens a browser.
|
|
78
|
+
|
|
68
79
|
Totals are reported per config file, never merged: a context window belongs to one client
|
|
69
80
|
session, so summing Cursor's servers into Claude Desktop's total would describe a session
|
|
70
81
|
nobody runs.
|
|
@@ -75,8 +86,9 @@ Not every client puts every tool definition in context on every request, so the
|
|
|
75
86
|
is not automatically your bill. Which client reads the config, and how that client is
|
|
76
87
|
configured **on this machine**, decides it — and `audit` reads that rather than assuming it.
|
|
77
88
|
|
|
78
|
-
**Clients with no default deferral on record** — Claude Desktop, Cursor, VS Code, Windsurf
|
|
79
|
-
The total is what every request carries, as in the
|
|
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
|
|
80
92
|
absence of a record about those clients, not a measurement of them, and the report says so
|
|
81
93
|
in those words.
|
|
82
94
|
|
|
@@ -97,6 +109,7 @@ deferral off in a settings file is not a machine running the default:
|
|
|
97
109
|
| `ANTHROPIC_BASE_URL` off `api.anthropic.com` | falls back to loading up front — consulted only while `ENABLE_TOOL_SEARCH` is unset |
|
|
98
110
|
| anything else in `ENABLE_TOOL_SEARCH` | not a documented value, so nothing is claimed from it |
|
|
99
111
|
| 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 |
|
|
112
|
+
| a server pinned `"alwaysLoad": true` in its entry | loads at session start whatever the setting says — read from the entry, named with its tokens, and left out of any threshold comparison |
|
|
100
113
|
|
|
101
114
|
On a machine where none of them is set, the same stack reads:
|
|
102
115
|
|
|
@@ -114,7 +127,7 @@ On a machine where none of them is set, the same stack reads:
|
|
|
114
127
|
a Microsoft Foundry deployment hosted on Azure, which rejects tool search server-side
|
|
115
128
|
Google Cloud's Agent Platform on a model earlier than the Claude 4.5 generation
|
|
116
129
|
a model without support for tool_reference blocks (before Sonnet 4.5 / Haiku 4.5 / Opus 4.5)
|
|
117
|
-
a
|
|
130
|
+
a tool whose _meta carries "anthropic/alwaysLoad": true, which this audit does not read from a capture
|
|
118
131
|
```
|
|
119
132
|
|
|
120
133
|
Set `ENABLE_TOOL_SEARCH=false` in that shell and the same config reports the opposite —
|
|
@@ -133,7 +146,7 @@ settings file exists and cannot be read, when the place that would decide sets t
|
|
|
133
146
|
to something that is not a string, and when `ENABLE_TOOL_SEARCH` holds a value Claude Code
|
|
134
147
|
does not document; and it will not pass an absence of a record off as a measurement. The
|
|
135
148
|
first two print as unanswered questions. The third prints as an answer that names
|
|
136
|
-
itself: for the
|
|
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
|
|
137
150
|
loaded up front, and the report says so in those words, "an absence of a record about the
|
|
138
151
|
client, not a measurement of it".
|
|
139
152
|
Full model, sources and dates: [METHODOLOGY §who pays the number](docs/METHODOLOGY.md#who-pays).
|
|
@@ -271,7 +284,7 @@ sample of that range; the full range is in
|
|
|
271
284
|
| filesystem (reference) | 2,823 | 14 |
|
|
272
285
|
| markitdown | 64 | 1 |
|
|
273
286
|
|
|
274
|
-
*(87 of
|
|
287
|
+
*(87 of 107 popular servers measured, each row dated by its own most recent sweep — full table in
|
|
275
288
|
[results/leaderboard.md](results/leaderboard.md); every failure is listed with its reason.
|
|
276
289
|
Each measured server also has a [detail page](https://athakur3.github.io/mcp-context-cost/servers/)
|
|
277
290
|
showing which tools its tokens are in.)*
|
|
@@ -344,12 +357,12 @@ number is *not*, config policy, failure taxonomy, frozen color bands, known dive
|
|
|
344
357
|
|---|---|
|
|
345
358
|
| `src/core/` | the measurement spec, executable — canonical form, tokenizer, bands, badge JSON |
|
|
346
359
|
| `src/sweep/` | raw-wire MCP stdio client + Dockerized batch sweep + leaderboard/dashboard generators |
|
|
347
|
-
| `src/audit/` | client-config discovery (
|
|
360
|
+
| `src/audit/` | client-config discovery (10 clients; JSON with comments, TOML, YAML), the remote probe, the per-stack report, and the baseline diff |
|
|
348
361
|
| `src/cli.ts` | `audit` (measure your own stack), `verify` (re-derive any published number), `measure` |
|
|
349
362
|
| `spec/fixtures/` | golden vectors shared by the TypeScript and bash implementations |
|
|
350
|
-
| `tools/` | the
|
|
363
|
+
| `tools/` | the scripts that call a network API — the Claude divergence run, the adoption reading, the registry scan — kept out of the package so the library stays offline |
|
|
351
364
|
| `upstream/` | `badge.sh` + composite-action patch + bash tests — the self-serve badge recipe, carried here |
|
|
352
|
-
| `servers.yaml` |
|
|
365
|
+
| `servers.yaml` | 107 curated candidates with live install metrics and provenance |
|
|
353
366
|
| `results/` · `badges/` | measurements, leaderboard, history series, shields endpoint JSONs |
|
|
354
367
|
| `docs/` | methodology, dashboard, and a generated page per measured server |
|
|
355
368
|
|
|
@@ -357,11 +370,26 @@ number is *not*, config policy, failure taxonomy, frozen color bands, known dive
|
|
|
357
370
|
|
|
358
371
|
```bash
|
|
359
372
|
npm ci
|
|
360
|
-
npm run sweep -- --name my-server --command "npx -y my-mcp-server"
|
|
361
|
-
cat badges/my-server.json # strict shields.io endpoint JSON
|
|
373
|
+
npm run sweep -- --no-persist --name my-server --command "npx -y my-mcp-server"
|
|
362
374
|
```
|
|
363
375
|
|
|
364
|
-
|
|
376
|
+
That prints the number and writes nothing. Published records
|
|
377
|
+
(`results/<name>/measurement.json`, `badges/<name>.json`, the `history.csv`
|
|
378
|
+
row) come from CI: a developer machine is a different architecture under
|
|
379
|
+
different load, and a measurement taken there describes it rather than the
|
|
380
|
+
server. `local-mcp`'s failing record was made on an arm64 laptop, and its
|
|
381
|
+
stderr named an architecture the record itself did not — which is why every
|
|
382
|
+
measurement now records `isolation.arch`. The entry turned out to be
|
|
383
|
+
unavailable on both architectures, and a record that says where it was made
|
|
384
|
+
is what lets that be told from a broken server. To get your server into the
|
|
385
|
+
leaderboard, add an entry to `servers.yaml` and open a pull request; the check
|
|
386
|
+
on that PR measures the entry read-only, and the rotation publishes it after
|
|
387
|
+
merge.
|
|
388
|
+
|
|
389
|
+
For a badge on your own README, run the published CLI in your server's own CI
|
|
390
|
+
(the [gate](#defend-the-number-dont-just-display-it) below writes
|
|
391
|
+
`results/my-server/measurement.json` and `badges/my-server.json` into that
|
|
392
|
+
repository), then in your README:
|
|
365
393
|
|
|
366
394
|
```markdown
|
|
367
395
|
[](<link target>)
|
|
@@ -455,6 +483,7 @@ public, one click each:
|
|
|
455
483
|
[re-sweep runs](https://github.com/athakur3/mcp-context-cost/actions/workflows/resweep.yml)
|
|
456
484
|
and [self-badge runs](https://github.com/athakur3/mcp-context-cost/actions/workflows/self-badge.yml).
|
|
457
485
|
See [ROADMAP.md](ROADMAP.md) for what's next — contributions welcome, especially new
|
|
458
|
-
`servers.yaml` entries.
|
|
486
|
+
`servers.yaml` entries; [CONTRIBUTING.md](CONTRIBUTING.md) says what an entry needs, the
|
|
487
|
+
order of steps that leaves a pull request green, and where its number comes from.
|
|
459
488
|
|
|
460
489
|
MIT © 2026
|
package/dist/audit/audit.d.ts
CHANGED
|
@@ -3,10 +3,20 @@ import { type ToolShapeBaseline, type ToolSuggestion } from '../core/tool-shape.
|
|
|
3
3
|
import { type CaptureIndex, type CaptureVerdict } from '../core/capture-index.js';
|
|
4
4
|
import type { Measurement, MeasurementStatus, ToolMeasurement } from '../core/types.js';
|
|
5
5
|
import type { ConfiguredServer, LoadedConfig } from './config.js';
|
|
6
|
+
import type { RemoteProbe } from './remote.js';
|
|
6
7
|
import { type DeferralVerdict, type ToolSearchEnv, type ToolSearchSource } from './deferral.js';
|
|
7
8
|
import { type AuditDiff, type IncreaseGate } from './diff.js';
|
|
8
9
|
export declare const DEFAULT_CONTEXT_WINDOW = 200000;
|
|
9
|
-
|
|
10
|
+
/**
|
|
11
|
+
* A server's status in a report: the measurement statuses, plus two that only
|
|
12
|
+
* a remote endpoint can have. `auth-walled` — it answered the unauthenticated
|
|
13
|
+
* `initialize` with 401 or 403: it works, and it wants a credential this audit
|
|
14
|
+
* does not hold. `unreachable` — no MCP answer came back at all: a connection
|
|
15
|
+
* failure, a timeout, or a status that is neither an answer nor a wall. Before
|
|
16
|
+
* 2026-09-06 every `url` entry was `remote-not-measurable`, which said nothing
|
|
17
|
+
* about the endpoint and under-counted exactly the stacks the audit is for.
|
|
18
|
+
*/
|
|
19
|
+
export type AuditStatus = MeasurementStatus | 'auth-walled' | 'unreachable';
|
|
10
20
|
export interface AuditServerResult {
|
|
11
21
|
name: string;
|
|
12
22
|
transport: 'stdio' | 'remote';
|
|
@@ -19,6 +29,10 @@ export interface AuditServerResult {
|
|
|
19
29
|
url?: string;
|
|
20
30
|
/** Names only — a server's env values never enter a report. */
|
|
21
31
|
envVarNames: string[];
|
|
32
|
+
/** Names only, and only for a remote entry that carries any — values never enter a report. */
|
|
33
|
+
headerNames?: string[];
|
|
34
|
+
/** Claude Code's `alwaysLoad: true`, read from the entry: loads at session start whatever the setting. */
|
|
35
|
+
alwaysLoad?: true;
|
|
22
36
|
canonicalSha256?: string | null;
|
|
23
37
|
/**
|
|
24
38
|
* Anthropic-request cost from the published Claude divergence run, only when
|
|
@@ -227,6 +241,12 @@ export declare function buildReport(configs: LoadedConfig[], measured: Map<strin
|
|
|
227
241
|
* the report says, rather than reporting a default it did not establish.
|
|
228
242
|
*/
|
|
229
243
|
settings?: ToolSearchSource[];
|
|
244
|
+
/**
|
|
245
|
+
* What each remote endpoint said to an unauthenticated `initialize`, keyed
|
|
246
|
+
* by `serverKey`. `runAudit` supplies it from `probeRemotes`; omitted, a
|
|
247
|
+
* remote entry is reported as not probed rather than as anything else.
|
|
248
|
+
*/
|
|
249
|
+
remotes?: Map<string, RemoteProbe>;
|
|
230
250
|
}): AuditReport;
|
|
231
251
|
/** Human output. JSON output is the report object itself. */
|
|
232
252
|
export declare function formatReport(report: AuditReport): string;
|
package/dist/audit/audit.js
CHANGED
|
@@ -85,7 +85,30 @@ export function serverKey(s) {
|
|
|
85
85
|
*/
|
|
86
86
|
function envSignature(s) {
|
|
87
87
|
const env = s.env ?? {};
|
|
88
|
-
|
|
88
|
+
const headers = s.headers ?? {};
|
|
89
|
+
return JSON.stringify([
|
|
90
|
+
Object.keys(env).sort().map((k) => [k, env[k]]),
|
|
91
|
+
// A remote's headers decide what it serves the way env decides for a
|
|
92
|
+
// process: a bearer token selects an account, and an account its tools.
|
|
93
|
+
Object.keys(headers).sort().map((k) => [k, headers[k]]),
|
|
94
|
+
]);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Every value an entry would spawn or send — env values, header values — so
|
|
98
|
+
* that none of them reaches a report by way of a server's own stderr. Values
|
|
99
|
+
* shorter than four characters are left alone: replacing every "1" in a
|
|
100
|
+
* message is not redaction.
|
|
101
|
+
*/
|
|
102
|
+
function secrets(s) {
|
|
103
|
+
return [...Object.values(s.env ?? {}), ...Object.values(s.headers ?? {})].filter((v) => v.length >= 4);
|
|
104
|
+
}
|
|
105
|
+
function redact(text, values) {
|
|
106
|
+
if (!text)
|
|
107
|
+
return text;
|
|
108
|
+
let out = text;
|
|
109
|
+
for (const v of values)
|
|
110
|
+
out = out.split(v).join('<redacted>');
|
|
111
|
+
return out;
|
|
89
112
|
}
|
|
90
113
|
/**
|
|
91
114
|
* The measurement keys that stand for more than one distinct server.
|
|
@@ -105,8 +128,6 @@ export function collapsedKeys(configs) {
|
|
|
105
128
|
if (cfg.error)
|
|
106
129
|
continue;
|
|
107
130
|
for (const s of cfg.servers) {
|
|
108
|
-
if (s.transport !== 'stdio')
|
|
109
|
-
continue;
|
|
110
131
|
const key = serverKey(s);
|
|
111
132
|
const seen = envs.get(key);
|
|
112
133
|
if (seen)
|
|
@@ -160,7 +181,12 @@ function attachDeferral(configs, contextWindow, opts) {
|
|
|
160
181
|
evaluateDeferral({
|
|
161
182
|
client: group[0].client,
|
|
162
183
|
sources: group.map((c) => c.source),
|
|
163
|
-
servers: group.flatMap((c) => c.servers.map((s) => ({
|
|
184
|
+
servers: group.flatMap((c) => c.servers.map((s) => ({
|
|
185
|
+
name: s.name,
|
|
186
|
+
tokens: s.tokens ?? 0,
|
|
187
|
+
claudeTokens: s.claudeTokens,
|
|
188
|
+
...(s.alwaysLoad ? { alwaysLoad: true } : {}),
|
|
189
|
+
}))),
|
|
164
190
|
skippedCount: group.reduce((a, c) => a + c.skipped.length, 0),
|
|
165
191
|
sharedMeasurements: group.reduce((a, c) => a + (opts.shared.get(c) ?? 0), 0),
|
|
166
192
|
}, { contextWindow, env: opts.env, settings: opts.settings, divergence: opts.divergence }));
|
|
@@ -219,17 +245,34 @@ export function buildReport(configs, measured, opts = {}) {
|
|
|
219
245
|
command: s.command,
|
|
220
246
|
url: s.url,
|
|
221
247
|
envVarNames: s.envVarNames,
|
|
248
|
+
...(s.headerNames?.length ? { headerNames: s.headerNames } : {}),
|
|
249
|
+
...(s.alwaysLoad ? { alwaysLoad: true } : {}),
|
|
222
250
|
};
|
|
251
|
+
const none = { tokens: null, toolCount: null, share: null };
|
|
223
252
|
if (s.transport === 'remote') {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
status: '
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
253
|
+
const probe = opts.remotes?.get(serverKey(s));
|
|
254
|
+
if (!probe) {
|
|
255
|
+
skipped.push({ ...base, ...none, status: 'unreachable', notes: `${s.url ?? 'url'} — not probed` });
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (probe.kind === 'auth-walled') {
|
|
259
|
+
// The server's own words: the status it sent and the header it named
|
|
260
|
+
// its authorization server with. A wall is a working server this
|
|
261
|
+
// audit holds no credential for; the session that holds one pays
|
|
262
|
+
// this server on top of the total below, which is therefore a floor.
|
|
263
|
+
skipped.push({
|
|
264
|
+
...base,
|
|
265
|
+
...none,
|
|
266
|
+
status: 'auth-walled',
|
|
267
|
+
notes: `${s.url} answered ${probe.detail} — wants a credential this audit does not hold`,
|
|
268
|
+
});
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (probe.kind === 'unreachable') {
|
|
272
|
+
skipped.push({ ...base, ...none, status: 'unreachable', notes: `${s.url}: ${probe.detail}` });
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
// Open: measured through the bridge, and read below like any launch.
|
|
233
276
|
}
|
|
234
277
|
const m = measured.get(serverKey(s));
|
|
235
278
|
if (!m) {
|
|
@@ -243,7 +286,7 @@ export function buildReport(configs, measured, opts = {}) {
|
|
|
243
286
|
tokens: null,
|
|
244
287
|
toolCount: null,
|
|
245
288
|
share: null,
|
|
246
|
-
notes: m.notes?.split('\n')[0]?.slice(0, 200),
|
|
289
|
+
notes: redact(m.notes?.split('\n')[0]?.slice(0, 200), secrets(s)),
|
|
247
290
|
});
|
|
248
291
|
continue;
|
|
249
292
|
}
|
|
@@ -258,7 +301,7 @@ export function buildReport(configs, measured, opts = {}) {
|
|
|
258
301
|
share: null, // filled once the total is known
|
|
259
302
|
canonicalSha256: m.canonicalSha256,
|
|
260
303
|
claudeTokens: opts.divergence ? (isCurrent(divRow, m.canonicalSha256 ?? null) ? divRow.claudeDelta : null) : undefined,
|
|
261
|
-
notes: m.status === 'dynamic' ? m.notes : undefined,
|
|
304
|
+
notes: m.status === 'dynamic' ? redact(m.notes, secrets(s)) : undefined,
|
|
262
305
|
});
|
|
263
306
|
for (const t of m.tools) {
|
|
264
307
|
tools.push({ server: s.name, tool: t.name, tokens: t.tokens });
|
|
@@ -330,8 +373,11 @@ export function buildReport(configs, measured, opts = {}) {
|
|
|
330
373
|
// server that failed to start contributes 0, so the stack reads lighter
|
|
331
374
|
// than it is and the budget passes on a number that is missing a server —
|
|
332
375
|
// exactly the PR the README says this gate catches. The server-level gate
|
|
333
|
-
// (core/server-diff.ts) already refuses this; so does this one now.
|
|
334
|
-
|
|
376
|
+
// (core/server-diff.ts) already refuses this; so does this one now. Every
|
|
377
|
+
// skipped row counts: an auth-walled endpoint is a working server the
|
|
378
|
+
// session pays for with its credential, and an unreachable one is a cost
|
|
379
|
+
// this could not establish, not a cost of zero.
|
|
380
|
+
const unestablished = results.flatMap((c) => c.skipped.map((s) => `${c.source}: ${s.name} (${s.status})`));
|
|
335
381
|
const over = (worst?.totalTokens ?? 0) > opts.budget;
|
|
336
382
|
report.budget = {
|
|
337
383
|
limit: opts.budget,
|
|
@@ -480,6 +526,14 @@ function deferralLines(d, skippedNames) {
|
|
|
480
526
|
lines.push(` ${s}`);
|
|
481
527
|
lines.push(' so they face the question below together, as their sum.');
|
|
482
528
|
}
|
|
529
|
+
// Read from the entries, so it is stated up front rather than listed among
|
|
530
|
+
// the conditions a reader has to check: whatever the setting says, these load.
|
|
531
|
+
if (d.mechanism === 'tool search' && d.alwaysLoad.servers.length) {
|
|
532
|
+
const n = d.alwaysLoad.servers.length;
|
|
533
|
+
lines.push(` ${n} server${n === 1 ? ' is' : 's are'} pinned "alwaysLoad": true and load${n === 1 ? 's' : ''} at session start whatever`);
|
|
534
|
+
lines.push(` the setting says: ${d.alwaysLoad.servers.join(', ')} — ${d.alwaysLoad.tokens.toLocaleString()} wire tokens,`);
|
|
535
|
+
lines.push(' left out of any threshold comparison below.');
|
|
536
|
+
}
|
|
483
537
|
if (d.mode === 'client-unknown') {
|
|
484
538
|
lines.push(' Which client reads this config is not known here, so whether it defers');
|
|
485
539
|
lines.push(' tool definitions by default is not known either. Read as loaded up front.');
|
package/dist/audit/config.d.ts
CHANGED
|
@@ -13,8 +13,19 @@ export interface ConfiguredServer {
|
|
|
13
13
|
envVarNames: string[];
|
|
14
14
|
/** Values, needed to spawn the server. NEVER serialize this. */
|
|
15
15
|
env?: Record<string, string>;
|
|
16
|
-
/** Remote endpoint —
|
|
16
|
+
/** Remote endpoint — probed, then measured through the bridge or reported as walled. */
|
|
17
17
|
url?: string;
|
|
18
|
+
/** Names only — a remote entry's header values never enter a report. Absent means none. */
|
|
19
|
+
headerNames?: string[];
|
|
20
|
+
/** Values, sent with the probe and the bridge. NEVER serialize this. */
|
|
21
|
+
headers?: Record<string, string>;
|
|
22
|
+
/**
|
|
23
|
+
* Claude Code's `alwaysLoad: true`: this server's tools load at session
|
|
24
|
+
* start whatever the tool-search setting says (its MCP documentation, §"Exempt
|
|
25
|
+
* a server from deferral", read 2026-09-06). Read from the entry, so the
|
|
26
|
+
* deferral verdict can count it rather than list it as a condition.
|
|
27
|
+
*/
|
|
28
|
+
alwaysLoad?: true;
|
|
18
29
|
}
|
|
19
30
|
/**
|
|
20
31
|
* JSON with comments and trailing commas — VS Code's mcp.json allows both, and
|
|
@@ -31,6 +42,7 @@ export declare function extractServers(doc: unknown, meta: {
|
|
|
31
42
|
client: string;
|
|
32
43
|
source: string;
|
|
33
44
|
cwd?: string;
|
|
45
|
+
env?: Record<string, string | undefined>;
|
|
34
46
|
}): ConfiguredServer[];
|
|
35
47
|
/**
|
|
36
48
|
* What one config document declares, servers and switched-off entries both.
|
|
@@ -45,6 +57,7 @@ export declare function extractDeclaration(doc: unknown, meta: {
|
|
|
45
57
|
client: string;
|
|
46
58
|
source: string;
|
|
47
59
|
cwd?: string;
|
|
60
|
+
env?: Record<string, string | undefined>;
|
|
48
61
|
}): {
|
|
49
62
|
servers: ConfiguredServer[];
|
|
50
63
|
disabled: string[];
|
|
@@ -52,6 +65,8 @@ export declare function extractDeclaration(doc: unknown, meta: {
|
|
|
52
65
|
export interface ConfigCandidate {
|
|
53
66
|
client: string;
|
|
54
67
|
path: string;
|
|
68
|
+
/** How the file is written. Absent means JSON, with comments and trailing commas tolerated. */
|
|
69
|
+
format?: 'json' | 'toml' | 'yaml';
|
|
55
70
|
}
|
|
56
71
|
/** Every place a client config is known to live, whether or not it exists. */
|
|
57
72
|
export declare function configCandidates(env: {
|
|
@@ -60,6 +75,8 @@ export declare function configCandidates(env: {
|
|
|
60
75
|
platform: NodeJS.Platform;
|
|
61
76
|
appData?: string;
|
|
62
77
|
}): ConfigCandidate[];
|
|
78
|
+
/** Parse one config file's text in the format its candidate declares. */
|
|
79
|
+
export declare function parseConfigText(text: string, format?: ConfigCandidate['format']): unknown;
|
|
63
80
|
export interface LoadedConfig {
|
|
64
81
|
client: string;
|
|
65
82
|
source: string;
|
|
@@ -84,7 +101,7 @@ export interface LoadedConfig {
|
|
|
84
101
|
allDisabled?: string[];
|
|
85
102
|
}
|
|
86
103
|
/** Read + parse the candidates that exist. Unreadable files are reported, not thrown. */
|
|
87
|
-
export declare function loadConfigs(candidates: ConfigCandidate[], cwd: string): LoadedConfig[];
|
|
104
|
+
export declare function loadConfigs(candidates: ConfigCandidate[], cwd: string, processEnv?: Record<string, string | undefined>): LoadedConfig[];
|
|
88
105
|
/** One file Claude Code reads its `env` block from. */
|
|
89
106
|
export interface SettingsCandidate {
|
|
90
107
|
scope: ToolSearchScope;
|
package/dist/audit/config.js
CHANGED
|
@@ -3,21 +3,37 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The leaderboard measures servers one at a time; `audit` measures the set a
|
|
5
5
|
* person actually has installed. That set lives in a client config file, and
|
|
6
|
-
* every client spells it slightly differently
|
|
6
|
+
* every client spells it slightly differently — each shape below is the one
|
|
7
|
+
* its client's own documentation shows, read on the date given:
|
|
7
8
|
*
|
|
8
|
-
* Claude Desktop / Claude Code / Cursor / Windsurf { "mcpServers": { ... } }
|
|
9
|
-
* VS Code (.vscode/mcp.json)
|
|
10
|
-
* Claude Code (~/.claude.json)
|
|
9
|
+
* Claude Desktop / Claude Code / Cursor / Windsurf / Kiro { "mcpServers": { ... } } JSON
|
|
10
|
+
* VS Code (.vscode/mcp.json) { "servers": { ... } } JSON
|
|
11
|
+
* Claude Code (~/.claude.json) also { "projects": { "<dir>": { "mcpServers": ... } } }
|
|
12
|
+
* Gemini CLI (~/.gemini/settings.json, 2026-09-06) { "mcpServers": { ... } }, remotes as `url` (SSE) or `httpUrl`
|
|
13
|
+
* Zed (~/.config/zed/settings.json, 2026-09-06) { "context_servers": { ... } }, comments allowed
|
|
14
|
+
* Codex CLI (~/.codex/config.toml, 2026-09-06) [mcp_servers.<name>] TOML
|
|
15
|
+
* Goose (~/.config/goose/config.yaml, 2026-09-06) extensions: { <name>: { type: stdio | streamable_http } } YAML
|
|
16
|
+
*
|
|
17
|
+
* A remote is `url` in most files, `serverUrl` in Windsurf's, `httpUrl` or
|
|
18
|
+
* `url` in Gemini's, `uri` in Goose's. An entry is off under `disabled: true`
|
|
19
|
+
* (Claude, Cursor, Kiro), `enabled = false` (Codex, Goose), a name in Gemini's
|
|
20
|
+
* `mcp.excluded` list, or a name in the project's `disabledMcpServers` list in
|
|
21
|
+
* `~/.claude.json`.
|
|
11
22
|
*
|
|
12
23
|
* Everything here is pure (paths in, servers out) so the discovery rules are
|
|
13
24
|
* testable without touching a real home directory.
|
|
14
25
|
*
|
|
15
26
|
* Env var VALUES are read (a server usually needs its key to start) but are
|
|
16
27
|
* never written to a report: report builders pick fields explicitly and only
|
|
17
|
-
* `envVarNames` is ever serialized.
|
|
28
|
+
* `envVarNames` is ever serialized. Header values a remote entry carries (a
|
|
29
|
+
* static bearer token, or one Codex sources from an environment variable by
|
|
30
|
+
* name) are held to the same rule: sent with the request, never reported —
|
|
31
|
+
* only `headerNames` is.
|
|
18
32
|
*/
|
|
19
33
|
import { existsSync, readFileSync } from 'node:fs';
|
|
20
34
|
import { join } from 'node:path';
|
|
35
|
+
import { parse as parseToml } from 'smol-toml';
|
|
36
|
+
import { parse as parseYaml } from 'yaml';
|
|
21
37
|
import { TOOL_SEARCH_VARS, } from './deferral.js';
|
|
22
38
|
/**
|
|
23
39
|
* JSON with comments and trailing commas — VS Code's mcp.json allows both, and
|
|
@@ -78,23 +94,69 @@ export function parseJsonc(text) {
|
|
|
78
94
|
}
|
|
79
95
|
return JSON.parse(out);
|
|
80
96
|
}
|
|
81
|
-
|
|
82
|
-
|
|
97
|
+
const firstString = (...vs) => vs.find((v) => typeof v === 'string' && v.trim() !== '');
|
|
98
|
+
/**
|
|
99
|
+
* The headers a remote entry would send, by name and by value.
|
|
100
|
+
*
|
|
101
|
+
* Codex sources two of its forms from the environment by name
|
|
102
|
+
* (`bearer_token_env_var`, `env_http_headers`), so a name the config carries
|
|
103
|
+
* is recorded whether or not this process can see a value for it — the config
|
|
104
|
+
* says the header exists; only the process decides whether it can be sent.
|
|
105
|
+
*/
|
|
106
|
+
function collectHeaders(raw, processEnv) {
|
|
107
|
+
const values = {};
|
|
108
|
+
const names = new Set();
|
|
109
|
+
for (const block of [raw.headers, raw.http_headers]) {
|
|
110
|
+
for (const [k, v] of Object.entries(block ?? {})) {
|
|
111
|
+
names.add(k);
|
|
112
|
+
if (typeof v === 'string')
|
|
113
|
+
values[k] = v;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
for (const [k, v] of Object.entries(raw.env_http_headers ?? {})) {
|
|
117
|
+
names.add(k);
|
|
118
|
+
const fromEnv = typeof v === 'string' ? processEnv[v] : undefined;
|
|
119
|
+
if (fromEnv !== undefined)
|
|
120
|
+
values[k] = fromEnv;
|
|
121
|
+
}
|
|
122
|
+
if (typeof raw.bearer_token_env_var === 'string') {
|
|
123
|
+
names.add('Authorization');
|
|
124
|
+
const token = processEnv[raw.bearer_token_env_var];
|
|
125
|
+
if (token !== undefined)
|
|
126
|
+
values.Authorization = `Bearer ${token}`;
|
|
127
|
+
}
|
|
128
|
+
return { names: [...names].sort(), values };
|
|
129
|
+
}
|
|
130
|
+
function toServer(name, raw, client, source, processEnv) {
|
|
131
|
+
if (raw.disabled === true || raw.enabled === false)
|
|
83
132
|
return null;
|
|
84
133
|
const env = {};
|
|
85
|
-
for (const [k, v] of Object.entries(raw.env ?? {})) {
|
|
134
|
+
for (const [k, v] of Object.entries(raw.env ?? raw.envs ?? {})) {
|
|
86
135
|
if (typeof v === 'string')
|
|
87
136
|
env[k] = v;
|
|
88
137
|
}
|
|
89
138
|
const envVarNames = Object.keys(env).sort();
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
139
|
+
const pinned = raw.alwaysLoad === true ? { alwaysLoad: true } : {};
|
|
140
|
+
const command = firstString(raw.command, raw.cmd);
|
|
141
|
+
const url = firstString(raw.url, raw.serverUrl, raw.httpUrl, raw.uri);
|
|
142
|
+
// Remote entries carry an endpoint (and sometimes a type) instead of a command.
|
|
143
|
+
if (!command && url) {
|
|
144
|
+
const { names, values } = collectHeaders(raw, processEnv);
|
|
145
|
+
return {
|
|
146
|
+
name,
|
|
147
|
+
client,
|
|
148
|
+
source,
|
|
149
|
+
transport: 'remote',
|
|
150
|
+
url,
|
|
151
|
+
envVarNames,
|
|
152
|
+
...(names.length ? { headerNames: names, headers: values } : {}),
|
|
153
|
+
...pinned,
|
|
154
|
+
};
|
|
93
155
|
}
|
|
94
|
-
if (
|
|
156
|
+
if (!command)
|
|
95
157
|
return null;
|
|
96
158
|
const args = Array.isArray(raw.args) ? raw.args.filter((a) => typeof a === 'string') : [];
|
|
97
|
-
const argv = [
|
|
159
|
+
const argv = [command, ...args];
|
|
98
160
|
return {
|
|
99
161
|
name,
|
|
100
162
|
client,
|
|
@@ -105,6 +167,7 @@ function toServer(name, raw, client, source) {
|
|
|
105
167
|
argv,
|
|
106
168
|
envVarNames,
|
|
107
169
|
env: envVarNames.length ? env : undefined,
|
|
170
|
+
...pinned,
|
|
108
171
|
};
|
|
109
172
|
}
|
|
110
173
|
/**
|
|
@@ -115,6 +178,9 @@ function toServer(name, raw, client, source) {
|
|
|
115
178
|
export function extractServers(doc, meta) {
|
|
116
179
|
return extractDeclaration(doc, meta).servers;
|
|
117
180
|
}
|
|
181
|
+
/** Goose extension types this file can launch; `builtin` and `platform` live inside goose itself. */
|
|
182
|
+
const GOOSE_LAUNCHABLE = new Set(['stdio', 'streamable_http']);
|
|
183
|
+
const stringList = (v) => Array.isArray(v) ? v.filter((x) => typeof x === 'string') : [];
|
|
118
184
|
/**
|
|
119
185
|
* What one config document declares, servers and switched-off entries both.
|
|
120
186
|
*
|
|
@@ -130,29 +196,54 @@ export function extractDeclaration(doc, meta) {
|
|
|
130
196
|
const d = doc;
|
|
131
197
|
const out = [];
|
|
132
198
|
const off = [];
|
|
133
|
-
const
|
|
199
|
+
const processEnv = meta.env ?? {};
|
|
200
|
+
// Names a list elsewhere in the same file switches off: Gemini's
|
|
201
|
+
// `mcp.excluded` ("Servers in this list will not be connected to"), and the
|
|
202
|
+
// per-project `disabledMcpServers` Claude Code writes into `~/.claude.json`
|
|
203
|
+
// when a server is toggled off in its /mcp panel (both read 2026-09-06).
|
|
204
|
+
const listedOff = new Set();
|
|
205
|
+
const mcp = d.mcp;
|
|
206
|
+
if (mcp && typeof mcp === 'object')
|
|
207
|
+
for (const n of stringList(mcp.excluded))
|
|
208
|
+
listedOff.add(n);
|
|
209
|
+
const project = meta.cwd && d.projects && typeof d.projects === 'object'
|
|
210
|
+
? d.projects[meta.cwd]
|
|
211
|
+
: undefined;
|
|
212
|
+
if (project && typeof project === 'object')
|
|
213
|
+
for (const n of stringList(project.disabledMcpServers))
|
|
214
|
+
listedOff.add(n);
|
|
215
|
+
const addBlock = (block, launchable = () => true) => {
|
|
134
216
|
if (!block || typeof block !== 'object')
|
|
135
217
|
return;
|
|
136
218
|
for (const [name, raw] of Object.entries(block)) {
|
|
137
219
|
if (!raw || typeof raw !== 'object')
|
|
138
220
|
continue;
|
|
221
|
+
const entry = raw;
|
|
222
|
+
if (!launchable(entry))
|
|
223
|
+
continue;
|
|
139
224
|
// Recorded before `toServer` drops it, which is the only difference this
|
|
140
225
|
// can still see: an entry it returns null for because the person turned
|
|
141
226
|
// it off, rather than because it is malformed or absent.
|
|
142
|
-
if (
|
|
227
|
+
if (entry.disabled === true || entry.enabled === false || listedOff.has(name)) {
|
|
143
228
|
off.push(name);
|
|
144
|
-
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
const s = toServer(name, entry, meta.client, meta.source, processEnv);
|
|
145
232
|
if (s)
|
|
146
233
|
out.push(s);
|
|
147
234
|
}
|
|
148
235
|
};
|
|
149
236
|
addBlock(d.mcpServers);
|
|
150
237
|
addBlock(d.servers); // VS Code
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
238
|
+
// Zed: an `extension` entry is provided by an installed extension, and
|
|
239
|
+
// nothing in this file says how to launch it.
|
|
240
|
+
addBlock(d.context_servers, (raw) => raw.source !== 'extension');
|
|
241
|
+
addBlock(d.mcp_servers); // Codex
|
|
242
|
+
// Goose: only the two types that name a process or an endpoint are servers
|
|
243
|
+
// this file can reach; `builtin` and `platform` are goose's own.
|
|
244
|
+
addBlock(d.extensions, (raw) => typeof raw.type === 'string' && GOOSE_LAUNCHABLE.has(raw.type));
|
|
245
|
+
if (project && typeof project === 'object')
|
|
246
|
+
addBlock(project.mcpServers);
|
|
156
247
|
// A name can legitimately appear in both blocks of the same file; keep the first.
|
|
157
248
|
const seen = new Set();
|
|
158
249
|
const servers = out.filter((s) => (seen.has(s.name) ? false : (seen.add(s.name), true)));
|
|
@@ -169,6 +260,14 @@ export function configCandidates(env) {
|
|
|
169
260
|
: platform === 'win32'
|
|
170
261
|
? join(env.appData ?? join(home, 'AppData', 'Roaming'), 'Claude', 'claude_desktop_config.json')
|
|
171
262
|
: join(home, '.config', 'Claude', 'claude_desktop_config.json');
|
|
263
|
+
// Paths each client's own documentation gives, read 2026-09-06. Zed's user
|
|
264
|
+
// settings path is documented for macOS and Linux only; Goose's Windows path
|
|
265
|
+
// is under %APPDATA%\Block\goose. A project-level file is nominated wherever
|
|
266
|
+
// the client documents one (Codex: trusted projects; Gemini, Zed, Kiro).
|
|
267
|
+
const appData = env.appData ?? join(home, 'AppData', 'Roaming');
|
|
268
|
+
const goose = platform === 'win32'
|
|
269
|
+
? join(appData, 'Block', 'goose', 'config', 'config.yaml')
|
|
270
|
+
: join(home, '.config', 'goose', 'config.yaml');
|
|
172
271
|
return [
|
|
173
272
|
{ client: 'claude-desktop', path: desktop },
|
|
174
273
|
{ client: 'claude-code', path: join(home, '.claude.json') },
|
|
@@ -177,10 +276,27 @@ export function configCandidates(env) {
|
|
|
177
276
|
{ client: 'cursor', path: join(cwd, '.cursor', 'mcp.json') },
|
|
178
277
|
{ client: 'vscode', path: join(cwd, '.vscode', 'mcp.json') },
|
|
179
278
|
{ client: 'windsurf', path: join(home, '.codeium', 'windsurf', 'mcp_config.json') },
|
|
279
|
+
{ client: 'codex', path: join(home, '.codex', 'config.toml'), format: 'toml' },
|
|
280
|
+
{ client: 'codex', path: join(cwd, '.codex', 'config.toml'), format: 'toml' },
|
|
281
|
+
{ client: 'gemini', path: join(home, '.gemini', 'settings.json') },
|
|
282
|
+
{ client: 'gemini', path: join(cwd, '.gemini', 'settings.json') },
|
|
283
|
+
...(platform === 'win32' ? [] : [{ client: 'zed', path: join(home, '.config', 'zed', 'settings.json') }]),
|
|
284
|
+
{ client: 'zed', path: join(cwd, '.zed', 'settings.json') },
|
|
285
|
+
{ client: 'kiro', path: join(home, '.kiro', 'settings', 'mcp.json') },
|
|
286
|
+
{ client: 'kiro', path: join(cwd, '.kiro', 'settings', 'mcp.json') },
|
|
287
|
+
{ client: 'goose', path: goose, format: 'yaml' },
|
|
180
288
|
];
|
|
181
289
|
}
|
|
290
|
+
/** Parse one config file's text in the format its candidate declares. */
|
|
291
|
+
export function parseConfigText(text, format = 'json') {
|
|
292
|
+
if (format === 'toml')
|
|
293
|
+
return parseToml(text);
|
|
294
|
+
if (format === 'yaml')
|
|
295
|
+
return parseYaml(text);
|
|
296
|
+
return parseJsonc(text);
|
|
297
|
+
}
|
|
182
298
|
/** Read + parse the candidates that exist. Unreadable files are reported, not thrown. */
|
|
183
|
-
export function loadConfigs(candidates, cwd) {
|
|
299
|
+
export function loadConfigs(candidates, cwd, processEnv = process.env) {
|
|
184
300
|
const out = [];
|
|
185
301
|
// Running from your home directory nominates `~/.cursor/mcp.json` twice —
|
|
186
302
|
// once as the home candidate, once as the cwd one. Loaded twice it is
|
|
@@ -195,8 +311,8 @@ export function loadConfigs(candidates, cwd) {
|
|
|
195
311
|
continue;
|
|
196
312
|
seen.add(c.path);
|
|
197
313
|
try {
|
|
198
|
-
const doc =
|
|
199
|
-
const { servers, disabled } = extractDeclaration(doc, { client: c.client, source: c.path, cwd });
|
|
314
|
+
const doc = parseConfigText(readFileSync(c.path, 'utf8'), c.format);
|
|
315
|
+
const { servers, disabled } = extractDeclaration(doc, { client: c.client, source: c.path, cwd, env: processEnv });
|
|
200
316
|
// A config with no MCP block at all (e.g. a ~/.claude.json holding only
|
|
201
317
|
// session history) is not worth a line in the report — it has no total.
|
|
202
318
|
// It is still worth carrying: it is the evidence that a client is on this
|