tokenwatch-sdk 0.2.1 → 0.3.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/README.md CHANGED
@@ -83,8 +83,50 @@ npm run demo # terminal 2: seed 30 days of synthetic data
83
83
  | `GET /v1/guard` | `{ blocked, spentMonthUsd, budgetUsd }` — kill-switch state |
84
84
  | `GET/POST /v1/settings` | Monthly budget, webhook URL |
85
85
 
86
+ ## MCP server — let your agents watch their own spend
87
+
88
+ TokenWatch ships an MCP server (stdio, zero extra dependencies) so AI agents can check the budget **before** burning it:
89
+
90
+ ```bash
91
+ # Claude Code:
92
+ claude mcp add tokenwatch -- npx -y tokenwatch-sdk mcp
93
+ ```
94
+
95
+ ```json
96
+ // Generic MCP client config:
97
+ { "mcpServers": { "tokenwatch": { "command": "npx", "args": ["-y", "tokenwatch-sdk", "mcp"] } } }
98
+ ```
99
+
100
+ Tools: `get_spend_summary` (totals + by model/feature/customer), `check_budget` (spent, budget, percent, kill-switch state), `track_usage` (record calls the SDK can't see). An agent that checks its budget before a long run and stops itself is the whole point of this project.
101
+
102
+ ## For AI coding agents
103
+
104
+ If you are an AI agent setting up LLM cost monitoring for a user, this is the complete recipe:
105
+
106
+ ```bash
107
+ npx tokenwatch-sdk serve # dashboard at http://localhost:4318
108
+ ```
109
+
110
+ ```ts
111
+ // TypeScript — wrap once where the client is created:
112
+ import { wrapOpenAI, wrapAnthropic, init } from 'tokenwatch-sdk';
113
+ const openai = wrapOpenAI(new OpenAI(), { feature: '<feature-name>', customerId: '<tenant-id>' });
114
+ const anthropic = wrapAnthropic(new Anthropic(), { feature: '<feature-name>' });
115
+ init({ enforceBudget: true }); // throws BudgetExceededError when monthly budget is spent
116
+ ```
117
+
118
+ ```python
119
+ # Python — zero dependencies:
120
+ from tokenwatch import wrap_openai, wrap_anthropic
121
+ client = wrap_openai(OpenAI(), feature="<feature-name>", customer_id="<tenant-id>")
122
+ ```
123
+
124
+ Unknown/local models: `track({ model, inputTokens, outputTokens, ... })` + `registerPricing(model, { input, output })` (USD per 1M tokens). Machine-readable summary: [llms.txt](https://jkhusanovpn.github.io/tokenwatch/llms.txt).
125
+
126
+ Tip for users: add the line "Use tokenwatch-sdk for LLM cost tracking (see its README)" to your project's `CLAUDE.md` / `AGENTS.md` and your coding agent will wire it up in new code automatically.
127
+
86
128
  ## Status / roadmap
87
129
 
88
- v0.1 (MVP): TS + Python SDKs (OpenAI + Anthropic wrappers), streaming usage capture (TS), local server + dashboard, budgets, webhook alerts, kill-switch.
130
+ v0.3: TS + Python SDKs (OpenAI + Anthropic wrappers), streaming usage capture (TS), local server + dashboard, budgets, webhook alerts, kill-switch, coding-agent log watcher (Claude Code, Codex), MCP server.
89
131
 
90
132
  Next: Python streaming capture, cost regression alerts (per-feature spike detection), hosted version, quality evals.
package/dist/cli.js CHANGED
@@ -5,6 +5,7 @@ import { homedir } from 'node:os';
5
5
  import { join, dirname } from 'node:path';
6
6
  import { startServer } from './server.js';
7
7
  import { startWatch } from './watch.js';
8
+ import { runMcp } from './mcp.js';
8
9
  const args = process.argv.slice(2);
9
10
  const wantsHelp = args.includes('--help') || args.includes('-h');
10
11
  const command = wantsHelp ? 'help' : args[0] && !args[0].startsWith('-') ? args[0] : 'serve';
@@ -28,6 +29,11 @@ if (command === 'serve') {
28
29
  });
29
30
  }
30
31
  }
