mcp-context-cost 0.6.0 → 0.7.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.
@@ -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.7.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",