mcp-context-cost 0.1.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/LICENSE +21 -0
- package/README.md +90 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +78 -0
- package/dist/core/badge.d.ts +10 -0
- package/dist/core/badge.js +21 -0
- package/dist/core/bands.d.ts +7 -0
- package/dist/core/bands.js +17 -0
- package/dist/core/canonical.d.ts +30 -0
- package/dist/core/canonical.js +72 -0
- package/dist/core/index.d.ts +5 -0
- package/dist/core/index.js +5 -0
- package/dist/core/snippet.d.ts +6 -0
- package/dist/core/snippet.js +9 -0
- package/dist/core/types.d.ts +50 -0
- package/dist/core/types.js +1 -0
- package/dist/sweep/client.d.ts +39 -0
- package/dist/sweep/client.js +204 -0
- package/dist/sweep/dashboard.d.ts +1 -0
- package/dist/sweep/dashboard.js +242 -0
- package/dist/sweep/docker.d.ts +32 -0
- package/dist/sweep/docker.js +49 -0
- package/dist/sweep/regen.d.ts +1 -0
- package/dist/sweep/regen.js +7 -0
- package/dist/sweep/report.d.ts +16 -0
- package/dist/sweep/report.js +81 -0
- package/dist/sweep/run.d.ts +11 -0
- package/dist/sweep/run.js +94 -0
- package/dist/sweep/sweep-all.d.ts +1 -0
- package/dist/sweep/sweep-all.js +50 -0
- package/package.json +52 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal MCP stdio client — deliberately NOT the official SDK: schema-parsing
|
|
3
|
+
* layers can reorder/strip keys, and our canonical bytes are defined as the
|
|
4
|
+
* tools array exactly as the wire carried it. JSON.parse preserves key order,
|
|
5
|
+
* so the objects captured here ARE the wire representation.
|
|
6
|
+
*/
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
const PROTOCOL_VERSION = '2025-06-18';
|
|
9
|
+
export class McpStdioClient {
|
|
10
|
+
child;
|
|
11
|
+
buffer = '';
|
|
12
|
+
nextId = 1;
|
|
13
|
+
pending = new Map();
|
|
14
|
+
stderrChunks = [];
|
|
15
|
+
exited;
|
|
16
|
+
constructor(command, args, env) {
|
|
17
|
+
this.child = spawn(command, args, {
|
|
18
|
+
env: { ...env },
|
|
19
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
20
|
+
});
|
|
21
|
+
this.child.stdout.setEncoding('utf8');
|
|
22
|
+
this.child.stdout.on('data', (chunk) => this.onData(chunk));
|
|
23
|
+
this.child.stderr.setEncoding('utf8');
|
|
24
|
+
this.child.stderr.on('data', (chunk) => {
|
|
25
|
+
this.stderrChunks.push(chunk);
|
|
26
|
+
if (this.stderrChunks.length > 200)
|
|
27
|
+
this.stderrChunks.shift();
|
|
28
|
+
});
|
|
29
|
+
// Writing to a dead pipe must not crash the sweep (EPIPE / write-after-end).
|
|
30
|
+
this.child.stdin.on('error', () => { });
|
|
31
|
+
this.exited = new Promise((resolve) => {
|
|
32
|
+
this.child.on('error', (err) => {
|
|
33
|
+
this.deadReason = `spawn failed: ${err.message}`;
|
|
34
|
+
for (const p of this.pending.values())
|
|
35
|
+
p.reject(new Error(this.deadReason));
|
|
36
|
+
this.pending.clear();
|
|
37
|
+
resolve();
|
|
38
|
+
});
|
|
39
|
+
this.child.on('exit', (code) => {
|
|
40
|
+
const tail = this.stderrTail.slice(-600);
|
|
41
|
+
this.deadReason = `server exited (code ${code})${tail ? `; stderr tail: ${tail}` : ''}`;
|
|
42
|
+
for (const p of this.pending.values())
|
|
43
|
+
p.reject(new Error(this.deadReason));
|
|
44
|
+
this.pending.clear();
|
|
45
|
+
resolve();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
onData(chunk) {
|
|
50
|
+
this.buffer += chunk;
|
|
51
|
+
let idx;
|
|
52
|
+
while ((idx = this.buffer.indexOf('\n')) >= 0) {
|
|
53
|
+
const line = this.buffer.slice(0, idx).trim();
|
|
54
|
+
this.buffer = this.buffer.slice(idx + 1);
|
|
55
|
+
if (!line)
|
|
56
|
+
continue;
|
|
57
|
+
let msg;
|
|
58
|
+
try {
|
|
59
|
+
msg = JSON.parse(line);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
continue; // non-JSON noise on stdout — ignore
|
|
63
|
+
}
|
|
64
|
+
if (msg && typeof msg.method === 'string') {
|
|
65
|
+
// Request or notification FROM the server. Ping is capability-free and
|
|
66
|
+
// must be answered; anything else gets a method-not-found so the server
|
|
67
|
+
// is never left hanging on an unanswered request. Server request ids may
|
|
68
|
+
// collide with ours, so 'method' presence is checked before id dispatch.
|
|
69
|
+
if (msg.id !== undefined && msg.id !== null) {
|
|
70
|
+
if (msg.method === 'ping')
|
|
71
|
+
this.send({ jsonrpc: '2.0', id: msg.id, result: {} });
|
|
72
|
+
else
|
|
73
|
+
this.send({ jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: 'method not found' } });
|
|
74
|
+
}
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (msg && typeof msg.id === 'number' && this.pending.has(msg.id)) {
|
|
78
|
+
const p = this.pending.get(msg.id);
|
|
79
|
+
this.pending.delete(msg.id);
|
|
80
|
+
if (msg.error)
|
|
81
|
+
p.reject(new Error(`server error ${msg.error.code}: ${msg.error.message}`));
|
|
82
|
+
else
|
|
83
|
+
p.resolve(msg.result);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
send(obj) {
|
|
88
|
+
this.child.stdin.write(JSON.stringify(obj) + '\n');
|
|
89
|
+
}
|
|
90
|
+
deadReason = null;
|
|
91
|
+
request(method, params, timeoutMs) {
|
|
92
|
+
if (this.deadReason)
|
|
93
|
+
return Promise.reject(new Error(this.deadReason));
|
|
94
|
+
const id = this.nextId++;
|
|
95
|
+
return new Promise((resolve, reject) => {
|
|
96
|
+
const timer = setTimeout(() => {
|
|
97
|
+
this.pending.delete(id);
|
|
98
|
+
reject(new Error(`timeout after ${timeoutMs}ms waiting for ${method}`));
|
|
99
|
+
}, timeoutMs);
|
|
100
|
+
this.pending.set(id, {
|
|
101
|
+
resolve: (v) => {
|
|
102
|
+
clearTimeout(timer);
|
|
103
|
+
resolve(v);
|
|
104
|
+
},
|
|
105
|
+
reject: (e) => {
|
|
106
|
+
clearTimeout(timer);
|
|
107
|
+
reject(e);
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
this.send({ jsonrpc: '2.0', id, method, params });
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
notify(method, params) {
|
|
114
|
+
this.send(params === undefined ? { jsonrpc: '2.0', method } : { jsonrpc: '2.0', method, params });
|
|
115
|
+
}
|
|
116
|
+
get stderrTail() {
|
|
117
|
+
return this.stderrChunks.join('').slice(-4000);
|
|
118
|
+
}
|
|
119
|
+
async close() {
|
|
120
|
+
this.child.stdin.end();
|
|
121
|
+
const killed = setTimeout(() => this.child.kill('SIGKILL'), 2000);
|
|
122
|
+
this.child.kill('SIGTERM');
|
|
123
|
+
await this.exited;
|
|
124
|
+
clearTimeout(killed);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Launch a stdio MCP server, run initialize + paginated tools/list, capture the
|
|
129
|
+
* wire-order tools array. Caller owns error handling/status mapping.
|
|
130
|
+
*/
|
|
131
|
+
export async function captureTools(spec, opts = {}) {
|
|
132
|
+
const timeoutMs = opts.timeoutMs ?? 60_000;
|
|
133
|
+
const [cmd, ...args] = typeof spec === 'string' ? splitCommand(spec) : [spec.command, ...spec.argv];
|
|
134
|
+
const client = new McpStdioClient(cmd, args, {
|
|
135
|
+
PATH: process.env.PATH,
|
|
136
|
+
HOME: process.env.HOME,
|
|
137
|
+
...opts.env,
|
|
138
|
+
});
|
|
139
|
+
try {
|
|
140
|
+
const init = await client.request('initialize', {
|
|
141
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
142
|
+
capabilities: {},
|
|
143
|
+
clientInfo: { name: 'mcp-context-cost', version: '0.1.0' },
|
|
144
|
+
}, timeoutMs);
|
|
145
|
+
client.notify('notifications/initialized');
|
|
146
|
+
const tools = [];
|
|
147
|
+
const seenCursors = new Set();
|
|
148
|
+
let cursor;
|
|
149
|
+
let pages = 0;
|
|
150
|
+
do {
|
|
151
|
+
if (++pages > 100)
|
|
152
|
+
throw new Error('tools/list pagination exceeded 100 pages — cursor loop suspected');
|
|
153
|
+
const res = await client.request('tools/list', cursor ? { cursor } : {}, timeoutMs);
|
|
154
|
+
if (Array.isArray(res?.tools))
|
|
155
|
+
tools.push(...res.tools);
|
|
156
|
+
cursor = typeof res?.nextCursor === 'string' && res.nextCursor.length > 0 ? res.nextCursor : undefined;
|
|
157
|
+
if (cursor) {
|
|
158
|
+
if (seenCursors.has(cursor))
|
|
159
|
+
throw new Error('tools/list returned a repeated cursor — pagination loop');
|
|
160
|
+
seenCursors.add(cursor);
|
|
161
|
+
}
|
|
162
|
+
} while (cursor);
|
|
163
|
+
return {
|
|
164
|
+
serverInfo: init?.serverInfo,
|
|
165
|
+
protocolVersion: init?.protocolVersion,
|
|
166
|
+
tools,
|
|
167
|
+
stderrTail: client.stderrTail,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
finally {
|
|
171
|
+
await client.close();
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/** Shell-free command splitting: honors single/double quotes, no expansion. */
|
|
175
|
+
export function splitCommand(line) {
|
|
176
|
+
const out = [];
|
|
177
|
+
let cur = '';
|
|
178
|
+
let quote = null;
|
|
179
|
+
for (const ch of line) {
|
|
180
|
+
if (quote) {
|
|
181
|
+
if (ch === quote)
|
|
182
|
+
quote = null;
|
|
183
|
+
else
|
|
184
|
+
cur += ch;
|
|
185
|
+
}
|
|
186
|
+
else if (ch === '"' || ch === "'") {
|
|
187
|
+
quote = ch;
|
|
188
|
+
}
|
|
189
|
+
else if (/\s/.test(ch)) {
|
|
190
|
+
if (cur) {
|
|
191
|
+
out.push(cur);
|
|
192
|
+
cur = '';
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
cur += ch;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (cur)
|
|
200
|
+
out.push(cur);
|
|
201
|
+
if (out.length === 0)
|
|
202
|
+
throw new Error('empty command');
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function generateDashboard(root?: string): string;
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-contained HTML dashboard generated from results/ + servers.yaml.
|
|
3
|
+
* npx tsx src/sweep/dashboard.ts [--out docs/dashboard.html]
|
|
4
|
+
* Regenerate after every sweep; the file doubles as the published artifact.
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
7
|
+
import { dirname, join } from 'node:path';
|
|
8
|
+
import { parse } from 'yaml';
|
|
9
|
+
import { bandColor } from '../core/bands.js';
|
|
10
|
+
const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
11
|
+
const BAND_META = {
|
|
12
|
+
brightgreen: { label: 'lean', range: '< 1K' },
|
|
13
|
+
green: { label: 'light', range: '1–5K' },
|
|
14
|
+
yellow: { label: 'moderate', range: '5–15K' },
|
|
15
|
+
orange: { label: 'heavy', range: '15–30K' },
|
|
16
|
+
red: { label: 'very heavy', range: '≥ 30K' },
|
|
17
|
+
};
|
|
18
|
+
export function generateDashboard(root = process.cwd()) {
|
|
19
|
+
const doc = parse(readFileSync(join(root, 'servers.yaml'), 'utf8'));
|
|
20
|
+
const rows = doc.servers.map((entry) => {
|
|
21
|
+
const p = join(root, 'results', entry.name, 'measurement.json');
|
|
22
|
+
return { entry, m: existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) : null };
|
|
23
|
+
});
|
|
24
|
+
const measured = rows
|
|
25
|
+
.filter((r) => r.m && (r.m.status === 'measured' || r.m.status === 'dynamic') && r.m.totalTokens !== null)
|
|
26
|
+
.sort((a, b) => (b.m.totalTokens ?? 0) - (a.m.totalTokens ?? 0));
|
|
27
|
+
const pending = rows.filter((r) => !r.m && !r.entry.remote);
|
|
28
|
+
const failed = rows.filter((r) => (r.m && !measured.includes(r)) || r.entry.remote);
|
|
29
|
+
const totals = measured.map((r) => r.m.totalTokens);
|
|
30
|
+
const median = totals.length ? totals.slice().sort((a, b) => a - b)[Math.floor(totals.length / 2)] : 0;
|
|
31
|
+
const max = totals.length ? Math.max(...totals) : 1;
|
|
32
|
+
const fmt = (n) => n.toLocaleString('en-US');
|
|
33
|
+
const now = new Date().toISOString().slice(0, 16).replace('T', ' ') + ' UTC';
|
|
34
|
+
const barRows = measured
|
|
35
|
+
.map((r, i) => {
|
|
36
|
+
const m = r.m;
|
|
37
|
+
const t = m.totalTokens;
|
|
38
|
+
const band = bandColor(t);
|
|
39
|
+
const meta = BAND_META[band];
|
|
40
|
+
const largest = [...m.tools].sort((a, b) => b.tokens - a.tokens)[0];
|
|
41
|
+
const pct = Math.max(1.2, (t / max) * 100);
|
|
42
|
+
return `<div class="row" tabindex="0" data-tip="${esc(m.toolCount)} tools · largest: ${esc(largest?.name)} (${fmt(largest?.tokens ?? 0)} tok) · ${esc(r.entry.category)} · ${esc(m.status)}${m.serverVersion ? ' · v' + esc(String(m.serverVersion).replace(/^v/, '')) : ''}">
|
|
43
|
+
<span class="rank">${i + 1}</span>
|
|
44
|
+
<span class="name">${esc(r.entry.name)}</span>
|
|
45
|
+
<span class="track"><span class="bar" style="width:${pct.toFixed(1)}%"></span></span>
|
|
46
|
+
<span class="val"><span class="dot dot-${band}" aria-hidden="true"></span>${fmt(t)}<span class="bandname">${meta.label}</span></span>
|
|
47
|
+
</div>`;
|
|
48
|
+
})
|
|
49
|
+
.join('\n');
|
|
50
|
+
const failRows = failed
|
|
51
|
+
.map((r) => {
|
|
52
|
+
const status = r.entry.remote ? 'remote-auth-wall' : (r.m?.status ?? 'not-run');
|
|
53
|
+
const note = r.entry.remote ? 'OAuth-gated remote server; listed, not measured' : (r.m?.notes ?? '');
|
|
54
|
+
return `<tr><td>${esc(r.entry.name)}</td><td><span class="chip">${esc(status)}</span></td><td class="note">${esc(note).slice(0, 160)}</td></tr>`;
|
|
55
|
+
})
|
|
56
|
+
.join('\n');
|
|
57
|
+
const tableRows = measured
|
|
58
|
+
.map((r, i) => {
|
|
59
|
+
const m = r.m;
|
|
60
|
+
return `<tr><td>${i + 1}</td><td>${esc(r.entry.name)}</td><td class="num">${fmt(m.totalTokens)}</td><td class="num">${esc(m.toolCount)}</td><td>${esc(BAND_META[bandColor(m.totalTokens)].label)}</td><td>${esc(r.entry.category)}</td></tr>`;
|
|
61
|
+
})
|
|
62
|
+
.join('\n');
|
|
63
|
+
const specimens = [measured[measured.length - 1], measured[0]]
|
|
64
|
+
.filter(Boolean)
|
|
65
|
+
.map((r) => {
|
|
66
|
+
const t = r.m.totalTokens;
|
|
67
|
+
const band = bandColor(t);
|
|
68
|
+
return `<div class="specimen"><span class="badge"><span class="badge-l">context cost</span><span class="badge-r badge-${band}">${fmt(t)} tokens</span></span><code>badges/${esc(r.entry.name)}.json</code></div>`;
|
|
69
|
+
})
|
|
70
|
+
.join('\n');
|
|
71
|
+
return `<title>mcp-context-cost</title>
|
|
72
|
+
<style>
|
|
73
|
+
:root {
|
|
74
|
+
--bg: #F7F8F7; --surface: #FFFFFF; --ink: #17211D; --muted: #5C6B64;
|
|
75
|
+
--line: #DCE2DF; --accent: #14635A; --accent-soft: rgba(20, 99, 90, 0.09);
|
|
76
|
+
--track: #ECF0EE;
|
|
77
|
+
--b-brightgreen: #2E7D4F; --b-green: #4F8A2E; --b-yellow: #A87A12;
|
|
78
|
+
--b-orange: #BA5A1E; --b-red: #A63B2C;
|
|
79
|
+
--badge-l: #555555; --badge-ink: #FFFFFF;
|
|
80
|
+
}
|
|
81
|
+
@media (prefers-color-scheme: dark) {
|
|
82
|
+
:root:not([data-theme="light"]) {
|
|
83
|
+
--bg: #151A18; --surface: #1C2321; --ink: #E4EAE7; --muted: #90A199;
|
|
84
|
+
--line: #2C3531; --accent: #58B3A4; --accent-soft: rgba(88, 179, 164, 0.12);
|
|
85
|
+
--track: #232B28;
|
|
86
|
+
--b-brightgreen: #6FC08D; --b-green: #8CBE62; --b-yellow: #D3A748;
|
|
87
|
+
--b-orange: #DB8A57; --b-red: #D97F6C;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
:root[data-theme="dark"] {
|
|
91
|
+
--bg: #151A18; --surface: #1C2321; --ink: #E4EAE7; --muted: #90A199;
|
|
92
|
+
--line: #2C3531; --accent: #58B3A4; --accent-soft: rgba(88, 179, 164, 0.12);
|
|
93
|
+
--track: #232B28;
|
|
94
|
+
--b-brightgreen: #6FC08D; --b-green: #8CBE62; --b-yellow: #D3A748;
|
|
95
|
+
--b-orange: #DB8A57; --b-red: #D97F6C;
|
|
96
|
+
}
|
|
97
|
+
* { box-sizing: border-box; }
|
|
98
|
+
body {
|
|
99
|
+
background: var(--bg); color: var(--ink); margin: 0; padding: 0 20px 80px;
|
|
100
|
+
font-family: "Avenir Next", "Helvetica Neue", system-ui, sans-serif;
|
|
101
|
+
font-size: 15px; line-height: 1.55;
|
|
102
|
+
}
|
|
103
|
+
.wrap { max-width: 880px; margin: 0 auto; }
|
|
104
|
+
header { padding: 48px 0 10px; }
|
|
105
|
+
.eyebrow { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 11px; letter-spacing: 0.13em; text-transform: uppercase; color: var(--accent); margin: 0 0 10px; }
|
|
106
|
+
h1 { font-family: "Futura", "Avenir Next", "Century Gothic", sans-serif; font-weight: 700; font-size: clamp(1.9rem, 5vw, 2.6rem); margin: 0 0 10px; letter-spacing: -0.01em; }
|
|
107
|
+
.sub { color: var(--muted); max-width: 62ch; margin: 0 0 8px; }
|
|
108
|
+
h2 { font-family: "Futura", "Avenir Next", sans-serif; font-size: 1.15rem; font-weight: 600; margin: 44px 0 4px; }
|
|
109
|
+
.h2sub { color: var(--muted); font-size: 0.88rem; margin: 0 0 16px; }
|
|
110
|
+
a { color: var(--accent); }
|
|
111
|
+
|
|
112
|
+
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; margin: 22px 0 6px; }
|
|
113
|
+
.stat { background: var(--surface); border: 1px solid var(--line); border-radius: 6px; padding: 12px 14px 10px; }
|
|
114
|
+
.stat .n { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-variant-numeric: tabular-nums; font-size: 1.45rem; font-weight: 600; display: block; }
|
|
115
|
+
.stat .l { font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); }
|
|
116
|
+
|
|
117
|
+
.board { background: var(--surface); border: 1px solid var(--line); border-radius: 8px; padding: 14px 16px; }
|
|
118
|
+
.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; }
|
|
119
|
+
.row:hover, .row:focus-visible { background: var(--accent-soft); }
|
|
120
|
+
.rank { font-family: ui-monospace, Menlo, monospace; font-size: 11px; color: var(--muted); text-align: right; font-variant-numeric: tabular-nums; }
|
|
121
|
+
.name { font-size: 0.86rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
122
|
+
.track { background: var(--track); border-radius: 3px; height: 12px; overflow: hidden; }
|
|
123
|
+
.bar { display: block; height: 100%; background: var(--accent); border-radius: 3px 3px 3px 3px; min-width: 3px; }
|
|
124
|
+
.val { font-family: ui-monospace, Menlo, monospace; font-variant-numeric: tabular-nums; font-size: 0.82rem; display: flex; align-items: center; gap: 6px; }
|
|
125
|
+
.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; box-shadow: 0 0 0 2px var(--surface); }
|
|
126
|
+
.dot-brightgreen { background: var(--b-brightgreen); } .dot-green { background: var(--b-green); }
|
|
127
|
+
.dot-yellow { background: var(--b-yellow); } .dot-orange { background: var(--b-orange); } .dot-red { background: var(--b-red); }
|
|
128
|
+
.bandname { color: var(--muted); font-size: 0.72rem; min-width: 62px; }
|
|
129
|
+
|
|
130
|
+
.legend { display: flex; flex-wrap: wrap; gap: 14px; margin: 10px 2px 0; font-size: 0.78rem; color: var(--muted); }
|
|
131
|
+
.legend span { display: inline-flex; align-items: center; gap: 5px; }
|
|
132
|
+
|
|
133
|
+
#tip { position: fixed; pointer-events: none; background: var(--ink); color: var(--bg); font-size: 0.78rem; padding: 6px 9px; border-radius: 5px; max-width: 340px; display: none; z-index: 10; line-height: 1.4; }
|
|
134
|
+
|
|
135
|
+
table { border-collapse: collapse; width: 100%; background: var(--surface); border: 1px solid var(--line); border-radius: 8px; overflow: hidden; font-size: 0.85rem; }
|
|
136
|
+
th { text-align: left; font-size: 11px; letter-spacing: 0.07em; text-transform: uppercase; color: var(--muted); font-weight: 600; }
|
|
137
|
+
th, td { padding: 7px 12px; border-bottom: 1px solid var(--line); }
|
|
138
|
+
tr:last-child td { border-bottom: none; }
|
|
139
|
+
td.num { font-family: ui-monospace, Menlo, monospace; font-variant-numeric: tabular-nums; text-align: right; }
|
|
140
|
+
td.note { color: var(--muted); font-size: 0.8rem; }
|
|
141
|
+
.chip { font-family: ui-monospace, Menlo, monospace; font-size: 11px; background: var(--accent-soft); color: var(--accent); border-radius: 999px; padding: 2px 9px; white-space: nowrap; }
|
|
142
|
+
.tablewrap { overflow-x: auto; }
|
|
143
|
+
|
|
144
|
+
.specimen { display: flex; align-items: center; gap: 14px; margin: 8px 0; }
|
|
145
|
+
.badge { display: inline-flex; font-family: "Helvetica Neue", Arial, sans-serif; font-size: 11px; border-radius: 3px; overflow: hidden; line-height: 1; }
|
|
146
|
+
.badge span { padding: 4px 7px; }
|
|
147
|
+
.badge-l { background: var(--badge-l); color: var(--badge-ink); }
|
|
148
|
+
.badge-r { color: var(--badge-ink); }
|
|
149
|
+
.badge-brightgreen { background: #4c1; } .badge-green { background: #97ca00; color: #333; }
|
|
150
|
+
.badge-yellow { background: #dfb317; color: #333; } .badge-orange { background: #fe7d37; } .badge-red { background: #e05d44; }
|
|
151
|
+
code { font-family: ui-monospace, Menlo, monospace; font-size: 0.8rem; color: var(--muted); }
|
|
152
|
+
pre { background: var(--surface); border: 1px solid var(--line); border-radius: 6px; padding: 12px 14px; overflow-x: auto; font-size: 0.8rem; }
|
|
153
|
+
details summary { cursor: pointer; color: var(--muted); font-size: 0.85rem; }
|
|
154
|
+
footer { margin-top: 48px; border-top: 1px solid var(--line); padding-top: 16px; color: var(--muted); font-size: 0.8rem; font-family: ui-monospace, Menlo, monospace; }
|
|
155
|
+
@media (prefers-reduced-motion: no-preference) { .bar { transition: width 0.4s ease; } }
|
|
156
|
+
</style>
|
|
157
|
+
<div class="wrap">
|
|
158
|
+
<header>
|
|
159
|
+
<p class="eyebrow">methodology v1.0 · o200k_base · generated ${now}</p>
|
|
160
|
+
<h1>mcp-context-cost</h1>
|
|
161
|
+
<p class="sub">What popular MCP servers cost in context tokens before the agent does any work — measured from raw <code>tools/list</code> captures, every number re-derivable from its published measurement file.</p>
|
|
162
|
+
</header>
|
|
163
|
+
|
|
164
|
+
<div class="stats">
|
|
165
|
+
<div class="stat"><span class="n">${measured.length}<span style="font-size:0.9rem;color:var(--muted)">/${rows.length}</span></span><span class="l">servers measured</span></div>
|
|
166
|
+
<div class="stat"><span class="n">${fmt(median)}</span><span class="l">median tokens</span></div>
|
|
167
|
+
<div class="stat"><span class="n">${fmt(max === 1 ? 0 : max)}</span><span class="l">priciest (${esc(measured[0]?.entry.name ?? '—')})</span></div>
|
|
168
|
+
<div class="stat"><span class="n">${pending.length}</span><span class="l">pending sweep</span></div>
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
<h2>Leaderboard</h2>
|
|
172
|
+
<p class="h2sub">Tokens = o200k_base count of the canonical <code>tools/list</code> bytes. Hover or focus a row for detail.</p>
|
|
173
|
+
<div class="board">
|
|
174
|
+
${barRows || '<p class="h2sub">Sweep in progress — first results land shortly.</p>'}
|
|
175
|
+
</div>
|
|
176
|
+
<div class="legend">
|
|
177
|
+
<span><span class="dot dot-brightgreen"></span>lean <1K</span>
|
|
178
|
+
<span><span class="dot dot-green"></span>light 1–5K</span>
|
|
179
|
+
<span><span class="dot dot-yellow"></span>moderate 5–15K</span>
|
|
180
|
+
<span><span class="dot dot-orange"></span>heavy 15–30K</span>
|
|
181
|
+
<span><span class="dot dot-red"></span>very heavy ≥30K</span>
|
|
182
|
+
</div>
|
|
183
|
+
|
|
184
|
+
<h2>Not measured — and why</h2>
|
|
185
|
+
<p class="h2sub">Every candidate appears; failures are findings, not omissions.</p>
|
|
186
|
+
<div class="tablewrap"><table>
|
|
187
|
+
<thead><tr><th>server</th><th>status</th><th>note</th></tr></thead>
|
|
188
|
+
<tbody>${failRows || '<tr><td colspan="3" class="note">none yet</td></tr>'}</tbody>
|
|
189
|
+
</table></div>
|
|
190
|
+
|
|
191
|
+
<h2>The badge</h2>
|
|
192
|
+
<p class="h2sub">A shields.io endpoint badge backed by a reproducible measurement — merge one line into a README.</p>
|
|
193
|
+
${specimens || '<p class="h2sub">Specimens render once the sweep lands.</p>'}
|
|
194
|
+
<pre>[](<methodology URL>)</pre>
|
|
195
|
+
|
|
196
|
+
<details><summary>Full data table</summary>
|
|
197
|
+
<div class="tablewrap" style="margin-top:10px"><table>
|
|
198
|
+
<thead><tr><th>#</th><th>server</th><th>tokens</th><th>tools</th><th>band</th><th>category</th></tr></thead>
|
|
199
|
+
<tbody>${tableRows}</tbody>
|
|
200
|
+
</table></div>
|
|
201
|
+
</details>
|
|
202
|
+
|
|
203
|
+
<footer>
|
|
204
|
+
Reproduce any number: <code>mcp-context-cost verify results/<server>/measurement.json</code> — re-derives tokens + SHA-256 from the raw capture.
|
|
205
|
+
Bands are provisional until frozen against the full-sweep distribution.
|
|
206
|
+
</footer>
|
|
207
|
+
</div>
|
|
208
|
+
<div id="tip" role="status"></div>
|
|
209
|
+
<script>
|
|
210
|
+
(function () {
|
|
211
|
+
var tip = document.getElementById('tip');
|
|
212
|
+
document.querySelectorAll('.row[data-tip]').forEach(function (row) {
|
|
213
|
+
function show() { tip.textContent = row.getAttribute('data-tip'); tip.style.display = 'block'; }
|
|
214
|
+
function hide() { tip.style.display = 'none'; }
|
|
215
|
+
row.addEventListener('mouseenter', show);
|
|
216
|
+
row.addEventListener('focus', show);
|
|
217
|
+
row.addEventListener('mouseleave', hide);
|
|
218
|
+
row.addEventListener('blur', hide);
|
|
219
|
+
row.addEventListener('mousemove', function (e) {
|
|
220
|
+
var x = Math.min(e.clientX + 14, window.innerWidth - tip.offsetWidth - 8);
|
|
221
|
+
tip.style.left = x + 'px';
|
|
222
|
+
tip.style.top = Math.min(e.clientY + 14, window.innerHeight - tip.offsetHeight - 8) + 'px';
|
|
223
|
+
});
|
|
224
|
+
row.addEventListener('focus', function () {
|
|
225
|
+
var r = row.getBoundingClientRect();
|
|
226
|
+
tip.style.left = r.left + 'px';
|
|
227
|
+
tip.style.top = r.bottom + 6 + 'px';
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
})();
|
|
231
|
+
</script>
|
|
232
|
+
`;
|
|
233
|
+
}
|
|
234
|
+
const isMain = process.argv[1]?.endsWith('dashboard.ts') || process.argv[1]?.endsWith('dashboard.js');
|
|
235
|
+
if (isMain) {
|
|
236
|
+
const i = process.argv.indexOf('--out');
|
|
237
|
+
const out = i >= 0 ? process.argv[i + 1] : 'docs/dashboard.html';
|
|
238
|
+
const html = generateDashboard();
|
|
239
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
240
|
+
writeFileSync(out, html);
|
|
241
|
+
console.log(`wrote ${out} (${(html.length / 1024).toFixed(0)}KB)`);
|
|
242
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Docker isolation for sweep measurements: clean filesystem, no ambient
|
|
3
|
+
* credentials, non-interactive, resource-capped. Network stays enabled because
|
|
4
|
+
* npx/uvx launches fetch the package at startup — the isolation claim is
|
|
5
|
+
* "credential-free clean environment", not an airgap, and each measurement
|
|
6
|
+
* records the isolation actually used.
|
|
7
|
+
*/
|
|
8
|
+
export interface DockerOptions {
|
|
9
|
+
/** Base image; default node:22-slim (use a uv image for python servers). */
|
|
10
|
+
image?: string;
|
|
11
|
+
/** Extra env var NAMES to pass through with dummy values. */
|
|
12
|
+
dummyEnv?: string[];
|
|
13
|
+
/** Container name, so a timed-out container can be force-removed. */
|
|
14
|
+
containerName?: string;
|
|
15
|
+
}
|
|
16
|
+
export declare const DEFAULT_NODE_IMAGE = "public.ecr.aws/docker/library/node:22-slim";
|
|
17
|
+
export declare const DEFAULT_PYTHON_IMAGE = "ghcr.io/astral-sh/uv:python3.12-bookworm-slim";
|
|
18
|
+
export interface IsolationRecord {
|
|
19
|
+
docker: boolean;
|
|
20
|
+
image?: string;
|
|
21
|
+
network?: string;
|
|
22
|
+
note?: string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Wrap a launch command line in `docker run`. The inner command is passed to
|
|
26
|
+
* `sh -lc` inside the container; quoting is preserved by argv (no host shell).
|
|
27
|
+
*/
|
|
28
|
+
export declare function dockerize(commandLine: string, opts?: DockerOptions): {
|
|
29
|
+
command: string;
|
|
30
|
+
argv: string[];
|
|
31
|
+
isolation: IsolationRecord;
|
|
32
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// ECR Public / ghcr mirrors — Docker Hub pulls hang on some networks (observed
|
|
2
|
+
// on this machine 2026-08-16: hub pulls stall indefinitely while ECR works).
|
|
3
|
+
export const DEFAULT_NODE_IMAGE = 'public.ecr.aws/docker/library/node:22-slim';
|
|
4
|
+
export const DEFAULT_PYTHON_IMAGE = 'ghcr.io/astral-sh/uv:python3.12-bookworm-slim';
|
|
5
|
+
/**
|
|
6
|
+
* Wrap a launch command line in `docker run`. The inner command is passed to
|
|
7
|
+
* `sh -lc` inside the container; quoting is preserved by argv (no host shell).
|
|
8
|
+
*/
|
|
9
|
+
export function dockerize(commandLine, opts = {}) {
|
|
10
|
+
const image = opts.image ?? (commandLine.trimStart().startsWith('uvx') ? DEFAULT_PYTHON_IMAGE : DEFAULT_NODE_IMAGE);
|
|
11
|
+
const argv = [
|
|
12
|
+
'run',
|
|
13
|
+
'--rm',
|
|
14
|
+
'-i',
|
|
15
|
+
...(opts.containerName ? ['--name', opts.containerName] : []),
|
|
16
|
+
'--pull=missing',
|
|
17
|
+
'--memory=1g',
|
|
18
|
+
'--pids-limit=512',
|
|
19
|
+
'--security-opt',
|
|
20
|
+
'no-new-privileges',
|
|
21
|
+
'-e',
|
|
22
|
+
'HOME=/tmp',
|
|
23
|
+
'-w',
|
|
24
|
+
'/tmp',
|
|
25
|
+
// Shared package caches across containers — packages only, no credentials.
|
|
26
|
+
'-v',
|
|
27
|
+
'mcp-ctx-npm-cache:/tmp/.npm-cache',
|
|
28
|
+
'-e',
|
|
29
|
+
'npm_config_cache=/tmp/.npm-cache',
|
|
30
|
+
'-v',
|
|
31
|
+
'mcp-ctx-uv-cache:/tmp/.uv-cache',
|
|
32
|
+
'-e',
|
|
33
|
+
'UV_CACHE_DIR=/tmp/.uv-cache',
|
|
34
|
+
];
|
|
35
|
+
for (const name of opts.dummyEnv ?? []) {
|
|
36
|
+
argv.push('-e', `${name}=dummy`);
|
|
37
|
+
}
|
|
38
|
+
argv.push(image, 'sh', '-lc', commandLine);
|
|
39
|
+
return {
|
|
40
|
+
command: 'docker',
|
|
41
|
+
argv,
|
|
42
|
+
isolation: {
|
|
43
|
+
docker: true,
|
|
44
|
+
image,
|
|
45
|
+
network: 'bridge',
|
|
46
|
+
note: 'network enabled for package fetch; clean FS, no host credentials',
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Regenerate leaderboard + dashboard from existing results/: npx tsx src/sweep/regen.ts */
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { parse } from 'yaml';
|
|
4
|
+
import { writeLeaderboard, percentiles } from './report.js';
|
|
5
|
+
const doc = parse(readFileSync('servers.yaml', 'utf8'));
|
|
6
|
+
writeLeaderboard(doc.servers);
|
|
7
|
+
console.log('leaderboard:', JSON.stringify(percentiles(doc.servers)));
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface ServerEntry {
|
|
2
|
+
name: string;
|
|
3
|
+
command: string;
|
|
4
|
+
package?: string;
|
|
5
|
+
env?: string[];
|
|
6
|
+
metric?: number;
|
|
7
|
+
metricSource?: string;
|
|
8
|
+
category?: string;
|
|
9
|
+
repo?: string;
|
|
10
|
+
remote?: boolean;
|
|
11
|
+
dockerImage?: string;
|
|
12
|
+
timeoutSeconds?: number;
|
|
13
|
+
}
|
|
14
|
+
export declare function writeLeaderboard(entries: ServerEntry[], root?: string): void;
|
|
15
|
+
/** Percentile helper for freezing color bands against the observed distribution. */
|
|
16
|
+
export declare function percentiles(entries: ServerEntry[], root?: string): Record<string, number>;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Leaderboard generation from results/<name>/measurement.json + servers.yaml
|
|
3
|
+
* metadata. Every yaml entry appears — failures included, no silent drops.
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
/** Neutralize markdown/table syntax in third-party strings (tool names, notes). */
|
|
8
|
+
function mdCell(s) {
|
|
9
|
+
return String(s ?? '')
|
|
10
|
+
.replace(/[|`[\]<>]/g, (c) => `\\${c}`)
|
|
11
|
+
.replace(/\r?\n/g, ' ')
|
|
12
|
+
.slice(0, 160);
|
|
13
|
+
}
|
|
14
|
+
function csvCell(s) {
|
|
15
|
+
const v = String(s ?? '');
|
|
16
|
+
return /[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v;
|
|
17
|
+
}
|
|
18
|
+
function loadRows(entries, root = process.cwd()) {
|
|
19
|
+
return entries.map((entry) => {
|
|
20
|
+
const p = join(root, 'results', entry.name, 'measurement.json');
|
|
21
|
+
return { entry, m: existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) : null };
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
export function writeLeaderboard(entries, root = process.cwd()) {
|
|
25
|
+
const rows = loadRows(entries, root);
|
|
26
|
+
const measured = rows
|
|
27
|
+
.filter((r) => r.m && (r.m.status === 'measured' || r.m.status === 'dynamic'))
|
|
28
|
+
.sort((a, b) => (b.m.totalTokens ?? 0) - (a.m.totalTokens ?? 0));
|
|
29
|
+
const unmeasured = rows.filter((r) => !measured.includes(r));
|
|
30
|
+
const md = [];
|
|
31
|
+
md.push('# MCP server context-cost leaderboard');
|
|
32
|
+
md.push('');
|
|
33
|
+
md.push(`Tokens = o200k_base count of the canonical \`tools/list\` bytes ([methodology v1.0](../docs/METHODOLOGY.md)). ` +
|
|
34
|
+
`Measured ${measured.length}/${rows.length} candidates; every candidate is listed — failures are findings, not omissions.`);
|
|
35
|
+
md.push('');
|
|
36
|
+
md.push('| # | server | tokens | tools | largest tool | status | category |');
|
|
37
|
+
md.push('|---:|---|---:|---:|---|---|---|');
|
|
38
|
+
measured.forEach((r, i) => {
|
|
39
|
+
const m = r.m;
|
|
40
|
+
const largest = [...m.tools].sort((a, b) => b.tokens - a.tokens)[0];
|
|
41
|
+
md.push(`| ${i + 1} | ${mdCell(r.entry.name)} | ${m.totalTokens.toLocaleString('en-US')} | ${m.toolCount} | ` +
|
|
42
|
+
`${largest ? `${mdCell(largest.name)} (${largest.tokens.toLocaleString('en-US')})` : '—'} | ${m.status} | ${mdCell(r.entry.category)} |`);
|
|
43
|
+
});
|
|
44
|
+
md.push('');
|
|
45
|
+
if (unmeasured.length > 0) {
|
|
46
|
+
md.push('## Not measured (and why)');
|
|
47
|
+
md.push('');
|
|
48
|
+
md.push('| server | status | note |');
|
|
49
|
+
md.push('|---|---|---|');
|
|
50
|
+
for (const r of unmeasured) {
|
|
51
|
+
const status = r.entry.remote ? 'remote-auth-wall' : (r.m?.status ?? 'not-yet-run');
|
|
52
|
+
md.push(`| ${mdCell(r.entry.name)} | ${status} | ${mdCell(r.m?.notes)} |`);
|
|
53
|
+
}
|
|
54
|
+
md.push('');
|
|
55
|
+
}
|
|
56
|
+
writeFileSync(join(root, 'results', 'leaderboard.md'), md.join('\n') + '\n');
|
|
57
|
+
const csv = ['name,tokens,toolCount,status,category,metric,metricSource'];
|
|
58
|
+
for (const r of rows) {
|
|
59
|
+
const m = r.m;
|
|
60
|
+
csv.push([
|
|
61
|
+
csvCell(r.entry.name),
|
|
62
|
+
m?.totalTokens ?? '',
|
|
63
|
+
m?.toolCount ?? '',
|
|
64
|
+
r.entry.remote ? 'remote-auth-wall' : (m?.status ?? 'not-yet-run'),
|
|
65
|
+
csvCell(r.entry.category),
|
|
66
|
+
r.entry.metric ?? '',
|
|
67
|
+
csvCell(r.entry.metricSource),
|
|
68
|
+
].join(','));
|
|
69
|
+
}
|
|
70
|
+
writeFileSync(join(root, 'results', 'leaderboard.csv'), csv.join('\n') + '\n');
|
|
71
|
+
}
|
|
72
|
+
/** Percentile helper for freezing color bands against the observed distribution. */
|
|
73
|
+
export function percentiles(entries, root = process.cwd()) {
|
|
74
|
+
const totals = loadRows(entries, root)
|
|
75
|
+
.map((r) => r.m?.totalTokens)
|
|
76
|
+
.filter((t) => typeof t === 'number')
|
|
77
|
+
.sort((a, b) => a - b);
|
|
78
|
+
// Nearest-rank percentile: ceil(p/100 * n) as 1-based rank (unbiased at exact multiples).
|
|
79
|
+
const at = (p) => totals[Math.min(totals.length - 1, Math.max(0, Math.ceil((p / 100) * totals.length) - 1))] ?? 0;
|
|
80
|
+
return { p25: at(25), p50: at(50), p75: at(75), p90: at(90), n: totals.length };
|
|
81
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Measurement } from '../core/types.js';
|
|
2
|
+
export interface MeasureOptions {
|
|
3
|
+
timeoutMs?: number;
|
|
4
|
+
env?: Record<string, string>;
|
|
5
|
+
root?: string;
|
|
6
|
+
docker?: boolean;
|
|
7
|
+
dockerImage?: string;
|
|
8
|
+
/** env var NAMES to provide as dummy values (docker mode). */
|
|
9
|
+
dummyEnv?: string[];
|
|
10
|
+
}
|
|
11
|
+
export declare function measureServer(name: string, command: string, opts?: MeasureOptions): Promise<Measurement>;
|