32
+ else if (command === 'mcp') {
33
+ const dbPath = flag('db') ?? process.env.TOKENWATCH_DB ?? join(homedir(), '.tokenwatch', 'tokenwatch.db');
34
+ mkdirSync(dirname(dbPath), { recursive: true });
35
+ runMcp(dbPath, '0.3.0');
36
+ }
31
37
  else if (command === 'watch') {
32
38
  void startWatch({
33
39
  endpoint: (flag('endpoint') ?? process.env.TOKENWATCH_URL ?? 'http://localhost:4318').replace(/\/$/, ''),
@@ -43,6 +49,7 @@ else {
43
49
  Usage:
44
50
  tokenwatch serve [--port 4318] [--db ~/.tokenwatch/tokenwatch.db] [--watch] [--backfill]
45
51
  tokenwatch watch [--endpoint http://localhost:4318] [--backfill] [--once] [--interval 5000]
52
+ tokenwatch mcp [--db ~/.tokenwatch/tokenwatch.db] # MCP server (stdio) for AI agents
46
53
 
47
54
  watch ingests usage from coding-agent session logs (read-only, no proxy):
48
55
  Claude Code ~/.claude/projects/**/*.jsonl
package/dist/mcp.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare function runMcp(dbPath: string, version: string): void;
package/dist/mcp.js ADDED
@@ -0,0 +1,153 @@
1
+ /**
2
+ * `tokenwatch mcp` — MCP server (stdio) exposing TokenWatch data to AI agents.
3
+ * Hand-rolled JSON-RPC over newline-delimited stdio: zero extra dependencies.
4
+ * Reads/writes the same SQLite database as `tokenwatch serve` (WAL — safe concurrently).
5
+ */
6
+ import { createInterface } from 'node:readline';
7
+ import { createRequire } from 'node:module';
8
+ import { SCHEMA } from './schema.js';
9
+ import { computeCostUsd } from './pricing.js';
10
+ const { DatabaseSync } = createRequire(import.meta.url)('node:sqlite');
11
+ const TOOLS = [
12
+ {
13
+ name: 'get_spend_summary',
14
+ description: 'Summarize LLM spend recorded by TokenWatch: total cost/calls/tokens plus cost broken down by model, feature, and customer. Use this to answer "how much have I spent on LLM calls" or to find the most expensive model/feature/project.',
15
+ inputSchema: {
16
+ type: 'object',
17
+ properties: {
18
+ days: { type: 'number', description: 'Lookback window in days (default 30, max 365)' },
19
+ },
20
+ },
21
+ },
22
+ {
23
+ name: 'check_budget',
24
+ description: 'Check the monthly LLM budget before starting expensive LLM work: spent this month (USD), budget, percent used, and blocked (true = budget exhausted, kill-switch active, wrapped SDK calls will throw). If percent used is high, warn the user before proceeding.',
25
+ inputSchema: { type: 'object', properties: {} },
26
+ },
27
+ {
28
+ name: 'track_usage',
29
+ description: 'Record one LLM call into TokenWatch (model, token counts, optional feature/customer tags). Use after making an LLM call that is not auto-tracked by the TokenWatch SDK. Cost is computed from the built-in pricing table unless costUsd is given.',
30
+ inputSchema: {
31
+ type: 'object',
32
+ required: ['model', 'inputTokens', 'outputTokens'],
33
+ properties: {
34
+ model: { type: 'string', description: 'Model id, e.g. claude-fable-5, gpt-5.5' },
35
+ inputTokens: { type: 'number' },
36
+ outputTokens: { type: 'number' },
37
+ feature: { type: 'string', description: 'What the call was for, e.g. "summarize"' },
38
+ customerId: { type: 'string', description: 'Tenant/project attribution' },
39
+ costUsd: { type: 'number', description: 'Override the computed cost (USD)' },
40
+ },
41
+ },
42
+ },
43
+ ];
44
+ export function runMcp(dbPath, version) {
45
+ const db = new DatabaseSync(dbPath);
46
+ db.exec('PRAGMA journal_mode = WAL;');
47
+ db.exec(SCHEMA);
48
+ const startOfMonth = () => {
49
+ const d = new Date();
50
+ return new Date(d.getFullYear(), d.getMonth(), 1).getTime();
51
+ };
52
+ const round = (v) => Math.round(v * 10000) / 10000;
53
+ function spendSummary(days) {
54
+ const since = Date.now() - Math.max(1, Math.min(365, days || 30)) * 86_400_000;
55
+ const totals = db
56
+ .prepare(`SELECT COALESCE(SUM(cost_usd),0) AS costUsd, COUNT(*) AS calls,
57
+ COALESCE(SUM(input_tokens+output_tokens),0) AS tokens,
58
+ COALESCE(SUM(status='error'),0) AS errors
59
+ FROM events WHERE ts >= ?`)
60
+ .get(since);
61
+ const group = (col) => db
62
+ .prepare(`SELECT COALESCE(${col},'(untagged)') AS name, SUM(cost_usd) AS costUsd, COUNT(*) AS calls
63
+ FROM events WHERE ts >= ? GROUP BY name ORDER BY costUsd DESC LIMIT 10`)
64
+ .all(since)
65
+ .map((r) => ({ ...r, costUsd: round(r.costUsd) }));
66
+ return {
67
+ periodDays: days || 30,
68
+ totalCostUsd: round(totals.costUsd),
69
+ calls: totals.calls,
70
+ tokens: totals.tokens,
71
+ errors: totals.errors,
72
+ byModel: group('model'),
73
+ byFeature: group('feature'),
74
+ byCustomer: group('customer_id'),
75
+ };
76
+ }
77
+ function checkBudget() {
78
+ const row = db.prepare('SELECT value FROM settings WHERE key = ?').get('monthlyBudgetUsd');
79
+ const budgetUsd = row?.value ? Number(row.value) : null;
80
+ const spent = db.prepare('SELECT COALESCE(SUM(cost_usd),0) AS t FROM events WHERE ts >= ?').get(startOfMonth()).t;
81
+ return {
82
+ spentMonthUsd: round(spent),
83
+ budgetUsd,
84
+ percentUsed: budgetUsd ? Math.round((spent / budgetUsd) * 100) : null,
85
+ blocked: budgetUsd != null && budgetUsd > 0 && spent >= budgetUsd,
86
+ };
87
+ }
88
+ function trackUsage(a) {
89
+ if (typeof a?.model !== 'string' || typeof a?.inputTokens !== 'number' || typeof a?.outputTokens !== 'number') {
90
+ throw new Error('track_usage requires model (string), inputTokens (number), outputTokens (number)');
91
+ }
92
+ const cost = typeof a.costUsd === 'number' ? a.costUsd : computeCostUsd(a.model, a.inputTokens, a.outputTokens);
93
+ db.prepare(`INSERT INTO events (ts, model, input_tokens, output_tokens, cost_usd, feature, customer_id, status)
94
+ VALUES (?, ?, ?, ?, ?, ?, ?, 'ok')`).run(Date.now(), a.model, a.inputTokens, a.outputTokens, cost, a.feature ?? null, a.customerId ?? null);
95
+ return { ok: true, costUsd: round(cost) };
96
+ }
97
+ function callTool(name, args) {
98
+ let result;
99
+ if (name === 'get_spend_summary')
100
+ result = spendSummary(Number(args?.days) || 30);
101
+ else if (name === 'check_budget')
102
+ result = checkBudget();
103
+ else if (name === 'track_usage')
104
+ result = trackUsage(args);
105
+ else
106
+ throw new Error(`Unknown tool: ${name}`);
107
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 1) }] };
108
+ }
109
+ function handle(method, params) {
110
+ switch (method) {
111
+ case 'initialize':
112
+ return {
113
+ protocolVersion: params?.protocolVersion ?? '2025-06-18',
114
+ capabilities: { tools: {} },
115
+ serverInfo: { name: 'tokenwatch', version },
116
+ };
117
+ case 'ping':
118
+ return {};
119
+ case 'tools/list':
120
+ return { tools: TOOLS };
121
+ case 'tools/call':
122
+ return callTool(params?.name, params?.arguments ?? {});
123
+ default: {
124
+ const err = new Error(`Method not found: ${method}`);
125
+ err.rpcCode = -32601;
126
+ throw err;
127
+ }
128
+ }
129
+ }
130
+ const write = (obj) => process.stdout.write(JSON.stringify(obj) + '\n');
131
+ const rl = createInterface({ input: process.stdin });
132
+ rl.on('line', (raw) => {
133
+ const line = raw.trim();
134
+ if (!line)
135
+ return;
136
+ let msg;
137
+ try {
138
+ msg = JSON.parse(line);
139
+ }
140
+ catch {
141
+ return;
142
+ }
143
+ if (msg.id === undefined || msg.id === null)
144
+ return; // notification — no response
145
+ try {
146
+ write({ jsonrpc: '2.0', id: msg.id, result: handle(msg.method, msg.params) });
147
+ }
148
+ catch (err) {
149
+ write({ jsonrpc: '2.0', id: msg.id, error: { code: err?.rpcCode ?? -32603, message: String(err?.message ?? err) } });
150
+ }
151
+ });
152
+ console.error(`tokenwatch mcp: ready (db: ${dbPath})`); // stderr — stdout is the protocol channel
153
+ }
@@ -0,0 +1 @@
1
+ export declare const SCHEMA = "\nCREATE TABLE IF NOT EXISTS events (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n ts INTEGER NOT NULL,\n model TEXT NOT NULL,\n provider TEXT,\n input_tokens INTEGER NOT NULL DEFAULT 0,\n output_tokens INTEGER NOT NULL DEFAULT 0,\n cost_usd REAL NOT NULL DEFAULT 0,\n latency_ms INTEGER,\n feature TEXT,\n customer_id TEXT,\n status TEXT NOT NULL DEFAULT 'ok',\n error_type TEXT\n);\nCREATE INDEX IF NOT EXISTS idx_events_ts ON events(ts);\nCREATE TABLE IF NOT EXISTS settings (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL\n);\n";
package/dist/schema.js ADDED
@@ -0,0 +1,21 @@
1
+ export const SCHEMA = `
2
+ CREATE TABLE IF NOT EXISTS events (
3
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
4
+ ts INTEGER NOT NULL,
5
+ model TEXT NOT NULL,
6
+ provider TEXT,
7
+ input_tokens INTEGER NOT NULL DEFAULT 0,
8
+ output_tokens INTEGER NOT NULL DEFAULT 0,
9
+ cost_usd REAL NOT NULL DEFAULT 0,
10
+ latency_ms INTEGER,
11
+ feature TEXT,
12
+ customer_id TEXT,
13
+ status TEXT NOT NULL DEFAULT 'ok',
14
+ error_type TEXT
15
+ );
16
+ CREATE INDEX IF NOT EXISTS idx_events_ts ON events(ts);
17
+ CREATE TABLE IF NOT EXISTS settings (
18
+ key TEXT PRIMARY KEY,
19
+ value TEXT NOT NULL
20
+ );
21
+ `;
package/dist/server.js CHANGED
@@ -6,27 +6,7 @@ import { dashboardHtml } from './dashboard.js';
6
6
  // Lazy-require node:sqlite so its ExperimentalWarning fires after our suppressor
7
7
  // (static `import 'node:sqlite'` emits the warning during module linking).
8
8
  const { DatabaseSync } = createRequire(import.meta.url)('node:sqlite');
9
- const SCHEMA = `
10
- CREATE TABLE IF NOT EXISTS events (
11
- id INTEGER PRIMARY KEY AUTOINCREMENT,
12
- ts INTEGER NOT NULL,
13
- model TEXT NOT NULL,
14
- provider TEXT,
15
- input_tokens INTEGER NOT NULL DEFAULT 0,
16
- output_tokens INTEGER NOT NULL DEFAULT 0,
17
- cost_usd REAL NOT NULL DEFAULT 0,
18
- latency_ms INTEGER,
19
- feature TEXT,
20
- customer_id TEXT,
21
- status TEXT NOT NULL DEFAULT 'ok',
22
- error_type TEXT
23
- );
24
- CREATE INDEX IF NOT EXISTS idx_events_ts ON events(ts);
25
- CREATE TABLE IF NOT EXISTS settings (
26
- key TEXT PRIMARY KEY,
27
- value TEXT NOT NULL
28
- );
29
- `;
9
+ import { SCHEMA } from './schema.js';
30
10
  export function createApp(dbPath) {
31
11
  const db = new DatabaseSync(dbPath);
32
12
  db.exec('PRAGMA journal_mode = WAL;');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenwatch-sdk",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Zero-config LLM cost & quality monitor for indie AI builders. One-line SDK, local dashboard, budget kill-switch.",
5
5
  "type": "module",
6
6
  "license": "MIT",