mcp-context-cost 0.4.0 → 0.6.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.
Files changed (41) hide show
  1. package/README.md +101 -33
  2. package/dist/audit/audit.d.ts +48 -0
  3. package/dist/audit/audit.js +392 -7
  4. package/dist/audit/config.d.ts +46 -0
  5. package/dist/audit/config.js +86 -2
  6. package/dist/audit/deferral.d.ts +366 -0
  7. package/dist/audit/deferral.js +403 -0
  8. package/dist/audit/run.d.ts +22 -0
  9. package/dist/audit/run.js +17 -1
  10. package/dist/cli.js +11 -0
  11. package/dist/core/adoption.d.ts +226 -0
  12. package/dist/core/adoption.js +432 -0
  13. package/dist/core/canonical.d.ts +6 -0
  14. package/dist/core/canonical.js +3 -0
  15. package/dist/core/index.d.ts +1 -0
  16. package/dist/core/index.js +1 -0
  17. package/dist/core/session-start.d.ts +102 -0
  18. package/dist/core/session-start.js +186 -0
  19. package/dist/core/types.d.ts +8 -0
  20. package/dist/sweep/client.d.ts +6 -1
  21. package/dist/sweep/client.js +1 -0
  22. package/dist/sweep/dashboard.d.ts +10 -0
  23. package/dist/sweep/dashboard.js +32 -16
  24. package/dist/sweep/docker.d.ts +23 -0
  25. package/dist/sweep/docker.js +16 -12
  26. package/dist/sweep/harness-guard.d.ts +57 -0
  27. package/dist/sweep/harness-guard.js +144 -0
  28. package/dist/sweep/history.d.ts +33 -1
  29. package/dist/sweep/history.js +60 -5
  30. package/dist/sweep/regen.js +6 -1
  31. package/dist/sweep/report.d.ts +16 -0
  32. package/dist/sweep/report.js +79 -5
  33. package/dist/sweep/run.d.ts +41 -0
  34. package/dist/sweep/run.js +129 -36
  35. package/dist/sweep/server-pages.js +31 -6
  36. package/dist/sweep/session-start.d.ts +3 -0
  37. package/dist/sweep/session-start.js +103 -0
  38. package/dist/sweep/shard.d.ts +41 -0
  39. package/dist/sweep/shard.js +58 -0
  40. package/dist/sweep/sweep-all.js +56 -2
  41. package/package.json +3 -1
package/dist/sweep/run.js CHANGED
@@ -15,6 +15,49 @@ function arg(name) {
15
15
  const i = process.argv.indexOf(`--${name}`);
16
16
  return i >= 0 ? process.argv[i + 1] : undefined;
17
17
  }
