mcp-context-cost 0.8.0 → 0.9.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
@@ -183,10 +183,9 @@ INCREASE FAIL:
183
183
  Add `--claude` to annotate each server with its Anthropic-request cost from the published
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
- when it doesn't. The run holds 20 rows — the top 20 measured servers by tokens; 19 of them
187
- still match the capture on disk here, which is why
188
- [results/leaderboard.md](results/leaderboard.md) prints a claude number for 19 and leaves the
189
- twentieth blank rather than stale. Most installs will show a mix:
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
188
+ still match today and silence for the rest. Most installs will show a mix:
190
189
 
191
190
  ```
192
191
  server tools tokens share claude
@@ -202,14 +201,14 @@ Flags: `--json` (full report on stdout, progress on stderr), `--budget N`,
202
201
 
203
202
  The number `audit` gives you is the same measurement, run across a curated set of public
204
203
  servers — which is how you can tell it is a measurement and not this tool's opinion. It also
205
- shows what you are choosing between: across the 69 servers measured, cost spans **1,700×**,
206
- from the 32-token `postgres` reference server to github's 54,422. The table below is a
204
+ shows what you are choosing between: across the 81 servers measured, cost spans **1,700×**,
205
+ from `postgres` at 32 tokens to `github` at 54,622. The table below is a
207
206
  sample of that range; the full range is in
208
207
  [results/leaderboard.md](results/leaderboard.md).
209
208
 
210
209
  | server | context cost | tools |
211
210
  |---|---:|---:|
212
- | github (official) | **54,422 tokens** | 44 |
211
+ | github (official) | **54,622 tokens** | 44 |
213
212
  | xcodebuildmcp | 26,594 | 24 |
214
213
  | brave-search | 25,456 | 8 |
215
214
  | notion | 17,500 | 24 |
@@ -217,7 +216,7 @@ sample of that range; the full range is in
217
216
  | filesystem (reference) | 2,823 | 14 |
218
217
  | markitdown | 64 | 1 |
219
218
 
220
- *(69 of 82 popular servers measured, sweeps of 2026-08-18 and 2026-08-19 — full table in
219
+ *(81 of 106 popular servers measured, each row dated by its own most recent sweep — full table in
221
220
  [results/leaderboard.md](results/leaderboard.md); every failure is listed with its reason.
222
221
  Each measured server also has a [detail page](https://athakur3.github.io/mcp-context-cost/servers/)
223
222
  showing which tools its tokens are in.)*
@@ -252,7 +251,7 @@ exact launch command. Disputes reduce to a byte-level diff:
252
251
 
253
252
  ```bash
254
253
  npx -y mcp-context-cost verify results/github/measurement.json
255
- # OK github-mcp-server: 54422 tokens (o200k_base, methodology 1.0) — capture, hash, and count all agree
254
+ # OK github-mcp-server: 54622 tokens (o200k_base, methodology 1.0) — capture, hash, and count all agree
256
255
 
257
256
  # or point it at a published measurement.json directly, no clone required
258
257
  npx -y mcp-context-cost verify --remote https://raw.githubusercontent.com/athakur3/mcp-context-cost/main/results/github/measurement.json
@@ -267,6 +266,12 @@ failed, `2` usage error.
267
266
  to the one that was published — which is exactly what `--claude` uses to decide whether it is
268
267
  allowed to show you a number.
269
268
 
