mcp-context-cost 0.2.0 → 0.4.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,31 @@
1
+ import type { Measurement } from '../core/types.js';
2
+ import { type DivergenceRun } from '../core/divergence.js';
3
+ import { type AuditReport } from './audit.js';
4
+ import { type LoadedConfig } from './config.js';
5
+ /** Where the published `tools-delta/v1` run lives when `--claude` doesn't override it. */
6
+ export declare const DEFAULT_DIVERGENCE_URL = "https://raw.githubusercontent.com/athakur3/mcp-context-cost/main/results/divergence.json";
7
+ export interface AuditOptions {
8
+ /** Explicit config path(s); when empty, every known client location is tried. */
9
+ configPaths?: string[];
10
+ cwd?: string;
11
+ home?: string;
12
+ timeoutMs?: number;
13
+ concurrency?: number;
14
+ docker?: boolean;
15
+ contextWindow?: number;
16
+ budget?: number;
17
+ /** Join each measured server against the published Claude divergence run. */
18
+ claude?: boolean;
19
+ /** Override the divergence.json source — mainly for tests and self-hosted mirrors. */
20
+ divergenceUrl?: string;
21
+ onProgress?: (name: string, done: number, total: number) => void;
22
+ }
23
+ /** Fetch and parse the published divergence run. Never throws: a failure is a report problem, not a crash. */
24
+ export declare function fetchDivergence(url: string): Promise<{
25
+ run: DivergenceRun | null;
26
+ problem?: string;
27
+ }>;
28
+ export declare function discover(opts?: AuditOptions): LoadedConfig[];
29
+ /** Measure every distinct stdio server across the given configs, once each. */
30
+ export declare function measureAll(configs: LoadedConfig[], opts?: AuditOptions): Promise<Map<string, Measurement>>;
31
+ export declare function runAudit(opts?: AuditOptions): Promise<AuditReport>;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Audit orchestration: discover configs, measure each distinct server once,
3
+ * hand the results to `buildReport`.
4
+ *
5
+ * Kept apart from audit.ts so the arithmetic stays spawn-free and testable.
6
+ */
7
+ import { homedir } from 'node:os';
8
+ import { measureServer } from '../sweep/run.js';
9
+ import { parseDivergence } from '../core/divergence.js';
10
+ import { buildReport, serverKey } from './audit.js';
11
+ import { configCandidates, loadConfigs } from './config.js';
12
+ /** Where the published `tools-delta/v1` run lives when `--claude` doesn't override it. */
13
+ export const DEFAULT_DIVERGENCE_URL = 'https://raw.githubusercontent.com/athakur3/mcp-context-cost/main/results/divergence.json';
14
+ /** Fetch and parse the published divergence run. Never throws: a failure is a report problem, not a crash. */
15
+ export async function fetchDivergence(url) {
16
+ try {
17
+ const res = await fetch(url, { signal: AbortSignal.timeout(15_000) });
18
+ if (!res.ok)
19
+ return { run: null, problem: `claude divergence: HTTP ${res.status} fetching ${url}` };
20
+ const run = parseDivergence(await res.text());
21
+ return run ? { run } : { run: null, problem: `claude divergence: malformed data at ${url}` };
22
+ }
23
+ catch (e) {
24
+ return { run: null, problem: `claude divergence: failed to fetch ${url}: ${e.message}` };
25
+ }
26
+ }
27
+ export function discover(opts = {}) {
28
+ const cwd = opts.cwd ?? process.cwd();
29
+ const home = opts.home ?? homedir();
30
+ const candidates = opts.configPaths && opts.configPaths.length
31
+ ? opts.configPaths.map((path) => ({ client: 'explicit', path }))
32
+ : configCandidates({ home, cwd, platform: process.platform, appData: process.env.APPDATA });
33
+ return loadConfigs(candidates, cwd);
34
+ }
35
+ /** Measure every distinct stdio server across the given configs, once each. */
36
+ export async function measureAll(configs, opts = {}) {
37
+ const unique = new Map();
38
+ for (const cfg of configs) {
39
+ for (const s of cfg.servers) {
40
+ if (s.transport !== 'stdio')
41
+ continue;
42
+ // Two clients pointing at the same argv are one measurement, not two.
43
+ if (!unique.has(serverKey(s)))
44
+ unique.set(serverKey(s), s);
45
+ }
46
+ }
47
+ const queue = [...unique.entries()];
48
+ const total = queue.length;
49
+ const measured = new Map();
50
+ let done = 0;
51
+ const worker = async () => {
52
+ for (let next = queue.shift(); next; next = queue.shift()) {
53
+ const [key, s] = next;
54
+ const m = await measureServer(s.name, s.command ?? '', {
55
+ argv: s.argv,
56
+ env: s.env,
57
+ timeoutMs: opts.timeoutMs ?? 60_000,
58
+ docker: opts.docker,
59
+ persist: false,
60
+ });
61
+ measured.set(key, m);
62
+ opts.onProgress?.(s.name, ++done, total);
63
+ }
64
+ };
65
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(opts.concurrency ?? 3, total || 1)) }, worker));
66
+ return measured;
67
+ }
68
+ export async function runAudit(opts = {}) {
69
+ const configs = discover(opts);
70
+ const measured = await measureAll(configs, opts);
71
+ let divergence = null;
72
+ let divergenceProblem;
73
+ if (opts.claude) {
74
+ const fetched = await fetchDivergence(opts.divergenceUrl ?? DEFAULT_DIVERGENCE_URL);
75
+ divergence = fetched.run;
76
+ divergenceProblem = fetched.problem;
77
+ }
78
+ const report = buildReport(configs, measured, {
79
+ contextWindow: opts.contextWindow,
80
+ budget: opts.budget,
81
+ divergence,
82
+ });
83
+ if (divergenceProblem)
84
+ report.problems.push(divergenceProblem);
85
+ return report;
86
+ }
package/dist/cli.d.ts CHANGED
@@ -6,3 +6,24 @@ export declare function verifyMeasurement(m: Measurement): {
6
6
  rederivedSha: string | null;
7
7
  problems: string[];
8
8
  };
