mcp-context-cost 0.14.0 → 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).
@@ -344,7 +357,7 @@ 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
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 |
@@ -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
@@ -37,11 +37,34 @@
37
37
  * generation; `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS` "keeps tool search
38
38
  * off. You can't override it by setting `ENABLE_TOOL_SEARCH` yourself."
39
39
  * A server with `alwaysLoad: true` loads at session start regardless.
40
+ * - The same page, re-read 2026-09-06 (the roadmap's dated re-read). The
41
+ * value table stands as quoted, and four things moved around it:
42
+ * (1) on Google Cloud's Agent Platform, tool search is on by default for
43
+ * the Claude 4.5 generation and later "the same as on the Anthropic API"
44
+ * — "Before v2.1.221, Claude Code disabled tool search for all models on
45
+ * Google Cloud's Agent Platform unless you set ENABLE_TOOL_SEARCH=true";
46
+ * the exception below already names only the earlier models. (2) Under
47
+ * `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS`, "your organization can keep
48
+ * tool search on through managed settings, on Claude Code v2.1.227 or
49
+ * later" — on a direct connection or a gateway, not on a cloud provider.
50
+ * This audit reads the managed settings file for the three variables and
51
+ * nothing else, so that override is not read here and the variable is
52
+ * still resolved as "off". (3) `alwaysLoad: true` is an entry field on
53
+ * every server type, and a tool can carry `"anthropic/alwaysLoad": true`
54
+ * in its `_meta`. The entry form is read from the config now
55
+ * (`DeferralServer.alwaysLoad`) and counted rather than listed; the
56
+ * per-tool form is still a listed condition. (4) "Claude Code truncates
57
+ * tool descriptions and server instructions at 2KB each", which bounds what
58
+ * a deferring session loads at start — noted beside the session-start
59
+ * metric in METHODOLOGY, not applied to any number here.
40
60
  *
41
- * No default deferral is on record here for the other four clients this tool
42
- * discovers. That is an absence of a record, not a measurement of those
43
- * clients, and it is printed as such the same rule the rest of this project
44
- * follows for a value it has not observed.
61
+ * No default deferral is on record here for the other clients this tool
62
+ * discovers Claude Desktop, Cursor, VS Code, Windsurf, and from 2026-09-06
63
+ * Codex CLI, Gemini CLI, Zed, Kiro and Goose, whose configuration pages were
64
+ * read that day and say nothing about deferring tool definitions. That is an
65
+ * absence of a record, not a measurement of those clients, and it is printed
66
+ * as such — the same rule the rest of this project follows for a value it has
67
+ * not observed.
45
68
  */
46
69
  import type { DivergenceRun } from '../core/divergence.js';
47
70
  /** Share of the context window at which deferral activates under `auto`. */
@@ -267,8 +290,17 @@ export declare const PUBLISHED_WIRE_TO_CLIENT_RATIO: WireToClientRatio;
267
290
  export declare function wireToClientRatio(run?: DivergenceRun | null): WireToClientRatio;
268
291
  /** One measured server, as the deferral arithmetic needs it. */
269
292
  export interface DeferralServer {
293
+ /** For naming the servers a verdict singles out; the arithmetic never reads it. */
294
+ name?: string;
270
295
  /** o200k tokens over the wire capture — the audit's own unit. */
271
296
  tokens: number;
297
+ /**
298
+ * The entry is pinned `alwaysLoad: true`, so its tools load at session start
299
+ * whatever the tool-search setting says, and it does not count toward a
300
+ * threshold — the documented `auto` mode counts "the tools it would
301
+ * otherwise defer".
302
+ */
303
+ alwaysLoad?: boolean;
272
304
  /**
273
305
  * Anthropic's own count for this server from a current divergence row, when
274
306
  * `--claude` supplied one. `null` means no current match, `undefined` means
@@ -367,6 +399,15 @@ export interface DeferralVerdict {
367
399
  sharedMeasurements: number;
368
400
  /** Conditions this cannot read, under which a deferring client pays in full. */
369
401
  exceptions: string[];
402
+ /**
403
+ * Servers in this scope pinned `alwaysLoad: true` in their entry, and their
404
+ * wire tokens. Read from the config, so it is stated rather than listed as a
405
+ * condition: whatever the mode, these load at session start.
406
+ */
407
+ alwaysLoad: {
408
+ servers: string[];
409
+ tokens: number;
410
+ };
370
411
  }
371
412
  /**
372
413
  * Read one session's deferral position. Pure arithmetic over a built scope — no
@@ -286,8 +286,23 @@ export function wireToClientRatio(run) {
286
286
  return PUBLISHED_WIRE_TO_CLIENT_RATIO;
287
287
  return { low, high, fixedOverhead, servers, source: `the ${run.measuredAt} ${run.model} divergence run` };
288
288
  }
289
- /** Clients this tool discovers that have no default deferral on record. */
290
- const NO_DEFERRAL_ON_RECORD = new Set(['claude-desktop', 'cursor', 'vscode', 'windsurf']);
289
+ /**
290
+ * Clients this tool discovers that have no default deferral on record. Each
291
+ * client's own MCP configuration page was read on the date config.ts gives
292
+ * and says nothing about deferring tool definitions — Windsurf's states a cap
293
+ * of 100 tools, which is a different thing and not a deferral.
294
+ */
295
+ const NO_DEFERRAL_ON_RECORD = new Set([
296
+ 'claude-desktop',
297
+ 'cursor',
298
+ 'vscode',
299
+ 'windsurf',
300
+ 'codex',
301
+ 'gemini',
302
+ 'zed',
303
+ 'kiro',
304
+ 'goose',
305
+ ]);
291
306
  /**
292
307
  * Where deferral does not apply even when the machine's setting says it should.
293
308
  * None of these can be read from the config or the environment, so they are
@@ -298,7 +313,7 @@ const EXCEPTIONS = [
298
313
  'a Microsoft Foundry deployment hosted on Azure, which rejects tool search server-side',
299
314
  "Google Cloud's Agent Platform on a model earlier than the Claude 4.5 generation",
300
315
  'a model without support for tool_reference blocks (before Sonnet 4.5 / Haiku 4.5 / Opus 4.5)',
301
- 'a server pinned with "alwaysLoad": true, whose tools load at session start regardless',
316
+ 'a tool whose _meta carries "anthropic/alwaysLoad": true, which this audit does not read from a capture',
302
317
  ];
303
318
  function estimate(servers, ratio) {
304
319
  let low = 0;
@@ -341,6 +356,11 @@ export function evaluateDeferral(scope, opts) {
341
356
  const wireTokens = scope.servers.reduce((a, s) => a + s.tokens, 0);
342
357
  const isFloor = scope.skippedCount > 0;
343
358
  const sharedMeasurements = scope.sharedMeasurements;
359
+ const pinned = scope.servers.filter((s) => s.alwaysLoad === true);
360
+ const alwaysLoad = {
361
+ servers: pinned.map((s) => s.name ?? '(unnamed)'),
362
+ tokens: pinned.reduce((a, s) => a + s.tokens, 0),
363
+ };
344
364
  // Every field a verdict carries, at its "nothing to say" value. Each mode
345
365
  // below overrides only what it can actually answer.
346
366
  const base = {
@@ -364,6 +384,7 @@ export function evaluateDeferral(scope, opts) {
364
384
  distanceTokens: null,
365
385
  crosses: null,
366
386
  exceptions: [],
387
+ alwaysLoad,
367
388
  };
368
389
  if (scope.client !== 'claude-code') {
369
390
  return {
@@ -422,7 +443,9 @@ export function evaluateDeferral(scope, opts) {
422
443
  };
423
444
  }
424
445
  const ratio = wireToClientRatio(opts.divergence);
425
- const clientTokens = estimate(scope.servers, ratio);
446
+ // Only what the client would otherwise defer is held against the threshold;
447
+ // a pinned server loads either way and is not part of the question.
448
+ const clientTokens = estimate(scope.servers.filter((s) => s.alwaysLoad !== true), ratio);
426
449
  // At-or-above, on the documented "defers all of them once the definitions
427
450
  // reach 10%". A range that is entirely over is over even if it is a floor:
428
451
  // more unmeasured tokens cannot take it back under.
@@ -0,0 +1,56 @@
1
+ /**
2
+ * What a remote MCP endpoint says to a request that carries no credential.
3
+ *
4
+ * `audit` used to skip every `url` entry as `remote-not-measurable`, which
5
+ * under-counted exactly the stacks the audit is for — vendors are moving to
6
+ * hosted endpoints. The obvious fix, handing every url to the `mcp-remote`
7
+ * bridge the sweep already measures open endpoints through, is wrong on a
8
+ * developer machine: against an OAuth-walled endpoint mcp-remote opens a
9
+ * browser window and waits for the callback (its README says so), which an
10
+ * audit has no business doing once per server in someone's config; and in a
11
+ * headless run it waits until this harness's timeout, so the row would read
12
+ * `timeout` — a word that blames the clock for a credential.
13
+ *
14
+ * So the endpoint is asked first, with the request every MCP session begins
15
+ * with: `initialize` over streamable HTTP, then a GET with an event-stream
16
+ * `accept` for an SSE endpoint that refuses POST. Probed 2026-09-06 from this
17
+ * repository: `mcp.linear.app/mcp`, `mcp.zapier.com/api/mcp/mcp` and
18
+ * `mcp.vercel.com` each answer `401` with a `WWW-Authenticate: Bearer …`
19
+ * header naming the OAuth resource; `mcp.deepwiki.com/mcp`, `learn.microsoft.com/api/mcp`,
20
+ * `docs.mcp.cloudflare.com/sse` and `huggingface.co/mcp` answer `200` with a
21
+ * session or a stream. Nothing is inferred from a hostname: the status line
22
+ * and the header are the server's own words, and the report quotes them.
23
+ *
24
+ * Only an endpoint that answered without a credential is handed to the
25
+ * bridge, so the bridge never has a reason to open a browser. Header VALUES a
26
+ * config carries (a static bearer token) are sent with the probe and the
27
+ * bridge, exactly as env values are spawned into a stdio server, and never
28
+ * written to a report — see `ConfiguredServer.headers` in config.ts.
29
+ */
30
+ export interface RemoteProbe {
31
+ /**
32
+ * `open`: answered the unauthenticated request as an MCP endpoint does.
33
+ * `auth-walled`: answered 401 or 403 — it works, and it wants a credential
34
+ * this audit does not hold. `unreachable`: no MCP answer arrived — a
35
+ * connection failure, a timeout, or a status that is neither of the above.
36
+ */
37
+ kind: 'open' | 'auth-walled' | 'unreachable';
38
+ /** The HTTP status that decided it, when a response arrived at all. */
39
+ status?: number;
40
+ /** The `WWW-Authenticate` header, verbatim (clipped), when the server sent one. */
41
+ wwwAuthenticate?: string;
42
+ /** One line a report can print: the server's own words, or the failure's. */
43
+ detail: string;
44
+ }
45
+ /** What a probe waits for an answer, unless the caller says otherwise. */
46
+ export declare const DEFAULT_PROBE_TIMEOUT_MS = 15000;
47
+ /**
48
+ * Ask one endpoint, once, and report what it said.
49
+ *
50
+ * Never throws: an endpoint this could not reach is a probe result, not a
51
+ * crash, because the audit goes on to the next server either way.
52
+ */
53
+ export declare function probeRemote(url: string, opts?: {
54
+ headers?: Record<string, string>;
55
+ timeoutMs?: number;
56
+ }): Promise<RemoteProbe>;
@@ -0,0 +1,126 @@
1
+ /**
2
+ * What a remote MCP endpoint says to a request that carries no credential.
3
+ *
4
+ * `audit` used to skip every `url` entry as `remote-not-measurable`, which
5
+ * under-counted exactly the stacks the audit is for — vendors are moving to
6
+ * hosted endpoints. The obvious fix, handing every url to the `mcp-remote`
7
+ * bridge the sweep already measures open endpoints through, is wrong on a
8
+ * developer machine: against an OAuth-walled endpoint mcp-remote opens a
9
+ * browser window and waits for the callback (its README says so), which an
10
+ * audit has no business doing once per server in someone's config; and in a
11
+ * headless run it waits until this harness's timeout, so the row would read
12
+ * `timeout` — a word that blames the clock for a credential.
13
+ *
14
+ * So the endpoint is asked first, with the request every MCP session begins
15
+ * with: `initialize` over streamable HTTP, then a GET with an event-stream
16
+ * `accept` for an SSE endpoint that refuses POST. Probed 2026-09-06 from this
17
+ * repository: `mcp.linear.app/mcp`, `mcp.zapier.com/api/mcp/mcp` and
18
+ * `mcp.vercel.com` each answer `401` with a `WWW-Authenticate: Bearer …`
19
+ * header naming the OAuth resource; `mcp.deepwiki.com/mcp`, `learn.microsoft.com/api/mcp`,
20
+ * `docs.mcp.cloudflare.com/sse` and `huggingface.co/mcp` answer `200` with a
21
+ * session or a stream. Nothing is inferred from a hostname: the status line
22
+ * and the header are the server's own words, and the report quotes them.
23
+ *
24
+ * Only an endpoint that answered without a credential is handed to the
25
+ * bridge, so the bridge never has a reason to open a browser. Header VALUES a
26
+ * config carries (a static bearer token) are sent with the probe and the
27
+ * bridge, exactly as env values are spawned into a stdio server, and never
28
+ * written to a report — see `ConfiguredServer.headers` in config.ts.
29
+ */
30
+ /** The first request of every MCP session, which is all a probe needs to send. */
31
+ const INITIALIZE = JSON.stringify({
32
+ jsonrpc: '2.0',
33
+ id: 1,
34
+ method: 'initialize',
35
+ params: {
36
+ protocolVersion: '2025-06-18',
37
+ capabilities: {},
38
+ clientInfo: { name: 'mcp-context-cost', version: '0' },
39
+ },
40
+ });
41
+ /** Long enough to be quoted whole in a report, short enough not to be the report. */
42
+ const CLIP = 300;
43
+ const clip = (s) => {
44
+ const one = s.replace(/\s+/g, ' ').trim();
45
+ return one.length > CLIP ? `${one.slice(0, CLIP)}…` : one;
46
+ };
47
+ /** What a probe waits for an answer, unless the caller says otherwise. */
48
+ export const DEFAULT_PROBE_TIMEOUT_MS = 15_000;
49
+ /**
50
+ * Ask one endpoint, once, and report what it said.
51
+ *
52
+ * Never throws: an endpoint this could not reach is a probe result, not a
53
+ * crash, because the audit goes on to the next server either way.
54
+ */
55
+ export async function probeRemote(url, opts = {}) {
56
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
57
+ const configured = opts.headers ?? {};
58
+ const attempt = async (init) => {
59
+ const res = await fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs), redirect: 'follow' });
60
+ // The headers are the answer. The body is not read — an event stream
61
+ // stays open for the life of a session, and this is not a session.
62
+ try {
63
+ await res.body?.cancel();
64
+ }
65
+ catch {
66
+ /* a body that refuses to be cancelled changes nothing the headers said */
67
+ }
68
+ return res;
69
+ };
70
+ try {
71
+ let res = await attempt({
72
+ method: 'POST',
73
+ headers: {
74
+ ...configured,
75
+ 'content-type': 'application/json',
76
+ accept: 'application/json, text/event-stream',
77
+ 'mcp-protocol-version': '2025-06-18',
78
+ },
79
+ body: INITIALIZE,
80
+ });
81
+ // The older SSE transport opens its stream on GET and may refuse the POST
82
+ // outright; an endpoint that does is asked the way it expects to be.
83
+ if (res.status === 404 || res.status === 405) {
84
+ res = await attempt({ method: 'GET', headers: { ...configured, accept: 'text/event-stream' } });
85
+ }
86
+ return classify(res);
87
+ }
88
+ catch (e) {
89
+ return { kind: 'unreachable', detail: describeFailure(e, timeoutMs) };
90
+ }
91
+ }
92
+ function classify(res) {
93
+ const www = res.headers.get('www-authenticate');
94
+ const type = (res.headers.get('content-type') ?? '').split(';')[0].trim().toLowerCase();
95
+ if (res.status === 401 || res.status === 403) {
96
+ return {
97
+ kind: 'auth-walled',
98
+ status: res.status,
99
+ ...(www ? { wwwAuthenticate: clip(www) } : {}),
100
+ detail: `HTTP ${res.status}${www ? ` — WWW-Authenticate: ${clip(www)}` : ''}`,
101
+ };
102
+ }
103
+ if (res.ok) {
104
+ // A JSON-RPC answer or an event stream is what an MCP endpoint sends. A
105
+ // 200 carrying HTML is a login page or a docs site at that address.
106
+ if (type === 'application/json' || type === 'text/event-stream') {
107
+ return { kind: 'open', status: res.status, detail: `HTTP ${res.status} ${type}` };
108
+ }
109
+ return {
110
+ kind: 'unreachable',
111
+ status: res.status,
112
+ detail: `HTTP ${res.status} with ${type || 'no content-type'}, which is not an MCP response`,
113
+ };
114
+ }
115
+ return { kind: 'unreachable', status: res.status, detail: `HTTP ${res.status}` };
116
+ }
117
+ function describeFailure(e, timeoutMs) {
118
+ const err = e;
119
+ if (err?.name === 'TimeoutError' || err?.name === 'AbortError')
120
+ return `no answer within ${timeoutMs}ms`;
121
+ // Node's fetch wraps the socket error: "fetch failed" with the code underneath.
122
+ const code = err?.cause?.code;
123
+ if (code)
124
+ return code;
125
+ return err?.cause?.message ?? err?.message ?? String(e);
126
+ }
@@ -4,7 +4,8 @@ import { type ToolShapeBaseline } from '../core/tool-shape.js';
4
4
  import { type CaptureIndex } from '../core/capture-index.js';
