@wartzar-bee/tokenscope-mcp 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.
Files changed (3) hide show
  1. package/README.md +77 -0
  2. package/package.json +33 -0
  3. package/server.mjs +242 -0
package/README.md ADDED
@@ -0,0 +1,77 @@
1
+ # tokenscope MCP server ⏣
2
+
3
+ **Let your AI agent answer "what did my Claude Code session cost, and what's eating my context?"**
4
+
5
+ A tiny [Model Context Protocol](https://modelcontextprotocol.io) server that wraps
6
+ [tokenscope](https://github.com/wartzar-bee/tokenscope)'s cost-attribution engine.
7
+ It reuses the **exact same analysis code** as the `tokenscope` CLI (`src/core.mjs`,
8
+ `src/share.mjs`, `src/benchmark.mjs`) — no cost logic is reimplemented.
9
+
10
+ **Local & read-only.** It only reads Claude Code session JSONL under
11
+ `~/.claude/projects` (or a path you pass). Nothing is sent anywhere. MIT.
12
+
13
+ ## Tools
14
+
15
+ | tool | what it does |
16
+ |------|--------------|
17
+ | `analyze_claude_cost` | Full cost + context attribution for a session (or your latest): total cost, output vs **re-sent cached context** vs cache-write vs fresh-input split, per-turn context peak/avg, cache efficiency, cost by model, subagent spend, tool counts, and plain-language insights. Optional `path`, `all`, and `pricing` overrides. |
18
+ | `get_cost_benchmark` | Percentiles vs tokenscope's shipped reference set (n=66 real sessions): how big your cost is, your re-sent-context share, and "more cache-efficient than ~P% of measured sessions". Pass a `path` or explicit `totalCost`/`resentPct`/`cacheEfficiency`. |
19
+ | `tokenscope_share_summary` | A privacy-safe shareable summary (aggregate numbers only — no paths or prompt/response content) plus a ready-to-paste markdown block. |
20
+
21
+ ## Install
22
+
23
+ Requires Node ≥ 18.
24
+
25
+ ### Claude Desktop / Claude Code / any MCP client
26
+
27
+ Add to your MCP config (e.g. `claude_desktop_config.json`, or `.mcp.json`):
28
+
29
+ ```json
30
+ {
31
+ "mcpServers": {
32
+ "tokenscope": {
33
+ "command": "npx",
34
+ "args": ["-y", "@wartzar-bee/tokenscope-mcp"]
35
+ }
36
+ }
37
+ }
38
+ ```
39
+
40
+ Or run it from a clone:
41
+
42
+ ```json
43
+ {
44
+ "mcpServers": {
45
+ "tokenscope": {
46
+ "command": "node",
47
+ "args": ["/path/to/tokenscope/mcp/server.mjs"]
48
+ }
49
+ }
50
+ }
51
+ ```
52
+
53
+ ### Manual / from source
54
+
55
+ ```bash
56
+ git clone https://github.com/wartzar-bee/tokenscope
57
+ cd tokenscope/mcp
58
+ npm install
59
+ npm start # starts the stdio MCP server
60
+ npm test # runs the self-test (22 checks against a synthetic session)
61
+ ```
62
+
63
+ ## Example
64
+
65
+ Once connected, ask your agent:
66
+
67
+ > "Use tokenscope to analyze my last Claude Code session and tell me what's eating my context."
68
+
69
+ The agent calls `analyze_claude_cost` and gets back the real breakdown — typically showing
70
+ that the majority of the bill is **context re-sent every turn** (cache reads), not model output.
71
+
72
+ ## How it relates to the CLI
73
+
74
+ The MCP server is a thin transport over the same engine the CLI uses for `tokenscope --json`
75
+ and `tokenscope --share`. If you prefer a terminal: `npx @wartzar-bee/tokenscope`.
76
+
77
+ MIT. Not affiliated with Anthropic.
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@wartzar-bee/tokenscope-mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for tokenscope — let AI agents analyze Claude Code session cost & context attribution (reuses tokenscope's engine; local, read-only).",
5
+ "type": "module",
6
+ "bin": { "tokenscope-mcp": "server.mjs" },
7
+ "scripts": {
8
+ "start": "node server.mjs",
9
+ "test": "node test/mcp.test.mjs"
10
+ },
11
+ "keywords": [
12
+ "mcp",
13
+ "model-context-protocol",
14
+ "claude-code",
15
+ "claude",
16
+ "token-usage",
17
+ "llm-cost",
18
+ "ai-cost",
19
+ "agentic-coding",
20
+ "context-window",
21
+ "anthropic"
22
+ ],
23
+ "repository": { "type": "git", "url": "git+https://github.com/wartzar-bee/tokenscope.git", "directory": "mcp" },
24
+ "homepage": "https://github.com/wartzar-bee/tokenscope/tree/master/mcp#readme",
25
+ "bugs": { "url": "https://github.com/wartzar-bee/tokenscope/issues" },
26
+ "engines": { "node": ">=18" },
27
+ "dependencies": {
28
+ "@modelcontextprotocol/sdk": "^1.0.0",
29
+ "@wartzar-bee/tokenscope": "^0.2.0"
30
+ },
31
+ "files": ["server.mjs", "README.md"],
32
+ "license": "MIT"
33
+ }
package/server.mjs ADDED
@@ -0,0 +1,242 @@
1
+ #!/usr/bin/env node
2
+ // tokenscope MCP server — exposes tokenscope's Claude Code cost-attribution engine
3
+ // as Model Context Protocol tools, so AI agents / MCP clients can ask
4
+ // "what did my Claude Code session cost, and what's eating my context?"
5
+ //
6
+ // It REUSES tokenscope's existing analysis engine (src/core.mjs, src/share.mjs,
7
+ // src/benchmark.mjs) — the exact same code path as the `--json` CLI output.
8
+ // No cost logic is reimplemented here; this is a thin MCP wrapper.
9
+ //
10
+ // Read-only & local: it only reads Claude Code session JSONL under
11
+ // ~/.claude/projects (or a path you pass). Nothing is sent anywhere.
12
+ // MIT. Not affiliated with Anthropic.
13
+
14
+ import { readFileSync, readdirSync, statSync, existsSync } from "node:fs";
15
+ import { homedir } from "node:os";
16
+ import { join, dirname } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+
19
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
20
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
21
+ import {
22
+ ListToolsRequestSchema,
23
+ CallToolRequestSchema,
24
+ } from "@modelcontextprotocol/sdk/types.js";
25
+
26
+ // Reuse the venture's REAL engine — no cost logic is reimplemented here.
27
+ // We import the exact same modules the `tokenscope` CLI uses. When this package
28
+ // is installed standalone (npx), they resolve from the `@wartzar-bee/tokenscope`
29
+ // dependency; when run from a clone of the monorepo, they resolve from ../src.
30
+ const __dir = dirname(fileURLToPath(import.meta.url));
31
+ async function loadEngine() {
32
+ // Prefer the local monorepo src (dev / clone), else the installed dependency.
33
+ const localSrc = join(__dir, "..", "src", "core.mjs");
34
+ const useLocal = existsSync(localSrc);
35
+ const base = useLocal ? join(__dir, "..", "src") + "/" : "@wartzar-bee/tokenscope/src/";
36
+ const imp = (m) => import(base + m);
37
+ const core = await imp("core.mjs");
38
+ const share = await imp("share.mjs");
39
+ const bench = await imp("benchmark.mjs");
40
+ const pricing = await imp("pricing.mjs");
41
+ return { ...core, ...share, ...bench, ...pricing };
42
+ }
43
+ const ENGINE = await loadEngine();
44
+ const { parse, analyze, shareSummary, shareMarkdown, benchmarkOf, BENCHMARK, percentileOf, DEFAULT_PRICING } = ENGINE;
45
+
46
+ const PKG = JSON.parse(readFileSync(join(__dir, "package.json"), "utf8"));
47
+
48
+ // ---- session discovery (same rules as bin/tokenscope.mjs) --------------------
49
+ function findJsonl(dir, out = []) {
50
+ let entries = [];
51
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return out; }
52
+ for (const e of entries) {
53
+ const p = join(dir, e.name);
54
+ if (e.isDirectory()) findJsonl(p, out);
55
+ else if (e.name.endsWith(".jsonl")) {
56
+ try { out.push({ p, m: statSync(p).mtimeMs, s: statSync(p).size }); } catch {}
57
+ }
58
+ }
59
+ return out;
60
+ }
61
+
62
+ function resolveFiles({ path, all } = {}) {
63
+ if (path) {
64
+ if (!existsSync(path)) throw new Error(`Not found: ${path}`);
65
+ if (statSync(path).isDirectory()) {
66
+ const files = findJsonl(path).map((f) => f.p);
67
+ if (!files.length) throw new Error(`No .jsonl session files under ${path}`);
68
+ return { files, label: path };
69
+ }
70
+ return { files: [path], label: path };
71
+ }
72
+ const projectsDir = join(homedir(), ".claude", "projects");
73
+ const found = findJsonl(projectsDir).filter((f) => f.s > 200);
74
+ if (!found.length) {
75
+ throw new Error(
76
+ `No Claude Code sessions found under ${projectsDir}. ` +
77
+ `Pass {"path": "<file-or-dir>.jsonl"} explicitly.`
78
+ );
79
+ }
80
+ if (all) return { files: found.map((f) => f.p), label: `all sessions (${found.length})` };
81
+ const latest = found.sort((a, b) => b.m - a.m)[0];
82
+ return { files: [latest.p], label: "latest session" };
83
+ }
84
+
85
+ function analyzePath({ path, all, pricing } = {}) {
86
+ const { files, label } = resolveFiles({ path, all });
87
+ let turns = [];
88
+ for (const f of files) {
89
+ try { turns = turns.concat(parse(readFileSync(f, "utf8"))); } catch {}
90
+ }
91
+ if (!turns.length) throw new Error("No assistant turns with usage found in the session(s).");
92
+ const pr = pricing ? { ...DEFAULT_PRICING, ...pricing } : DEFAULT_PRICING;
93
+ return { label, analysis: analyze(turns, pr) };
94
+ }
95
+
96
+ // JSON shape mirrors the CLI's `--json` (curve dropped to stay compact).
97
+ function toJson(label, a) {
98
+ return { label, ...a, context: { ...a.context, curve: undefined } };
99
+ }
100
+
101
+ // ---- MCP tool definitions ----------------------------------------------------
102
+ const TOOLS = [
103
+ {
104
+ name: "analyze_claude_cost",
105
+ description:
106
+ "Analyze a Claude Code session's token cost and context attribution. " +
107
+ "Reads Claude Code session JSONL (read-only, local) and returns the total cost, " +
108
+ "the spend breakdown (model output vs re-sent cached context vs cache-write vs fresh input), " +
109
+ "the per-turn context-growth peak/avg, cache efficiency, cost by model, subagent spend, " +
110
+ "tool-call counts, and plain-language insights. With no path it analyzes your most recent " +
111
+ "Claude Code session under ~/.claude/projects.",
112
+ inputSchema: {
113
+ type: "object",
114
+ properties: {
115
+ path: {
116
+ type: "string",
117
+ description: "Optional path to a session .jsonl file or a directory of them. Omit to use your latest Claude Code session.",
118
+ },
119
+ all: {
120
+ type: "boolean",
121
+ description: "Aggregate ALL sessions under ~/.claude/projects (ignored if `path` is set). Default false.",
122
+ },
123
+ pricing: {
124
+ type: "object",
125
+ description: 'Optional per-model price overrides, e.g. {"claude-opus-4": {"in": 15, "out": 75}} (USD per 1M tokens).',
126
+ },
127
+ },
128
+ },
129
+ },
130
+ {
131
+ name: "get_cost_benchmark",
132
+ description:
133
+ "Compare a Claude Code session against tokenscope's shipped reference set of real sessions " +
134
+ "(n=66) and report percentiles: how big the session's cost is vs the set, its re-sent-context share, " +
135
+ "and how cache-efficient it is ('more cache-efficient than ~P% of measured sessions'). " +
136
+ "Either pass a `path` to a session, or pass explicit `totalCost`/`resentPct`/`cacheEfficiency` numbers. " +
137
+ "This is a yardstick reference set, not a population census.",
138
+ inputSchema: {
139
+ type: "object",
140
+ properties: {
141
+ path: { type: "string", description: "Optional session .jsonl path/dir to derive the metrics from." },
142
+ all: { type: "boolean", description: "With no path-derived metrics, aggregate ALL sessions. Default false." },
143
+ totalCost: { type: "number", description: "Session total cost (USD). Used if `path` is omitted." },
144
+ resentPct: { type: "number", description: "Percent of spend that was re-sent (cached) context. Used if `path` is omitted." },
145
+ cacheEfficiency: { type: "number", description: "Cache efficiency percent (0-100). Used if `path` is omitted." },
146
+ },
147
+ },
148
+ },
149
+ {
150
+ name: "tokenscope_share_summary",
151
+ description:
152
+ "Produce a privacy-safe, shareable summary of a Claude Code session built ONLY from aggregate " +
153
+ "numbers (no file paths, no prompt/response content) — safe to paste in public. Returns both a " +
154
+ "structured summary object and a ready-to-paste markdown block. With no path it uses your latest session.",
155
+ inputSchema: {
156
+ type: "object",
157
+ properties: {
158
+ path: { type: "string", description: "Optional session .jsonl path/dir. Omit to use your latest session." },
159
+ all: { type: "boolean", description: "Aggregate ALL sessions. Default false." },
160
+ },
161
+ },
162
+ },
163
+ ];
164
+
165
+ const server = new Server(
166
+ { name: "tokenscope", version: PKG.version },
167
+ { capabilities: { tools: {} } }
168
+ );
169
+
170
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
171
+
172
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
173
+ const { name, arguments: args = {} } = req.params;
174
+ try {
175
+ if (name === "analyze_claude_cost") {
176
+ const { label, analysis } = analyzePath(args);
177
+ return { content: [{ type: "text", text: JSON.stringify(toJson(label, analysis), null, 2) }] };
178
+ }
179
+
180
+ if (name === "get_cost_benchmark") {
181
+ let metrics;
182
+ if (args.path || args.all) {
183
+ const { analysis } = analyzePath({ path: args.path, all: args.all });
184
+ const s = shareSummary(analysis);
185
+ metrics = { totalCost: s.totalCost, resentPct: s.split.cacheRead.pct, cacheEfficiency: s.cacheEfficiency };
186
+ } else if (args.totalCost != null || args.resentPct != null || args.cacheEfficiency != null) {
187
+ metrics = {
188
+ totalCost: args.totalCost ?? BENCHMARK.costUsd.p50,
189
+ resentPct: args.resentPct ?? BENCHMARK.resentPct.p50,
190
+ cacheEfficiency: args.cacheEfficiency ?? BENCHMARK.cacheEff.p50,
191
+ };
192
+ } else {
193
+ // No input at all → describe the reference set itself.
194
+ return {
195
+ content: [{
196
+ type: "text",
197
+ text: JSON.stringify({
198
+ reference: BENCHMARK,
199
+ note: "Pass `path` or explicit totalCost/resentPct/cacheEfficiency to get your percentiles.",
200
+ }, null, 2),
201
+ }],
202
+ };
203
+ }
204
+ const bench = benchmarkOf({
205
+ totalCost: metrics.totalCost,
206
+ split: { cacheRead: { pct: metrics.resentPct } },
207
+ cacheEfficiency: metrics.cacheEfficiency,
208
+ });
209
+ return {
210
+ content: [{ type: "text", text: JSON.stringify({ input: metrics, benchmark: bench }, null, 2) }],
211
+ };
212
+ }
213
+
214
+ if (name === "tokenscope_share_summary") {
215
+ const { analysis } = analyzePath({ path: args.path, all: args.all });
216
+ const summary = shareSummary(analysis);
217
+ const markdown = shareMarkdown(analysis);
218
+ return {
219
+ content: [{ type: "text", text: JSON.stringify({ summary, markdown }, null, 2) }],
220
+ };
221
+ }
222
+
223
+ throw new Error(`Unknown tool: ${name}`);
224
+ } catch (err) {
225
+ return {
226
+ isError: true,
227
+ content: [{ type: "text", text: `tokenscope error: ${err && err.message ? err.message : String(err)}` }],
228
+ };
229
+ }
230
+ });
231
+
232
+ // expose helpers for the test harness without starting the transport
233
+ export { TOOLS, analyzePath, server, percentileOf };
234
+
235
+ // Start stdio transport unless imported for tests.
236
+ const isMain = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
237
+ if (isMain) {
238
+ const transport = new StdioServerTransport();
239
+ await server.connect(transport);
240
+ // stderr is fine; stdout is the MCP channel.
241
+ console.error(`tokenscope MCP server v${PKG.version} ready (stdio) — tools: ${TOOLS.map((t) => t.name).join(", ")}`);
242
+ }