9
+ /** Derives a servers.yaml-style slug from a remote URL's hostname, e.g. mcp.deepwiki.com -> deepwiki. */
10
+ export declare function slugFromUrl(url: string): string;
11
+ /** Installed version, for error messages that need to say which one you are running. */
12
+ export declare function cliVersion(): string;
13
+ /**
14
+ * Reject flags this build does not know.
15
+ *
16
+ * An older CLI used to ignore an unrecognised flag and carry on. That is the exact failure
17
+ * this project exists to catch, in our own tool: `audit --baseline base.json
18
+ * --max-increase 2000` on a build without those flags ran a plain audit and **exited 0** —
19
+ * a green CI check on a gate that never ran. The README documents flags before they are
20
+ * published, so the version skew is not hypothetical; it is the normal case for anyone
21
+ * running `npx -y mcp-context-cost`.
22
+ *
23
+ * So an unknown flag is a usage error, and the message names the running version, because
24
+ * the likeliest cause is that the reader's command is newer than their install.
25
+ */
26
+ export declare function unknownFlags(argv: string[], spec: {
27
+ value: string[];
28
+ boolean: string[];
29
+ }): string[];
package/dist/cli.js CHANGED
@@ -2,14 +2,26 @@
2
2
  /**
3
3
  * mcp-context-cost CLI — the dispute drill as a command.
4
4
  *
5
+ * mcp-context-cost audit [--budget N] [--claude] [--json] measure the servers in your
6
+ * own MCP config; exit 1 if over budget.
7
+ * mcp-context-cost audit --baseline <report.json> [--max-increase N] diff against a
8
+ * stored earlier report; exit 1 if this
9
+ * config change adds more than N tokens
10
+ * to every request (or if it can't tell).
11
+ * --claude adds each server's Anthropic-
12
+ * request cost where the published capture
13
+ * hash matches what's installed.
5
14
  * mcp-context-cost verify <measurement.json> [--json] re-derive the number from the
6
15
  * published capture; exit 1 on mismatch
7
16
  * mcp-context-cost verify --remote <url> [--json] same, fetched from a measurement URL
8
17
  * mcp-context-cost measure --name x --command "npx -y ..." one-off measurement
18
+ * mcp-context-cost measure --remote <url> [--name x] same, via the mcp-remote bridge
19
+ * (name defaults to the URL's hostname)
9
20
  *
10
- * Exit codes: 0 ok, 1 verification/measurement failed, 2 usage error.
21
+ * Exit codes: 0 ok, 1 verification/measurement/budget failed, 2 usage error.
11
22
  */
12
23
  import { readFileSync } from 'node:fs';
24
+ import { createRequire } from 'node:module';
13
25
  import { canonicalString, countTokens, sha256Hex } from './core/canonical.js';
14
26
  import { toBadge } from './core/badge.js';