5
5
  import { type AuditReport } from './audit.js';
6
6
  import { type ToolSearchEnv, type ToolSearchSource } from './deferral.js';
7
- import { type LoadedConfig } from './config.js';
7
+ import { type RemoteProbe } from './remote.js';
8
+ import { type ConfiguredServer, type LoadedConfig } from './config.js';
8
9
  /** Where the published `tools-delta/v1` run lives when `--claude` doesn't override it. */
9
10
  export declare const DEFAULT_DIVERGENCE_URL = "https://raw.githubusercontent.com/athakur3/mcp-context-cost/main/results/divergence.json";
10
11
  /** Where the published `tool-shape/v1` baseline lives when `--suggest` doesn't override it. */
@@ -45,6 +46,12 @@ export interface AuditOptions {
45
46
  * those files off the machine being audited (`discoverSettings`).
46
47
  */
47
48
  settings?: ToolSearchSource[];
49
+ /**
50
+ * What each remote endpoint said to an unauthenticated `initialize`, keyed
51
+ * like `measureAll`'s map. `runAudit` probes them (`probeRemotes`); a test can
52
+ * state the answers instead of reaching a network.
53
+ */
54
+ remotes?: Map<string, RemoteProbe>;
48
55
  onProgress?: (name: string, done: number, total: number) => void;
49
56
  }