18
+ /**
19
+ * Marks a startup-failure that was re-attempted from a cold package cache and
20
+ * failed the same way. Its absence on a startup-failure means the retry never
21
+ * ran (host mode, or a self-containerised command), not that it passed.
22
+ */
23
+ export const RETRY_CONFIRMED_PREFIX = 'reproduced with the shared package cache bypassed; ';
24
+ /**
25
+ * Whether a failed measurement gets a second attempt with the shared package
26
+ * caches bypassed (see `DockerOptions.noSharedCache`).
27
+ *
28
+ * A poisoned cache entry and a genuinely broken server produce the same exit
29
+ * code, so a `startup-failure` is not published until it reproduces from a cold
30
+ * cache. Deliberately only `startup-failure`: a `timeout` is usually the slow
31
+ * install a cold retry would only make slower (it has its own retry — see
32
+ * `retriesWithLongerTimeout`), `auth-required` is a real answer about the
33
+ * server, and a command that is already its own `docker run` has no cache mount
34
+ * to bypass.
35
+ */
36
+ export function retriesWithoutSharedCache(status, docker, command) {
37
+ return status === 'startup-failure' && docker && !command.trimStart().startsWith('docker ');
38
+ }
39
+ /**
40
+ * Marks a `timeout` that was re-attempted on a larger budget and timed out
41
+ * again. Its absence on a timeout means the retry never ran, not that it passed.
42
+ */
43
+ export const TIMEOUT_CONFIRMED_PREFIX = 'reproduced on double the timeout budget; ';
44
+ /** A timed-out measurement is re-attempted on this multiple of its budget. */
45
+ export const TIMEOUT_RETRY_FACTOR = 2;
46
+ /**
47
+ * Whether a timed-out measurement gets a second attempt on a larger budget.
48
+ *
49
+ * Sweeps and `audit` both run a worker pool, and a server that starts slowly
50
+ * under that contention is indistinguishable from one that never starts: both
51
+ * come back `timeout`. Two servers were published as failures for exactly this
52
+ * reason (`puppeteer` and `kubernetes`, 2026-08-19) and measured normally when
53
+ * re-run alone, so a timeout is not published until it survives a second, wider
54
+ * budget. Unlike the cold-cache retry this is isolation-independent — contention
55
+ * is not a property of the package cache, so host runs and commands that are
56
+ * their own `docker run` are retried too.
57
+ */
58
+ export function retriesWithLongerTimeout(status) {
59
+ return status === 'timeout';
60
+ }
18
61
  export async function measureServer(name, command, opts = {}) {
19
62
  const persist = opts.persist !== false;
20
63
  // The name becomes a directory when persisting; that's the only reason it's
@@ -23,57 +66,107 @@ export async function measureServer(name, command, opts = {}) {
23
66
  throw new Error(`invalid server name '${name}' — letters/digits/dot/dash/underscore only`);
24
67
  }
25
68
  const root = opts.root ?? process.cwd();
26
- let spec = opts.argv && opts.argv.length ? { command: opts.argv[0], argv: opts.argv.slice(1) } : command;
69
+ const hostSpec = opts.argv && opts.argv.length ? { command: opts.argv[0], argv: opts.argv.slice(1) } : command;
27
70
  let isolation = { docker: false };
28
- let containerName;
29
- if (opts.docker && command.trimStart().startsWith('docker ')) {
30
- // Command is already a container — wrapping it again would need docker-in-docker.
31
- isolation = { docker: true, note: 'command is itself a docker run (host-spawned container)' };
32
- }
33
- else if (opts.docker) {
34
- containerName = `mcp-ctx-${name}-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
71
+ const containerNames = [];
72
+ // A fresh container name per capture: some servers don't exit on stdin close
73
+ // (background timers keep the event loop alive), so `close()`'s SIGKILL only
74
+ // detaches the host-side `docker run` CLI — the container itself can outlive
75
+ // it. Reusing one name across both tools/list captures then races `--rm`'s
76
+ // async cleanup: the second `docker run --name X` collides with the first
77
+ // container mid-removal. Distinct names sidestep the race entirely; the
78
+ // `finally` below force-removes every name this call created either way.
79
+ function buildSpec(noSharedCache = false) {
80
+ if (opts.docker && command.trimStart().startsWith('docker ')) {
81
+ // Command is already a container — wrapping it again would need docker-in-docker.
82
+ isolation = { docker: true, note: 'command is itself a docker run (host-spawned container)' };
83
+ return hostSpec;
84
+ }
85
+ if (!opts.docker)
86
+ return hostSpec;
87
+ const containerName = `mcp-ctx-${name}-${process.pid}-${Math.floor(Math.random() * 1e6)}-${containerNames.length}`;
88
+ containerNames.push(containerName);
35
89
  const d = dockerize(command, {
36
90
  image: opts.dockerImage,
37
91
  dummyEnv: opts.dummyEnv,
92
+ dummyEnvValues: opts.dummyEnvValues,
38
93
  containerName,
39
94
  needsGit: opts.needsGit,
95
+ noSharedCache,
40
96
  });
41
- spec = { command: d.command, argv: d.argv };
42
97
  isolation = d.isolation;
98
+ return { command: d.command, argv: d.argv };
99
+ }
100
+ async function attempt(noSharedCache, timeoutMsOverride) {
101
+ // A cold install has to fetch everything again, so the retry gets its own
102
+ // floor — otherwise the bypass would trade a cache-poisoned startup-failure
103
+ // for a timeout and report just as wrongly.
104
+ const attemptOpts = timeoutMsOverride !== undefined
105
+ ? { ...opts, timeoutMs: timeoutMsOverride }
106
+ : noSharedCache
107
+ ? { ...opts, timeoutMs: Math.max(opts.timeoutMs ?? 60_000, 240_000) }
108
+ : opts;
109
+ let r;
110
+ try {
111
+ const first = await captureTools(buildSpec(noSharedCache), attemptOpts);
112
+ const second = await captureTools(buildSpec(noSharedCache), attemptOpts);
113
+ r = measureTools(first.tools, {
114
+ serverName: first.serverInfo?.name ?? name,
115
+ serverVersion: first.serverInfo?.version,
116
+ launchCommand: command,
117
+ envVarNames: [...Object.keys(opts.env ?? {}), ...(opts.dummyEnv ?? [])],
118
+ instructions: first.instructions,
119
+ });
120
+ if (canonicalString(first.tools) !== canonicalString(second.tools)) {
121
+ r.status = 'dynamic';
122
+ r.notes = 'tools/list differed between two runs; value is for the first capture';
123
+ }
124
+ }
125
+ catch (err) {
126
+ const msg = err instanceof Error ? err.message : String(err);
127
+ const status = msg.includes('timeout')
128
+ ? 'timeout'
129
+ : /auth|unauthorized|401|forbidden|credential|api.?key|token/i.test(msg)
130
+ ? 'auth-required'
131
+ : 'startup-failure';
132
+ r = failedMeasurement(status, { serverName: name, launchCommand: command, notes: msg.slice(0, 700) });
133
+ }
134
+ r.isolation = isolation;
135
+ r.timeoutMs = attemptOpts.timeoutMs ?? 60_000;
136
+ return r;
43
137
  }
44
138
  let m;
45
139
  try {
46
- const first = await captureTools(spec, opts);
47
- const second = await captureTools(spec, opts);
48
- m = measureTools(first.tools, {
49
- serverName: first.serverInfo?.name ?? name,
50
- serverVersion: first.serverInfo?.version,
51
- launchCommand: command,
52
- envVarNames: [...Object.keys(opts.env ?? {}), ...(opts.dummyEnv ?? [])],
53
- });
54
- m.isolation = isolation;
55
- m.timeoutMs = opts.timeoutMs ?? 60_000;
56
- if (canonicalString(first.tools) !== canonicalString(second.tools)) {
57
- m.status = 'dynamic';
58
- m.notes = 'tools/list differed between two runs; value is for the first capture';
140
+ m = await attempt(false);
141
+ if (retriesWithoutSharedCache(m.status, opts.docker === true, command)) {
142
+ const retry = await attempt(true);
143
+ if (retry.status !== 'startup-failure') {
144
+ m = retry;
145
+ }
146
+ else {
147
+ // Keep the warm attempt — its stderr is the one worth reading — but say
148
+ // that the failure survived a cold cache, so a reader can tell a real
149
+ // breakage from a cache artifact without re-running the sweep.
150
+ m.notes = `${RETRY_CONFIRMED_PREFIX}${m.notes ?? ''}`.slice(0, 700);
151
+ }
152
+ }
153
+ else if (retriesWithLongerTimeout(m.status)) {
154
+ const retry = await attempt(false, (opts.timeoutMs ?? 60_000) * TIMEOUT_RETRY_FACTOR);
155
+ // The retry replaces the first attempt either way: it is the wider budget,
156
+ // so its `timeoutMs` is the number a reader needs to judge the verdict.
157
+ m = retry;
158
+ if (retry.status === 'timeout') {
159
+ m.notes = `${TIMEOUT_CONFIRMED_PREFIX}${m.notes ?? ''}`.slice(0, 700);
160
+ }
59
161
  }
60
- }
61
- catch (err) {
62
- const msg = err instanceof Error ? err.message : String(err);
63
- const status = msg.includes('timeout')
64
- ? 'timeout'
65
- : /auth|unauthorized|401|forbidden|credential|api.?key|token/i.test(msg)
66
- ? 'auth-required'
67
- : 'startup-failure';
68
- m = failedMeasurement(status, { serverName: name, launchCommand: command, notes: msg.slice(0, 700) });
69
- m.isolation = isolation;
70
- m.timeoutMs = opts.timeoutMs ?? 60_000;
71
162
  }
72
163
  finally {
73
- if (containerName) {
74
- // Killing the docker CLI on timeout orphans the container — remove it.
164
+ if (containerNames.length) {
165
+ // Killing the docker CLI on timeout/non-exit orphans the container — remove it.
75
166
  const { spawn } = await import('node:child_process');
76
- spawn('docker', ['rm', '-f', containerName], { stdio: 'ignore' }).on('error', () => { });
167
+ for (const n of containerNames) {
168
+ spawn('docker', ['rm', '-f', n], { stdio: 'ignore' }).on('error', () => { });
169
+ }
77
170
  }
78
171
  }
79
172
  if (!persist)
@@ -11,7 +11,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
11
11
  import { join } from 'node:path';
12
12
  import { bandColor, BAND_META } from '../core/bands.js';
13
13
  import { loadDivergence, mdCell } from './report.js';
14
- import { parseHistory } from './history.js';
14
+ import { parseHistory, plottableSeries } from './history.js';
15
15
  import { claudeRatio, fieldSelectionShare, isCurrent } from '../core/divergence.js';
16
16
  /**
17
17
  * Pages are served from GitHub Pages (docs/), but results/ and badges/ are not
@@ -123,17 +123,42 @@ export function renderServerPage(entry, m, history = [], divergence = null) {
123
123
  md.push(...divergenceSection(divRow, divergence));
124
124
  }
125
125
  if (history.length > 1) {
126
+ // A change is only a change if both numbers were taken the same way, so the
127
+ // delta column is blank across an isolation boundary rather than reporting a
128
+ // difference the harness produced.
129
+ const plottable = plottableSeries(history);
130
+ const comparableFrom = plottable.rows[0]?.date;
126
131
  md.push('## Over time');
127
132
  md.push('');
128
- md.push('| date | tokens | tools | change |');
129
- md.push('|---|---:|---:|---:|');
133
+ md.push('| date | tokens | tools | measured in | change |');
134
+ md.push('|---|---:|---:|---|---:|');
130
135
  history.forEach((h, i) => {
131
136
  const prev = history[i - 1];
132
- const delta = prev ? h.tokens - prev.tokens : null;
133
- const change = delta === null ? '—' : delta === 0 ? 'no change' : `${delta > 0 ? '+' : ''}${fmt(delta)}`;
134
- md.push(`| ${mdCell(h.date)} | ${fmt(h.tokens)} | ${h.toolCount} | ${change} |`);
137
+ const comparable = prev && (!prev.isolation || !h.isolation || prev.isolation === h.isolation);
138
+ const delta = prev && comparable ? h.tokens - prev.tokens : null;
139
+ const change = !prev
140
+ ? '—'
141
+ : !comparable
142
+ ? 'not comparable'
143
+ : delta === 0
144
+ ? 'no change'
145
+ : `${delta > 0 ? '+' : ''}${fmt(delta)}`;
146
+ md.push(`| ${mdCell(h.date)} | ${fmt(h.tokens)} | ${h.toolCount} | ${mdCell(h.isolation || 'not recorded')} | ${change} |`);
135
147
  });
136
148
  md.push('');
149
+ if (plottable.dropped) {
150
+ md.push(`> The first ${plottable.dropped} row${plottable.dropped === 1 ? ' was' : 's were'} measured under a ` +
151
+ `different isolation than the current measurement, so the published trend starts at ` +
152
+ `${mdCell(comparableFrom)}. Numbers taken on the host and inside a container are not ` +
153
+ `interchangeable — the package a \`@latest\` tag resolves to, the runtime version and the ` +
154
+ `ambient environment can all differ.`);
155
+ md.push('');
156
+ }
157
+ else if (plottable.conditionsUnknown) {
158
+ md.push('> Some of these sweeps predate the `isolation` column, so the conditions they were ' +
159
+ 'measured under are not on record.');
160
+ md.push('');
161
+ }
137
162
  md.push(`Full series: [results/history.csv](${BLOB}/results/history.csv).`);
