mcp-context-cost 0.11.3 → 0.12.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
@@ -184,7 +184,7 @@ Add `--claude` to annotate each server with its Anthropic-request cost from the
184
184
  [Claude divergence](docs/METHODOLOGY.md#claude-divergence) run — an exact number when the
185
185
  published capture hash matches what you have installed, `—` (silence, not a stale guess)
186
186
  when it doesn't. The run holds 20 rows — the top 20 measured servers by tokens when it ran —
187
- and [results/leaderboard.md](results/leaderboard.md) prints a claude number for the 16 that
187
+ and [results/leaderboard.md](results/leaderboard.md) prints a claude number for the 12 that
188
188
  still match today and silence for the rest. Most installs will show a mix:
189
189
 
190
190
  ```
@@ -235,7 +235,7 @@ Flags: `--json` (full report on stdout, progress on stderr), `--budget N`,
235
235
 
236
236
  The number `audit` gives you is the same measurement, run across a curated set of public
237
237
  servers — which is how you can tell it is a measurement and not this tool's opinion. It also
238
- shows what you are choosing between: across the 81 servers measured, cost spans **1,700×**,
238
+ shows what you are choosing between: across the 83 servers measured, cost spans **1,700×**,
239
239
  from `postgres` at 32 tokens to `github` at 54,622. The table below is a
240
240
  sample of that range; the full range is in
241
241
  [results/leaderboard.md](results/leaderboard.md).
@@ -244,13 +244,13 @@ sample of that range; the full range is in
244
244
  |---|---:|---:|
245
245
  | github (official) | **54,622 tokens** | 44 |
246
246
  | xcodebuildmcp | 26,594 | 24 |
247
- | brave-search | 25,456 | 8 |
247
+ | brave-search | 25,487 | 8 |
248
248
  | notion | 17,500 | 24 |
249
249
  | playwright *(4.8M installs/week)* | 4,024 | 24 |
250
250
  | filesystem (reference) | 2,823 | 14 |
251
251
  | markitdown | 64 | 1 |
252
252
 
253
- *(81 of 106 popular servers measured, each row dated by its own most recent sweep — full table in
253
+ *(83 of 106 popular servers measured, each row dated by its own most recent sweep — full table in
254
254
  [results/leaderboard.md](results/leaderboard.md); every failure is listed with its reason.
255
255
  Each measured server also has a [detail page](https://athakur3.github.io/mcp-context-cost/servers/)
256
256
  showing which tools its tokens are in.)*
@@ -260,7 +260,7 @@ answers a question no client asks: **what did this server cost last month?**
260
260
  [results/regressions.md](results/regressions.md) reports each server's most recent movement —
261
261
  dated to when it happened, separated into *shipped more tools* versus *same tools, rewritten*,
262
262
  and compared only within one isolation. The ecosystem ratchets upward: of the servers whose
263
- cost has moved at all, 9 moved up against 1 that moved down. Method:
263
+ cost has moved at all, 11 moved up against 6 that moved down. Method:
264
264
  [cost movement](docs/METHODOLOGY.md#cost-movement).
265
265
 
266
266
  If you publish a server, the same measurement is available as a badge, so your users can see
@@ -1,5 +1,15 @@
1
1
  /** Status taxonomy — every swept server gets exactly one; no silent drops. */
2
- export type MeasurementStatus = 'measured' | 'auth-required' | 'startup-failure' | 'timeout' | 'dynamic' | 'remote-auth-wall';
2
+ export type MeasurementStatus = 'measured' | 'auth-required' | 'startup-failure' | 'timeout'
3
+ /**
4
+ * This harness cannot run the server, for a reason that is a property of the
5
+ * harness rather than of the software: an OS or architecture the package does
6
+ * not ship for, or a backing service the isolation deliberately does not
7
+ * provide. Distinct from `startup-failure`, which asserts the server did not
8
+ * come up — a claim about someone else's code that these entries do not
9
+ * support. Only ever set when the entry declares the reason AND the failure's
10
+ * own text corroborates it (see `notApplicable` in report.ts).
11
+ */
12
+ | 'not-applicable' | 'dynamic' | 'remote-auth-wall';
3
13
  export interface ToolMeasurement {
4
14
  name: string;
5
15
  /** Tokens of the whole tool object, canonically serialized. */
@@ -43,6 +53,18 @@ export interface Measurement {
43
53
  image?: string;
44
54
  network?: string;
45
55
  note?: string;
56
+ /**
57
+ * The architecture the measurement ran on, as `<platform>/<arch>` (e.g.
58
+ * `linux/amd64`). Part of the isolation because some packages ship builds
59
+ * for only some of them: `local-mcp` was published as a startup failure
60
+ * for weeks on the strength of a run whose real finding was "this laptop
61
+ * is arm64 and the package has no arm64 runtime" — a fact about the
62
+ * machine that the record gave no way to see.
63
+ *
64
+ * Absent on records written before this was captured, which is why it is
65
+ * optional; absence means unknown, never "the same as yours".
66
+ */
67
+ arch?: string;
46
68
  };
47
69
  /** Request timeout in force during this measurement. */
48
70
  timeoutMs?: number;
@@ -13,6 +13,28 @@ export interface WireCapture {
13
13
  instructions: string | null;
14
14
  stderrTail: string;
15
15
  }
16
+ /**
17
+ * The part of a dead server's stderr worth keeping as evidence.
18
+ *
19
+ * A failure record is only useful if it contains the failure, and a plain tail
20
+ * reliably keeps the least useful part. `npx` prints a deprecation warning per
21
+ * transitive dependency and a version notice at the end, and a crashing process
22
+ * prints its message *before* the stack — so the last N characters of stderr
23
+ * are npm warnings and stack frames on exactly the servers whose failure needs
24
+ * explaining. Several published records ended up saying nothing about why the
25
+ * server did not start.
26
+ *
27
+ * This is not cosmetic. `run.ts` classifies a failure by reading these words:
28
+ * a record whose message was cut off is filed as `startup-failure` — the server
29
+ * is broken — when the surviving text would have said `auth-required`. Spending
30
+ * the budget on the message rather than the frames is what keeps the published
31
+ * taxonomy describing the server.
32
+ *
33
+ * Noise is only dropped while something else survives. A package that fails
34
+ * *inside* npm (EBADPLATFORM, a failed postinstall) has npm's own lines as its
35
+ * only evidence, and a server whose whole output is a stack keeps the stack.
36
+ */
37
+ export declare function evidenceTail(stderr: string, limit?: number): string;
16
38
  export declare class McpStdioClient {
17
39
  private child;
18
40
  private buffer;
@@ -6,6 +6,92 @@
6
6
  */
7
7
  import { spawn } from 'node:child_process';
8
8
  const PROTOCOL_VERSION = '2025-06-18';
9
+ /**
10
+ * The part of a dead server's stderr worth keeping as evidence.
11
+ *
12
+ * A failure record is only useful if it contains the failure, and a plain tail
13
+ * reliably keeps the least useful part. `npx` prints a deprecation warning per
14
+ * transitive dependency and a version notice at the end, and a crashing process
15
+ * prints its message *before* the stack — so the last N characters of stderr
16
+ * are npm warnings and stack frames on exactly the servers whose failure needs
17
+ * explaining. Several published records ended up saying nothing about why the
18
+ * server did not start.
19
+ *
20
+ * This is not cosmetic. `run.ts` classifies a failure by reading these words:
21
+ * a record whose message was cut off is filed as `startup-failure` — the server
22
+ * is broken — when the surviving text would have said `auth-required`. Spending
23
+ * the budget on the message rather than the frames is what keeps the published
24
+ * taxonomy describing the server.
25
+ *
26
+ * Noise is only dropped while something else survives. A package that fails
27
+ * *inside* npm (EBADPLATFORM, a failed postinstall) has npm's own lines as its
28
+ * only evidence, and a server whose whole output is a stack keeps the stack.
29
+ */
30
+ export function evidenceTail(stderr, limit = 600) {
31
+ const withoutNoise = drop(stderr, (l) => /^npm (warn|notice)\b/.test(l));
32
+ const withoutFrames = drop(withoutNoise, (l) => /^at\s/.test(l));
33
+ return bothEnds(withoutFrames, limit);
34
+ }
35
+ /**
36
+ * Keep the start and the end, eliding the middle.
37
+ *
38
+ * Dropping npm noise and stack frames is not enough on its own: a CLI that
39
+ * rejects its environment often prints one line saying why and then its entire
40
+ * usage screen, which is neither. kubernetes-mcp-server does exactly that, and
41
+ * a tail-only budget kept forty lines of flag documentation while discarding
42
+ * "no current-context is set and no contexts are defined in kubeconfig" — the
43
+ * only sentence in the output that explained anything.
44
+ *
45
+ * Failures put their explanation at one end or the other — a crash message
46
+ * above its aftermath, or an error at the end of a log — so both ends are kept
47
+ * and the middle is what goes. The split leans towards the head because a
48
+ * message that precedes its own noise is the more common shape here.
49
+ */
50
+ function bothEnds(text, limit) {
51
+ if (text.length <= limit)
52
+ return text;
53
+ const elision = '\n […] \n';
54
+ const budget = Math.max(0, limit - elision.length);
55
+ const lines = text.split('\n');
56
+ // Whole lines only: a boundary cut mid-word ("ool/prompt change") reads as
57
+ // corruption and loses the token an evidence string would match on.
58
+ const take = (from, to, cap, fromEnd) => {
59
+ const out = [];
60
+ let used = 0;
61
+ for (let i = fromEnd ? to : from; fromEnd ? i >= from : i <= to; i += fromEnd ? -1 : 1) {
62
+ const cost = lines[i].length + 1;
63
+ if (used + cost > cap)
64
+ break;
65
+ fromEnd ? out.unshift(lines[i]) : out.push(lines[i]);
66
+ used += cost;
67
+ }
68
+ return { out, used };
69
+ };
70
+ const headCap = Math.ceil(budget * 0.6);
71
+ const head = take(0, lines.length - 1, headCap, false);
72
+ // Whole lines, except when the first line alone overruns the budget. A server
73
+ // that logs structured JSON puts its entire message on one line, so that line
74
+ // is both the most informative thing in the output and the only one that can
75
+ // never fit — slack-mcp-server's `{"level":"fatal","message":"Authentication
76
+ // required: ..."}` was dropped in full, and the record it left behind said a
77
+ // child process exited. Truncated evidence beats none.
78
+ const headText = head.out.length > 0 ? head.out.join('\n') : lines[0].slice(0, headCap);
79
+ const headUsed = head.out.length > 0 ? head.used : headText.length;
80
+ const tailFrom = head.out.length > 0 ? head.out.length : 1;
81
+ const tail = take(tailFrom, lines.length - 1, budget - headUsed, true);
82
+ if (headText === '' && tail.out.length === 0)
83
+ return text.slice(0, budget) + elision;
84
+ return `${headText}${elision}${tail.out.join('\n')}`;
85
+ }
86
+ /** Drop matching lines, keeping the input whole if that would leave nothing. */
87
+ function drop(text, isNoise) {
88
+ const kept = text
89
+ .split('\n')
90
+ .filter((l) => !isNoise(l.trim()))
91
+ .join('\n')
92
+ .trim();
93
+ return kept || text.trim();
94
+ }
9
95
  export class McpStdioClient {
10
96
  child;
11
97
  buffer = '';
@@ -37,7 +123,7 @@ export class McpStdioClient {
37
123
  resolve();
38
124
  });
39
125
  this.child.on('exit', (code) => {
40
- const tail = this.stderrTail.slice(-600);
126
+ const tail = evidenceTail(this.stderrTail);
41
127
  this.deadReason = `server exited (code ${code})${tail ? `; stderr tail: ${tail}` : ''}`;
42
128
  for (const p of this.pending.values())
43
129
  p.reject(new Error(this.deadReason));
@@ -95,7 +181,12 @@ export class McpStdioClient {
95
181
  return new Promise((resolve, reject) => {
96
182
  const timer = setTimeout(() => {
97
183
  this.pending.delete(id);
98
- reject(new Error(`timeout after ${timeoutMs}ms waiting for ${method}`));
184
+ // A process that is killed mid-hang never reaches the exit handler, so
185
+ // without this a timed-out record carries no evidence at all — it says
186
+ // only that we waited. What the server managed to print before it
187
+ // stopped answering is usually the whole explanation.
188
+ const tail = evidenceTail(this.stderrTail);
189
+ reject(new Error(`timeout after ${timeoutMs}ms waiting for ${method}${tail ? `; stderr tail: ${tail}` : ''}`));
99
190
  }, timeoutMs);
100
191
  this.pending.set(id, {
101
192
  resolve: (v) => {
@@ -23,6 +23,22 @@ export interface ServerEntry {
23
23
  * before ever reaching tools/list. See docker.ts `dummyEnvValues`.
24
24
  */
25
25
  envValues?: Record<string, string>;
26
+ /**
27
+ * Declares that a failure of this entry is this harness's limitation, not the
28
+ * server's — an OS or architecture the package does not ship for, or a
29
+ * backing service the isolation deliberately does not provide.
30
+ *
31
+ * `evidence` is what keeps the declaration honest. The status only becomes
32
+ * `not-applicable` when the failure's own text contains that substring, so an
33
+ * annotation left behind after upstream changes cannot quietly absorb a real
34
+ * breakage: the server fails a different way, the evidence stops matching,
35
+ * and it is published as the failure it actually is. The entry is still
36
+ * attempted every sweep, so the day it starts working it simply measures.
37
+ */
38
+ notApplicable?: {
39
+ reason: string;
40
+ evidence: string;
41
+ };
26
42
  }
27
43
  export interface Row {
28
44
  entry: ServerEntry;
@@ -1,4 +1,40 @@
1
1
  import type { Measurement } from '../core/types.js';
2
+ /**
3
+ * Which kind of failure a dead server's own words describe.
4
+ *
5
+ * The distinction is the published one: `auth-required` says the server works
6
+ * and this harness has no credentials for it, `startup-failure` says the server
7
+ * did not come up. Only the text decides, so it matters that the text reaching
8
+ * here is the part that explains the failure rather than whatever happened to
9
+ * fall in the last few hundred bytes — see `evidenceTail` in client.ts, which
10
+ * exists because a truncated message was being filed as a broken server.
11
+ */
12
+ export declare function classifyFailure(msg: string): 'timeout' | 'auth-required' | 'startup-failure';
13
+ /**
14
+ * The architecture a measurement ran on, in Docker's vocabulary (`linux/amd64`).
15
+ *
16
+ * Worth recording because a package can ship builds for some architectures and
17
+ * not others, and then the *machine* decides the result. `local-mcp` sat
18
+ * published as a startup failure on the strength of a run whose actual finding
19
+ * was that the laptop was arm64 and the package ships no arm64 runtime — and
20
+ * the record gave a reader no way to notice.
21
+ *
22
+ * Containers are linux whatever the host is; with no explicit `--platform` they
23
+ * take the host's architecture, so that is the half worth reporting.
24
+ */
25
+ export declare function measuringArch(docker: boolean): string;
26
+ /**
27
+ * The declared reason, when this failure is the one the entry warned about.
28
+ *
29
+ * Corroboration is the whole point: an entry may declare that this harness
30
+ * cannot run it, but only the failure's own words can confirm that *this*
31
+ * failure is that one. A macOS-only package that starts failing for some new
32
+ * reason stops matching, and is published as the failure it actually is.
33
+ */
34
+ export declare function notApplicableReason(declared: {
35
+ reason: string;
36
+ evidence: string;
37
+ } | undefined, msg: string): string | null;
2
38
  export interface MeasureOptions {
3
39
  timeoutMs?: number;
4
40
  env?: Record<string, string>;
@@ -11,6 +47,11 @@ export interface MeasureOptions {
11
47
  dummyEnvValues?: Record<string, string>;
12
48
  /** Install `git` in the container before launch (docker mode) — see docker.ts. */
13
49
  needsGit?: boolean;
50
+ /** Declared harness limitation for this entry — see `notApplicable` in report.ts. */
51
+ notApplicable?: {
52
+ reason: string;
53
+ evidence: string;
54
+ };
14
55
  /**
15
56
  * Exact argv, when the caller already has it (client configs store command and
16
57
  * args separately). Avoids re-splitting a joined string on spaces, which would
package/dist/sweep/run.js CHANGED
@@ -11,6 +11,56 @@ import { captureTools } from './client.js';
11
11
  import { DockerHarnessFault, defaultImageFor, dockerize, ensureImage, isDockerRunFailure } from './docker.js';
12
12
  import { measureTools, failedMeasurement, canonicalString } from '../core/canonical.js';
13
13
  import { toBadge } from '../core/badge.js';
14
+ /**
15
+ * Which kind of failure a dead server's own words describe.
16
+ *
17
+ * The distinction is the published one: `auth-required` says the server works
18
+ * and this harness has no credentials for it, `startup-failure` says the server
19
+ * did not come up. Only the text decides, so it matters that the text reaching
20
+ * here is the part that explains the failure rather than whatever happened to
21
+ * fall in the last few hundred bytes — see `evidenceTail` in client.ts, which
22
+ * exists because a truncated message was being filed as a broken server.
23
+ */
24
+ export function classifyFailure(msg) {
25
+ // Matched against this harness's own phrasing, not the bare word: these
26
+ // messages carry the server's stderr, and a server that prints "connection
27
+ // timeout" before dying did not time out — it exited, and saying otherwise
28
+ // blames the clock for a breakage.
29
+ if (/timeout after \d+ms waiting for/.test(msg))
30
+ return 'timeout';
31
+ return /auth|unauthorized|401|forbidden|credential|api.?key|token/i.test(msg)
32
+ ? 'auth-required'
33
+ : 'startup-failure';
34
+ }
35
+ /**
36
+ * The architecture a measurement ran on, in Docker's vocabulary (`linux/amd64`).
37
+ *
38
+ * Worth recording because a package can ship builds for some architectures and
39
+ * not others, and then the *machine* decides the result. `local-mcp` sat
40
+ * published as a startup failure on the strength of a run whose actual finding
41
+ * was that the laptop was arm64 and the package ships no arm64 runtime — and
42
+ * the record gave a reader no way to notice.
43
+ *
44
+ * Containers are linux whatever the host is; with no explicit `--platform` they
45
+ * take the host's architecture, so that is the half worth reporting.
46
+ */
47
+ export function measuringArch(docker) {
48
+ const arch = process.arch === 'x64' ? 'amd64' : process.arch;
49
+ return `${docker ? 'linux' : process.platform}/${arch}`;
50
+ }
51
+ /**
52
+ * The declared reason, when this failure is the one the entry warned about.
53
+ *
54
+ * Corroboration is the whole point: an entry may declare that this harness
55
+ * cannot run it, but only the failure's own words can confirm that *this*
56
+ * failure is that one. A macOS-only package that starts failing for some new
57
+ * reason stops matching, and is published as the failure it actually is.
58
+ */
59
+ export function notApplicableReason(declared, msg) {
60
+ if (!declared?.evidence)
61
+ return null;
62
+ return msg.toLowerCase().includes(declared.evidence.toLowerCase()) ? declared.reason : null;
63
+ }
14
64
  function arg(name) {
15
65
  const i = process.argv.indexOf(`--${name}`);
16
66
  return i >= 0 ? process.argv[i + 1] : undefined;
@@ -135,14 +185,17 @@ export async function measureServer(name, command, opts = {}) {
135
185
  if (dockerWrapped && isDockerRunFailure(msg)) {
136
186
  throw new DockerHarnessFault(`docker could not run the container for ${name}: ${msg.slice(0, 400)}`);
137
187
  }
138
- const status = msg.includes('timeout')
139
- ? 'timeout'
140
- : /auth|unauthorized|401|forbidden|credential|api.?key|token/i.test(msg)
141
- ? 'auth-required'
142
- : 'startup-failure';
143
- r = failedMeasurement(status, { serverName: name, launchCommand: command, notes: msg.slice(0, 700) });
188
+ const declared = notApplicableReason(opts.notApplicable, msg);
189
+ r = failedMeasurement(declared ? 'not-applicable' : classifyFailure(msg), {
190
+ serverName: name,
191
+ launchCommand: command,
192
+ // The declared reason leads, but the raw failure stays behind it: the
193
+ // record has to remain checkable against the run that produced it.
194
+ notes: (declared ? `${declared} — ${msg}` : msg).slice(0, 700),
195
+ });
144
196
  }
145
- r.isolation = isolation;
197
+ const iso = isolation ?? { docker: false };
198
+ r.isolation = { ...iso, arch: measuringArch(iso.docker) };
146
199
  r.timeoutMs = attemptOpts.timeoutMs ?? 60_000;
147
200
  return r;
148
201
  }
@@ -18,7 +18,7 @@ import { DockerHarnessFault } from './docker.js';
18
18
  import { writeLeaderboard } from './report.js';
19
19
  import { appendHistory } from './history.js';
20
20
  import { appendToolVectors, writeRegressions } from './regressions.js';
21
- import { snapshot, verdict, restore } from './harness-guard.js';
21
+ import { MIN_REGRESSIONS, snapshot, verdict, restore } from './harness-guard.js';
22
22
  import { selectShard, shardIndexForDate } from './shard.js';
23
23
  function arg(name) {
24
24
  const i = process.argv.indexOf(`--${name}`);
@@ -55,6 +55,18 @@ if (shards !== undefined) {
55
55
  const index = shardIndexArg ?? shardIndexForDate(new Date(), shards);
56
56
  entries = selectShard(sweepable, shards, index);
57
57
  shardLabel = `, shard ${index + 1}/${shards}`;
58
+ // The harness guard needs MIN_REGRESSIONS previously-good servers to fail
59
+ // together before it will call a broken runner rather than broken servers.
60
+ // A slice smaller than that floor can never reach it, so a wedged Docker
61
+ // daemon would publish the whole slice as startup failures with nothing to
62
+ // trip. `--shards` is the one knob that can shrink a slice under the floor
63
+ // unattended, so it refuses here instead of measuring through it.
64
+ if (entries.length < MIN_REGRESSIONS) {
65
+ console.error(`--shards ${shards} cuts a ${entries.length}-server slice, below the ` +
66
+ `${MIN_REGRESSIONS}-server floor the harness guard needs to tell a broken runner ` +
67
+ `from broken servers. Use a smaller --shards, or --only to measure a handful by name.`);
68
+ process.exit(2);
69
+ }
58
70
  console.log(`shard ${index + 1}/${shards} of ${sweepable.length} sweepable: ${entries.map((e) => e.name).join(', ')}`);
59
71
  }
60
72
  console.log(`sweeping ${entries.length} servers (docker=${docker}, concurrency=${concurrency}${shardLabel})`);
@@ -81,6 +93,7 @@ async function worker() {
81
93
  dummyEnv: e.env ?? [],
82
94
  dummyEnvValues: e.envValues,
83
95
  needsGit: e.needsGit,
96
+ notApplicable: e.notApplicable,
84
97
  });
85
98
  }
86
99
  catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-context-cost",
3
- "version": "0.11.3",
3
+ "version": "0.12.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",