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 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`), and Windsurf or pass `--config <path>`. Servers are measured
64
- by the same path as the published leaderboard (dual `tools/list` capture, `o200k_base` over
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 example above. That sentence is an
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 server pinned with "alwaysLoad": true, whose tools load at session start regardless
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 four discovered clients with no default on record — `claude-desktop`, `cursor`, `vscode`, `windsurf` — the tokens are counted as
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 106 popular servers measured, each row dated by its own most recent sweep — full table in
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 (5 clients, JSONC-tolerant), the per-stack report, and the baseline diff |
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 one script that calls a network API (Claude divergence); kept out of the package so the library stays offline |
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` | 106 curated candidates with live install metrics and provenance |
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
- Then in your README:
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
  [![context cost](https://img.shields.io/endpoint?url=<raw URL of badges/my-server.json>)](<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
@@ -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
- export type AuditStatus = MeasurementStatus | 'remote-not-measurable';
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;
@@ -85,7 +85,30 @@ export function serverKey(s) {
85
85
  */
86
86
  function envSignature(s) {
87
87
  const env = s.env ?? {};
88
- return JSON.stringify(Object.keys(env).sort().map((k) => [k, env[k]]));
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) => ({ tokens: s.tokens ?? 0, claudeTokens: s.claudeTokens }))),
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
- skipped.push({
225
- ...base,
226
- status: 'remote-not-measurable',
227
- tokens: null,
228
- toolCount: null,
229
- share: null,
230
- notes: `remote endpoint (${s.url ?? 'url'}) stdio measurement does not apply`,
231
- });
232
- continue;
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
- const unestablished = results.flatMap((c) => c.skipped.filter((s) => s.status !== 'remote-not-measurable').map((s) => `${c.source}: ${s.name} (${s.status})`));
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.');
@@ -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 — recorded so the report can say why it was skipped. */
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;
@@ -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) { "servers": { ... } }
10
- * Claude Code (~/.claude.json) also { "projects": { "<dir>": { "mcpServers": ... } } }
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
- function toServer(name, raw, client, source) {
82
- if (raw.disabled === true)
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
- // Remote entries carry a url (and sometimes type http/sse) instead of a command.
91
- if (!raw.command && typeof raw.url === 'string') {
92
- return { name, client, source, transport: 'remote', url: raw.url, envVarNames };
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 (typeof raw.command !== 'string' || raw.command.trim() === '')
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 = [raw.command, ...args];
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 addBlock = (block) => {
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 (raw.disabled === true)
227
+ if (entry.disabled === true || entry.enabled === false || listedOff.has(name)) {
143
228
  off.push(name);
144
- const s = toServer(name, raw, meta.client, meta.source);
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
- if (meta.cwd && d.projects && typeof d.projects === 'object') {
152
- const project = d.projects[meta.cwd];
153
- if (project && typeof project === 'object')
154
- addBlock(project.mcpServers);
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 = parseJsonc(readFileSync(c.path, 'utf8'));
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