138
163
  md.push('');
139
164
  }
@@ -0,0 +1,3 @@
1
+ import { type SessionStartRun } from '../core/session-start.js';
2
+ export declare function loadSessionStart(root?: string): SessionStartRun | null;
3
+ export declare function writeSessionStart(run: SessionStartRun, root?: string): void;
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Instructions capture — the half of the session-start load that `tools/list`
3
+ * does not carry:
4
+ * npx tsx src/sweep/session-start.ts [--docker] [--only a,b] [--concurrency 3]
5
+ *
6
+ * Writes results/session-start.json. Deliberately writes nothing else: the
7
+ * measurements on disk keep their numbers, their hashes and their dates, and a
8
+ * capture taken today never gets filed under a sweep taken on another day.
9
+ *
10
+ * This is a backfill, and it is meant to shrink. Every sweep from now on records
11
+ * `serverInstructions` inside the measurement itself, which takes precedence
12
+ * over anything here (see core/session-start.ts), so a server drops out of
13
+ * needing this file the first time it is re-measured.
14
+ *
15
+ * A server is launched the same way a sweep launches it, through `measureServer`
16
+ * — same isolation, same dummy env, same retries. That costs a full tools/list
17
+ * to obtain a string from `initialize`, which is the price of the row being
18
+ * comparable to the published measurement rather than merely adjacent to it:
19
+ * the fresh capture's `canonicalSha256` is what the row is filed under, so a
20
+ * server whose tools have drifted since publication is marked stale instead of
21
+ * having today's instructions attached to yesterday's tools.
22
+ */
23
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
24
+ import { join, resolve } from 'node:path';
25
+ import { fileURLToPath } from 'node:url';
26
+ import { parse } from 'yaml';
27
+ import { measureServer } from './run.js';
28
+ import { SESSION_START_METHOD, parseSessionStart, toSessionStartRow, } from '../core/session-start.js';
29
+ export function loadSessionStart(root = process.cwd()) {
30
+ const p = join(root, 'results', 'session-start.json');
31
+ return existsSync(p) ? parseSessionStart(readFileSync(p, 'utf8')) : null;
32
+ }
33
+ export function writeSessionStart(run, root = process.cwd()) {
34
+ // Key order sorted so a re-run of the same servers produces no diff noise.
35
+ const servers = {};
36
+ for (const name of Object.keys(run.servers).sort())
37
+ servers[name] = run.servers[name];
38
+ writeFileSync(join(root, 'results', 'session-start.json'), JSON.stringify({ ...run, servers }, null, 2) + '\n');
39
+ }
40
+ function arg(name) {
41
+ const i = process.argv.indexOf(`--${name}`);
42
+ return i >= 0 ? process.argv[i + 1] : undefined;
43
+ }
44
+ // Exact path match, for the reason src/sweep/run.ts states: any other file whose
45
+ // name merely ends the same way would otherwise run this block.
46
+ const isMain = process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
47
+ if (isMain) {
48
+ const doc = parse(readFileSync('servers.yaml', 'utf8'));
49
+ const only = arg('only')?.split(',');
50
+ const docker = process.argv.includes('--docker');
51
+ const concurrency = Number(arg('concurrency') ?? 3);
52
+ const defaultTimeout = Number(arg('default-timeout') ?? 60);
53
+ const entries = doc.servers.filter((s) => {
54
+ if (only && !only.includes(s.name))
55
+ return false;
56
+ return !s.remote; // a remote server never reaches initialize without credentials
57
+ });
58
+ console.log(`capturing instructions for ${entries.length} servers (docker=${docker}, concurrency=${concurrency})`);
59
+ // Merged, not replaced: a run over `--only` must not delete the rows it did
60
+ // not visit. A row it did visit is overwritten, errors included.
61
+ const prior = loadSessionStart();
62
+ const servers = { ...(prior?.servers ?? {}) };
63
+ const queue = [...entries];
64
+ async function worker() {
65
+ for (let e = queue.shift(); e; e = queue.shift()) {
66
+ const m = await measureServer(e.name, e.command, {
67
+ timeoutMs: (e.timeoutSeconds ?? defaultTimeout) * 1000,
68
+ docker,
69
+ dockerImage: e.dockerImage,
70
+ dummyEnv: e.env ?? [],
71
+ dummyEnvValues: e.envValues,
72
+ needsGit: e.needsGit,
73
+ persist: false, // the measurements on disk are not this run's to rewrite
74
+ });
75
+ if (m.status !== 'measured' && m.status !== 'dynamic') {
76
+ servers[e.name] = {
77
+ instructions: '',
78
+ instructionsTokens: 0,
79
+ instructionsSha256: '',
80
+ capturedSha256: null,
81
+ error: `${m.status}: ${(m.notes ?? '').slice(0, 200)}`,
82
+ };
83
+ console.log(` ${e.name}: ${m.status}`);
84
+ continue;
85
+ }
86
+ const row = toSessionStartRow(m.serverInstructions ?? null, {
87
+ capturedSha256: m.canonicalSha256,
88
+ serverVersion: m.serverVersion,
89
+ });
90
+ servers[e.name] = row;
91
+ console.log(` ${e.name}: ${row.instructionsTokens} instruction tokens`);
92
+ }
93
+ }
94
+ await Promise.all(Array.from({ length: Math.max(1, concurrency) }, () => worker()));
95
+ writeSessionStart({
96
+ method: SESSION_START_METHOD,
97
+ measuredAt: new Date().toISOString().slice(0, 10),
98
+ isolation: docker ? 'docker (same images and limits as a sweep)' : 'host process (no container)',
99
+ servers,
100
+ });
101
+ const ok = Object.values(servers).filter((r) => !r.error).length;
102
+ console.log(`done: ${ok}/${Object.keys(servers).length} rows carry instructions; results/session-start.json written`);
103
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Which servers does *this week's* re-sweep measure?
3
+ *
4
+ * `history.csv` only grows for servers that get measured again, and the only
5
+ * scheduled measurement this project runs is the weekly self-badge job — one
6
+ * server, `memory`. Every other row's trend line therefore stops at whatever
7
+ * date a maintenance run last swept by hand. Re-measuring all 82 on a schedule
8
+ * is not the fix: a cold runner pays a full image pull and package install per
9
+ * server with no cache volume to carry over, so the whole set does not fit in
10
+ * one job's budget comfortably or cheaply.
11
+ *
12
+ * So the set rotates. Each week measures one deterministic slice, and after
13
+ * `shardCount` weeks every server has been re-measured exactly once. The
14
+ * schedule needs no state anywhere — the slice is a function of the date, so a
15
+ * missed week is simply a week's worth of servers measured a cycle later, not a
16
+ * cursor left pointing at the wrong place.
17
+ */
18
+ /**
19
+ * Whole UTC weeks since the Unix epoch. Only differences matter — this is a
20
+ * counter that advances once every 7 days, not a calendar week number, so it
21
+ * has no year boundary to get wrong.
22
+ */
23
+ export declare function weekIndex(date: Date): number;
24
+ /** The shard this date falls in, always in `[0, shardCount)`. */
25
+ export declare function shardIndexForDate(date: Date, shardCount: number): number;
26
+ /**
27
+ * Deal `items` into `shardCount` shards round-robin and return shard `index`.
28
+ *
29
+ * Interleaving by position rather than slicing contiguously is deliberate:
30
+ * `servers.yaml` is written in rough cost order within its groups, so a
31
+ * contiguous slice would hand one week every heavyweight server and another
32
+ * week nothing but cheap ones. Round-robin spreads the expensive entries
33
+ * across shards, which is what keeps any single week inside a runner's budget.
34
+ *
35
+ * The cost of positional dealing is that inserting a server shifts the ones
36
+ * after it by one shard. That is harmless — a shifted server is measured one
37
+ * week early or late, once, and every server is still measured exactly once per
38
+ * cycle. It is worth naming, though: shard membership is stable only as long as
39
+ * `servers.yaml`'s order is.
40
+ */
41
+ export declare function selectShard<T>(items: readonly T[], shardCount: number, index: number): T[];
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Which servers does *this week's* re-sweep measure?
3
+ *
4
+ * `history.csv` only grows for servers that get measured again, and the only
5
+ * scheduled measurement this project runs is the weekly self-badge job — one
6
+ * server, `memory`. Every other row's trend line therefore stops at whatever
7
+ * date a maintenance run last swept by hand. Re-measuring all 82 on a schedule
8
+ * is not the fix: a cold runner pays a full image pull and package install per
9
+ * server with no cache volume to carry over, so the whole set does not fit in
10
+ * one job's budget comfortably or cheaply.
11
+ *
12
+ * So the set rotates. Each week measures one deterministic slice, and after
13
+ * `shardCount` weeks every server has been re-measured exactly once. The
14
+ * schedule needs no state anywhere — the slice is a function of the date, so a
15
+ * missed week is simply a week's worth of servers measured a cycle later, not a
16
+ * cursor left pointing at the wrong place.
17
+ */
18
+ const MS_PER_DAY = 86_400_000;
19
+ /**
20
+ * Whole UTC weeks since the Unix epoch. Only differences matter — this is a
21
+ * counter that advances once every 7 days, not a calendar week number, so it
22
+ * has no year boundary to get wrong.
23
+ */
24
+ export function weekIndex(date) {
25
+ const utcDay = Math.floor(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()) / MS_PER_DAY);
26
+ return Math.floor(utcDay / 7);
27
+ }
28
+ /** The shard this date falls in, always in `[0, shardCount)`. */
29
+ export function shardIndexForDate(date, shardCount) {
30
+ if (!Number.isInteger(shardCount) || shardCount < 1) {
31
+ throw new Error(`shardCount must be a positive integer, got ${shardCount}`);
32
+ }
33
+ return weekIndex(date) % shardCount;
34
+ }
35
+ /**
36
+ * Deal `items` into `shardCount` shards round-robin and return shard `index`.
37
+ *
38
+ * Interleaving by position rather than slicing contiguously is deliberate:
39
+ * `servers.yaml` is written in rough cost order within its groups, so a
40
+ * contiguous slice would hand one week every heavyweight server and another
41
+ * week nothing but cheap ones. Round-robin spreads the expensive entries
42
+ * across shards, which is what keeps any single week inside a runner's budget.
43
+ *
44
+ * The cost of positional dealing is that inserting a server shifts the ones
45
+ * after it by one shard. That is harmless — a shifted server is measured one
46
+ * week early or late, once, and every server is still measured exactly once per
47
+ * cycle. It is worth naming, though: shard membership is stable only as long as
48
+ * `servers.yaml`'s order is.
49
+ */
50
+ export function selectShard(items, shardCount, index) {
51
+ if (!Number.isInteger(shardCount) || shardCount < 1) {
52
+ throw new Error(`shardCount must be a positive integer, got ${shardCount}`);
53
+ }
54
+ if (!Number.isInteger(index) || index < 0 || index >= shardCount) {
55
+ throw new Error(`shard index must be in [0, ${shardCount}), got ${index}`);
56
+ }
57
+ return items.filter((_, i) => i % shardCount === index);
58
+ }
@@ -1,14 +1,23 @@
1
1
  /**
2
2
  * Batch sweep over servers.yaml:
3
3
  * npx tsx src/sweep/sweep-all.ts [--docker] [--only name1,name2] [--concurrency 3]
4
+ * [--shards N [--shard-index K]]
4
5
  * Writes results/<name>/measurement.json + badges/<name>.json per server, then
5
6
  * regenerates the leaderboard.
7
+ *
8
+ * `--shards N` measures only this week's rotating slice (see shard.ts), so a
9
+ * scheduled job can keep every server's history growing without sweeping all of
10
+ * servers.yaml in one runner. `--shard-index K` pins the slice instead of
11
+ * deriving it from the date — for reproducing a given week, not for scheduled
12
+ * use.
6
13
  */
