mcp-context-cost 0.6.0 → 0.8.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
@@ -70,11 +70,12 @@ deferral off in a settings file is not a machine running the default:
70
70
  |---|---|
71
71
  | nothing set (the default) | every definition deferred, at any size — no threshold applies |
72
72
  | `ENABLE_TOOL_SEARCH=true` | same: every definition deferred |
73
- | `ENABLE_TOOL_SEARCH=false` | deferral off — every request carries the full total |
73
+ | `ENABLE_TOOL_SEARCH=false` | deferral off — every request carries the full total. In a settings `env` block that is the **string** `"false"`; the JSON boolean `false` is the last row, not this one |
74
74
  | `ENABLE_TOOL_SEARCH=auto` / `auto:N` | deferred only once definitions reach 10% / N% of the context window |
75
75
  | `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS` set | tool search off — read first, because `ENABLE_TOOL_SEARCH` cannot override it |
76
76
  | `ANTHROPIC_BASE_URL` off `api.anthropic.com` | falls back to loading up front — consulted only while `ENABLE_TOOL_SEARCH` is unset |
77
77
  | anything else in `ENABLE_TOOL_SEARCH` | not a documented value, so nothing is claimed from it |
78
+ | 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 |
78
79
 
79
80
  On a machine where none of them is set, the same stack reads:
80
81
 
@@ -105,11 +106,13 @@ costs **more** than loading the definitions would.
105
106
  Three things the report will not do: it will not convert between units silently (in
106
107
  threshold mode the stack is compared as a range, because the audit counts wire bytes and the
107
108
  threshold is counted in what the client sends to the API — measured at 0.20×–1.92× across 20
108
- servers); it will not pick a winner when two places on the machine set the same variable to
109
- different values, or when a settings file exists and cannot be read; and it will not pass an
110
- absence of a record off as a measurement. The first two print as unanswered questions. The
111
- third prints as an answer that names itself: for the four discovered clients with no default
112
- on record `claude-desktop`, `cursor`, `vscode`, `windsurf` the tokens are counted as
109
+ servers); it will not claim a posture the machine did not state readably, which is four
110
+ refusals and not one when two places set the same variable to different values, when a
111
+ settings file exists and cannot be read, when the place that would decide sets the variable
112
+ to something that is not a string, and when `ENABLE_TOOL_SEARCH` holds a value Claude Code
113
+ does not document; and it will not pass an absence of a record off as a measurement. The
114
+ first two print as unanswered questions. The third prints as an answer that names
115
+ itself: for the four discovered clients with no default on record — `claude-desktop`, `cursor`, `vscode`, `windsurf` — the tokens are counted as
113
116
  loaded up front, and the report says so in those words, "an absence of a record about the
114
117
  client, not a measurement of it".