15
27
  export function verifyMeasurement(m) {
@@ -28,8 +40,156 @@ export function verifyMeasurement(m) {
28
40
  problems.push(`toolCount mismatch: capture has ${m.rawToolsCapture.length}, stored ${m.toolCount}`);
29
41
  return { ok: problems.length === 0, rederivedTokens: tokens, rederivedSha: sha, problems };
30
42
  }
43
+ /** Derives a servers.yaml-style slug from a remote URL's hostname, e.g. mcp.deepwiki.com -> deepwiki. */
44
+ export function slugFromUrl(url) {
45
+ const host = new URL(url).hostname.replace(/^(www|mcp)\./, '');
46
+ return host.replace(/[^a-z0-9]+/gi, '-').replace(/^-+|-+$/g, '').toLowerCase() || 'remote';
47
+ }
48
+ /** Installed version, for error messages that need to say which one you are running. */
49
+ export function cliVersion() {
50
+ try {
51
+ return createRequire(import.meta.url)('../package.json').version;
52
+ }
53
+ catch {
54
+ return 'unknown';
55
+ }
56
+ }
57
+ /**
58
+ * Reject flags this build does not know.
59
+ *
60
+ * An older CLI used to ignore an unrecognised flag and carry on. That is the exact failure
61
+ * this project exists to catch, in our own tool: `audit --baseline base.json
62
+ * --max-increase 2000` on a build without those flags ran a plain audit and **exited 0** —
63
+ * a green CI check on a gate that never ran. The README documents flags before they are
64
+ * published, so the version skew is not hypothetical; it is the normal case for anyone
65
+ * running `npx -y mcp-context-cost`.
66
+ *
67
+ * So an unknown flag is a usage error, and the message names the running version, because
68
+ * the likeliest cause is that the reader's command is newer than their install.
69
+ */
70
+ export function unknownFlags(argv, spec) {
71
+ const known = new Set([...spec.value, ...spec.boolean]);
72
+ const unknown = [];
73
+ for (let i = 0; i < argv.length; i++) {
74
+ const tok = argv[i];
75
+ if (!tok.startsWith('--'))
76
+ continue;
77
+ const name = tok.slice(2).split('=')[0];
78
+ if (!known.has(name)) {
79
+ unknown.push(tok.split('=')[0]);
80
+ continue;
81
+ }
82
+ // Skip a value-taking flag's value, so `--command "--weird"` is not read as a flag.
83
+ if (spec.value.includes(name) && !tok.includes('='))
84
+ i++;
85
+ }
86
+ return unknown;
87
+ }
88
+ function rejectUnknownFlags(cmd, argv, spec) {
89
+ const bad = unknownFlags(argv, spec);
90
+ if (!bad.length)
91
+ return;
92
+ const all = [...spec.value, ...spec.boolean].sort().map((f) => `--${f}`).join(' ');
93
+ console.error(`unknown flag for \`${cmd}\`: ${bad.join(', ')}`);
94
+ console.error(`this is mcp-context-cost ${cliVersion()} — if you copied the command from the README,`);
95
+ console.error(`your install may be older than the docs. Try: npx -y mcp-context-cost@latest ${cmd} ...`);
96
+ console.error(`known flags for ${cmd}: ${all}`);
97
+ process.exit(2);
98
+ }
31
99
  const [, , cmd, ...rest] = process.argv;
32
- if (cmd === 'verify') {
100
+ if (cmd === 'audit') {
101
+ rejectUnknownFlags('audit', rest, {
102
+ value: ['config', 'budget', 'baseline', 'max-increase', 'context', 'timeout', 'concurrency', 'divergence-url'],
103
+ boolean: ['json', 'docker', 'claude'],
104
+ });
105
+ const argOf = (name) => {
106
+ const i = rest.indexOf(`--${name}`);
107
+ return i >= 0 ? rest[i + 1] : undefined;
108
+ };
109
+ const all = (name) => rest.flatMap((a, i) => (a === `--${name}` && rest[i + 1] ? [rest[i + 1]] : []));
110
+ const json = rest.includes('--json');
111
+ const numeric = (name) => {
112
+ const raw = argOf(name);
113
+ if (raw === undefined)
114
+ return undefined;
115
+ const v = Number(raw);
116
+ if (!Number.isFinite(v) || v <= 0) {
117
+ console.error(`--${name} must be a positive number, got '${raw}'`);
118
+ process.exit(2);
119
+ }
120
+ return v;
121
+ };
122
+ const nonNegative = (name) => {
123
+ const raw = argOf(name);
124
+ if (raw === undefined)
125
+ return undefined;
126
+ const v = Number(raw);
127
+ if (!Number.isFinite(v) || v < 0) {
128
+ console.error(`--${name} must be zero or a positive number, got '${raw}'`);
129
+ process.exit(2);
130
+ }
131
+ return v;
132
+ };
133
+ const budget = numeric('budget');
134
+ const baselinePath = argOf('baseline');
135
+ const maxIncrease = nonNegative('max-increase');
136
+ if (maxIncrease !== undefined && !baselinePath) {
137
+ console.error('--max-increase needs a --baseline to measure the increase against');
138
+ process.exit(2);
139
+ }
140
+ const { buildDiff, evaluateIncreaseGate, parseBaselineReport } = await import('./audit/diff.js');
141
+ // Read and shape-check the baseline BEFORE measuring anything: a typo in the path
142
+ // should cost a second, not a full server sweep that is then thrown away.
143
+ let baseline;
144
+ if (baselinePath) {
145
+ let raw;
146
+ try {
147
+ raw = readFileSync(baselinePath, 'utf8');
148
+ }
149
+ catch (e) {
150
+ console.error(`cannot read baseline ${baselinePath}: ${e.message}`);
151
+ process.exit(2);
152
+ }
153
+ const parsed = parseBaselineReport(raw);
154
+ if (!parsed.report) {
155
+ console.error(`${baselinePath}: ${parsed.problem}`);
156
+ process.exit(2);
157
+ }
158
+ baseline = parsed.report;
159
+ }
160
+ const { runAudit } = await import('./audit/run.js');
161
+ const { formatReport } = await import('./audit/audit.js');
162
+ const report = await runAudit({
163
+ configPaths: all('config'),
164
+ budget,
165
+ contextWindow: numeric('context'),
166
+ timeoutMs: numeric('timeout'),
167
+ concurrency: numeric('concurrency'),
168
+ docker: rest.includes('--docker'),
169
+ claude: rest.includes('--claude'),
170
+ divergenceUrl: argOf('divergence-url'),
171
+ // Progress goes to stderr so `--json` stdout stays a single parseable object.
172
+ onProgress: json ? undefined : (name, done, total) => process.stderr.write(` [${done}/${total}] ${name}\n`),
173
+ });
174
+ if (report.configs.length === 0) {
175
+ const where = report.problems.length ? `\n${report.problems.map((p) => ` ${p}`).join('\n')}` : '';
176
+ if (json)
177
+ console.log(JSON.stringify(report));
178
+ else
179
+ console.error(`no MCP config found. Looked in the standard Claude Desktop / Claude Code / Cursor / VS Code / Windsurf locations.${where}\n` +
180
+ `Point at one explicitly: mcp-context-cost audit --config <path/to/mcp.json>`);
181
+ process.exit(1);
182
+ }
183
+ if (baseline) {
184
+ report.diff = buildDiff(baseline, report);
185
+ if (maxIncrease !== undefined)
186
+ report.increaseGate = evaluateIncreaseGate(report.diff, maxIncrease);
187
+ }
188
+ console.log(json ? JSON.stringify(report) : formatReport(report));
189
+ process.exit(report.budget?.over || report.increaseGate?.pass === false ? 1 : 0);
190
+ }
191
+ else if (cmd === 'verify') {
192
+ rejectUnknownFlags('verify', rest, { value: ['remote'], boolean: ['json'] });
33
193
  const json = rest.includes('--json');
34
194
  const remoteIdx = rest.indexOf('--remote');
35
195
  const remoteUrl = remoteIdx >= 0 ? rest[remoteIdx + 1] : undefined;
@@ -76,21 +236,36 @@ if (cmd === 'verify') {
76
236
  process.exit(1);
77
237
  }
78
238
  else if (cmd === 'measure') {
239
+ rejectUnknownFlags('measure', rest, {
240
+ value: ['name', 'command', 'remote', 'timeout', 'docker-image'],
241
+ boolean: ['docker'],
242
+ });
79
243
  const argOf = (name) => {
80
244
  const i = rest.indexOf(`--${name}`);
81
245
  return i >= 0 ? rest[i + 1] : undefined;
82
246
  };
83
- const name = argOf('name');
84
247
  const command = argOf('command');
85
- if (!name || !command) {
248
+ const remoteUrl = argOf('remote');
249
+ if (remoteUrl && !/^https?:\/\//i.test(remoteUrl)) {
250
+ console.error(`--remote must be an http(s) URL, got '${remoteUrl}'`);
251
+ process.exit(2);
252
+ }
253
+ if (!command && !remoteUrl) {
254
+ console.error('usage: mcp-context-cost measure --name <slug> --command "npx -y <server>" [--timeout ms] [--docker]');
255
+ console.error(' mcp-context-cost measure --remote <url> [--name <slug>] [--timeout ms] [--docker]');
256
+ process.exit(2);
257
+ }
258
+ const name = argOf('name') ?? (remoteUrl ? slugFromUrl(remoteUrl) : undefined);
259
+ if (!name) {
86
260
  console.error('usage: mcp-context-cost measure --name <slug> --command "npx -y <server>" [--timeout ms] [--docker]');
87
261
  process.exit(2);
88
262
  }
89
263
  const { measureServer } = await import('./sweep/run.js');
90
- const m = await measureServer(name, command, {
264
+ const m = await measureServer(name, remoteUrl ? `npx -y mcp-remote ${remoteUrl}` : command, {
91
265
  timeoutMs: Number(argOf('timeout') ?? 60_000),
92
266
  docker: rest.includes('--docker'),
93
267
  dockerImage: argOf('docker-image'),
268
+ argv: remoteUrl ? ['npx', '-y', 'mcp-remote', remoteUrl] : undefined,
94
269
  });
95
270
  const ok = m.status === 'measured' || m.status === 'dynamic';
96
271
  console.log(ok
@@ -104,8 +279,14 @@ else if (cmd !== undefined && cmd !== '--help' && cmd !== '-h') {
104
279
  }
105
280
  else {
106
281
  console.log('mcp-context-cost — reproducible context-cost measurement for MCP servers');
282
+ console.log(' audit [--config <path>] [--budget N] [--claude] measure the servers in your own MCP config');
283
+ console.log(' [--json] [--context N] [--timeout ms] [--concurrency N] [--docker]');
284
+ console.log(' [--baseline <report.json>] [--max-increase N] diff against an earlier');
285
+ console.log(' audit --json report; --max-increase');
286
+ console.log(' fails when a change adds too much');
107
287
  console.log(' verify <measurement.json> [--json] re-derive tokens+sha from the published capture');
108
288
  console.log(' verify --remote <url> [--json] same, fetched from a measurement URL');
109
289
  console.log(' measure --name x --command "npx -y <server>" run a one-off measurement');
110
- console.log('exit codes: 0 ok, 1 verification/measurement failed, 2 usage error');
290
+ console.log(' measure --remote <url> [--name x] measure a remote server via mcp-remote');
291
+ console.log('exit codes: 0 ok, 1 verification/measurement/budget failed, 2 usage error');
111
292
  }
@@ -1 +1,9 @@
1
+ /**
2
+ * A 12-point-max inline trend line, oldest to newest. Muted stroke (this is
3
+ * texture, not a headline number) with the current value picked out as an
4
+ * accent dot, per the sparkline spec: de-emphasis hue for the line, accent
5
+ * for "now". Flat series still draw a level line rather than faking a zero
6
+ * baseline. Returns '' when there's nothing to trend (0-1 points).
7
+ */
8
+ export declare function renderSparkline(tokens: number[]): string;
1
9
  export declare function generateDashboard(root?: string): string;
@@ -7,6 +7,38 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
7
7
  import { dirname, join } from 'node:path';
8
8
  import { parse } from 'yaml';
9
9
  import { bandColor, BAND_META } from '../core/bands.js';
10
+ import { parseHistory } from './history.js';
11
+ /** Longest series a sparkline plots — a stat-tile trend, not a full chart. */
12
+ const SPARK_MAX_POINTS = 12;
13
+ /**
14
+ * A 12-point-max inline trend line, oldest to newest. Muted stroke (this is
15
+ * texture, not a headline number) with the current value picked out as an
16
+ * accent dot, per the sparkline spec: de-emphasis hue for the line, accent
17
+ * for "now". Flat series still draw a level line rather than faking a zero
18
+ * baseline. Returns '' when there's nothing to trend (0-1 points).
19
+ */
20
+ export function renderSparkline(tokens) {
21
+ const points = tokens.slice(-SPARK_MAX_POINTS);
22
+ if (points.length < 2)
23
+ return '';
24
+ const w = 56;
25
+ const h = 18;
26
+ const pad = 3;
27
+ const min = Math.min(...points);
28
+ const max = Math.max(...points);
29
+ const range = max - min;
30
+ const stepX = (w - pad * 2) / (points.length - 1);
31
+ const coords = points.map((v, i) => {
32
+ const x = pad + i * stepX;
33
+ const y = range === 0 ? h / 2 : pad + (h - pad * 2) * (1 - (v - min) / range);
34
+ return `${x.toFixed(1)},${y.toFixed(1)}`;
35
+ });
36
+ const [lastX, lastY] = coords[coords.length - 1].split(',');
37
+ return `<svg class="spark" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" aria-hidden="true" focusable="false">
38
+ <polyline points="${coords.join(' ')}" fill="none" stroke="var(--muted)" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
39
+ <circle cx="${lastX}" cy="${lastY}" r="2.2" fill="var(--accent)" stroke="var(--surface)" stroke-width="1"/>
40
+ </svg>`;
41
+ }
10
42
  const esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
11
43
  export function generateDashboard(root = process.cwd()) {
12
44
  const doc = parse(readFileSync(join(root, 'servers.yaml'), 'utf8'));
@@ -15,6 +47,12 @@ export function generateDashboard(root = process.cwd()) {
15
47
  ? JSON.parse(readFileSync(divergencePath, 'utf8'))
16
48
  : {};
17
49
  const dSrv = divergence.servers ?? {};
50
+ const historyPath = join(root, 'results', 'history.csv');
51
+ const history = existsSync(historyPath) ? parseHistory(readFileSync(historyPath, 'utf8')) : [];
52
+ const seriesFor = (name) => history
53
+ .filter((h) => h.server === name)
54
+ .sort((a, b) => a.date.localeCompare(b.date))
55
+ .map((h) => h.tokens);
18
56
  const rows = doc.servers.map((entry) => {
19
57
  const p = join(root, 'results', entry.name, 'measurement.json');
20
58
  return { entry, m: existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) : null };
@@ -39,11 +77,15 @@ export function generateDashboard(root = process.cwd()) {
39
77
  const pct = Math.max(1.2, (t / max) * 100);
40
78
  const div = dSrv[r.entry.name];
41
79
  const claudeTip = div ? ` · in a Claude request: ${fmt(div.claudeDelta)} tok` : '';
80
+ const series = seriesFor(r.entry.name);
81
+ const spark = renderSparkline(series);
82
+ const trendTip = series.length > 1 ? ` · ${series.length}-sweep trend: ${series[0].toLocaleString('en-US')} → ${series[series.length - 1].toLocaleString('en-US')}` : '';
42
83
  // The whole row is the link to the server's detail page (docs/servers/).
43
- return `<a class="row" href="servers/${encodeURIComponent(r.entry.name)}.html" data-tip="${esc(m.toolCount)} tools · largest: ${esc(largest?.name)} (${fmt(largest?.tokens ?? 0)} tok)${claudeTip} · ${esc(r.entry.category)} · ${esc(m.status)}${m.serverVersion ? ' · v' + esc(String(m.serverVersion).replace(/^v/, '')) : ''}">
84
+ return `<a class="row" href="servers/${encodeURIComponent(r.entry.name)}.html" data-tip="${esc(m.toolCount)} tools · largest: ${esc(largest?.name)} (${fmt(largest?.tokens ?? 0)} tok)${claudeTip}${trendTip} · ${esc(r.entry.category)} · ${esc(m.status)}${m.serverVersion ? ' · v' + esc(String(m.serverVersion).replace(/^v/, '')) : ''}">
44
85
  <span class="rank">${i + 1}</span>
45
86
  <span class="name">${esc(r.entry.name)}</span>
46
87
  <span class="track"><span class="bar" style="width:${pct.toFixed(1)}%"></span></span>
88
+ <span class="spark-cell">${spark}</span>
47
89
  <span class="val"><span class="dot dot-${band}" aria-hidden="true"></span>${fmt(t)}<span class="bandname">${meta.label}</span></span>
48
90
  </a>`;
49
91
  })
@@ -59,7 +101,11 @@ export function generateDashboard(root = process.cwd()) {
59
101
  .map((r, i) => {
60
102
  const m = r.m;
61
103
  const div = dSrv[r.entry.name];
62
- return `<tr><td>${i + 1}</td><td>${esc(r.entry.name)}</td><td class="num">${fmt(m.totalTokens)}</td><td class="num">${div ? fmt(div.claudeDelta) : '—'}</td><td class="num">${esc(m.toolCount)}</td><td>${esc(BAND_META[bandColor(m.totalTokens)].label)}</td><td>${esc(r.entry.category)}</td></tr>`;
104
+ const series = seriesFor(r.entry.name);
105
+ const trend = series.length > 1
106
+ ? `${series[series.length - 1] - series[0] >= 0 ? '+' : ''}${fmt(series[series.length - 1] - series[0])} / ${series.length}d`
107
+ : '—';
108
+ return `<tr><td>${i + 1}</td><td>${esc(r.entry.name)}</td><td class="num">${fmt(m.totalTokens)}</td><td class="num">${div ? fmt(div.claudeDelta) : '—'}</td><td class="num">${esc(m.toolCount)}</td><td>${esc(BAND_META[bandColor(m.totalTokens)].label)}</td><td>${esc(r.entry.category)}</td><td class="num">${trend}</td></tr>`;
63
109
  })
64
110
  .join('\n');
65
111
  const specimens = [measured[measured.length - 1], measured[0]]
@@ -117,13 +163,15 @@ export function generateDashboard(root = process.cwd()) {
117
163
  .stat .l { font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); }
118
164
 
119
165
  .board { background: var(--surface); border: 1px solid var(--line); border-radius: 8px; padding: 14px 16px; }
120
- .row { display: grid; grid-template-columns: 2ch minmax(120px, 190px) 1fr max-content; gap: 10px; align-items: center; padding: 3px 4px; border-radius: 4px; outline: none; color: inherit; text-decoration: none; }
166
+ .row { display: grid; grid-template-columns: 2ch minmax(120px, 190px) 1fr 56px max-content; gap: 10px; align-items: center; padding: 3px 4px; border-radius: 4px; outline: none; color: inherit; text-decoration: none; }
121
167
  .row:hover, .row:focus-visible { background: var(--accent-soft); }
122
168
  .row:hover .name, .row:focus-visible .name { text-decoration: underline; }
123
169
  .rank { font-family: ui-monospace, Menlo, monospace; font-size: 11px; color: var(--muted); text-align: right; font-variant-numeric: tabular-nums; }
124
170
  .name { font-size: 0.86rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
125
171
  .track { background: var(--track); border-radius: 3px; height: 12px; overflow: hidden; }
126
172
  .bar { display: block; height: 100%; background: var(--accent); border-radius: 3px 3px 3px 3px; min-width: 3px; }
173
+ .spark-cell { display: flex; align-items: center; justify-content: center; height: 18px; }
174
+ .spark-cell:empty { visibility: hidden; }
127
175
  .val { font-family: ui-monospace, Menlo, monospace; font-variant-numeric: tabular-nums; font-size: 0.82rem; display: flex; align-items: center; gap: 6px; }
128
176
  .dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; box-shadow: 0 0 0 2px var(--surface); }
129
177
  .dot-brightgreen { background: var(--b-brightgreen); } .dot-green { background: var(--b-green); }
@@ -172,7 +220,7 @@ export function generateDashboard(root = process.cwd()) {
172
220
  </div>
173
221
 
174
222
  <h2>Leaderboard</h2>
175
- <p class="h2sub">Tokens = o200k_base count of the canonical <code>tools/list</code> bytes — the wire payload. What a <em>Claude request</em> actually carries can differ sharply (github: 54,422 on the wire, ${dSrv['github'] ? fmt(dSrv['github'].claudeDelta) : '…'} in a request — 80% of its schema bytes are fields no Anthropic request sends). Hover a row for both numbers; <a href="METHODOLOGY.html#claude-divergence">method</a>.</p>
223
+ <p class="h2sub">Tokens = o200k_base count of the canonical <code>tools/list</code> bytes — the wire payload. What a <em>Claude request</em> actually carries can differ sharply (github: 54,422 on the wire, ${dSrv['github'] ? fmt(dSrv['github'].claudeDelta) : '…'} in a request — 80% of its schema bytes are fields no Anthropic request sends). The trend line plots tokens across every sweep date on record (oldest→newest, dot = current); servers with one sweep so far show no line yet. Hover a row for exact numbers; <a href="METHODOLOGY.html#claude-divergence">method</a>.</p>
176
224
  <div class="board">
177
225
  ${barRows || '<p class="h2sub">Sweep in progress — first results land shortly.</p>'}
178
226
  </div>
@@ -198,7 +246,7 @@ ${barRows || '<p class="h2sub">Sweep in progress — first results land shortly.
198
246
 
199
247
  <details><summary>Full data table</summary>
200
248
  <div class="tablewrap" style="margin-top:10px"><table>
201
- <thead><tr><th>#</th><th>server</th><th>tokens (o200k)</th><th>claude req</th><th>tools</th><th>band</th><th>category</th></tr></thead>
249
+ <thead><tr><th>#</th><th>server</th><th>tokens (o200k)</th><th>claude req</th><th>tools</th><th>band</th><th>category</th><th>trend</th></tr></thead>
202
250
  <tbody>${tableRows}</tbody>
203
251
  </table></div>
204
252
  </details>
@@ -12,6 +12,14 @@ export interface DockerOptions {
12
12
  dummyEnv?: string[];
13
13
  /** Container name, so a timed-out container can be force-removed. */
14
14
  containerName?: string;
15
+ /**
16
+ * Install `git` inside the container before launch. The slim base images
17
+ * carry no VCS, so `uvx --from git+...` installs fail with "Git executable
18
+ * not found" — this only prefixes the containerized invocation, never the
19
+ * recorded `launchCommand`, so the published command stays what a user with
20
+ * git already on PATH would actually run.
21
+ */
22
+ needsGit?: boolean;
15
23
  }
16
24
  export declare const DEFAULT_NODE_IMAGE = "public.ecr.aws/docker/library/node:22-slim";
17
25
  export declare const DEFAULT_PYTHON_IMAGE = "ghcr.io/astral-sh/uv:python3.12-bookworm-slim";
@@ -35,7 +35,10 @@ export function dockerize(commandLine, opts = {}) {
35
35
  for (const name of opts.dummyEnv ?? []) {
36
36
  argv.push('-e', `${name}=dummy`);
37
37
  }
38
- argv.push(image, 'sh', '-lc', commandLine);
38
+ const gitPrefix = opts.needsGit
39
+ ? 'command -v git >/dev/null 2>&1 || (apt-get update -qq && apt-get install -y -qq --no-install-recommends git >/dev/null 2>&1); '
40
+ : '';
41
+ argv.push(image, 'sh', '-lc', gitPrefix + commandLine);
39
42
  return {
40
43
  command: 'docker',
41
44
  argv,
@@ -43,7 +46,9 @@ export function dockerize(commandLine, opts = {}) {
43
46
  docker: true,
44
47
  image,
45
48
  network: 'bridge',
46
- note: 'network enabled for package fetch; clean FS, no host credentials',
49
+ note: opts.needsGit
50
+ ? 'network enabled for package fetch; clean FS, no host credentials, git installed'
51
+ : 'network enabled for package fetch; clean FS, no host credentials',
47
52
  },
48
53
  };
49
54
  }
@@ -11,6 +11,8 @@ export interface ServerEntry {
11
11
  remote?: boolean;
12
12
  dockerImage?: string;
13
13
  timeoutSeconds?: number;
14
+ /** Launch needs the `git` binary (e.g. `uvx --from git+...`) — absent from the slim isolation images. */
15
+ needsGit?: boolean;
14
16
  }
15
17
  /** Neutralize markdown/table syntax in third-party strings (tool names, notes). */
16
18
  export declare function mdCell(s: unknown): string;
@@ -7,5 +7,18 @@ export interface MeasureOptions {
7
7
  dockerImage?: string;
8
8
  /** env var NAMES to provide as dummy values (docker mode). */
9
9
  dummyEnv?: string[];
10
+ /** Install `git` in the container before launch (docker mode) — see docker.ts. */
11
+ needsGit?: boolean;
12
+ /**
13
+ * Exact argv, when the caller already has it (client configs store command and
14
+ * args separately). Avoids re-splitting a joined string on spaces, which would
15
+ * break any path containing one. Host path only — docker still wraps `command`.
16
+ */
17
+ argv?: string[];
18
+ /**
19
+ * Write results/<name>/measurement.json + badges/<name>.json (default true).
20
+ * `audit` runs in the user's own directory and must not litter it.
21
+ */
22
+ persist?: boolean;
10
23
  }
11
24
  export declare function measureServer(name: string, command: string, opts?: MeasureOptions): Promise<Measurement>;
package/dist/sweep/run.js CHANGED
@@ -5,7 +5,8 @@
5
5
  * Runs tools/list capture TWICE; differing tool sets -> status "dynamic".
6
6
  */
7
7
  import { mkdirSync, writeFileSync } from 'node:fs';
8
- import { join } from 'node:path';
8
+ import { join, resolve } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
9
10
  import { captureTools } from './client.js';
10
11
  import { dockerize } from './docker.js';
11
12
  import { measureTools, failedMeasurement, canonicalString } from '../core/canonical.js';
@@ -15,11 +16,14 @@ function arg(name) {
15
16
  return i >= 0 ? process.argv[i + 1] : undefined;
16
17
  }
17
18
  export async function measureServer(name, command, opts = {}) {
18
- if (!/^[a-z0-9][a-z0-9._-]*$/i.test(name) || name.includes('..')) {
19
+ const persist = opts.persist !== false;
20
+ // The name becomes a directory when persisting; that's the only reason it's
21
+ // constrained, so in-memory callers may use whatever the config called it.
22
+ if (persist && (!/^[a-z0-9][a-z0-9._-]*$/i.test(name) || name.includes('..'))) {
19
23
  throw new Error(`invalid server name '${name}' — letters/digits/dot/dash/underscore only`);
20
24
  }
21
25
  const root = opts.root ?? process.cwd();
22
- let spec = command;
26
+ let spec = opts.argv && opts.argv.length ? { command: opts.argv[0], argv: opts.argv.slice(1) } : command;
23
27
  let isolation = { docker: false };
24
28
  let containerName;
25
29
  if (opts.docker && command.trimStart().startsWith('docker ')) {
@@ -28,7 +32,12 @@ export async function measureServer(name, command, opts = {}) {
28
32
  }
29
33
  else if (opts.docker) {
30
34
  containerName = `mcp-ctx-${name}-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
31
- const d = dockerize(command, { image: opts.dockerImage, dummyEnv: opts.dummyEnv, containerName });
35
+ const d = dockerize(command, {
36
+ image: opts.dockerImage,
37
+ dummyEnv: opts.dummyEnv,
38
+ containerName,
39
+ needsGit: opts.needsGit,
40
+ });
32
41
  spec = { command: d.command, argv: d.argv };
33
42
  isolation = d.isolation;
34
43
  }
@@ -67,6 +76,8 @@ export async function measureServer(name, command, opts = {}) {
67
76
  spawn('docker', ['rm', '-f', containerName], { stdio: 'ignore' }).on('error', () => { });
68
77
  }
69
78
  }
79
+ if (!persist)
80
+ return m;
70
81
  const resultDir = join(root, 'results', name);
71
82
  mkdirSync(resultDir, { recursive: true });
72
83
  writeFileSync(join(resultDir, 'measurement.json'), JSON.stringify(m, null, 2) + '\n');
@@ -74,7 +85,10 @@ export async function measureServer(name, command, opts = {}) {
74
85
  writeFileSync(join(root, 'badges', `${name}.json`), JSON.stringify(toBadge(m)) + '\n');
75
86
  return m;
76
87
  }
77
- const isMain = process.argv[1]?.endsWith('run.ts') || process.argv[1]?.endsWith('run.js');
88
+ // Exact path match, not endsWith('run.ts'): any other file whose name happens to
89
+ // end in "run.ts" (src/audit/run.ts, a scratch dryrun.ts) would otherwise run this
90
+ // block and exit 2 on missing --name.
91
+ const isMain = process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
78
92
  if (isMain) {
79
93
  const name = arg('name');
80
94
  const command = arg('command');
@@ -36,6 +36,7 @@ async function worker() {
36
36
  docker,
37
37
  dockerImage: e.dockerImage,
38
38
  dummyEnv: e.env ?? [],
39
+ needsGit: e.needsGit,
39
40
  });
40
41
  const secs = ((Date.now() - started) / 1000).toFixed(0);
41
42
  summary[e.name] =