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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 mcp-context-cost contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,90 @@
1
+ # mcp-context-cost
2
+
3
+ **Reproducible context-cost badges for MCP servers.**
4
+
5
+ Every MCP server you wire into an agent injects its tool schemas into the model's context
6
+ before any work happens. That cost is invisible — and it varies by **1,700×** across
7
+ popular servers:
8
+
9
+ | server | context cost | tools |
10
+ |---|---:|---:|
11
+ | github (official) | **54,422 tokens** | 44 |
12
+ | brave-search | 25,456 | 8 |
13
+ | notion | 17,500 | 24 |
14
+ | playwright *(4.8M installs/week)* | 4,024 | 24 |
15
+ | filesystem (reference) | 2,823 | 14 |
16
+ | markitdown | 64 | 1 |
17
+
18
+ *(57 of 82 popular servers measured, 2026-08-16 sweep — full table in
19
+ [results/leaderboard.md](results/leaderboard.md); every failure is listed with its reason.)*
20
+
21
+ This project makes that cost **legible and disputable**:
22
+
23
+ ```
24
+ [context cost | 12,430 tokens] ← shields.io badge, linked to the methodology
25
+ ```
26
+
27
+ ## Why trust the number?
28
+
29
+ Every badge is backed by a `measurement.json` containing the raw `tools/list` capture, the
30
+ SHA-256 of its canonical bytes, the pinned tokenizer (`o200k_base`), and the exact launch
31
+ command. Disputes reduce to a byte-level diff:
32
+
33
+ ```bash
34
+ npx tsx src/cli.ts verify results/github/measurement.json
35
+ # OK github-mcp-server: 54422 tokens (o200k_base, methodology 1.0) — capture, hash, and count all agree
36
+ ```
37
+
38
+ Full definition: [docs/METHODOLOGY.md](docs/METHODOLOGY.md) — what is counted, what the
39
+ number is *not*, config policy, failure taxonomy, frozen color bands, known divergences.
40
+
41
+ ## What's in the repo
42
+
43
+ | path | what |
44
+ |---|---|
45
+ | `src/core/` | the measurement spec, executable — canonical form, tokenizer, bands, badge JSON |
46
+ | `src/sweep/` | raw-wire MCP stdio client + Dockerized batch sweep + leaderboard/dashboard generators |
47
+ | `src/cli.ts` | `verify` (re-derive any published number) and `measure` |
48
+ | `spec/fixtures/` | golden vectors shared by the TypeScript and bash implementations |
49
+ | `upstream/` | staged contribution to [sd2k/mcp-tokens-action](https://github.com/sd2k/mcp-tokens-action): `badge.sh` + action patch + tests |
50
+ | `servers.yaml` | 82 curated candidates with live install metrics and provenance |
51
+ | `results/` · `badges/` | measurements, leaderboard, shields endpoint JSONs |
52
+
53
+ ## Measure your own server
54
+
55
+ ```bash
56
+ npm ci
57
+ npm run sweep -- --name my-server --command "npx -y my-mcp-server"
58
+ cat badges/my-server.json # strict shields.io endpoint JSON
59
+ ```
60
+
61
+ Then in your README:
62
+
63
+ ```markdown
64
+ [![context cost](https://img.shields.io/endpoint?url=<raw URL of badges/my-server.json>)](<methodology URL>)
65
+ ```
66
+
67
+ Or self-serve from CI via the (staged) mcp-tokens-action badge inputs — see
68
+ [upstream/action-patch.md](upstream/action-patch.md).
69
+
70
+ ## Development
71
+
72
+ ```bash
73
+ npm test # 24 TS tests incl. golden fixtures + dispute drills
74
+ npx tsc --noEmit # typecheck
75
+ ./upstream/tests/badge-test.sh # 21 bash tests — byte-identical to the TS reference
76
+ npm run sweep:all -- --docker # full curated sweep (Docker isolation)
77
+ ```
78
+
79
+ Notable engineering choices: the MCP client is a deliberate ~150-line raw-wire
80
+ implementation (SDK schema-parsing can reorder keys, which would corrupt canonical bytes);
81
+ sweep servers run in credential-free Docker containers with recorded isolation; the badge
82
+ color bands are frozen against the observed distribution of the first full sweep.
83
+
84
+ ## Status
85
+
86
+ Pre-launch. Measurement pipeline, leaderboard, methodology, and upstream contribution are
87
+ complete; the badge-adoption campaign and launch posts are drafted in `campaign/` awaiting
88
+ go-ahead. Build log: [PROGRESS.md](PROGRESS.md).
89
+
90
+ MIT © 2026
package/dist/cli.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ import type { Measurement } from './core/types.js';
3
+ export declare function verifyMeasurement(m: Measurement): {
4
+ ok: boolean;
5
+ rederivedTokens: number | null;
6
+ rederivedSha: string | null;
7
+ problems: string[];
8
+ };
package/dist/cli.js ADDED
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * mcp-context-cost CLI — the dispute drill as a command.
4
+ *
5
+ * mcp-context-cost verify <measurement.json> re-derive the number from the
6
+ * published capture; exit 1 on mismatch
7
+ * mcp-context-cost measure --name x --command "npx -y ..." one-off measurement
8
+ */
9
+ import { readFileSync } from 'node:fs';
10
+ import { canonicalString, countTokens, sha256Hex } from './core/canonical.js';
11
+ import { toBadge } from './core/badge.js';
12
+ export function verifyMeasurement(m) {
13
+ const problems = [];
14
+ if (!m.rawToolsCapture) {
15
+ return { ok: false, rederivedTokens: null, rederivedSha: null, problems: ['no rawToolsCapture in measurement'] };
16
+ }
17
+ const canonical = canonicalString(m.rawToolsCapture);
18
+ const tokens = countTokens(canonical);
19
+ const sha = sha256Hex(canonical);
20
+ if (tokens !== m.totalTokens)
21
+ problems.push(`token mismatch: re-derived ${tokens}, stored ${m.totalTokens}`);
22
+ if (sha !== m.canonicalSha256)
23
+ problems.push(`sha mismatch: re-derived ${sha}, stored ${m.canonicalSha256}`);
24
+ if (m.toolCount !== m.rawToolsCapture.length)
25
+ problems.push(`toolCount mismatch: capture has ${m.rawToolsCapture.length}, stored ${m.toolCount}`);
26
+ return { ok: problems.length === 0, rederivedTokens: tokens, rederivedSha: sha, problems };
27
+ }
28
+ const [, , cmd, ...rest] = process.argv;
29
+ if (cmd === 'verify') {
30
+ const path = rest.find((a) => !a.startsWith('--'));
31
+ if (!path) {
32
+ console.error('usage: mcp-context-cost verify <measurement.json>');
33
+ process.exit(2);
34
+ }
35
+ const m = JSON.parse(readFileSync(path, 'utf8'));
36
+ const r = verifyMeasurement(m);
37
+ if (r.ok) {
38
+ console.log(`OK ${m.serverName}: ${r.rederivedTokens} tokens (${m.encoding}, methodology ${m.methodologyVersion}) — capture, hash, and count all agree`);
39
+ console.log(`badge: ${JSON.stringify(toBadge(m))}`);
40
+ process.exit(0);
41
+ }
42
+ console.error(`FAIL ${m.serverName}:`);
43
+ for (const p of r.problems)
44
+ console.error(` - ${p}`);
45
+ process.exit(1);
46
+ }
47
+ else if (cmd === 'measure') {
48
+ const argOf = (name) => {
49
+ const i = rest.indexOf(`--${name}`);
50
+ return i >= 0 ? rest[i + 1] : undefined;
51
+ };
52
+ const name = argOf('name');
53
+ const command = argOf('command');
54
+ if (!name || !command) {
55
+ console.error('usage: mcp-context-cost measure --name <slug> --command "npx -y <server>" [--timeout ms] [--docker]');
56
+ process.exit(2);
57
+ }
58
+ const { measureServer } = await import('./sweep/run.js');
59
+ const m = await measureServer(name, command, {
60
+ timeoutMs: Number(argOf('timeout') ?? 60_000),
61
+ docker: rest.includes('--docker'),
62
+ dockerImage: argOf('docker-image'),
63
+ });
64
+ const ok = m.status === 'measured' || m.status === 'dynamic';
65
+ console.log(ok
66
+ ? `${name}: ${m.totalTokens} tokens across ${m.toolCount} tools (${m.status}) — results/${name}/measurement.json, badges/${name}.json`
67
+ : `${name}: ${m.status} — ${m.notes ?? ''}`);
68
+ process.exit(ok ? 0 : 1);
69
+ }
70
+ else if (cmd !== undefined && cmd !== '--help' && cmd !== '-h') {
71
+ console.error(`unknown command: ${cmd}`);
72
+ process.exit(2);
73
+ }
74
+ else {
75
+ console.log('mcp-context-cost — reproducible context-cost measurement for MCP servers');
76
+ console.log(' verify <measurement.json> re-derive tokens+sha from the published capture');
77
+ console.log(' measure --name x --command "npx -y <server>" run a one-off measurement');
78
+ }
@@ -0,0 +1,10 @@
1
+ import type { BadgeJson, Measurement } from './types.js';
2
+ export declare const BADGE_LABEL = "context cost";
3
+ export declare const BADGE_CACHE_SECONDS = 3600;
4
+ export declare function formatTokens(totalTokens: number): string;
5
+ /**
6
+ * Measurement -> strict shields.io endpoint JSON. Non-measured statuses render
7
+ * grey "unknown" (used only in sweep snapshots; maintainer CI publishes only
8
+ * on success, so their badges degrade to last-known-good instead).
9
+ */
10
+ export declare function toBadge(m: Measurement): BadgeJson;
@@ -0,0 +1,21 @@
1
+ import { bandColor, UNKNOWN_COLOR } from './bands.js';
2
+ export const BADGE_LABEL = 'context cost';
3
+ export const BADGE_CACHE_SECONDS = 3600;
4
+ export function formatTokens(totalTokens) {
5
+ return `${totalTokens.toLocaleString('en-US')} tokens`;
6
+ }
7
+ /**
8
+ * Measurement -> strict shields.io endpoint JSON. Non-measured statuses render
9
+ * grey "unknown" (used only in sweep snapshots; maintainer CI publishes only
10
+ * on success, so their badges degrade to last-known-good instead).
11
+ */
12
+ export function toBadge(m) {
13
+ const measurable = (m.status === 'measured' || m.status === 'dynamic') && m.totalTokens !== null;
14
+ return {
15
+ schemaVersion: 1,
16
+ label: BADGE_LABEL,
17
+ message: measurable ? formatTokens(m.totalTokens) : 'unknown',
18
+ color: measurable ? bandColor(m.totalTokens) : UNKNOWN_COLOR,
19
+ cacheSeconds: BADGE_CACHE_SECONDS,
20
+ };
21
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Provisional color bands (v0) — to be frozen against observed sweep
3
+ * percentiles before launch (see METHODOLOGY.md). Any change bumps the
4
+ * methodology version.
5
+ */
6
+ export declare function bandColor(totalTokens: number): string;
7
+ export declare const UNKNOWN_COLOR = "lightgrey";
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Provisional color bands (v0) — to be frozen against observed sweep
3
+ * percentiles before launch (see METHODOLOGY.md). Any change bumps the
4
+ * methodology version.
5
+ */
6
+ export function bandColor(totalTokens) {
7
+ if (totalTokens < 1_000)
8
+ return 'brightgreen';
9
+ if (totalTokens < 5_000)
10
+ return 'green';
11
+ if (totalTokens < 15_000)
12
+ return 'yellow';
13
+ if (totalTokens < 30_000)
14
+ return 'orange';
15
+ return 'red';
16
+ }
17
+ export const UNKNOWN_COLOR = 'lightgrey';
@@ -0,0 +1,30 @@
1
+ import type { Measurement, MeasurementStatus } from './types.js';
2
+ export declare const METHODOLOGY_VERSION = "1.0";
3
+ /**
4
+ * Canonical form: JSON.stringify over the PARSED tools array (no whitespace,
5
+ * first-occurrence key order, JSON.parse semantics — duplicate keys last-wins,
6
+ * numbers/strings re-serialized per ECMA-404). This is deliberately defined on
7
+ * the parsed value, not raw wire bytes: every JSON implementation reproduces it
8
+ * from the published capture, which is what makes numbers re-derivable anywhere.
9
+ */
10
+ export declare function canonicalString(tools: unknown[]): string;
11
+ export declare function sha256Hex(text: string): string;
12
+ export declare function countTokens(text: string): number;
13
+ /**
14
+ * totalTokens is authoritative (tokens of the whole canonical array — array
15
+ * punctuation included); per-tool numbers are diagnostic and won't sum to it.
16
+ */
17
+ export declare function measureTools(tools: unknown[], meta: {
18
+ serverName: string;
19
+ serverVersion?: string;
20
+ launchCommand?: string;
21
+ envVarNames?: string[];
22
+ measuredAt?: string;
23
+ }): Measurement;
24
+ export declare function failedMeasurement(status: Exclude<MeasurementStatus, 'measured' | 'dynamic'>, meta: {
25
+ serverName: string;
26
+ serverVersion?: string;
27
+ launchCommand?: string;
28
+ notes?: string;
29
+ measuredAt?: string;
30
+ }): Measurement;
@@ -0,0 +1,72 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { getEncoding } from 'js-tiktoken';
3
+ export const METHODOLOGY_VERSION = '1.0';
4
+ const enc = getEncoding('o200k_base');
5
+ /**
6
+ * Canonical form: JSON.stringify over the PARSED tools array (no whitespace,
7
+ * first-occurrence key order, JSON.parse semantics — duplicate keys last-wins,
8
+ * numbers/strings re-serialized per ECMA-404). This is deliberately defined on
9
+ * the parsed value, not raw wire bytes: every JSON implementation reproduces it
10
+ * from the published capture, which is what makes numbers re-derivable anywhere.
11
+ */
12
+ export function canonicalString(tools) {
13
+ return JSON.stringify(tools);
14
+ }
15
+ export function sha256Hex(text) {
16
+ return createHash('sha256').update(text, 'utf8').digest('hex');
17
+ }
18
+ export function countTokens(text) {
19
+ // disallowedSpecial=[] — special-token strings (e.g. "<|endoftext|>") in tool
20
+ // descriptions are counted as ordinary text instead of throwing.
21
+ return enc.encode(text, undefined, []).length;
22
+ }
23
+ /**
24
+ * totalTokens is authoritative (tokens of the whole canonical array — array
25
+ * punctuation included); per-tool numbers are diagnostic and won't sum to it.
26
+ */
27
+ export function measureTools(tools, meta) {
28
+ const canonical = canonicalString(tools);
29
+ const perTool = tools.map((t) => {
30
+ const tool = t;
31
+ return {
32
+ name: tool.name ?? '(unnamed)',
33
+ tokens: countTokens(JSON.stringify(t)),
34
+ descriptionTokens: tool.description ? countTokens(tool.description) : 0,
35
+ inputSchemaTokens: tool.inputSchema ? countTokens(JSON.stringify(tool.inputSchema)) : 0,
36
+ };
37
+ });
38
+ return {
39
+ methodologyVersion: METHODOLOGY_VERSION,
40
+ provider: 'tiktoken',
41
+ encoding: 'o200k_base',
42
+ status: 'measured',
43
+ totalTokens: countTokens(canonical),
44
+ toolCount: tools.length,
45
+ tools: perTool,
46
+ canonicalSha256: sha256Hex(canonical),
47
+ rawToolsCapture: tools,
48
+ measuredAt: meta.measuredAt ?? new Date().toISOString(),
49
+ serverName: meta.serverName,
50
+ serverVersion: meta.serverVersion,
51
+ launchCommand: meta.launchCommand,
52
+ envVarNames: meta.envVarNames,
53
+ };
54
+ }
55
+ export function failedMeasurement(status, meta) {
56
+ return {
57
+ methodologyVersion: METHODOLOGY_VERSION,
58
+ provider: 'tiktoken',
59
+ encoding: 'o200k_base',
60
+ status,
61
+ totalTokens: null,
62
+ toolCount: null,
63
+ tools: [],
64
+ canonicalSha256: null,
65
+ rawToolsCapture: null,
66
+ measuredAt: meta.measuredAt ?? new Date().toISOString(),
67
+ serverName: meta.serverName,
68
+ serverVersion: meta.serverVersion,
69
+ launchCommand: meta.launchCommand,
70
+ notes: meta.notes,
71
+ };
72
+ }
@@ -0,0 +1,5 @@
1
+ export * from './types.js';
2
+ export * from './canonical.js';
3
+ export * from './bands.js';
4
+ export * from './badge.js';
5
+ export * from './snippet.js';
@@ -0,0 +1,5 @@
1
+ export * from './types.js';
2
+ export * from './canonical.js';
3
+ export * from './bands.js';
4
+ export * from './badge.js';
5
+ export * from './snippet.js';
@@ -0,0 +1,6 @@
1
+ /**
2
+ * README badge snippet. Shields' `link` param does not work inside GitHub
3
+ * READMEs, so the image is wrapped in a markdown link to the methodology page
4
+ * — that link is the whole point of the badge.
5
+ */
6
+ export declare function readmeSnippet(badgeJsonRawUrl: string, methodologyUrl: string): string;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * README badge snippet. Shields' `link` param does not work inside GitHub
3
+ * READMEs, so the image is wrapped in a markdown link to the methodology page
4
+ * — that link is the whole point of the badge.
5
+ */
6
+ export function readmeSnippet(badgeJsonRawUrl, methodologyUrl) {
7
+ const img = `https://img.shields.io/endpoint?url=${encodeURIComponent(badgeJsonRawUrl)}`;
8
+ return `[![context cost](${img})](${methodologyUrl})`;
9
+ }
@@ -0,0 +1,50 @@
1
+ /** Status taxonomy — every swept server gets exactly one; no silent drops. */
2
+ export type MeasurementStatus = 'measured' | 'auth-required' | 'startup-failure' | 'timeout' | 'dynamic' | 'remote-auth-wall';
3
+ export interface ToolMeasurement {
4
+ name: string;
5
+ /** Tokens of the whole tool object, canonically serialized. */
6
+ tokens: number;
7
+ descriptionTokens: number;
8
+ inputSchemaTokens: number;
9
+ }
10
+ /**
11
+ * The full reproducibility record published next to every badge.
12
+ * Re-tokenizing `rawToolsCapture` must reproduce `totalTokens` exactly.
13
+ */
14
+ export interface Measurement {
15
+ methodologyVersion: string;
16
+ provider: 'tiktoken';
17
+ encoding: 'o200k_base';
18
+ status: MeasurementStatus;
19
+ totalTokens: number | null;
20
+ toolCount: number | null;
21
+ tools: ToolMeasurement[];
22
+ /** SHA-256 hex of the canonical bytes (see canonical.ts). */
23
+ canonicalSha256: string | null;
24
+ /** The tools array exactly as returned by tools/list, all pages concatenated. */
25
+ rawToolsCapture: unknown[] | null;
26
+ measuredAt: string;
27
+ serverName: string;
28
+ serverVersion?: string;
29
+ /** Exact launch command; env var NAMES only, never values. */
30
+ launchCommand?: string;
31
+ envVarNames?: string[];
32
+ /** How the server was isolated during measurement (docker image, network). */
33
+ isolation?: {
34
+ docker: boolean;
35
+ image?: string;
36
+ network?: string;
37
+ note?: string;
38
+ };
39
+ /** Request timeout in force during this measurement. */
40
+ timeoutMs?: number;
41
+ notes?: string;
42
+ }
43
+ /** Strict shields.io endpoint-badge schema — nothing extra. */
44
+ export interface BadgeJson {
45
+ schemaVersion: 1;
46
+ label: string;
47
+ message: string;
48
+ color: string;
49
+ cacheSeconds: number;
50
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,39 @@
1
+ export interface WireCapture {
2
+ serverInfo?: {
3
+ name?: string;
4
+ version?: string;
5
+ };
6
+ protocolVersion?: string;
7
+ tools: unknown[];
8
+ /** true when a second tools/list returned a different tool set */
9
+ stderrTail: string;
10
+ }
11
+ export declare class McpStdioClient {
12
+ private child;
13
+ private buffer;
14
+ private nextId;
15
+ private pending;
16
+ private stderrChunks;
17
+ private exited;
18
+ constructor(command: string, args: string[], env: Record<string, string | undefined>);
19
+ private onData;
20
+ private send;
21
+ private deadReason;
22
+ request(method: string, params: unknown, timeoutMs: number): Promise<any>;
23
+ notify(method: string, params?: unknown): void;
24
+ get stderrTail(): string;
25
+ close(): Promise<void>;
26
+ }
27
+ /**
28
+ * Launch a stdio MCP server, run initialize + paginated tools/list, capture the
29
+ * wire-order tools array. Caller owns error handling/status mapping.
30
+ */
31
+ export declare function captureTools(spec: string | {
32
+ command: string;
33
+ argv: string[];
34
+ }, opts?: {
35
+ timeoutMs?: number;
36
+ env?: Record<string, string>;
37
+ }): Promise<WireCapture>;
38
+ /** Shell-free command splitting: honors single/double quotes, no expansion. */
39
+ export declare function splitCommand(line: string): string[];