50
57
  /** Fetch and parse the published capture index. Never throws: a failure is a report problem, not a crash. */
@@ -72,6 +79,29 @@ export declare function discover(opts?: AuditOptions): LoadedConfig[];
72
79
  * launched this audit answers for the wrong machine.
73
80
  */
74
81
  export declare function discoverSettings(opts?: AuditOptions): ToolSearchSource[];
75
- /** Measure every distinct stdio server across the given configs, once each. */
82
+ /**
83
+ * Ask every distinct remote endpoint what it says to an unauthenticated
84
+ * `initialize`, once each. This comes before any launch — remote.ts says why an
85
+ * endpoint is asked before the bridge is pointed at it.
86
+ */
87
+ export declare function probeRemotes(configs: LoadedConfig[], opts?: AuditOptions): Promise<Map<string, RemoteProbe>>;
88
+ /**
89
+ * The bridge launch for an endpoint that answered without a credential.
90
+ *
91
+ * `argv` is exact, for the host path; `command` is the same launch as one
92
+ * shell line, for the docker path, which runs it through `sh -lc` (docker.ts).
93
+ * Both carry the entry's header values, as a stdio launch carries env values.
94
+ * `display` carries the names only, and is the form a report may print.
95
+ */
96
+ export declare function bridgeLaunch(s: ConfiguredServer): {
97
+ argv: string[];
98
+ command: string;
99
+ display: string;
100
+ };
101
+ /**
102
+ * Measure every distinct server across the given configs, once each: each
103
+ * stdio server by its launch, and each remote endpoint the probe found open
104
+ * through the bridge. A walled or unreachable endpoint is not launched at all.
105
+ */
76
106
  export declare function measureAll(configs: LoadedConfig[], opts?: AuditOptions): Promise<Map<string, Measurement>>;