115
118
  Full model, sources and dates: [METHODOLOGY §who pays the number](docs/METHODOLOGY.md#who-pays).
@@ -101,14 +101,19 @@ export interface AuditReport {
101
101
  contextWindow: number;
102
102
  configs: AuditConfigResult[];
103
103
  /**
104
- * Client configs that were read and parsed but declare no servers. They get
105
- * no report line — there is nothing to total — but they are the record that a
106
- * client is installed here, which is not the same machine as one with no
107
- * client at all.
104
+ * Client configs that were read and parsed and have nothing to total. They
105
+ * get no report line, but they are the record that a client is installed
106
+ * here, which is not the same machine as one with no client at all.
107
+ *
108
+ * `allDisabled`, when present, is why there is nothing to total: the file
109
+ * declares those servers and every one of them is switched off. A config
110
+ * without it declares no servers at all. The two are different facts about a
111
+ * file this opened, so they are not published as one.
108
112
  */
109
113
  emptyConfigs: {
110
114
  client: string;
111
115
  source: string;
116
+ allDisabled?: string[];
112
117
  }[];
113
118
  budget?: {
114
119
  limit: number;
@@ -177,10 +177,13 @@ export function buildReport(configs, measured, opts = {}) {
177
177
  problems.push(`${cfg.source}: ${cfg.error}`);
178
178
  continue;
179
179
  }
180
- // Parsed, and declares nothing. Not a problem and not a report line: it is
181
- // recorded as itself, so a reader is told what this machine actually has.
180
+ // Parsed, and nothing to total. Not a problem and not a report line: it is
181
+ // recorded as itself, so a reader is told what this machine actually has
182
+ // including, where that is the case, that what it has is servers turned off.
182
183
  if (cfg.declaresNothing || cfg.servers.length === 0) {
183
- emptyConfigs.push({ client: cfg.client, source: cfg.source });
184
+ emptyConfigs.push(cfg.allDisabled?.length
185
+ ? { client: cfg.client, source: cfg.source, allDisabled: cfg.allDisabled }
186
+ : { client: cfg.client, source: cfg.source });
184
187
  continue;
185
188
  }
186
189
  const ok = [];
@@ -32,6 +32,23 @@ export declare function extractServers(doc: unknown, meta: {
32
32
  source: string;
33
33
  cwd?: string;
34
34
  }): ConfiguredServer[];
35
+ /**
36
+ * What one config document declares, servers and switched-off entries both.
37
+ *
38
+ * `extractServers` above answers "what is there to measure", which is the
39
+ * question almost every caller has. This answers the wider one, because a file
40
+ * that declares three servers and switches all three off has nothing to measure
41
+ * and is still not a file that declares nothing — and the only place that
42
+ * difference is still visible is here, before the off ones are dropped.
43
+ */
44
+ export declare function extractDeclaration(doc: unknown, meta: {
45
+ client: string;
46
+ source: string;
47
+ cwd?: string;
48
+ }): {
49
+ servers: ConfiguredServer[];
50
+ disabled: string[];
51
+ };
35
52
  export interface ConfigCandidate {
36
53
  client: string;
37
54
  path: string;
@@ -57,6 +74,14 @@ export interface LoadedConfig {
57
74
  * first.
58
75
  */
59
76
  declaresNothing?: true;
77
+ /**
78
+ * Set instead of `declaresNothing` when the file was read and parsed cleanly,
79
+ * declares servers, and every one of them is switched off — the names, in the
80
+ * file's own words. There is nothing to total either way, but a person who
81
+ * turned their servers off is not a person who declared none, and telling
82
+ * them the file declares nothing is a false statement about a file this read.
83
+ */
84
+ allDisabled?: string[];
60
85
  }
61
86
  /** Read + parse the candidates that exist. Unreadable files are reported, not thrown. */
62
87
  export declare function loadConfigs(candidates: ConfigCandidate[], cwd: string): LoadedConfig[];
@@ -113,16 +113,34 @@ function toServer(name, raw, client, source) {
113
113
  * servers by absolute directory.
114
114
  */
115
115
  export function extractServers(doc, meta) {
116
+ return extractDeclaration(doc, meta).servers;
117
+ }
118
+ /**
119
+ * What one config document declares, servers and switched-off entries both.
120
+ *
121
+ * `extractServers` above answers "what is there to measure", which is the
122
+ * question almost every caller has. This answers the wider one, because a file
123
+ * that declares three servers and switches all three off has nothing to measure
124
+ * and is still not a file that declares nothing — and the only place that
125
+ * difference is still visible is here, before the off ones are dropped.
126
+ */
127
+ export function extractDeclaration(doc, meta) {
116
128
  if (!doc || typeof doc !== 'object')
117
- return [];
129
+ return { servers: [], disabled: [] };
118
130
  const d = doc;
119
131
  const out = [];
132
+ const off = [];
120
133
  const addBlock = (block) => {
121
134
  if (!block || typeof block !== 'object')
122
135
  return;
123
136
  for (const [name, raw] of Object.entries(block)) {
124
137
  if (!raw || typeof raw !== 'object')
125
138
  continue;
139
+ // Recorded before `toServer` drops it, which is the only difference this
140
+ // can still see: an entry it returns null for because the person turned
141
+ // it off, rather than because it is malformed or absent.
142
+ if (raw.disabled === true)
143
+ off.push(name);
126
144
  const s = toServer(name, raw, meta.client, meta.source);
127
145
  if (s)
128
146
  out.push(s);
@@ -137,7 +155,11 @@ export function extractServers(doc, meta) {
137
155
  }
138
156
  // A name can legitimately appear in both blocks of the same file; keep the first.
139
157
  const seen = new Set();
140
- return out.filter((s) => (seen.has(s.name) ? false : (seen.add(s.name), true)));
158
+ const servers = out.filter((s) => (seen.has(s.name) ? false : (seen.add(s.name), true)));
159
+ // A name that is off in one block and live in another is a live server, not a
160
+ // switched-off one, so it is not reported as both.
161
+ const disabled = [...new Set(off.filter((n) => !seen.has(n)))].sort();
162
+ return { servers, disabled };
141
163
  }
142
164
  /** Every place a client config is known to live, whether or not it exists. */
143
165
  export function configCandidates(env) {
@@ -165,12 +187,19 @@ export function loadConfigs(candidates, cwd) {
165
187
  continue;
166
188
  try {
167
189
  const doc = parseJsonc(readFileSync(c.path, 'utf8'));
168
- const servers = extractServers(doc, { client: c.client, source: c.path, cwd });
190
+ const { servers, disabled } = extractDeclaration(doc, { client: c.client, source: c.path, cwd });
169
191
  // A config with no MCP block at all (e.g. a ~/.claude.json holding only
170
192
  // session history) is not worth a line in the report — it has no total.
171
193
  // It is still worth carrying: it is the evidence that a client is on this
172
194
  // machine, which is what tells an empty client apart from no client.
173
195
  if (servers.length === 0) {
196
+ // ...and one whose every declared server is switched off is carried as
197
+ // that, not as one declaring nothing: the second is a claim about the
198
+ // file that the file itself contradicts.
199
+ if (disabled.length) {
200
+ out.push({ client: c.client, source: c.path, servers: [], allDisabled: disabled });
201
+ continue;
202
+ }
174
203
  out.push({ client: c.client, source: c.path, servers: [], declaresNothing: true });
175
204
  continue;
176
205
  }
package/dist/cli.js CHANGED
@@ -176,9 +176,26 @@ if (cmd === 'audit') {
176
176
  const empty = report.emptyConfigs ?? [];
177
177
  if (json)
178
178
  console.log(JSON.stringify(report));
179
- // A machine whose client config was found, opened and parsed, and simply
180
- // declares nothing, is told that — being told no client was found anywhere
181
- // would send a reader looking for an install they already have.
179
+ // A machine whose client config was found, opened and parsed, and has
180
+ // nothing to total, is told that — being told no client was found anywhere
181
+ // would send a reader looking for an install they already have. Which of
182
+ // the two reasons it is gets said, because "declares nothing" is a false
183
+ // statement about a file that declares servers and switches them off.
184
+ else if (empty.some((c) => c.allDisabled?.length))
185
+ console.error(`${empty.length === 1 ? 'an MCP client config was found' : `${empty.length} MCP client configs were found`}, ` +
186
+ `and ${empty.length === 1 ? 'it has no server' : 'none of them has a server'} to measure:\n` +
187
+ empty
188
+ .map((c) => {
189
+ const off = c.allDisabled ?? [];
190
+ if (!off.length)
191
+ return ` ${c.client}: ${c.source} — declares no servers at all`;
192
+ return (` ${c.client}: ${c.source} — declares ${off.length} server${off.length === 1 ? '' : 's'}, ` +
193
+ `and ${off.length === 1 ? 'it is' : 'every one of them is'} switched off: ${off.join(', ')}`);
194
+ })
195
+ .join('\n') +
196
+ `${where}\n` +
197
+ `${empty.length === 1 ? 'It was' : 'They were'} read and parsed; a switched-off server is not started, so it costs nothing to measure.\n` +
198
+ `Switch one back on, declare one, or point at a different config: mcp-context-cost audit --config <path/to/mcp.json>`);
182
199
  else if (empty.length)
183
200
  console.error(`${empty.length === 1 ? 'an MCP client config was found' : `${empty.length} MCP client configs were found`}, ` +
184
201
  `and ${empty.length === 1 ? 'it declares' : 'they declare'} no servers:\n` +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-context-cost",
3
- "version": "0.6.0",
3
+ "version": "0.8.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",