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.
- package/README.md +101 -33
- package/dist/audit/audit.d.ts +48 -0
- package/dist/audit/audit.js +392 -7
- package/dist/audit/config.d.ts +46 -0
- package/dist/audit/config.js +86 -2
- package/dist/audit/deferral.d.ts +366 -0
- package/dist/audit/deferral.js +403 -0
- package/dist/audit/run.d.ts +22 -0
- package/dist/audit/run.js +17 -1
- package/dist/cli.js +11 -0
- package/dist/core/adoption.d.ts +226 -0
- package/dist/core/adoption.js +432 -0
- package/dist/core/canonical.d.ts +6 -0
- package/dist/core/canonical.js +3 -0
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.js +1 -0
- package/dist/core/session-start.d.ts +102 -0
- package/dist/core/session-start.js +186 -0
- package/dist/core/types.d.ts +8 -0
- package/dist/sweep/client.d.ts +6 -1
- package/dist/sweep/client.js +1 -0
- package/dist/sweep/dashboard.d.ts +10 -0
- package/dist/sweep/dashboard.js +32 -16
- package/dist/sweep/docker.d.ts +23 -0
- package/dist/sweep/docker.js +16 -12
- package/dist/sweep/harness-guard.d.ts +57 -0
- package/dist/sweep/harness-guard.js +144 -0
- package/dist/sweep/history.d.ts +33 -1
- package/dist/sweep/history.js +60 -5
- package/dist/sweep/regen.js +6 -1
- package/dist/sweep/report.d.ts +16 -0
- package/dist/sweep/report.js +79 -5
- package/dist/sweep/run.d.ts +41 -0
- package/dist/sweep/run.js +129 -36
- package/dist/sweep/server-pages.js +31 -6
- package/dist/sweep/session-start.d.ts +3 -0
- package/dist/sweep/session-start.js +103 -0
- package/dist/sweep/shard.d.ts +41 -0
- package/dist/sweep/shard.js +58 -0
- package/dist/sweep/sweep-all.js +56 -2
- 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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
133
|
-
const
|
|
134
|
-
|
|
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,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
|
+
}
|
package/dist/sweep/sweep-all.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
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"
|