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,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single-server measurement runner:
|
|
3
|
+
* npm run sweep -- --name memory --command "npx -y @modelcontextprotocol/server-memory"
|
|
4
|
+
* Writes results/<name>/measurement.json and badges/<name>.json.
|
|
5
|
+
* Runs tools/list capture TWICE; differing tool sets -> status "dynamic".
|
|
6
|
+
*/
|
|
7
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { captureTools } from './client.js';
|
|
10
|
+
import { dockerize } from './docker.js';
|
|
11
|
+
import { measureTools, failedMeasurement, canonicalString } from '../core/canonical.js';
|
|
12
|
+
import { toBadge } from '../core/badge.js';
|
|
13
|
+
function arg(name) {
|
|
14
|
+
const i = process.argv.indexOf(`--${name}`);
|
|
15
|
+
return i >= 0 ? process.argv[i + 1] : undefined;
|
|
16
|
+
}
|
|
17
|
+
export async function measureServer(name, command, opts = {}) {
|
|
18
|
+
if (!/^[a-z0-9][a-z0-9._-]*$/i.test(name) || name.includes('..')) {
|
|
19
|
+
throw new Error(`invalid server name '${name}' — letters/digits/dot/dash/underscore only`);
|
|
20
|
+
}
|
|
21
|
+
const root = opts.root ?? process.cwd();
|
|
22
|
+
let spec = command;
|
|
23
|
+
let isolation = { docker: false };
|
|
24
|
+
let containerName;
|
|
25
|
+
if (opts.docker && command.trimStart().startsWith('docker ')) {
|
|
26
|
+
// Command is already a container — wrapping it again would need docker-in-docker.
|
|
27
|
+
isolation = { docker: true, note: 'command is itself a docker run (host-spawned container)' };
|
|
28
|
+
}
|
|
29
|
+
else if (opts.docker) {
|
|
30
|
+
containerName = `mcp-ctx-${name}-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
|
|
31
|
+
const d = dockerize(command, { image: opts.dockerImage, dummyEnv: opts.dummyEnv, containerName });
|
|
32
|
+
spec = { command: d.command, argv: d.argv };
|
|
33
|
+
isolation = d.isolation;
|
|
34
|
+
}
|
|
35
|
+
let m;
|
|
36
|
+
try {
|
|
37
|
+
const first = await captureTools(spec, opts);
|
|
38
|
+
const second = await captureTools(spec, opts);
|
|
39
|
+
m = measureTools(first.tools, {
|
|
40
|
+
serverName: first.serverInfo?.name ?? name,
|
|
41
|
+
serverVersion: first.serverInfo?.version,
|
|
42
|
+
launchCommand: command,
|
|
43
|
+
envVarNames: [...Object.keys(opts.env ?? {}), ...(opts.dummyEnv ?? [])],
|
|
44
|
+
});
|
|
45
|
+
m.isolation = isolation;
|
|
46
|
+
m.timeoutMs = opts.timeoutMs ?? 60_000;
|
|
47
|
+
if (canonicalString(first.tools) !== canonicalString(second.tools)) {
|
|
48
|
+
m.status = 'dynamic';
|
|
49
|
+
m.notes = 'tools/list differed between two runs; value is for the first capture';
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
54
|
+
const status = msg.includes('timeout')
|
|
55
|
+
? 'timeout'
|
|
56
|
+
: /auth|unauthorized|401|forbidden|credential|api.?key|token/i.test(msg)
|
|
57
|
+
? 'auth-required'
|
|
58
|
+
: 'startup-failure';
|
|
59
|
+
m = failedMeasurement(status, { serverName: name, launchCommand: command, notes: msg.slice(0, 700) });
|
|
60
|
+
m.isolation = isolation;
|
|
61
|
+
m.timeoutMs = opts.timeoutMs ?? 60_000;
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
if (containerName) {
|
|
65
|
+
// Killing the docker CLI on timeout orphans the container — remove it.
|
|
66
|
+
const { spawn } = await import('node:child_process');
|
|
67
|
+
spawn('docker', ['rm', '-f', containerName], { stdio: 'ignore' }).on('error', () => { });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const resultDir = join(root, 'results', name);
|
|
71
|
+
mkdirSync(resultDir, { recursive: true });
|
|
72
|
+
writeFileSync(join(resultDir, 'measurement.json'), JSON.stringify(m, null, 2) + '\n');
|
|
73
|
+
mkdirSync(join(root, 'badges'), { recursive: true });
|
|
74
|
+
writeFileSync(join(root, 'badges', `${name}.json`), JSON.stringify(toBadge(m)) + '\n');
|
|
75
|
+
return m;
|
|
76
|
+
}
|
|
77
|
+
const isMain = process.argv[1]?.endsWith('run.ts') || process.argv[1]?.endsWith('run.js');
|
|
78
|
+
if (isMain) {
|
|
79
|
+
const name = arg('name');
|
|
80
|
+
const command = arg('command');
|
|
81
|
+
if (!name || !command) {
|
|
82
|
+
console.error('usage: npm run sweep -- --name <slug> --command "<launch command>"');
|
|
83
|
+
process.exit(2);
|
|
84
|
+
}
|
|
85
|
+
const m = await measureServer(name, command, {
|
|
86
|
+
timeoutMs: Number(arg('timeout') ?? 60_000),
|
|
87
|
+
docker: process.argv.includes('--docker'),
|
|
88
|
+
dockerImage: arg('docker-image'),
|
|
89
|
+
});
|
|
90
|
+
console.log(m.status === 'measured' || m.status === 'dynamic'
|
|
91
|
+
? `${name}: ${m.totalTokens} tokens across ${m.toolCount} tools (${m.status})`
|
|
92
|
+
: `${name}: ${m.status} — ${m.notes ?? ''}`);
|
|
93
|
+
process.exit(m.status === 'measured' || m.status === 'dynamic' ? 0 : 1);
|
|
94
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Batch sweep over servers.yaml:
|
|
3
|
+
* npx tsx src/sweep/sweep-all.ts [--docker] [--only name1,name2] [--concurrency 3]
|
|
4
|
+
* Writes results/<name>/measurement.json + badges/<name>.json per server, then
|
|
5
|
+
* regenerates the leaderboard.
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
import { parse } from 'yaml';
|
|
9
|
+
import { measureServer } from './run.js';
|
|
10
|
+
import { writeLeaderboard } from './report.js';
|
|
11
|
+
function arg(name) {
|
|
12
|
+
const i = process.argv.indexOf(`--${name}`);
|
|
13
|
+
return i >= 0 ? process.argv[i + 1] : undefined;
|
|
14
|
+
}
|
|
15
|
+
const doc = parse(readFileSync('servers.yaml', 'utf8'));
|
|
16
|
+
const only = arg('only')?.split(',');
|
|
17
|
+
const docker = process.argv.includes('--docker');
|
|
18
|
+
const concurrency = Number(arg('concurrency') ?? 3);
|
|
19
|
+
const defaultTimeout = Number(arg('default-timeout') ?? 60);
|
|
20
|
+
const entries = doc.servers.filter((s) => {
|
|
21
|
+
if (only && !only.includes(s.name))
|
|
22
|
+
return false;
|
|
23
|
+
if (s.remote)
|
|
24
|
+
return false; // remote servers handled separately (mcp-remote bridge)
|
|
25
|
+
return true;
|
|
26
|
+
});
|
|
27
|
+
console.log(`sweeping ${entries.length} servers (docker=${docker}, concurrency=${concurrency})`);
|
|
28
|
+
const queue = [...entries];
|
|
29
|
+
const summary = {};
|
|
30
|
+
async function worker() {
|
|
31
|
+
for (let e = queue.shift(); e; e = queue.shift()) {
|
|
32
|
+
const started = Date.now();
|
|
33
|
+
const m = await measureServer(e.name, e.command, {
|
|
34
|
+
timeoutMs: (e.timeoutSeconds ?? defaultTimeout) * 1000,
|
|
35
|
+
docker,
|
|
36
|
+
dockerImage: e.dockerImage,
|
|
37
|
+
dummyEnv: e.env ?? [],
|
|
38
|
+
});
|
|
39
|
+
const secs = ((Date.now() - started) / 1000).toFixed(0);
|
|
40
|
+
summary[e.name] =
|
|
41
|
+
m.status === 'measured' || m.status === 'dynamic'
|
|
42
|
+
? `${m.totalTokens} tokens / ${m.toolCount} tools (${m.status}, ${secs}s)`
|
|
43
|
+
: `${m.status} (${secs}s)`;
|
|
44
|
+
console.log(` ${e.name}: ${summary[e.name]}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
await Promise.all(Array.from({ length: Math.max(1, concurrency) }, () => worker()));
|
|
48
|
+
writeLeaderboard(doc.servers);
|
|
49
|
+
const measured = Object.values(summary).filter((s) => s.includes('tokens')).length;
|
|
50
|
+
console.log(`done: ${measured}/${entries.length} measured; leaderboard regenerated`);
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mcp-context-cost",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Reproducible context-cost badges for MCP servers — measure what a server's tool schemas cost before the agent does any work",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/athakur3/mcp-context-cost.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://athakur3.github.io/mcp-context-cost/",
|
|
12
|
+
"bugs": "https://github.com/athakur3/mcp-context-cost/issues",
|
|
13
|
+
"keywords": [
|
|
14
|
+
"mcp",
|
|
15
|
+
"model-context-protocol",
|
|
16
|
+
"tokens",
|
|
17
|
+
"context-window",
|
|
18
|
+
"badge",
|
|
19
|
+
"shields",
|
|
20
|
+
"developer-tools"
|
|
21
|
+
],
|
|
22
|
+
"bin": {
|
|
23
|
+
"mcp-context-cost": "dist/cli.js"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist",
|
|
27
|
+
"README.md",
|
|
28
|
+
"LICENSE"
|
|
29
|
+
],
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "tsc",
|
|
32
|
+
"test": "vitest run",
|
|
33
|
+
"test:badge": "./upstream/tests/badge-test.sh",
|
|
34
|
+
"typecheck": "tsc --noEmit",
|
|
35
|
+
"sweep": "tsx src/sweep/run.ts",
|
|
36
|
+
"sweep:all": "tsx src/sweep/sweep-all.ts",
|
|
37
|
+
"verify": "tsx src/cli.ts verify"
|
|
38
|
+
},
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=20"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"js-tiktoken": "^1.0.21",
|
|
44
|
+
"yaml": "^2.9.0"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@types/node": "^26.2.0",
|
|
48
|
+
"tsx": "^4.23.12",
|
|
49
|
+
"typescript": "^7.0.2",
|
|
50
|
+
"vitest": "^4.1.10"
|
|
51
|
+
}
|
|
52
|
+
}
|