mcp-context-cost 0.4.0 → 0.5.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 (40) hide show
  1. package/README.md +91 -25
  2. package/dist/audit/audit.d.ts +38 -0
  3. package/dist/audit/audit.js +372 -7
  4. package/dist/audit/config.d.ts +38 -0
  5. package/dist/audit/config.js +64 -0
  6. package/dist/audit/deferral.d.ts +346 -0
  7. package/dist/audit/deferral.js +376 -0
  8. package/dist/audit/run.d.ts +22 -0
  9. package/dist/audit/run.js +17 -1
  10. package/dist/core/adoption.d.ts +226 -0
  11. package/dist/core/adoption.js +432 -0
  12. package/dist/core/canonical.d.ts +6 -0
  13. package/dist/core/canonical.js +3 -0
  14. package/dist/core/index.d.ts +1 -0
  15. package/dist/core/index.js +1 -0
  16. package/dist/core/session-start.d.ts +102 -0
  17. package/dist/core/session-start.js +186 -0
  18. package/dist/core/types.d.ts +8 -0
  19. package/dist/sweep/client.d.ts +6 -1
  20. package/dist/sweep/client.js +1 -0
  21. package/dist/sweep/dashboard.d.ts +10 -0
  22. package/dist/sweep/dashboard.js +32 -16
  23. package/dist/sweep/docker.d.ts +23 -0
  24. package/dist/sweep/docker.js +16 -12
  25. package/dist/sweep/harness-guard.d.ts +57 -0
  26. package/dist/sweep/harness-guard.js +144 -0
  27. package/dist/sweep/history.d.ts +33 -1
  28. package/dist/sweep/history.js +60 -5
  29. package/dist/sweep/regen.js +6 -1
  30. package/dist/sweep/report.d.ts +16 -0
  31. package/dist/sweep/report.js +79 -5
  32. package/dist/sweep/run.d.ts +41 -0
  33. package/dist/sweep/run.js +129 -36
  34. package/dist/sweep/server-pages.js +31 -6
  35. package/dist/sweep/session-start.d.ts +3 -0
  36. package/dist/sweep/session-start.js +103 -0
  37. package/dist/sweep/shard.d.ts +41 -0
  38. package/dist/sweep/shard.js +58 -0
  39. package/dist/sweep/sweep-all.js +56 -2
  40. package/package.json +3 -1
@@ -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.5.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"