269
+ The number is also cross-checked against the other CLI that measures this,
270
+ [`sd2k/mcp-tokens`](https://github.com/sd2k/mcp-tokens): the leaderboard's **mcp-tokens**
271
+ column publishes its count beside ours wherever both tools saw the same tool set — same
272
+ o200k encoding, differences documented, the divergence published rather than left to be
273
+ discovered. Method: [CLI cross-check](docs/METHODOLOGY.md#cli-cross-check).
274
+
270
275
  Full definition: [docs/METHODOLOGY.md](docs/METHODOLOGY.md) — what is counted, what the
271
276
  number is *not*, config policy, failure taxonomy, frozen color bands, known divergences.
272
277
 
@@ -324,7 +329,7 @@ color bands are frozen against the observed distribution of the first full sweep
324
329
 
325
330
  ## Status
326
331
 
327
- Active. 46 of the 69 numbers come from the sweep of 2026-08-19 and 23 from 2026-08-18. Two
332
+ Active. Every row carries the date of its own most recent measurement. Two
328
333
  weekly jobs re-measure the set — the `memory` reference server on Mondays, and a rotating
329
334
  sixth of the list on Wednesdays, so every row comes round within six weeks. Read each row's
330
335
  date as the date it means, and don't take the cadence on trust — the build history is
package/dist/cli.js CHANGED
@@ -159,18 +159,33 @@ if (cmd === 'audit') {
159
159
  }
160
160
  const { runAudit } = await import('./audit/run.js');
161
161
  const { formatReport } = await import('./audit/audit.js');
162
- const report = await runAudit({
163
- configPaths: all('config'),
164
- budget,
165
- contextWindow: numeric('context'),
166
- timeoutMs: numeric('timeout'),
167
- concurrency: numeric('concurrency'),
168
- docker: rest.includes('--docker'),
169
- claude: rest.includes('--claude'),
170
- divergenceUrl: argOf('divergence-url'),
171
- // Progress goes to stderr so `--json` stdout stays a single parseable object.
172
- onProgress: json ? undefined : (name, done, total) => process.stderr.write(` [${done}/${total}] ${name}\n`),
173
- });
162
+ const { DockerHarnessFault } = await import('./sweep/docker.js');
163
+ let report;
164
+ try {
165
+ report = await runAudit({
166
+ configPaths: all('config'),
167
+ budget,
168
+ contextWindow: numeric('context'),
169
+ timeoutMs: numeric('timeout'),
170
+ concurrency: numeric('concurrency'),
171
+ docker: rest.includes('--docker'),
172
+ claude: rest.includes('--claude'),
173
+ divergenceUrl: argOf('divergence-url'),
174
+ // Progress goes to stderr so `--json` stdout stays a single parseable object.
175
+ onProgress: json ? undefined : (name, done, total) => process.stderr.write(` [${done}/${total}] ${name}\n`),
176
+ });
177
+ }
178
+ catch (e) {
179
+ // Docker failing as docker means every measurement through it would be a
180
+ // statement about this machine, so the audit refuses whole rather than
181
+ // reporting each server as broken.
182
+ if (e instanceof DockerHarnessFault) {
183
+ console.error(`audit --docker cannot answer for this machine: ${e.message}`);
184
+ console.error('Fix Docker here, or run without --docker.');
185
+ process.exit(1);
186
+ }
187
+ throw e;
188
+ }
174
189
  if (report.configs.length === 0) {
175
190
  const where = report.problems.length ? `\n${report.problems.map((p) => ` ${p}`).join('\n')}` : '';
176
191
  const empty = report.emptyConfigs ?? [];
@@ -0,0 +1,93 @@
1
+ import type { Measurement } from './types.js';
2
+ /** Method identifier, versioned independently of METHODOLOGY_VERSION. */
3
+ export declare const CROSS_CHECK_METHOD = "cli-cross-check/v1";
4
+ /** The CLI being cross-checked against, and the release this run pins. */
5
+ export declare const CROSS_CHECK_CLI = "sd2k/mcp-tokens";
6
+ export declare const CROSS_CHECK_CLI_VERSION = "v0.2.5";
7
+ /**
8
+ * The exact analyze invocation, recorded in every run. `--model gpt-4o` is
9
+ * load-bearing: tiktoken-rs maps it to o200k_base, and without a model the CLI
10
+ * falls back to cl100k_base — a systematic difference that would swamp the one
11
+ * being measured (spec/upstream-notes.md, finding 1).
12
+ */
13
+ export declare const CROSS_CHECK_CLI_ARGS: readonly ['analyze', '--provider', 'tiktoken', '--model', 'gpt-4o', '--format', 'json'];
14
+ export interface CrossCheckRow {
15
+ /** o200k count of our fresh canonical capture from this run — the headline. */
16
+ ourTokens: number;
17
+ /**
18
+ * o200k count of the same capture's name/description/input_schema
19
+ * projection — what the CLI's structs actually model, and therefore the
20
+ * number its count is compared against.
21
+ */
22
+ ourMappedTokens: number;
23
+ /** The CLI's `.tools.total` for the same server, launched by the CLI itself. */
24
+ cliTokens: number;
25
+ ourToolCount: number;
26
+ cliToolCount: number;
27
+ /** Both tools saw the same tool names (order-insensitive) — the comparison is between like and like. */
28
+ toolSetMatches: boolean;
29
+ /**
30
+ * Our fresh measurement listed dynamically (tools/list differed between its
31
+ * own two captures). The CLI's launch is a third capture, so even with
32
+ * matching names its residual mixes content drift with counter disagreement
33
+ * — recorded, never printed.
34
+ */
35
+ dynamic: boolean;
36
+ /** canonicalSha256 of our capture in this run; null when our measurement failed. */
37
+ capturedSha256: string | null;
38
+ /** Set when either side could not produce a number; nothing is published from the row. */
39
+ error?: string;
40
+ }
41
+ export interface CrossCheckRun {
42
+ method: string;
43
+ cli: string;
44
+ cliVersion: string;
45
+ /** The analyze invocation, verbatim, so the encoding choice is auditable. */
46
+ cliArgs: string[];
47
+ /** UTC day the run was taken (YYYY-MM-DD). */
48
+ measuredAt: string;
49
+ isolation: string;
50
+ servers: Record<string, CrossCheckRow>;
51
+ }
52
+ export declare function parseCrossCheck(text: string): CrossCheckRun | null;
53
+ /** What the CLI's JSON report contributes to a row. */
54
+ export interface CliReport {
55
+ total: number;
56
+ count: number;
57
+ names: string[];
58
+ }
59
+ /**
60
+ * Read the CLI's `--format json` report. The report is that CLI's contract, not
61
+ * ours, so this is deliberately narrow: `.tools.total` (the number the CLI's
62
+ * own action publishes), `.tools.count`, and the per-tool names — and a report
63
+ * that does not carry a usable total is a named problem, never a zero.
64
+ */
65
+ export declare function parseCliReport(text: string): {
66
+ report?: CliReport;
67
+ problem?: string;
68
+ };
69
+ /** Order-insensitive equality of the two tool-name lists, repeats included. */
70
+ export declare function sameToolSet(ours: string[], theirs: string[]): boolean;
71
+ /**
72
+ * Build a row from our fresh measurement and the CLI's report. The caller
73
+ * decides whether to run the CLI at all; a failed side arrives here as an
74
+ * `error`, and the row keeps whatever the other side established.
75
+ */
76
+ export declare function toCrossCheckRow(m: Measurement, cli: {
77
+ report?: CliReport;
78
+ problem?: string;
79
+ }): CrossCheckRow;
80
+ /**
81
+ * A row is only printable while it compares like with like: no error on either
82
+ * side, the same tool names seen by both tools, and our capture still the one
83
+ * published — the exact staleness rule the claude column follows.
84
+ */
85
+ export declare function isComparable(row: CrossCheckRow | undefined, canonicalSha256: string | null): row is CrossCheckRow;
86
+ /**
87
+ * Signed divergence of the CLI's count from our count of the projection it
88
+ * models, in percent. Against the mapped number and not the headline: the gap
89
+ * to the headline is field selection, published separately per server, and
90
+ * folding it in here would bury the number this column exists to check —
91
+ * whether two independent counters agree on the fields both count.
92
+ */
93
+ export declare function divergencePct(row: CrossCheckRow): number | null;
@@ -0,0 +1,176 @@
1
+ /**
2
+ * CLI cross-check — the other tool's number, published beside ours.
3
+ *
4
+ * `sd2k/mcp-tokens` measures the same thing this project measures, differently
5
+ * in two documented ways (spec/upstream-notes.md): its tiktoken provider picks
6
+ * the encoding from a `--model` argument with a cl100k_base fallback, and it
7
+ * counts a `serde_json` re-serialization of deserialized tool structs rather
8
+ * than the wire bytes. Measured (2026-09-03, addendum in the notes), the
9
+ * second difference is nearly all field modeling: the CLI's count lands within
10
+ * a fraction of a percent of our o200k count of the three-field
11
+ * name/description/input_schema projection — the same projection the Claude
12
+ * divergence starts from — not of the full capture. So the published
13
+ * comparison is against `mappedTokens` of the same fresh capture: like counted
14
+ * against like, with the field-selection gap already published separately as
15
+ * each server's field-selection share. The honest move is the one the notes
16
+ * chose on day one: publish the divergence ourselves rather than leave it to
17
+ * be discovered by critics.
18
+ *
19
+ * Every row is filed under the `canonicalSha256` of a capture taken by OUR
20
+ * client in the same run, minutes from the CLI's own launch — the session-start
21
+ * discipline: a re-sweep that moves the published capture marks the row stale
22
+ * instead of leaving a comparison against bytes that no longer exist. And a
23
+ * comparison is only printed when both tools counted the same tool set: the CLI
24
+ * launches the server itself, so on a server that changed between the two
25
+ * launches (or lists dynamically) the two numbers describe different schemas,
26
+ * and publishing their difference as "divergence" would be a category error.
27
+ * Those rows keep their data in the run file and print silence.
28
+ *
29
+ * Versioned independently of the o200k methodology, like `tools-delta/v1` and
30
+ * `deferred-load/v1`: a new published number, no change to the definition of
31
+ * the first. No `totalTokens` and no canonical hash moves.
32
+ */
33
+ import { mappedTokens } from './divergence.js';
34
+ import { toolNames } from './session-start.js';
35
+ /** Method identifier, versioned independently of METHODOLOGY_VERSION. */
36
+ export const CROSS_CHECK_METHOD = 'cli-cross-check/v1';
37
+ /** The CLI being cross-checked against, and the release this run pins. */
38
+ export const CROSS_CHECK_CLI = 'sd2k/mcp-tokens';
39
+ export const CROSS_CHECK_CLI_VERSION = 'v0.2.5';
40
+ /**
41
+ * The exact analyze invocation, recorded in every run. `--model gpt-4o` is
42
+ * load-bearing: tiktoken-rs maps it to o200k_base, and without a model the CLI
43
+ * falls back to cl100k_base — a systematic difference that would swamp the one
44
+ * being measured (spec/upstream-notes.md, finding 1).
45
+ */
46
+ export const CROSS_CHECK_CLI_ARGS = ['analyze', '--provider', 'tiktoken', '--model', 'gpt-4o', '--format', 'json'];
47
+ export function parseCrossCheck(text) {
48
+ let run;
49
+ try {
50
+ run = JSON.parse(text);
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ const r = run;
56
+ if (!r || typeof r.cliVersion !== 'string' || typeof r.measuredAt !== 'string')
57
+ return null;
58
+ if (!r.servers || typeof r.servers !== 'object')
59
+ return null;
60
+ return {
61
+ method: typeof r.method === 'string' ? r.method : CROSS_CHECK_METHOD,
62
+ cli: typeof r.cli === 'string' ? r.cli : CROSS_CHECK_CLI,
63
+ cliVersion: r.cliVersion,
64
+ cliArgs: Array.isArray(r.cliArgs) ? r.cliArgs.map(String) : [...CROSS_CHECK_CLI_ARGS],
65
+ measuredAt: r.measuredAt,
66
+ isolation: typeof r.isolation === 'string' ? r.isolation : 'not recorded',
67
+ servers: r.servers,
68
+ };
69
+ }
70
+ /**
71
+ * Read the CLI's `--format json` report. The report is that CLI's contract, not
72
+ * ours, so this is deliberately narrow: `.tools.total` (the number the CLI's
73
+ * own action publishes), `.tools.count`, and the per-tool names — and a report
74
+ * that does not carry a usable total is a named problem, never a zero.
75
+ */
76
+ export function parseCliReport(text) {
77
+ let parsed;
78
+ try {
79
+ parsed = JSON.parse(text);
80
+ }
81
+ catch {
82
+ // `--format json` puts the report on stdout, but a launcher inside the same
83
+ // pipe can precede it with noise; the report is the outermost JSON object.
84
+ const start = text.indexOf('{');
85
+ const end = text.lastIndexOf('}');
86
+ if (start < 0 || end <= start)
87
+ return { problem: 'CLI stdout carried no JSON report' };
88
+ try {
89
+ parsed = JSON.parse(text.slice(start, end + 1));
90
+ }
91
+ catch {
92
+ return { problem: 'CLI stdout carried no parseable JSON report' };
93
+ }
94
+ }
95
+ const tools = parsed.tools;
96
+ if (!tools || typeof tools !== 'object')
97
+ return { problem: 'CLI report has no .tools object' };
98
+ if (typeof tools.total !== 'number' || !Number.isFinite(tools.total) || tools.total < 0) {
99
+ return { problem: 'CLI report has no usable .tools.total' };
100
+ }
101
+ const items = Array.isArray(tools.items) ? tools.items : [];
102
+ const names = [];
103
+ for (const item of items) {
104
+ const name = (item ?? {}).name;
105
+ if (typeof name === 'string' && name !== '')
106
+ names.push(name);
107
+ }
108
+ return {
109
+ report: {
110
+ total: tools.total,
111
+ count: typeof tools.count === 'number' ? tools.count : items.length,
112
+ names,
113
+ },
114
+ };
115
+ }
116
+ /** Order-insensitive equality of the two tool-name lists, repeats included. */
117
+ export function sameToolSet(ours, theirs) {
118
+ if (ours.length !== theirs.length)
119
+ return false;
120
+ const a = [...ours].sort();
121
+ const b = [...theirs].sort();
122
+ return a.every((name, i) => name === b[i]);
123
+ }
124
+ /**
125
+ * Build a row from our fresh measurement and the CLI's report. The caller
126
+ * decides whether to run the CLI at all; a failed side arrives here as an
127
+ * `error`, and the row keeps whatever the other side established.
128
+ */
129
+ export function toCrossCheckRow(m, cli) {
130
+ const measured = m.status === 'measured' || m.status === 'dynamic';
131
+ const raw = measured && Array.isArray(m.rawToolsCapture) ? m.rawToolsCapture : [];
132
+ const ours = toolNames(raw);
133
+ const row = {
134
+ ourTokens: measured && typeof m.totalTokens === 'number' ? m.totalTokens : 0,
135
+ ourMappedTokens: raw.length > 0 ? mappedTokens(raw) : 0,
136
+ cliTokens: cli.report?.total ?? 0,
137
+ ourToolCount: measured && typeof m.toolCount === 'number' ? m.toolCount : 0,
138
+ cliToolCount: cli.report?.count ?? 0,
139
+ toolSetMatches: cli.report !== undefined && measured ? sameToolSet(ours, cli.report.names) : false,
140
+ dynamic: m.status === 'dynamic',
141
+ capturedSha256: measured ? m.canonicalSha256 : null,
142
+ };
143
+ if (!measured)
144
+ row.error = `our measurement: ${m.status}: ${(m.notes ?? '').slice(0, 200)}`;
145
+ else if (cli.problem)
146
+ row.error = `cli: ${cli.problem.slice(0, 200)}`;
147
+ return row;
148
+ }
149
+ /**
150
+ * A row is only printable while it compares like with like: no error on either
151
+ * side, the same tool names seen by both tools, and our capture still the one
152
+ * published — the exact staleness rule the claude column follows.
153
+ */
154
+ export function isComparable(row, canonicalSha256) {
155
+ return (row !== undefined &&
156
+ row.error === undefined &&
157
+ row.toolSetMatches &&
158
+ !row.dynamic &&
159
+ row.capturedSha256 !== null &&
160
+ canonicalSha256 !== null &&
161
+ row.capturedSha256 === canonicalSha256 &&
162
+ row.ourTokens > 0 &&
163
+ row.ourMappedTokens > 0);
164
+ }
165
+ /**
166
+ * Signed divergence of the CLI's count from our count of the projection it
167
+ * models, in percent. Against the mapped number and not the headline: the gap
168
+ * to the headline is field selection, published separately per server, and
169
+ * folding it in here would bury the number this column exists to check —
170
+ * whether two independent counters agree on the fields both count.
171
+ */
172
+ export function divergencePct(row) {
173
+ if (row.ourMappedTokens <= 0)
174
+ return null;
175
+ return ((row.cliTokens - row.ourMappedTokens) / row.ourMappedTokens) * 100;
176
+ }
@@ -0,0 +1,31 @@
1
+ import type { ServerEntry } from './report.js';
2
+ import { type CrossCheckRun } from '../core/cross-check.js';
3
+ export declare function loadCrossCheck(root?: string): CrossCheckRun | null;
4
+ export declare function writeCrossCheck(run: CrossCheckRun, root?: string): void;
5
+ /** Release-asset triple for where the CLI will actually run. */
6
+ export declare function cliTriple(docker: boolean, platform?: NodeJS.Platform, arch?: NodeJS.Architecture): string;
7
+ /**
8
+ * Fetch the pinned CLI release for `triple` into a host cache, verifying the
9
+ * archive against the release's own `.sha256` before anything is extracted or
10
+ * executed. `MCP_TOKENS_BIN` overrides the whole dance — that is how the tests
11
+ * substitute a shim, and how an airgapped machine supplies its own copy.
12
+ */
13
+ export declare function ensureCliBinary(triple: string): Promise<string>;
14
+ interface CliOutcome {
15
+ code: number | null;
16
+ stdout: string;
17
+ stderr: string;
18
+ timedOut: boolean;
19
+ }
20
+ /**
21
+ * Run the CLI against one server, host or containerized. In docker mode the
22
+ * binary is bind-mounted read-only and the CLI launches the server inside the
23
+ * container — same image, limits, dummy env and shared package caches as the
24
+ * measurement that just ran, so the npx install it pays is already warm.
25
+ */
26
+ export declare function runCli(binPath: string, entry: ServerEntry, opts: {
27
+ docker: boolean;
28
+ timeoutMs: number;
29
+ graceMs?: number;
30
+ }): Promise<CliOutcome>;
31
+ export {};