mcp-context-cost 0.7.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.
@@ -0,0 +1,342 @@
1
+ /**
2
+ * CLI cross-check runner — measure each server twice in one sitting, once with
3
+ * our client and once with `sd2k/mcp-tokens`, and file the pair under the
4
+ * capture our side took:
5
+ * npx tsx src/sweep/cross-check.ts [--docker] [--only a,b] [--concurrency 3]
6
+ * [--shards N [--shard-index K]]
7
+ * [--default-timeout 60]
8
+ *
9
+ * Writes results/cross-check.json and nothing else — the measurements on disk
10
+ * keep their numbers, hashes and dates (the session-start discipline). Our
11
+ * measurement is taken fresh through `measureServer` — same isolation, same
12
+ * dummy env, same retries as a sweep — because the row is only worth publishing
13
+ * while it compares like with like: the fresh capture's `canonicalSha256` is
14
+ * what the row is filed under, and the CLI's tool names are checked against the
15
+ * capture's, so a server that changed between the two launches records data but
16
+ * prints silence.
17
+ *
18
+ * The CLI is a pinned release binary, fetched once into a host cache and
19
+ * verified against the release's own SHA-256 before it is ever executed; in
20
+ * docker mode it is bind-mounted read-only into the same image, limits and
21
+ * package-cache volumes a sweep uses, so the server it launches runs under the
22
+ * exact isolation every published measurement ran under.
23
+ */
24
+ import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
25
+ import { createHash } from 'node:crypto';
26
+ import { spawn } from 'node:child_process';
27
+ import { homedir, tmpdir } from 'node:os';
28
+ import { join, resolve } from 'node:path';
29
+ import { fileURLToPath } from 'node:url';
30
+ import { parse } from 'yaml';
31
+ import { measureServer } from './run.js';
32
+ import { DockerHarnessFault, defaultImageFor, dockerize } from './docker.js';
33
+ import { splitCommand } from './client.js';
34
+ import { selectShard, shardIndexForDate } from './shard.js';
35
+ import { CROSS_CHECK_CLI, CROSS_CHECK_CLI_ARGS, CROSS_CHECK_CLI_VERSION, CROSS_CHECK_METHOD, divergencePct, parseCliReport, parseCrossCheck, toCrossCheckRow, } from '../core/cross-check.js';
36
+ export function loadCrossCheck(root = process.cwd()) {
37
+ const p = join(root, 'results', 'cross-check.json');
38
+ return existsSync(p) ? parseCrossCheck(readFileSync(p, 'utf8')) : null;
39
+ }
40
+ export function writeCrossCheck(run, root = process.cwd()) {
41
+ // Key order sorted so a re-run of the same servers produces no diff noise.
42
+ const servers = {};
43
+ for (const name of Object.keys(run.servers).sort())
44
+ servers[name] = run.servers[name];
45
+ writeFileSync(join(root, 'results', 'cross-check.json'), JSON.stringify({ ...run, servers }, null, 2) + '\n');
46
+ }
47
+ /** Release-asset triple for where the CLI will actually run. */
48
+ export function cliTriple(docker, platform = process.platform, arch = process.arch) {
49
+ if (docker) {
50
+ // The container is Linux whatever the host is; its architecture is the host's.
51
+ return arch === 'arm64' ? 'aarch64-unknown-linux-gnu' : 'x86_64-unknown-linux-gnu';
52
+ }
53
+ const cpu = arch === 'arm64' ? 'aarch64' : 'x86_64';
54
+ if (platform === 'darwin')
55
+ return `${cpu}-apple-darwin`;
56
+ if (platform === 'linux')
57
+ return `${cpu}-unknown-linux-gnu`;
58
+ throw new Error(`no ${CROSS_CHECK_CLI} release asset for ${platform}/${arch} — run with --docker`);
59
+ }
60
+ function sh(command, args) {
61
+ return new Promise((resolvePromise) => {
62
+ const child = spawn(command, args, { stdio: ['ignore', 'ignore', 'pipe'] });
63
+ let stderr = '';
64
+ child.stderr.setEncoding('utf8');
65
+ child.stderr.on('data', (chunk) => (stderr = (stderr + chunk).slice(-4000)));
66
+ child.on('error', (err) => resolvePromise({ code: null, stderr: String(err.message) }));
67
+ child.on('exit', (code) => resolvePromise({ code, stderr }));
68
+ });
69
+ }
70
+ /**
71
+ * Fetch the pinned CLI release for `triple` into a host cache, verifying the
72
+ * archive against the release's own `.sha256` before anything is extracted or
73
+ * executed. `MCP_TOKENS_BIN` overrides the whole dance — that is how the tests
74
+ * substitute a shim, and how an airgapped machine supplies its own copy.
75
+ */
76
+ export async function ensureCliBinary(triple) {
77
+ const override = process.env.MCP_TOKENS_BIN;
78
+ if (override)
79
+ return override;
80
+ const cacheDir = join(homedir(), '.cache', 'mcp-context-cost', 'mcp-tokens', `${CROSS_CHECK_CLI_VERSION}-${triple}`);
81
+ const binPath = join(cacheDir, 'mcp-tokens');
82
+ if (existsSync(binPath))
83
+ return binPath;
84
+ const asset = `mcp-tokens-${triple}.tar.xz`;
85
+ const base = `https://github.com/${CROSS_CHECK_CLI}/releases/download/${CROSS_CHECK_CLI_VERSION}`;
86
+ const fetchBytes = async (url) => {
87
+ const res = await fetch(url);
88
+ if (!res.ok)
89
+ throw new Error(`GET ${url}: HTTP ${res.status}`);
90
+ return Buffer.from(await res.arrayBuffer());
91
+ };
92
+ const archive = await fetchBytes(`${base}/${asset}`);
93
+ const sumFile = (await fetchBytes(`${base}/${asset}.sha256`)).toString('utf8');
94
+ const expected = sumFile.trim().split(/\s+/)[0]?.toLowerCase();
95
+ const actual = createHash('sha256').update(archive).digest('hex');
96
+ if (!expected || expected !== actual) {
97
+ throw new Error(`${asset}: SHA-256 mismatch — expected ${expected ?? '(unparseable)'}, got ${actual}; refusing to run it`);
98
+ }
99
+ const work = join(tmpdir(), `mcp-tokens-${process.pid}-${Math.floor(Math.random() * 1e6)}`);
100
+ mkdirSync(work, { recursive: true });
101
+ try {
102
+ const archivePath = join(work, asset);
103
+ writeFileSync(archivePath, archive);
104
+ const tar = await sh('tar', ['-xJf', archivePath, '-C', work]);
105
+ if (tar.code !== 0)
106
+ throw new Error(`tar failed extracting ${asset}: ${tar.stderr.slice(-200)}`);
107
+ const found = findFile(work, 'mcp-tokens');
108
+ if (!found)
109
+ throw new Error(`${asset} did not contain an mcp-tokens binary`);
110
+ mkdirSync(cacheDir, { recursive: true });
111
+ chmodSync(found, 0o755);
112
+ renameSync(found, binPath);
113
+ }
114
+ finally {
115
+ rmSync(work, { recursive: true, force: true });
116
+ }
117
+ return binPath;
118
+ }
119
+ function findFile(dir, name) {
120
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
121
+ const p = join(dir, entry.name);
122
+ if (entry.isDirectory()) {
123
+ const hit = findFile(p, name);
124
+ if (hit)
125
+ return hit;
126
+ }
127
+ else if (entry.name === name) {
128
+ return p;
129
+ }
130
+ }
131
+ return null;
132
+ }
133
+ /**
134
+ * Run the CLI against one server, host or containerized. In docker mode the
135
+ * binary is bind-mounted read-only and the CLI launches the server inside the
136
+ * container — same image, limits, dummy env and shared package caches as the
137
+ * measurement that just ran, so the npx install it pays is already warm.
138
+ */
139
+ export function runCli(binPath, entry, opts) {
140
+ // The CLI's own deadline gets the entry's budget — its default is 30s, which
141
+ // published as a failure for a server (postgres-mcp) that legitimately takes
142
+ // longer to start and measures fine on the same budget our client gets. Its
143
+ // --timeout is in seconds, for server startup.
144
+ const cliArgs = [...CROSS_CHECK_CLI_ARGS, '--timeout', String(Math.ceil(opts.timeoutMs / 1000))];
145
+ let command = binPath;
146
+ let argv = [...cliArgs, '--', ...splitCommand(entry.command)];
147
+ let containerName = null;
148
+ if (opts.docker) {
149
+ containerName = `mcp-ctx-xchk-${entry.name}-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
150
+ // The slim images carry no OpenSSL shared library and the CLI's linux-gnu
151
+ // build links libssl.so.3 — installed inside the container before launch,
152
+ // the same shape as dockerize's needsGit: it prefixes the containerized
153
+ // invocation only and is never part of any recorded command.
154
+ const sslPrefix = 'ldconfig -p 2>/dev/null | grep -q libssl.so.3 || ' +
155
+ '(apt-get update -qq && apt-get install -y -qq --no-install-recommends libssl3 >/dev/null 2>&1); ';
156
+ const d = dockerize(`${sslPrefix}/opt/mcp-tokens ${cliArgs.join(' ')} -- ${entry.command}`, {
157
+ // Explicit, from the SERVER's command: dockerize's image sniffing reads
158
+ // the front of the command line, which here is the ssl prefix and the
159
+ // CLI, not the `uvx …` it is about to launch — left implicit, every
160
+ // Python server's CLI run would land in the node image, uvx-less.
161
+ image: entry.dockerImage ?? defaultImageFor(entry.command),
162
+ dummyEnv: entry.env ?? [],
163
+ dummyEnvValues: entry.envValues,
164
+ needsGit: entry.needsGit,
165
+ containerName,
166
+ binds: [`${binPath}:/opt/mcp-tokens:ro`],
167
+ });
168
+ command = d.command;
169
+ argv = d.argv;
170
+ }
171
+ return new Promise((resolvePromise) => {
172
+ const child = spawn(command, argv, {
173
+ stdio: ['ignore', 'pipe', 'pipe'],
174
+ env: { PATH: process.env.PATH, HOME: process.env.HOME },
175
+ });
176
+ let stdout = '';
177
+ let stderr = '';
178
+ let timedOut = false;
179
+ child.stdout.setEncoding('utf8');
180
+ child.stdout.on('data', (chunk) => (stdout += chunk));
181
+ child.stderr.setEncoding('utf8');
182
+ child.stderr.on('data', (chunk) => (stderr = (stderr + chunk).slice(-4000)));
183
+ // Grace beyond the CLI's own deadline: startup is what its timeout covers,
184
+ // and the counting that follows deserves to finish rather than be killed
185
+ // at the exact same instant.
186
+ const timer = setTimeout(() => {
187
+ timedOut = true;
188
+ child.kill('SIGKILL');
189
+ if (containerName)
190
+ spawn('docker', ['rm', '-f', containerName], { stdio: 'ignore' }).on('error', () => { });
191
+ }, opts.timeoutMs + (opts.graceMs ?? 30_000));
192
+ child.on('error', (err) => {
193
+ clearTimeout(timer);
194
+ resolvePromise({ code: null, stdout, stderr: String(err.message), timedOut });
195
+ });
196
+ child.on('exit', (code) => {
197
+ clearTimeout(timer);
198
+ resolvePromise({ code, stdout, stderr, timedOut });
199
+ });
200
+ });
201
+ }
202
+ function arg(name) {
203
+ const i = process.argv.indexOf(`--${name}`);
204
+ return i >= 0 ? process.argv[i + 1] : undefined;
205
+ }
206
+ // Exact path match, for the reason src/sweep/run.ts states: any other file whose
207
+ // name merely ends the same way would otherwise run this block.
208
+ const isMain = process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
209
+ if (isMain) {
210
+ const doc = parse(readFileSync('servers.yaml', 'utf8'));
211
+ const only = arg('only')?.split(',');
212
+ const docker = process.argv.includes('--docker');
213
+ const concurrency = Number(arg('concurrency') ?? 3);
214
+ const defaultTimeout = Number(arg('default-timeout') ?? 60);
215
+ const shards = arg('shards') === undefined ? undefined : Number(arg('shards'));
216
+ const shardIndexArg = arg('shard-index') === undefined ? undefined : Number(arg('shard-index'));
217
+ if (shards !== undefined && only) {
218
+ // Same refusal as sweep-all, same reason: a slice that belongs to no cycle
219
+ // must not be producible by accident.
220
+ console.error('--shards and --only both select servers; pass one or the other');
221
+ process.exit(2);
222
+ }
223
+ if (shardIndexArg !== undefined && shards === undefined) {
224
+ console.error('--shard-index needs --shards');
225
+ process.exit(2);
226
+ }
227
+ let entries = doc.servers.filter((s) => {
228
+ if (only && !only.includes(s.name))
229
+ return false;
230
+ return !s.remote; // a remote server never reaches tools/list without credentials
231
+ });
232
+ if (shards !== undefined) {
233
+ const index = shardIndexArg ?? shardIndexForDate(new Date(), shards);
234
+ entries = selectShard(entries, shards, index);
235
+ console.log(`shard ${index + 1}/${shards}: ${entries.map((e) => e.name).join(', ')}`);
236
+ }
237
+ // A row can only ever print by matching the hash of a published measurement,
238
+ // so a server with no good number on record has no comparison to make — the
239
+ // launches would be spent on a row that cannot become printable until a
240
+ // sweep publishes a capture for it. Skipped after shard selection, so the
241
+ // slice stays the sweep's slice.
242
+ const skipped = [];
243
+ entries = entries.filter((e) => {
244
+ const p = join(process.cwd(), 'results', e.name, 'measurement.json');
245
+ if (!existsSync(p)) {
246
+ skipped.push(e.name);
247
+ return false;
248
+ }
249
+ try {
250
+ const status = JSON.parse(readFileSync(p, 'utf8')).status;
251
+ if (status === 'measured' || status === 'dynamic')
252
+ return true;
253
+ }
254
+ catch {
255
+ // An unreadable record is not a good number on record.
256
+ }
257
+ skipped.push(e.name);
258
+ return false;
259
+ });
260
+ if (skipped.length > 0) {
261
+ console.log(`skipping ${skipped.length} with no published number to compare against: ${skipped.join(', ')}`);
262
+ }
263
+ const triple = cliTriple(docker);
264
+ const binPath = await ensureCliBinary(triple);
265
+ // A command that is already its own `docker run` cannot have the CLI
266
+ // containerized around it — that would be docker from inside docker — so for
267
+ // those entries the CLI runs host-side and the server still runs in its own
268
+ // container, the same shape measureServer records for these commands.
269
+ let hostBinPromise = null;
270
+ const hostBin = () => (hostBinPromise ??= ensureCliBinary(cliTriple(false)));
271
+ console.log(`cross-checking ${entries.length} servers against ${CROSS_CHECK_CLI} ${CROSS_CHECK_CLI_VERSION} ` +
272
+ `(${triple}, docker=${docker}, concurrency=${concurrency})`);
273
+ // Merged, not replaced: a run over `--only` or one shard must not delete the
274
+ // rows it did not visit. A row it did visit is overwritten, errors included.
275
+ const prior = loadCrossCheck();
276
+ const servers = { ...(prior?.servers ?? {}) };
277
+ const queue = [...entries];
278
+ async function worker() {
279
+ for (let e = queue.shift(); e; e = queue.shift()) {
280
+ const timeoutMs = (e.timeoutSeconds ?? defaultTimeout) * 1000;
281
+ let m;
282
+ try {
283
+ m = await measureServer(e.name, e.command, {
284
+ timeoutMs,
285
+ docker,
286
+ dockerImage: e.dockerImage,
287
+ dummyEnv: e.env ?? [],
288
+ dummyEnvValues: e.envValues,
289
+ needsGit: e.needsGit,
290
+ persist: false, // the measurements on disk are not this run's to rewrite
291
+ });
292
+ }
293
+ catch (err) {
294
+ if (!(err instanceof DockerHarnessFault))
295
+ throw err;
296
+ // A machine fault is not a fact about the server: leave its prior row alone.
297
+ console.log(` ${e.name}: docker harness fault — skipped, prior row untouched`);
298
+ continue;
299
+ }
300
+ if (m.status !== 'measured' && m.status !== 'dynamic') {
301
+ servers[e.name] = toCrossCheckRow(m, {});
302
+ console.log(` ${e.name}: our measurement ${m.status} — recorded, nothing to compare`);
303
+ continue;
304
+ }
305
+ const selfDocker = e.command.trimStart().startsWith('docker ');
306
+ const out = await runCli(docker && selfDocker ? await hostBin() : binPath, e, {
307
+ docker: docker && !selfDocker,
308
+ timeoutMs,
309
+ });
310
+ const cli = out.timedOut
311
+ ? { problem: `timeout after ${timeoutMs}ms` }
312
+ : out.code !== 0
313
+ ? { problem: `exited ${out.code}: ${out.stderr.slice(-200)}` }
314
+ : parseCliReport(out.stdout);
315
+ const row = toCrossCheckRow(m, cli);
316
+ servers[e.name] = row;
317
+ const pct = divergencePct(row);
318
+ console.log(` ${e.name}: ours ${row.ourTokens} (mapped ${row.ourMappedTokens}), cli ${row.cliTokens}` +
319
+ (row.error
320
+ ? ` — ${row.error}`
321
+ : !row.toolSetMatches
322
+ ? ` — tool sets differ (${row.ourToolCount} vs ${row.cliToolCount} tools), not comparable`
323
+ : row.dynamic
324
+ ? ` (${pct !== null && pct >= 0 ? '+' : ''}${pct?.toFixed(1)}% vs mapped) — dynamic listing, recorded but never printed`
325
+ : ` (${pct !== null && pct >= 0 ? '+' : ''}${pct?.toFixed(1)}% vs mapped)`));
326
+ }
327
+ }
328
+ await Promise.all(Array.from({ length: Math.max(1, concurrency) }, () => worker()));
329
+ writeCrossCheck({
330
+ method: CROSS_CHECK_METHOD,
331
+ cli: CROSS_CHECK_CLI,
332
+ cliVersion: CROSS_CHECK_CLI_VERSION,
333
+ cliArgs: [...CROSS_CHECK_CLI_ARGS],
334
+ measuredAt: new Date().toISOString().slice(0, 10),
335
+ isolation: (docker ? 'docker (same images, limits and package caches as a sweep)' : 'host process (no container)') +
336
+ (process.env.MCP_TOKENS_BIN ? '; binary supplied via MCP_TOKENS_BIN' : ''),
337
+ servers,
338
+ });
339
+ const rows = Object.values(servers);
340
+ const clean = rows.filter((r) => !r.error && r.toolSetMatches && !r.dynamic);
341
+ console.log(`done: ${clean.length}/${rows.length} rows comparable; results/cross-check.json written`);
342
+ }
@@ -30,6 +30,13 @@ export interface DockerOptions {
30
30
  * git already on PATH would actually run.
31
31
  */
32
32
  needsGit?: boolean;
33
+ /**
34
+ * Extra `-v` bind mounts, verbatim (`host:container:ro`). Used to hand a
35
+ * host-verified binary into the container (the cross-check CLI); mounts here
36
+ * should be read-only so the isolation claim — clean FS, no host credentials
37
+ * — survives them.
38
+ */
39
+ binds?: string[];
33
40
  /**
34
41
  * Skip the shared npm/uv cache volumes, paying a cold install for a clean one.
35
42
  *
@@ -46,6 +53,54 @@ export interface DockerOptions {
46
53
  }
47
54
  export declare const DEFAULT_NODE_IMAGE = "public.ecr.aws/docker/library/node:22-slim";
48
55
  export declare const DEFAULT_PYTHON_IMAGE = "ghcr.io/astral-sh/uv:python3.12-bookworm-slim";
56
+ /** The image a launch command gets when the entry does not name one. */
57
+ export declare function defaultImageFor(commandLine: string): string;
58
+ /**
59
+ * Docker failed, not the server. A measurement that ends this way is a
60
+ * statement about this machine — the daemon, the registry, the network — and
61
+ * must never be recorded as the server's startup-failure: the 2026-08-26
62
+ * re-sweep published exactly that lie about `sequential-thinking` when the
63
+ * runner could not pull the base image, and the harness guard never saw it
64
+ * because it watches for populations of regressions, not single rows.
65
+ */
66
+ export declare class DockerHarnessFault extends Error {
67
+ }
68
+ /**
69
+ * Whether a capture-failure message describes `docker run` failing as docker
70
+ * rather than the contained server failing as itself.
71
+ *
72
+ * Docker reserves exit code 125 for its own failures, but a contained process
73
+ * that exits 125 passes that code through indistinguishably — so the code alone
74
+ * is not enough, and docker's own stderr voice ("Unable to find image …",
75
+ * "docker: Error response from daemon: …", "docker: Cannot connect …") is
76
+ * required alongside it. Only meaningful for a command this code wrapped in
77
+ * `docker run` itself; a config whose command is already `docker run …` owns
78
+ * its exit codes.
79
+ */
80
+ export declare function isDockerRunFailure(message: string): boolean;
81
+ export interface EnsureImageOptions {
82
+ /** Run one docker invocation — injectable so tests never need a daemon. */
83
+ run?: (args: string[]) => Promise<{
84
+ code: number | null;
85
+ stderr: string;
86
+ }>;
87
+ /** Waits between pull attempts; attempts = delays + 1. */
88
+ delaysMs?: number[];
89
+ sleep?: (ms: number) => Promise<void>;
90
+ }
91
+ /**
92
+ * Make sure a base image is present before any container needs it, retrying the
93
+ * pull. `docker run --pull=missing` pulls lazily, so a transient registry
94
+ * failure lands mid-measurement and gets read as the server refusing to start —
95
+ * pulling up front, with retries, is what keeps a registry hiccup from ever
96
+ * reaching a measurement. Failures throw `DockerHarnessFault`.
97
+ *
98
+ * Results are memoized per image for the life of the process (only on the real
99
+ * docker path — injected runners are for tests), so concurrent sweep workers
100
+ * share one pull, and an image that could not be pulled after retries is not
101
+ * re-attempted by every remaining server in the sweep.
102
+ */
103
+ export declare function ensureImage(image: string, opts?: EnsureImageOptions): Promise<void>;
49
104
  export interface IsolationRecord {
50
105
  docker: boolean;
51
106
  image?: string;
@@ -2,12 +2,102 @@
2
2
  // on this machine 2026-08-16: hub pulls stall indefinitely while ECR works).
3
3
  export const DEFAULT_NODE_IMAGE = 'public.ecr.aws/docker/library/node:22-slim';
4
4
  export const DEFAULT_PYTHON_IMAGE = 'ghcr.io/astral-sh/uv:python3.12-bookworm-slim';
5
+ /** The image a launch command gets when the entry does not name one. */
6
+ export function defaultImageFor(commandLine) {
7
+ return commandLine.trimStart().startsWith('uvx') ? DEFAULT_PYTHON_IMAGE : DEFAULT_NODE_IMAGE;
8
+ }
9
+ /**
10
+ * Docker failed, not the server. A measurement that ends this way is a
11
+ * statement about this machine — the daemon, the registry, the network — and
12
+ * must never be recorded as the server's startup-failure: the 2026-08-26
13
+ * re-sweep published exactly that lie about `sequential-thinking` when the
14
+ * runner could not pull the base image, and the harness guard never saw it
15
+ * because it watches for populations of regressions, not single rows.
16
+ */
17
+ export class DockerHarnessFault extends Error {
18
+ }
19
+ /**
20
+ * Whether a capture-failure message describes `docker run` failing as docker
21
+ * rather than the contained server failing as itself.
22
+ *
23
+ * Docker reserves exit code 125 for its own failures, but a contained process
24
+ * that exits 125 passes that code through indistinguishably — so the code alone
25
+ * is not enough, and docker's own stderr voice ("Unable to find image …",
26
+ * "docker: Error response from daemon: …", "docker: Cannot connect …") is
27
+ * required alongside it. Only meaningful for a command this code wrapped in
28
+ * `docker run` itself; a config whose command is already `docker run …` owns
29
+ * its exit codes.
30
+ */
31
+ export function isDockerRunFailure(message) {
32
+ return /server exited \(code 125\)/.test(message) && /Unable to find image|docker: /.test(message);
33
+ }
34
+ function runDocker(args) {
35
+ return import('node:child_process').then(({ spawn }) => new Promise((resolve) => {
36
+ const child = spawn('docker', args, { stdio: ['ignore', 'ignore', 'pipe'] });
37
+ let stderr = '';
38
+ child.stderr.setEncoding('utf8');
39
+ child.stderr.on('data', (chunk) => (stderr = (stderr + chunk).slice(-4000)));
40
+ child.on('error', (err) => resolve({ code: null, stderr: String(err.message) }));
41
+ child.on('exit', (code) => resolve({ code, stderr }));
42
+ }));
43
+ }
44
+ async function ensureImageOnce(image, opts) {
45
+ const run = opts.run ?? runDocker;
46
+ const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
47
+ const delays = opts.delaysMs ?? [2_000, 8_000];
48
+ const noDocker = (stderr) => new DockerHarnessFault(`docker is not runnable on this machine: ${stderr.trim() || 'spawn docker failed'}`);
49
+ const inspect = await run(['image', 'inspect', image]);
50
+ if (inspect.code === 0)
51
+ return;
52
+ if (inspect.code === null && /ENOENT/i.test(inspect.stderr))
53
+ throw noDocker(inspect.stderr);
54
+ let lastStderr = '';
55
+ for (let attempt = 1; attempt <= delays.length + 1; attempt++) {
56
+ const pull = await run(['pull', image]);
57
+ if (pull.code === 0)
58
+ return;
59
+ lastStderr = pull.stderr;
60
+ // A missing docker binary cannot appear on a later attempt.
61
+ if (pull.code === null && /ENOENT/i.test(pull.stderr))
62
+ throw noDocker(pull.stderr);
63
+ if (attempt <= delays.length)
64
+ await sleep(delays[attempt - 1]);
65
+ }
66
+ throw new DockerHarnessFault(`could not pull ${image} after ${delays.length + 1} attempts — ` +
67
+ `a statement about this machine and its registry path, not about any server: ${lastStderr.slice(-300).trim()}`);
68
+ }
69
+ const ensured = new Map();
70
+ /**
71
+ * Make sure a base image is present before any container needs it, retrying the
72
+ * pull. `docker run --pull=missing` pulls lazily, so a transient registry
73
+ * failure lands mid-measurement and gets read as the server refusing to start —
74
+ * pulling up front, with retries, is what keeps a registry hiccup from ever
75
+ * reaching a measurement. Failures throw `DockerHarnessFault`.
76
+ *
77
+ * Results are memoized per image for the life of the process (only on the real
78
+ * docker path — injected runners are for tests), so concurrent sweep workers
79
+ * share one pull, and an image that could not be pulled after retries is not
80
+ * re-attempted by every remaining server in the sweep.
81
+ */
82
+ export function ensureImage(image, opts = {}) {
83
+ if (opts.run)
84
+ return ensureImageOnce(image, opts);
85
+ let p = ensured.get(image);
86
+ if (!p) {
87
+ p = ensureImageOnce(image, opts);
88
+ // Mark handled so a memoized rejection never trips unhandled-rejection
89
+ // before the next caller awaits it.
90
+ p.catch(() => { });
91
+ ensured.set(image, p);
92
+ }
93
+ return p;
94
+ }
5
95
  /**
6
96
  * Wrap a launch command line in `docker run`. The inner command is passed to
7
97
  * `sh -lc` inside the container; quoting is preserved by argv (no host shell).
8
98
  */
9
99
  export function dockerize(commandLine, opts = {}) {
10
- const image = opts.image ?? (commandLine.trimStart().startsWith('uvx') ? DEFAULT_PYTHON_IMAGE : DEFAULT_NODE_IMAGE);
100
+ const image = opts.image ?? defaultImageFor(commandLine);
11
101
  const argv = [
12
102
  'run',
13
103
  '--rm',
@@ -36,6 +126,9 @@ export function dockerize(commandLine, opts = {}) {
36
126
  'UV_CACHE_DIR=/tmp/.uv-cache',
37
127
  ]),
38
128
  ];
129
+ for (const bind of opts.binds ?? []) {
130
+ argv.push('-v', bind);
131
+ }
39
132
  for (const name of opts.dummyEnv ?? []) {
40
133
  argv.push('-e', `${name}=${opts.dummyEnvValues?.[name] ?? 'dummy'}`);
41
134
  }
@@ -0,0 +1,114 @@
1
+ import { type ServerEntry } from './report.js';
2
+ export interface PublishedStats {
3
+ candidateTotal: number;
4
+ measuredCount: number;
5
+ max: {
6
+ name: string;
7
+ tokens: number;
8
+ };
9
+ second: {
10
+ name: string;
11
+ tokens: number;
12
+ };
13
+ min: {
14
+ name: string;
15
+ tokens: number;
16
+ };
17
+ /** max/min, floored to two significant digits — a span claim must not overstate. */
18
+ spanTimes: number;
19
+ /** The heaviest server's share of the default context window, rounded %. */
20
+ maxContextSharePct: number;
21
+ /** The servers README's sample table names, with their current numbers. */
22
+ sample: Record<string, {
23
+ tokens: number;
24
+ tools: number;
25
+ }>;
26
+ claude: {
27
+ runSize: number;
28
+ /** Rows the leaderboard prints a claude number for: measured AND capture-current. */
29
+ currentCount: number;
30
+ heaviestClaudeName: string | null;
31
+ github: {
32
+ badgeTokens: number;
33
+ mappedTokens: number;
34
+ claudeTokens: number;
35
+ droppedPct: number;
36
+ };
37
+ notion: {
38
+ badgeTokens: number;
39
+ claudeTokens: number;
40
+ };
41
+ /** Field-selection share across the run's rows, as fractions of the payload. */
42
+ shareMin: number;
43
+ shareMax: number;
44
+ /** claudeDelta / o200kFull across the run's rows that carry a number. */
45
+ ratioMin: number;
46
+ ratioMax: number;
47
+ };
48
+ deferralCostlierCount: number;
49
+ verify: {
50
+ serverName: string;
51
+ tokens: number;
52
+ };
53
+ }
54
+ /** Named in README's sample table — the choice is editorial, the numbers are not. */
55
+ export declare const SAMPLE_SERVERS: readonly ['github', 'xcodebuildmcp', 'brave-search', 'notion', 'playwright', 'filesystem', 'markitdown'];
56
+ export declare function floorToTwoSignificant(n: number): number;
57
+ export declare function computePublishedStats(entries: ServerEntry[], root?: string): PublishedStats;
58
+ export type PageFile = 'README.md' | 'docs/index.md' | 'docs/METHODOLOGY.md';
59
+ export declare const PAGE_FILES: PageFile[];
60
+ /**
61
+ * One maintained sentence. `template` is its exact words with slots — `{n}` a
62
+ * comma-formatted count, `{d}` a bare integer, `{f}` a decimal, `{w}` a server
63
+ * or package name — and `values` is what the slots must hold for the data on
64
+ * disk.
65
+ */
66
+ export interface Claim {
67
+ file: PageFile;
68
+ id: string;
69
+ template: string;
70
+ values(stats: PublishedStats): string[];
71
+ }
72
+ export declare const PAGE_CLAIMS: Claim[];
73
+ /**
74
+ * Claims whose truth the data decides but whose words no template can rewrite —
75
+ * prose whose shape would have to change with the answer. These are asserted in
76
+ * the suite, never patched: if one goes false a person rewrites the sentence.
77
+ */
78
+ export interface CheckClaim {
79
+ file: PageFile;
80
+ id: string;
81
+ /** The page's words, template-escaped like any claim (wrapping-tolerant). */
82
+ words: string;
83
+ /** null when the data agrees with the words; otherwise why it does not. */
84
+ holds(stats: PublishedStats): string | null;
85
+ }
86
+ export declare const CHECK_CLAIMS: CheckClaim[];
87
+ /** Fixed words with `\s+` for every gap (prose wraps; a claim is its words, not its layout). */
88
+ export declare function compileTemplate(template: string): RegExp;
89
+ export interface ClaimApplication {
90
+ text: string;
91
+ /** Why the claim could not be applied — a missing or ambiguous anchor. */
92
+ problem: string | null;
93
+ /** True when a slot was rewritten. */
94
+ changed: boolean;
95
+ }
96
+ /** Apply one claim: find its anchor exactly once, splice the slots to `want`, touch nothing else. */
97
+ export declare function applyClaim(text: string, claim: Pick<Claim, 'file' | 'id' | 'template'>, want: string[]): ClaimApplication;
98
+ export interface PagePatch {
99
+ text: string;
100
+ problems: string[];
101
+ /** Claim ids whose slots were rewritten. */
102
+ updated: string[];
103
+ }
104
+ /** Apply every claim for one page to its text. Slots are spliced in place; anchors are never rewritten. */
105
+ export declare function patchPageText(file: PageFile, text: string, stats: PublishedStats): PagePatch;
106
+ export interface PublishedStatsResult {
107
+ problems: string[];
108
+ updated: string[];
109
+ changedFiles: PageFile[];
110
+ }
111
+ /** Compute stats and report what regen would rewrite, without writing anything. */
112
+ export declare function verifyPublishedPages(entries: ServerEntry[], root?: string): PublishedStatsResult;
113
+ /** Compute stats and rewrite the pages in place. Returns what changed and any refusals. */
114
+ export declare function applyPublishedStats(entries: ServerEntry[], root?: string): PublishedStatsResult;