77
107
  export declare function runAudit(opts?: AuditOptions): Promise<AuditReport>;
package/dist/audit/run.js CHANGED
@@ -11,6 +11,7 @@ import { parseToolShapeBaseline } from '../core/tool-shape.js';
11
11
  import { parseCaptureIndex } from '../core/capture-index.js';
12
12
  import { buildReport, serverKey } from './audit.js';
13
13
  import { toolSearchEnv } from './deferral.js';
14
+ import { DEFAULT_PROBE_TIMEOUT_MS, probeRemote } from './remote.js';
14
15
  import { configCandidates, loadConfigs, loadSettingsSources, settingsCandidates, } from './config.js';
15
16
  /** Where the published `tools-delta/v1` run lives when `--claude` doesn't override it. */
16
17
  export const DEFAULT_DIVERGENCE_URL = 'https://raw.githubusercontent.com/athakur3/mcp-context-cost/main/results/divergence.json';
@@ -78,12 +79,63 @@ export function discoverSettings(opts = {}) {
78
79
  const home = opts.home ?? homedir();
79
80
  return loadSettingsSources(settingsCandidates({ home, cwd, platform: process.platform, programData: process.env.ProgramData }));
80
81
  }
81
- /** Measure every distinct stdio server across the given configs, once each. */
82
+ /**
83
+ * Ask every distinct remote endpoint what it says to an unauthenticated
84
+ * `initialize`, once each. This comes before any launch — remote.ts says why an
85
+ * endpoint is asked before the bridge is pointed at it.
86
+ */
87
+ export async function probeRemotes(configs, opts = {}) {
88
+ const unique = new Map();
89
+ for (const cfg of configs) {
90
+ for (const s of cfg.servers) {
91
+ if (s.transport !== 'remote' || !s.url)
92
+ continue;
93
+ if (!unique.has(serverKey(s)))
94
+ unique.set(serverKey(s), s);
95
+ }
96
+ }
97
+ const out = new Map();
98
+ // One HTTP exchange each; the launch timeout is the wrong bound for it.
99
+ const timeoutMs = Math.min(opts.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS, DEFAULT_PROBE_TIMEOUT_MS);
100
+ await Promise.all([...unique].map(async ([key, s]) => {
101
+ out.set(key, await probeRemote(s.url, { headers: s.headers, timeoutMs }));
102
+ }));
103
+ return out;
104
+ }
105
+ /**
106
+ * The bridge launch for an endpoint that answered without a credential.
107
+ *
108
+ * `argv` is exact, for the host path; `command` is the same launch as one
109
+ * shell line, for the docker path, which runs it through `sh -lc` (docker.ts).
110
+ * Both carry the entry's header values, as a stdio launch carries env values.
111
+ * `display` carries the names only, and is the form a report may print.
112
+ */
113
+ export function bridgeLaunch(s) {
114
+ const url = s.url ?? '';
115
+ const argv = ['npx', '-y', 'mcp-remote', url];
116
+ const display = [...argv];
117
+ // mcp-remote refuses a plain-http endpoint unless told it is on purpose.
118
+ if (/^http:/i.test(url)) {
119
+ argv.push('--allow-http');
120
+ display.push('--allow-http');
121
+ }
122
+ for (const [k, v] of Object.entries(s.headers ?? {})) {
123
+ argv.push('--header', `${k}: ${v}`);
124
+ display.push('--header', k);
125
+ }
126
+ const shellQuote = (a) => (/^[A-Za-z0-9_@%+=:,./-]+$/.test(a) ? a : `'${a.replace(/'/g, `'\\''`)}'`);
127
+ return { argv, command: argv.map(shellQuote).join(' '), display: display.join(' ') };
128
+ }
129
+ /**
130
+ * Measure every distinct server across the given configs, once each: each
131
+ * stdio server by its launch, and each remote endpoint the probe found open
132
+ * through the bridge. A walled or unreachable endpoint is not launched at all.
133
+ */
82
134
  export async function measureAll(configs, opts = {}) {
83
135
  const unique = new Map();
84
136
  for (const cfg of configs) {
85
137
  for (const s of cfg.servers) {
86
- if (s.transport !== 'stdio')
138
+ if (s.transport === 'remote' && opts.remotes?.get(serverKey(s))?.kind !== 'open')
87
139
  continue;
88
140
  // Two clients pointing at the same argv are one measurement, not two.
89
141
  if (!unique.has(serverKey(s)))
@@ -97,8 +149,9 @@ export async function measureAll(configs, opts = {}) {
97
149
  const worker = async () => {
98
150
  for (let next = queue.shift(); next; next = queue.shift()) {
99
151
  const [key, s] = next;
100
- const m = await measureServer(s.name, s.command ?? '', {
101
- argv: s.argv,
152
+ const launch = s.transport === 'remote' ? bridgeLaunch(s) : null;
153
+ const m = await measureServer(s.name, launch ? launch.command : (s.command ?? ''), {
154
+ argv: launch ? launch.argv : s.argv,
102
155
  env: s.env,
103
156
  timeoutMs: opts.timeoutMs ?? 60_000,
104
157
  docker: opts.docker,
@@ -113,7 +166,8 @@ export async function measureAll(configs, opts = {}) {
113
166
  }
114
167
  export async function runAudit(opts = {}) {
115
168
  const configs = discover(opts);
116
- const measured = await measureAll(configs, opts);
169
+ const remotes = opts.remotes ?? (await probeRemotes(configs, opts));
170
+ const measured = await measureAll(configs, { ...opts, remotes });
117
171
  let divergence = null;
118
172
  let divergenceProblem;
119
173
  if (opts.claude) {
@@ -143,6 +197,7 @@ export async function runAudit(opts = {}) {
143
197
  captureIndex,
144
198
  env: opts.env ?? toolSearchEnv(process.env),
145
199
  settings: opts.settings ?? discoverSettings(opts),
200
+ remotes,
146
201
  });
147
202
  if (divergenceProblem)
148
203
  report.problems.push(divergenceProblem);
package/dist/cli.js CHANGED
@@ -342,7 +342,8 @@ if (cmd === 'audit') {
342
342
  console.error(`no MCP config found at the path(s) given: ${all('config').join(', ')}. ` +
343
343
  `Nothing else was searched, because --config was set.`);
344
344
  else
345
- console.error(`no MCP config found. Looked in the standard Claude Desktop / Claude Code / Cursor / VS Code / Windsurf locations.${where}\n` +
345
+ console.error(`no MCP config found. Looked in the standard Claude Desktop / Claude Code / Cursor / VS Code / Windsurf / ` +
346
+ `Codex CLI / Gemini CLI / Zed / Kiro / Goose locations.${where}\n` +
346
347
  `Point at one explicitly: mcp-context-cost audit --config <path/to/mcp.json>`);
347
348
  process.exit(1);
348
349
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-context-cost",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Measure what your MCP servers cost in context tokens — audit your own config, or badge the server you publish",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -53,6 +53,7 @@
53
53
  },
54
54
  "dependencies": {
55
55
  "js-tiktoken": "^1.0.21",
56
+ "smol-toml": "1.8.0",
56
57
  "yaml": "^2.9.0"
57
58
  },
58
59
  "devDependencies": {