7
14
  import { readFileSync } from 'node:fs';
8
15
  import { parse } from 'yaml';
9
16
  import { measureServer } from './run.js';
10
17
  import { writeLeaderboard } from './report.js';
11
18
  import { appendHistory } from './history.js';
19
+ import { snapshot, verdict, restore } from './harness-guard.js';
20
+ import { selectShard, shardIndexForDate } from './shard.js';
12
21
  function arg(name) {
13
22
  const i = process.argv.indexOf(`--${name}`);
14
23
  return i >= 0 ? process.argv[i + 1] : undefined;
@@ -18,16 +27,43 @@ const only = arg('only')?.split(',');
18
27
  const docker = process.argv.includes('--docker');
19
28
  const concurrency = Number(arg('concurrency') ?? 3);
20
29
  const defaultTimeout = Number(arg('default-timeout') ?? 60);
21
- const entries = doc.servers.filter((s) => {
30
+ const shards = arg('shards') === undefined ? undefined : Number(arg('shards'));
31
+ const shardIndexArg = arg('shard-index') === undefined ? undefined : Number(arg('shard-index'));
32
+ if (shards !== undefined && only) {
33
+ // Both narrow the set, but the sharded one is meant to be a *complete*
34
+ // partition of servers.yaml. Silently intersecting them would produce a slice
35
+ // that belongs to no cycle and still looks like a normal week's sweep in the log.
36
+ console.error('--shards and --only both select servers; pass one or the other');
37
+ process.exit(2);
38
+ }
39
+ if (shardIndexArg !== undefined && shards === undefined) {
40
+ console.error('--shard-index needs --shards');
41
+ process.exit(2);
42
+ }
43
+ const sweepable = doc.servers.filter((s) => {
22
44
  if (only && !only.includes(s.name))
23
45
  return false;
24
46
  if (s.remote)
25
47
  return false; // remote servers handled separately (mcp-remote bridge)
26
48
  return true;
27
49
  });
28
- console.log(`sweeping ${entries.length} servers (docker=${docker}, concurrency=${concurrency})`);
50
+ let entries = sweepable;
51
+ let shardLabel = '';
52
+ if (shards !== undefined) {
53
+ const index = shardIndexArg ?? shardIndexForDate(new Date(), shards);
54
+ entries = selectShard(sweepable, shards, index);
55
+ shardLabel = `, shard ${index + 1}/${shards}`;
56
+ console.log(`shard ${index + 1}/${shards} of ${sweepable.length} sweepable: ${entries.map((e) => e.name).join(', ')}`);
57
+ }
58
+ console.log(`sweeping ${entries.length} servers (docker=${docker}, concurrency=${concurrency}${shardLabel})`);
59
+ // Taken before anything is measured: measureServer persists each result the
60
+ // moment it has one, so this is the only chance to hold on to what was
61
+ // published in case this sweep turns out to be measuring through a broken
62
+ // harness (see harness-guard.ts).
63
+ const prior = snapshot(entries.map((e) => e.name));
29
64
  const queue = [...entries];
30
65
  const summary = {};
66
+ const statuses = new Map();
31
67
  async function worker() {
32
68
  for (let e = queue.shift(); e; e = queue.shift()) {
33
69
  const started = Date.now();
@@ -36,9 +72,11 @@ async function worker() {
36
72
  docker,
37
73
  dockerImage: e.dockerImage,
38
74
  dummyEnv: e.env ?? [],
75
+ dummyEnvValues: e.envValues,
39
76
  needsGit: e.needsGit,
40
77
  });
41
78
  const secs = ((Date.now() - started) / 1000).toFixed(0);
79
+ statuses.set(e.name, m.status);
42
80
  summary[e.name] =
43
81
  m.status === 'measured' || m.status === 'dynamic'
44
82
  ? `${m.totalTokens} tokens / ${m.toolCount} tools (${m.status}, ${secs}s)`
@@ -47,7 +85,23 @@ async function worker() {
47
85
  }
48
86
  }
49
87
  await Promise.all(Array.from({ length: Math.max(1, concurrency) }, () => worker()));
88
+ // Before publishing anything: is this sweep a statement about the servers, or
89
+ // about the machine that measured them?
90
+ const v = verdict(prior, statuses);
91
+ console.log(`harness check: ${v.reason}`);
92
+ if (v.fault) {
93
+ const restored = restore(prior, v.regressed);
94
+ console.error(`\nHARNESS FAULT — refusing to publish this sweep.\n` +
95
+ ` regressed: ${v.regressed.join(', ')}\n` +
96
+ ` restored ${restored.length} previous measurement(s) and badge(s); ` +
97
+ `leaderboard and history.csv are untouched.\n` +
98
+ ` Check the measurement environment (Docker daemon, network, disk) and re-run.`);
99
+ process.exit(1);
100
+ }
50
101
  // Serial, after every worker has finished: history.csv is a read-modify-write.
102
+ // Always the full set: the leaderboard is regenerated from what is on disk, so
103
+ // a shard sweep republishes every server's most recent measurement rather than
104
+ // dropping the servers this week did not touch.
51
105
  writeLeaderboard(doc.servers);
52
106
  const h = appendHistory();
53
107
  const measured = Object.values(summary).filter((s) => s.includes('tokens')).length;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-context-cost",
3
- "version": "0.4.0",
3
+ "version": "0.6.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",
@@ -37,7 +37,9 @@
37
37
  "test": "vitest run",
38
38
  "test:badge": "./upstream/tests/badge-test.sh",
39
39
  "typecheck": "tsc --noEmit && tsc -p tsconfig.tools.json",
40
+ "adoption": "tsx tools/measure-adoption.ts",
40
41
  "divergence": "tsx tools/measure-divergence.ts",
42
+ "session-start": "tsx src/sweep/session-start.ts",
41
43
  "sweep": "tsx src/sweep/run.ts",
42
44
  "sweep:all": "tsx src/sweep/sweep-all.ts",
43
45
  "verify": "tsx src/cli.ts verify"