chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,755 @@
|
||||
# CodeBurn MCP Server — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a `codeburn mcp` stdio MCP server exposing CodeBurn's usage/cost data to AI agents via two tools (`get_usage`, `get_savings`), each returning a markdown table plus typed structured JSON.
|
||||
|
||||
**Architecture:** Extract the existing `status --format menubar-json` aggregation into one reusable `buildMenubarPayloadForRange(periodInfo, opts)` (with an `optimize` boolean — the only expensive call, `scanAndDetect`, is skipped for `get_usage`). A long-lived in-process `McpServer` registers the two tools, injects the aggregator for testability, coalesces concurrent calls, hashes project names by default, and relies on the existing 180 s parser cache for warm reuse.
|
||||
|
||||
**Tech Stack:** TypeScript (ESM, `type: module`, node ≥ 22.13), commander, `@modelcontextprotocol/sdk@^1.29` (v1), zod, tsup, vitest.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- **Create `src/usage-aggregator.ts`** — owns `buildPeriodData` (moved from `main.ts`) and the extracted `buildMenubarPayloadForRange(periodInfo, opts): Promise<MenubarPayload>`. Single responsibility: turn a resolved date range + filters into a `MenubarPayload`.
|
||||
- **Create `src/mcp/redact.ts`** — `pseudonym()` + `redactProjectNames(payload, include)`. Privacy only.
|
||||
- **Create `src/mcp/tables.ts`** — markdown renderers (`renderSummaryTable`, `renderBreakdownTable`, `renderSavingsTable`). Presentation only.
|
||||
- **Create `src/mcp/server.ts`** — `createServer(deps)` (tool registration + handlers, aggregator injectable) and `startStdioServer(version)` (loadPricing + pre-warm + stdio transport).
|
||||
- **Modify `src/main.ts`** — import `buildPeriodData`/`buildMenubarPayloadForRange` from the aggregator; refactor the `status` menubar branch to call the aggregator; add `.command('mcp')`.
|
||||
- **Modify `package.json`** — add `@modelcontextprotocol/sdk` + `zod` deps.
|
||||
- **Modify `tsup.config.ts`** — `external: ['@modelcontextprotocol/sdk', 'zod']`.
|
||||
- **Create tests** — `tests/usage-aggregator.test.ts`, `tests/mcp-redact.test.ts`, `tests/mcp-tables.test.ts`, `tests/mcp-server.test.ts`.
|
||||
|
||||
Internal MCP period names map to CodeBurn's: `today→today`, `last_7_days→week`, `last_30_days→30days`, `month_to_date→month`, `last_6_months→all` (≈6 months).
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Add dependencies and externalize them in the bundle
|
||||
|
||||
**Files:**
|
||||
- Modify: `package.json` (dependencies)
|
||||
- Modify: `tsup.config.ts`
|
||||
|
||||
- [ ] **Step 1: Add runtime deps**
|
||||
|
||||
Run: `npm install @modelcontextprotocol/sdk@^1.29.0 zod@^3.25.0 --save-exact=false`
|
||||
Expected: both appear under `"dependencies"` in `package.json`; `node_modules/@modelcontextprotocol/sdk` and `node_modules/zod` exist.
|
||||
|
||||
- [ ] **Step 2: Externalize them so they are not inlined into `dist/main.js`**
|
||||
|
||||
Edit `tsup.config.ts` to:
|
||||
|
||||
```ts
|
||||
import { defineConfig } from 'tsup'
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/main.ts'],
|
||||
format: ['esm'],
|
||||
target: 'node20',
|
||||
outDir: 'dist',
|
||||
clean: true,
|
||||
splitting: false,
|
||||
sourcemap: true,
|
||||
dts: false,
|
||||
external: ['@modelcontextprotocol/sdk', 'zod'],
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build and confirm the SDK is external (not bundled)**
|
||||
|
||||
Run: `npm run build && node -e "const s=require('fs').readFileSync('dist/main.js','utf8'); if(/from'@modelcontextprotocol\/sdk|require\(.@modelcontextprotocol\/sdk./.test(s.replace(/\s/g,''))||!/McpServer/.test(s)){} console.log('built, bytes', s.length)"`
|
||||
Expected: build succeeds, prints `built, bytes <n>`. (The SDK isn't imported anywhere yet, so this just verifies the config builds.)
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add package.json package-lock.json tsup.config.ts
|
||||
git commit -m "build(mcp): add @modelcontextprotocol/sdk + zod, externalize in tsup"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Move `buildPeriodData` into a shared module
|
||||
|
||||
`buildPeriodData` is currently a private function in `main.ts:410`. The aggregator needs it, so move it to the new module and import it back into `main.ts`.
|
||||
|
||||
**Files:**
|
||||
- Create: `src/usage-aggregator.ts`
|
||||
- Modify: `src/main.ts:410` (remove local def), import section
|
||||
|
||||
- [ ] **Step 1: Create the module with `buildPeriodData` moved verbatim**
|
||||
|
||||
Create `src/usage-aggregator.ts`. Move the entire `function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData { ... }` body from `main.ts` into it, exported, with imports it needs:
|
||||
|
||||
```ts
|
||||
import type { ProjectSummary } from './types.js'
|
||||
import { type PeriodData } from './menubar-json.js'
|
||||
|
||||
export function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData {
|
||||
// ... exact body moved from main.ts:410 ...
|
||||
}
|
||||
```
|
||||
|
||||
(Copy the body unchanged. Add any imports the body references — e.g. `getShortModelName` from `./models.js` — until `npx tsc --noEmit` is clean.)
|
||||
|
||||
- [ ] **Step 2: Update `main.ts` to import it and delete the local copy**
|
||||
|
||||
Remove the `function buildPeriodData(...) {...}` at `main.ts:410`. Add to the import block near the top:
|
||||
|
||||
```ts
|
||||
import { buildPeriodData } from './usage-aggregator.js'
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Typecheck + run the full suite (parity guard)**
|
||||
|
||||
Run: `npx tsc --noEmit && npm test`
|
||||
Expected: typecheck clean; all existing tests pass (the `status` paths still use `buildPeriodData`, now imported).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/usage-aggregator.ts src/main.ts
|
||||
git commit -m "refactor: move buildPeriodData into usage-aggregator module"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Extract `buildMenubarPayloadForRange`
|
||||
|
||||
Move the `status` menubar-json aggregation block (`main.ts:485–759`, everything after `periodInfo`/`now` are computed, ending at the `console.log`) into the aggregator as a function that **returns** the payload instead of printing it.
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/usage-aggregator.ts` (add the function)
|
||||
- Modify: `src/main.ts:476–761` (call it)
|
||||
- Test: existing `tests/cli-status-menubar.test.ts` is the parity guard
|
||||
|
||||
- [ ] **Step 1: Add the function signature + types to `src/usage-aggregator.ts`**
|
||||
|
||||
```ts
|
||||
import { homedir } from 'node:os'
|
||||
import type { ProjectSummary, DateRange } from './types.js'
|
||||
import { type PeriodData, type ProviderCost, type BreakdownArrays, type MenubarPayload, buildMenubarPayload } from './menubar-json.js'
|
||||
import { parseAllSessions, getAllProviders, filterProjectsByName, filterProjectsByDays } from './parser.js'
|
||||
import { aggregateProjectsIntoDays, buildPeriodDataFromDays } from './day-aggregator.js'
|
||||
import { aggregateModelEfficiency } from './model-efficiency.js'
|
||||
import { scanAndDetect } from './optimize.js'
|
||||
import { hydrateCache, loadDailyCache, getDaysInRange, toDateString, BACKFILL_DAYS, type DailyCache } from './daily-cache.js'
|
||||
|
||||
export type PeriodInfo = { range: DateRange; label: string }
|
||||
export type AggregateOpts = {
|
||||
provider?: string
|
||||
project?: string[]
|
||||
exclude?: string[]
|
||||
daysSelection?: { range: DateRange; label: string; days: Set<string> } | null
|
||||
optimize?: boolean
|
||||
}
|
||||
|
||||
export async function buildMenubarPayloadForRange(
|
||||
periodInfo: PeriodInfo,
|
||||
opts: AggregateOpts = {},
|
||||
): Promise<MenubarPayload> {
|
||||
const pf = opts.provider ?? 'all'
|
||||
const daysSelection = opts.daysSelection ?? null
|
||||
const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project ?? [], opts.exclude ?? [])
|
||||
// ... moved block from main.ts:485–757 (now/todayStart/... through breakdowns) ...
|
||||
const optimize = opts.optimize === false ? null : await scanAndDetect(scanProjects, scanRange)
|
||||
return buildMenubarPayload(currentData, providers, optimize, dailyHistory, retryTax, routingWaste, breakdowns)
|
||||
}
|
||||
```
|
||||
|
||||
Move lines `main.ts:485–757` (from `const now = new Date()` through the `breakdowns` IIFE) into the body unchanged. The block already references only the symbols imported above plus `homedir()`. `hydrateCache` is imported from `./daily-cache.js` (same module as `loadDailyCache`); if tsc reports a different source, follow the import it suggests.
|
||||
|
||||
- [ ] **Step 2: Precondition note — pricing**
|
||||
|
||||
The block assumes `loadPricing()` already ran. The `status` action calls it at `main.ts:473`; the MCP server will call it at startup (Task 6). Do **not** call `loadPricing()` inside the function.
|
||||
|
||||
- [ ] **Step 3: Refactor the `status` menubar branch to call it**
|
||||
|
||||
Replace `main.ts:485–760` (the inline block + `console.log` + `return`) with:
|
||||
|
||||
```ts
|
||||
const payload = await buildMenubarPayloadForRange(periodInfo, {
|
||||
provider: pf,
|
||||
project: opts.project,
|
||||
exclude: opts.exclude,
|
||||
daysSelection,
|
||||
optimize: opts.optimize !== false,
|
||||
})
|
||||
console.log(JSON.stringify(payload))
|
||||
return
|
||||
```
|
||||
|
||||
Keep `main.ts:477–484` (the `daysSelection`/`customRange`/`daySelection`/`periodInfo` resolution) — `periodInfo` and `daysSelection` are the inputs now passed in. Add `buildMenubarPayloadForRange` to the existing import from `./usage-aggregator.js`.
|
||||
|
||||
- [ ] **Step 4: Typecheck + parity test**
|
||||
|
||||
Run: `npx tsc --noEmit && npm test -- cli-status-menubar`
|
||||
Expected: clean typecheck; `tests/cli-status-menubar.test.ts` passes — i.e. `status --format menubar-json` output is unchanged (parity).
|
||||
|
||||
- [ ] **Step 5: Add a direct unit test for the aggregator**
|
||||
|
||||
Create `tests/usage-aggregator.test.ts`:
|
||||
|
||||
```ts
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildMenubarPayloadForRange } from '../src/usage-aggregator.js'
|
||||
import { getDateRange } from '../src/cli-date.js'
|
||||
|
||||
describe('buildMenubarPayloadForRange', () => {
|
||||
it('returns a zero payload with no data and skips optimize when optimize:false', async () => {
|
||||
const payload = await buildMenubarPayloadForRange(getDateRange('today'), { provider: 'all', optimize: false })
|
||||
expect(payload.current.cost).toBe(0)
|
||||
expect(payload.current.calls).toBe(0)
|
||||
expect(payload.optimize.findingCount).toBe(0)
|
||||
expect(Array.isArray(payload.current.topProjects)).toBe(true)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
Run: `npm test -- usage-aggregator`
|
||||
Expected: PASS (uses the empty real environment; `scanAndDetect` not called because `optimize:false`).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/usage-aggregator.ts src/main.ts tests/usage-aggregator.test.ts
|
||||
git commit -m "refactor: extract buildMenubarPayloadForRange for reuse by MCP"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Project-name redaction
|
||||
|
||||
**Files:**
|
||||
- Create: `src/mcp/redact.ts`
|
||||
- Test: `tests/mcp-redact.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `tests/mcp-redact.test.ts`:
|
||||
|
||||
```ts
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { pseudonym, redactProjectNames } from '../src/mcp/redact.js'
|
||||
import type { MenubarPayload } from '../src/menubar-json.js'
|
||||
|
||||
function payload(): MenubarPayload {
|
||||
const base = {
|
||||
name: 'secret-client-repo', cost: 5, sessions: 2, avgCostPerSession: 2.5, sessionDetails: [],
|
||||
}
|
||||
return {
|
||||
generated: '', optimize: { findingCount: 0, savingsUSD: 0, topFindings: [] }, history: { daily: [] },
|
||||
current: {
|
||||
label: 'Today', cost: 5, calls: 10, sessions: 2, oneShotRate: null, inputTokens: 0, outputTokens: 0,
|
||||
cacheHitPercent: 0, topActivities: [], topModels: [], providers: {},
|
||||
topProjects: [base], modelEfficiency: [],
|
||||
topSessions: [{ project: 'secret-client-repo', cost: 5, calls: 10, date: '2026-06-01' }],
|
||||
retryTax: { totalUSD: 0, retries: 0, editTurns: 0, byModel: [] },
|
||||
routingWaste: { totalSavingsUSD: 0, baselineModel: '', baselineCostPerEdit: 0, byModel: [] },
|
||||
tools: [], skills: [], subagents: [], mcpServers: [],
|
||||
},
|
||||
} as MenubarPayload
|
||||
}
|
||||
|
||||
describe('redact', () => {
|
||||
it('pseudonym is stable and path-free', () => {
|
||||
expect(pseudonym('a')).toBe(pseudonym('a'))
|
||||
expect(pseudonym('secret-client-repo')).toMatch(/^project-[0-9a-f]{6}$/)
|
||||
})
|
||||
it('hashes project names by default, preserves numbers', () => {
|
||||
const out = redactProjectNames(payload(), false)
|
||||
expect(out.current.topProjects[0]!.name).toMatch(/^project-[0-9a-f]{6}$/)
|
||||
expect(out.current.topSessions[0]!.project).toMatch(/^project-[0-9a-f]{6}$/)
|
||||
expect(out.current.topProjects[0]!.cost).toBe(5)
|
||||
})
|
||||
it('keeps real names when include=true', () => {
|
||||
const out = redactProjectNames(payload(), true)
|
||||
expect(out.current.topProjects[0]!.name).toBe('secret-client-repo')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify failure**
|
||||
|
||||
Run: `npm test -- mcp-redact`
|
||||
Expected: FAIL ("Cannot find module '../src/mcp/redact.js'").
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
Create `src/mcp/redact.ts`:
|
||||
|
||||
```ts
|
||||
import { createHash } from 'node:crypto'
|
||||
import type { MenubarPayload } from '../menubar-json.js'
|
||||
|
||||
export function pseudonym(name: string): string {
|
||||
return `project-${createHash('sha256').update(name).digest('hex').slice(0, 6)}`
|
||||
}
|
||||
|
||||
export function redactProjectNames(payload: MenubarPayload, includeNames: boolean): MenubarPayload {
|
||||
if (includeNames) return payload
|
||||
return {
|
||||
...payload,
|
||||
current: {
|
||||
...payload.current,
|
||||
topProjects: payload.current.topProjects.map(p => ({ ...p, name: pseudonym(p.name) })),
|
||||
topSessions: payload.current.topSessions.map(s => ({ ...s, project: pseudonym(s.project) })),
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to verify pass**
|
||||
|
||||
Run: `npm test -- mcp-redact`
|
||||
Expected: PASS (3 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/mcp/redact.ts tests/mcp-redact.test.ts
|
||||
git commit -m "feat(mcp): hash project names by default with opt-in reveal"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Markdown table renderers
|
||||
|
||||
**Files:**
|
||||
- Create: `src/mcp/tables.ts`
|
||||
- Test: `tests/mcp-tables.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `tests/mcp-tables.test.ts`:
|
||||
|
||||
```ts
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { renderSummaryTable, renderBreakdownTable, renderSavingsTable } from '../src/mcp/tables.js'
|
||||
import type { MenubarPayload } from '../src/menubar-json.js'
|
||||
|
||||
function payload(): MenubarPayload {
|
||||
return {
|
||||
generated: '', optimize: { findingCount: 1, savingsUSD: 2.5, topFindings: [{ title: 'Trim system prompt', impact: 'high', savingsUSD: 2.5 }] }, history: { daily: [] },
|
||||
current: {
|
||||
label: 'Last 7 Days', cost: 12.5, calls: 100, sessions: 4, oneShotRate: 0.5, inputTokens: 1000, outputTokens: 500,
|
||||
cacheHitPercent: 80, topActivities: [{ name: 'feature', cost: 8, turns: 30, oneShotRate: 0.6 }],
|
||||
topModels: [{ name: 'Opus 4.8', cost: 10, calls: 60 }], providers: { 'claude code': 12.5 },
|
||||
topProjects: [{ name: 'project-abc123', cost: 12.5, sessions: 4, avgCostPerSession: 3.125, sessionDetails: [] }],
|
||||
modelEfficiency: [], topSessions: [],
|
||||
retryTax: { totalUSD: 1.2, retries: 4, editTurns: 20, byModel: [{ name: 'Opus 4.8', taxUSD: 1.2, retries: 4, retriesPerEdit: 0.2 }] },
|
||||
routingWaste: { totalSavingsUSD: 3, baselineModel: 'Haiku 4.5', baselineCostPerEdit: 0.01, byModel: [{ name: 'Opus 4.8', costPerEdit: 0.05, editTurns: 20, actualUSD: 1, counterfactualUSD: 0.2, savingsUSD: 0.8 }] },
|
||||
tools: [], skills: [], subagents: [], mcpServers: [],
|
||||
},
|
||||
} as MenubarPayload
|
||||
}
|
||||
|
||||
describe('tables', () => {
|
||||
it('summary shows headline cost and top models', () => {
|
||||
const t = renderSummaryTable(payload())
|
||||
expect(t).toContain('Last 7 Days')
|
||||
expect(t).toContain('Opus 4.8')
|
||||
expect(t).toContain('| Model | Cost | Calls |')
|
||||
})
|
||||
it('breakdown by provider lists providers', () => {
|
||||
expect(renderBreakdownTable(payload(), 'provider', 20)).toContain('claude code')
|
||||
})
|
||||
it('breakdown handles empty dimension gracefully', () => {
|
||||
const p = payload(); p.current.topActivities = []
|
||||
expect(renderBreakdownTable(p, 'task', 20)).toContain('no data')
|
||||
})
|
||||
it('savings shows retry tax and routing waste', () => {
|
||||
const t = renderSavingsTable(payload())
|
||||
expect(t).toContain('Retry tax')
|
||||
expect(t).toContain('Routing waste')
|
||||
expect(t).toContain('Trim system prompt')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify failure**
|
||||
|
||||
Run: `npm test -- mcp-tables`
|
||||
Expected: FAIL ("Cannot find module '../src/mcp/tables.js'").
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
Create `src/mcp/tables.ts`:
|
||||
|
||||
```ts
|
||||
import { formatCost, formatTokens } from '../format.js'
|
||||
import type { MenubarPayload } from '../menubar-json.js'
|
||||
|
||||
export type BreakdownBy = 'project' | 'model' | 'task' | 'provider'
|
||||
|
||||
function mdTable(headers: string[], rows: string[][]): string {
|
||||
const head = `| ${headers.join(' | ')} |`
|
||||
const sep = `| ${headers.map(() => '---').join(' | ')} |`
|
||||
if (rows.length === 0) return `${head}\n${sep}\n| _(no data)_ |${' |'.repeat(headers.length - 1)}`
|
||||
return [head, sep, ...rows.map(r => `| ${r.join(' | ')} |`)].join('\n')
|
||||
}
|
||||
|
||||
const pct = (n: number) => `${Math.round(n)}%`
|
||||
const oneShot = (r: number | null) => (r === null ? 'n/a' : pct(r * 100))
|
||||
|
||||
export function renderSummaryTable(p: MenubarPayload): string {
|
||||
const c = p.current
|
||||
return [
|
||||
`**${c.label}** — ${formatCost(c.cost)} · ${c.calls} calls · ${c.sessions} sessions`,
|
||||
`cache hit ${pct(c.cacheHitPercent)} · one-shot ${oneShot(c.oneShotRate)} · in ${formatTokens(c.inputTokens)} / out ${formatTokens(c.outputTokens)}`,
|
||||
'',
|
||||
'_Top models_',
|
||||
mdTable(['Model', 'Cost', 'Calls'], c.topModels.slice(0, 5).map(m => [m.name, formatCost(m.cost), String(m.calls)])),
|
||||
'',
|
||||
'_Top projects_',
|
||||
mdTable(['Project', 'Cost', 'Sessions'], c.topProjects.slice(0, 5).map(x => [x.name, formatCost(x.cost), String(x.sessions)])),
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function renderBreakdownTable(p: MenubarPayload, by: BreakdownBy, limit: number): string {
|
||||
const c = p.current
|
||||
if (by === 'model') return mdTable(['Model', 'Cost', 'Calls'], c.topModels.slice(0, limit).map(m => [m.name, formatCost(m.cost), String(m.calls)]))
|
||||
if (by === 'project') return mdTable(['Project', 'Cost', 'Sessions'], c.topProjects.slice(0, limit).map(x => [x.name, formatCost(x.cost), String(x.sessions)]))
|
||||
if (by === 'task') return mdTable(['Task', 'Cost', 'Turns', 'One-shot'], c.topActivities.slice(0, limit).map(a => [a.name, formatCost(a.cost), String(a.turns), oneShot(a.oneShotRate)]))
|
||||
return mdTable(['Provider', 'Cost'], Object.entries(c.providers).sort(([, a], [, b]) => b - a).slice(0, limit).map(([name, cost]) => [name, formatCost(cost)]))
|
||||
}
|
||||
|
||||
export function renderSavingsTable(p: MenubarPayload): string {
|
||||
const c = p.current
|
||||
const findings = mdTable(['Finding', 'Impact', 'Saves'], p.optimize.topFindings.slice(0, 10).map(f => [f.title, f.impact, formatCost(f.savingsUSD)]))
|
||||
const retry = mdTable(['Model', 'Retry tax', 'Retries'], c.retryTax.byModel.map(m => [m.name, formatCost(m.taxUSD), String(m.retries)]))
|
||||
const routing = mdTable(['Model', 'Overpaid', 'vs baseline'], c.routingWaste.byModel.map(m => [m.name, formatCost(m.savingsUSD), c.routingWaste.baselineModel]))
|
||||
return [
|
||||
`**Savings — ${c.label}**`,
|
||||
`Optimize findings: ${p.optimize.findingCount} (≈ ${formatCost(p.optimize.savingsUSD)})`,
|
||||
findings, '',
|
||||
`_Retry tax_ — ${formatCost(c.retryTax.totalUSD)} on ${c.retryTax.retries} retries`,
|
||||
retry, '',
|
||||
`_Routing waste_ — ${formatCost(c.routingWaste.totalSavingsUSD)} vs ${c.routingWaste.baselineModel || 'n/a'}`,
|
||||
routing,
|
||||
].join('\n')
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to verify pass**
|
||||
|
||||
Run: `npm test -- mcp-tables`
|
||||
Expected: PASS (4 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/mcp/tables.ts tests/mcp-tables.test.ts
|
||||
git commit -m "feat(mcp): markdown table renderers for usage and savings"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: MCP server (tools, schemas, handlers, coalescing)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/mcp/server.ts`
|
||||
- Test: `tests/mcp-server.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing test (in-memory transport, injected aggregator)**
|
||||
|
||||
Create `tests/mcp-server.test.ts`:
|
||||
|
||||
```ts
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
|
||||
import { createServer } from '../src/mcp/server.js'
|
||||
import type { MenubarPayload } from '../src/menubar-json.js'
|
||||
|
||||
function fakePayload(calls = 100): MenubarPayload {
|
||||
return {
|
||||
generated: '', optimize: { findingCount: 1, savingsUSD: 2, topFindings: [{ title: 'X', impact: 'high', savingsUSD: 2 }] }, history: { daily: [] },
|
||||
current: {
|
||||
label: 'Today', cost: 9, calls, sessions: 1, oneShotRate: 0.5, inputTokens: 10, outputTokens: 5, cacheHitPercent: 50,
|
||||
topActivities: [{ name: 'feature', cost: 9, turns: 5, oneShotRate: 0.5 }], topModels: [{ name: 'Opus 4.8', cost: 9, calls }],
|
||||
providers: { 'claude code': 9 }, topProjects: [{ name: 'real-repo', cost: 9, sessions: 1, avgCostPerSession: 9, sessionDetails: [] }],
|
||||
modelEfficiency: [], topSessions: [{ project: 'real-repo', cost: 9, calls, date: '2026-06-01' }],
|
||||
retryTax: { totalUSD: 1, retries: 2, editTurns: 5, byModel: [{ name: 'Opus 4.8', taxUSD: 1, retries: 2, retriesPerEdit: 0.4 }] },
|
||||
routingWaste: { totalSavingsUSD: 1, baselineModel: 'Haiku 4.5', baselineCostPerEdit: 0.01, byModel: [] },
|
||||
tools: [], skills: [], subagents: [], mcpServers: [],
|
||||
},
|
||||
} as MenubarPayload
|
||||
}
|
||||
|
||||
async function connect(aggregate: any) {
|
||||
const server = createServer({ version: 'test', aggregate })
|
||||
const [a, b] = InMemoryTransport.createLinkedPair()
|
||||
const client = new Client({ name: 'test', version: '1' })
|
||||
await Promise.all([server.connect(a), client.connect(b)])
|
||||
return client
|
||||
}
|
||||
|
||||
describe('mcp server', () => {
|
||||
it('exposes exactly two read-only tools', async () => {
|
||||
const client = await connect(async () => fakePayload())
|
||||
const { tools } = await client.listTools()
|
||||
expect(tools.map(t => t.name).sort()).toEqual(['get_savings', 'get_usage'])
|
||||
expect(tools.find(t => t.name === 'get_usage')!.annotations?.readOnlyHint).toBe(true)
|
||||
})
|
||||
it('get_usage hashes project names by default', async () => {
|
||||
const client = await connect(async () => fakePayload())
|
||||
const res: any = await client.callTool({ name: 'get_usage', arguments: { period: 'today', by: 'project' } })
|
||||
expect(JSON.stringify(res)).not.toContain('real-repo')
|
||||
expect(JSON.stringify(res)).toMatch(/project-[0-9a-f]{6}/)
|
||||
expect(res.isError).toBeFalsy()
|
||||
})
|
||||
it('get_usage reveals names when opted in', async () => {
|
||||
const client = await connect(async () => fakePayload())
|
||||
const res: any = await client.callTool({ name: 'get_usage', arguments: { period: 'today', by: 'project', include_project_names: true } })
|
||||
expect(JSON.stringify(res)).toContain('real-repo')
|
||||
})
|
||||
it('empty data returns a friendly message, not a zero table', async () => {
|
||||
const client = await connect(async () => fakePayload(0))
|
||||
const res: any = await client.callTool({ name: 'get_usage', arguments: { period: 'today' } })
|
||||
expect(res.content[0].text.toLowerCase()).toContain('no usage')
|
||||
})
|
||||
it('aggregator failure surfaces as isError', async () => {
|
||||
const client = await connect(async () => { throw new Error('boom') })
|
||||
const res: any = await client.callTool({ name: 'get_savings', arguments: {} })
|
||||
expect(res.isError).toBe(true)
|
||||
expect(res.content[0].text).toContain('boom')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify failure**
|
||||
|
||||
Run: `npm test -- mcp-server`
|
||||
Expected: FAIL ("Cannot find module '../src/mcp/server.js'").
|
||||
|
||||
- [ ] **Step 3: Implement the server**
|
||||
|
||||
Create `src/mcp/server.ts`:
|
||||
|
||||
```ts
|
||||
import { z } from 'zod'
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
||||
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'
|
||||
import { getDateRange } from '../cli-date.js'
|
||||
import { loadPricing } from '../models.js'
|
||||
import { buildMenubarPayloadForRange, type PeriodInfo } from '../usage-aggregator.js'
|
||||
import type { MenubarPayload } from '../menubar-json.js'
|
||||
import { redactProjectNames } from './redact.js'
|
||||
import { renderSummaryTable, renderBreakdownTable, renderSavingsTable, type BreakdownBy } from './tables.js'
|
||||
|
||||
const PERIOD = { today: 'today', last_7_days: 'week', last_30_days: '30days', month_to_date: 'month', last_6_months: 'all' } as const
|
||||
type McpPeriod = keyof typeof PERIOD
|
||||
const periodSchema = z.enum(['today', 'last_7_days', 'last_30_days', 'month_to_date', 'last_6_months'])
|
||||
|
||||
type Aggregate = (periodInfo: PeriodInfo, opts: { provider?: string; optimize?: boolean }) => Promise<MenubarPayload>
|
||||
|
||||
const INSTRUCTIONS =
|
||||
'CodeBurn exposes local AI-coding spend data. Use get_usage for spend/usage and breakdowns (fast); ' +
|
||||
'use get_savings to find cost reductions (slower — runs a deeper analysis). Project names are pseudonymized ' +
|
||||
'unless include_project_names is true. All data is read locally from this machine; last_6_months is the widest ' +
|
||||
'window. Numbers reflect the most recent scan and may lag the current session by up to a few minutes.'
|
||||
|
||||
export function createServer(deps: { version: string; aggregate?: Aggregate }): McpServer {
|
||||
const aggregate = deps.aggregate ?? buildMenubarPayloadForRange
|
||||
const inflight = new Map<string, Promise<MenubarPayload>>()
|
||||
|
||||
const getPayload = (period: McpPeriod, optimize: boolean): Promise<MenubarPayload> => {
|
||||
const key = `${optimize ? 'sav' : 'use'}:${period}`
|
||||
const existing = inflight.get(key)
|
||||
if (existing) return existing
|
||||
const { range, label } = getDateRange(PERIOD[period])
|
||||
const p = aggregate({ range, label }, { provider: 'all', optimize }).finally(() => inflight.delete(key))
|
||||
inflight.set(key, p)
|
||||
return p
|
||||
}
|
||||
|
||||
const server = new McpServer({ name: 'codeburn', version: deps.version }, { instructions: INSTRUCTIONS })
|
||||
|
||||
server.registerTool(
|
||||
'get_usage',
|
||||
{
|
||||
title: 'CodeBurn — usage & cost',
|
||||
description:
|
||||
'Show AI coding token spend and usage for a period. Omit `by` for a headline summary; set `by` to break ' +
|
||||
'it down by project, model, task, or provider (Claude Code / Cursor / Codex). Fast. Local to this machine.',
|
||||
inputSchema: {
|
||||
period: periodSchema.default('today'),
|
||||
by: z.enum(['project', 'model', 'task', 'provider']).optional(),
|
||||
limit: z.number().int().min(1).max(100).default(20),
|
||||
include_project_names: z.boolean().default(false),
|
||||
},
|
||||
outputSchema: {
|
||||
period: z.string(),
|
||||
empty: z.boolean(),
|
||||
totals: z.object({ costUSD: z.number(), calls: z.number(), sessions: z.number(), cacheHitPercent: z.number(), oneShotRate: z.number().nullable() }),
|
||||
breakdown: z.array(z.object({ name: z.string(), costUSD: z.number() })).nullable(),
|
||||
},
|
||||
annotations: { title: 'CodeBurn — usage & cost', readOnlyHint: true, openWorldHint: false, idempotentHint: true },
|
||||
},
|
||||
async ({ period, by, limit, include_project_names }): Promise<CallToolResult> => {
|
||||
try {
|
||||
const payload = redactProjectNames(await getPayload(period, false), include_project_names)
|
||||
const c = payload.current
|
||||
const totals = { costUSD: c.cost, calls: c.calls, sessions: c.sessions, cacheHitPercent: c.cacheHitPercent, oneShotRate: c.oneShotRate }
|
||||
if (c.calls === 0) {
|
||||
return { content: [{ type: 'text', text: `No usage recorded for ${c.label} yet — run some coding sessions and try again.` }], structuredContent: { period: c.label, empty: true, totals, breakdown: null } }
|
||||
}
|
||||
const text = by ? renderBreakdownTable(payload, by as BreakdownBy, limit) : renderSummaryTable(payload)
|
||||
const breakdown = by ? breakdownRows(payload, by as BreakdownBy, limit) : null
|
||||
return { content: [{ type: 'text', text }], structuredContent: { period: c.label, empty: false, totals, breakdown } }
|
||||
} catch (err) {
|
||||
return { content: [{ type: 'text', text: `codeburn: failed to read usage — ${err instanceof Error ? err.message : String(err)}` }], isError: true }
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
server.registerTool(
|
||||
'get_savings',
|
||||
{
|
||||
title: 'CodeBurn — savings opportunities',
|
||||
description:
|
||||
'Find ways to reduce AI coding cost for a period: optimization findings, retry tax (money spent re-doing ' +
|
||||
'work), and routing waste (what you would have saved on a cheaper model). Slower than get_usage.',
|
||||
inputSchema: { period: periodSchema.default('last_7_days'), include_project_names: z.boolean().default(false) },
|
||||
outputSchema: {
|
||||
period: z.string(),
|
||||
optimize: z.object({ findingCount: z.number(), savingsUSD: z.number(), topFindings: z.array(z.object({ title: z.string(), impact: z.string(), savingsUSD: z.number() })) }),
|
||||
retryTaxUSD: z.number(),
|
||||
routingWasteUSD: z.number(),
|
||||
},
|
||||
annotations: { title: 'CodeBurn — savings opportunities', readOnlyHint: true, openWorldHint: false, idempotentHint: true },
|
||||
},
|
||||
async ({ period, include_project_names }): Promise<CallToolResult> => {
|
||||
try {
|
||||
const payload = redactProjectNames(await getPayload(period, true), include_project_names)
|
||||
const c = payload.current
|
||||
return {
|
||||
content: [{ type: 'text', text: renderSavingsTable(payload) }],
|
||||
structuredContent: { period: c.label, optimize: payload.optimize, retryTaxUSD: c.retryTax.totalUSD, routingWasteUSD: c.routingWaste.totalSavingsUSD },
|
||||
}
|
||||
} catch (err) {
|
||||
return { content: [{ type: 'text', text: `codeburn: failed to compute savings — ${err instanceof Error ? err.message : String(err)}` }], isError: true }
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
function breakdownRows(p: MenubarPayload, by: BreakdownBy, limit: number): Array<{ name: string; costUSD: number }> {
|
||||
const c = p.current
|
||||
if (by === 'model') return c.topModels.slice(0, limit).map(m => ({ name: m.name, costUSD: m.cost }))
|
||||
if (by === 'project') return c.topProjects.slice(0, limit).map(x => ({ name: x.name, costUSD: x.cost }))
|
||||
if (by === 'task') return c.topActivities.slice(0, limit).map(a => ({ name: a.name, costUSD: a.cost }))
|
||||
return Object.entries(c.providers).sort(([, a], [, b]) => b - a).slice(0, limit).map(([name, cost]) => ({ name, costUSD: cost }))
|
||||
}
|
||||
|
||||
export async function startStdioServer(version: string): Promise<void> {
|
||||
await loadPricing()
|
||||
const server = createServer({ version })
|
||||
// Pre-warm the parser cache for the common case; ignore failures.
|
||||
void buildMenubarPayloadForRange(getDateRange('today'), { provider: 'all', optimize: false }).catch(() => {})
|
||||
await server.connect(new StdioServerTransport())
|
||||
}
|
||||
```
|
||||
|
||||
> If the installed SDK rejects the raw-shape `inputSchema`/`outputSchema` (object of zod validators), wrap each in `z.object({ ... })` — the in-memory test in Step 4 will surface this immediately. Likewise, if `InMemoryTransport`'s import path differs, it is exported from `@modelcontextprotocol/sdk/inMemory.js` in v1.
|
||||
|
||||
- [ ] **Step 4: Run to verify pass**
|
||||
|
||||
Run: `npm test -- mcp-server`
|
||||
Expected: PASS (5 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/mcp/server.ts tests/mcp-server.test.ts
|
||||
git commit -m "feat(mcp): get_usage + get_savings tools with annotations, schemas, coalescing"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Wire the `codeburn mcp` command
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/main.ts` (add command + stdout guard)
|
||||
|
||||
- [ ] **Step 1: Add the command**
|
||||
|
||||
In `src/main.ts`, alongside the other `.command(...)` registrations (e.g. after the `status` block), add:
|
||||
|
||||
```ts
|
||||
program
|
||||
.command('mcp')
|
||||
.description('Run a Model Context Protocol server (stdio) exposing usage + savings to AI agents')
|
||||
.action(async () => {
|
||||
// stdout MUST carry only JSON-RPC; route stray logs to stderr.
|
||||
console.log = ((...args: unknown[]) => process.stderr.write(args.join(' ') + '\n')) as typeof console.log
|
||||
const { startStdioServer } = await import('./mcp/server.js')
|
||||
await startStdioServer(version)
|
||||
})
|
||||
```
|
||||
|
||||
(`version` is already in scope at `main.ts:30`.)
|
||||
|
||||
- [ ] **Step 2: Build**
|
||||
|
||||
Run: `npm run build`
|
||||
Expected: succeeds. `src/mcp/server.ts` is reachable from the `main.ts` import graph (via the dynamic import), so tsup bundles it; the `@modelcontextprotocol/sdk` and `zod` imports stay external (Task 1).
|
||||
|
||||
- [ ] **Step 3: Smoke-test the built server over stdio**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"smoke","version":"1"}}}' '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' | node dist/cli.js mcp 2>/dev/null | head -2
|
||||
```
|
||||
Expected: two JSON-RPC result lines on stdout; the second lists `get_usage` and `get_savings`. No non-JSON noise on stdout (warnings, if any, went to stderr).
|
||||
|
||||
- [ ] **Step 4: Verify the SDK is external in the bundle**
|
||||
|
||||
Run: `node -e "const s=require('fs').readFileSync('dist/main.js','utf8'); console.log('McpServer source inlined?', /class McpServer/.test(s))"`
|
||||
Expected: `McpServer source inlined? false` (it's imported from node_modules at runtime, not bundled).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/main.ts
|
||||
git commit -m "feat(mcp): add 'codeburn mcp' stdio command with stdout guard"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Full suite + final verification
|
||||
|
||||
**Files:** none (verification)
|
||||
|
||||
- [ ] **Step 1: Run everything**
|
||||
|
||||
Run: `npx tsc --noEmit && npm test && npm run build`
|
||||
Expected: typecheck clean; all tests pass (incl. the pre-existing suite — parity preserved); build succeeds.
|
||||
|
||||
- [ ] **Step 2: Manual end-to-end against real data (optional but recommended)**
|
||||
|
||||
Run: `node dist/cli.js mcp` then, in another shell, point a local MCP client (or the smoke command from Task 7) at it and call `get_usage {"period":"today"}` and `get_savings {"period":"last_7_days"}`. Confirm tables render and project names are hashed.
|
||||
|
||||
- [ ] **Step 3: Commit any cleanup**
|
||||
|
||||
```bash
|
||||
git add -A && git commit -m "chore(mcp): final verification" || echo "nothing to commit"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deferred (not in v1 — see spec §10 and note below)
|
||||
|
||||
- **Per-provider graceful degradation (`degraded[]`).** The spec (§5/§8) called for `allSettled` per-provider isolation. That requires changing the shared `parseAllSessions` loop (`parser.ts:2133`), which every command uses — out of scope for v1 to avoid destabilizing the parser. v1 handles failures at the tool boundary (`isError: true` with the message). A malformed provider aborting a scan is a **pre-existing** behavior shared with the `status`/menubar path, not introduced here. *This is the one intentional deviation from the committed spec.*
|
||||
- HTTP/SSE transport, `compare_periods`, MCP prompts/resources, README + registry listings.
|
||||
|
||||
## Self-Review
|
||||
|
||||
- **Spec coverage:** in-process stdio server (Task 6/7) ✓; cheap vs optimize split via `optimize` flag (Task 3, refined — `scanAndDetect` is the only expensive call) ✓; 2 tools with annotations + outputSchema + isError (Task 6) ✓; hash-by-default redaction (Task 4) ✓; in-flight coalescing + pre-warm (Task 6) ✓; tsup external + pinned deps (Task 1) ✓; stdout guard (Task 7) ✓; period-enum rename + `last_6_months` semantics (Task 6 + instructions) ✓; empty-state message (Task 6) ✓; token discipline via `limit` + summarized history (Task 6, no daily array returned) ✓. Deviation: per-provider `degraded[]` deferred (documented above).
|
||||
- **Placeholders:** none — every code step has full source; the only "move verbatim" steps (Tasks 2–3) are mechanical relocations of existing, cited code, gated by the existing parity test.
|
||||
- **Type consistency:** `buildMenubarPayloadForRange(PeriodInfo, AggregateOpts)`, `redactProjectNames(MenubarPayload, boolean)`, `renderBreakdownTable(payload, BreakdownBy, limit)`, `createServer({version, aggregate})` — names/signatures consistent across tasks and tests.
|
||||
@@ -0,0 +1,125 @@
|
||||
# CodeBurn MCP Server — Design Spec
|
||||
|
||||
- **Date:** 2026-06-02
|
||||
- **Status:** Approved design (pre-implementation)
|
||||
- **Author:** brainstormed + validator/devil's-advocate hardened
|
||||
|
||||
## 1. Context & Goal
|
||||
|
||||
CodeBurn already aggregates rich AI-coding usage/cost data (by task, model, project, provider; retry tax; routing waste; optimize findings; 365-day history). This spec adds an **MCP server** that exposes that data to AI agents (Claude Code, Cursor, Claude Desktop) so an agent can answer "where did my tokens go?" and "how do I spend less?" mid-conversation.
|
||||
|
||||
**Why (and why not):** This is a **product / differentiation** play, not a downloads play. We measured that MCP is a niche *download* lever (`@ccusage/mcp` ≈ 1.1% of ccusage's downloads; competitor tokscale ships no MCP), and the real download lever is npx-first CLI positioning — tracked separately. The MCP's value is agent-facing utility on data competitors don't expose (retry tax, routing waste, one-shot rate, task attribution).
|
||||
|
||||
## 2. Decisions (from brainstorming)
|
||||
|
||||
1. **Use case:** unified — serves both live self-optimization and historical analysis from one tool set.
|
||||
2. **Output contract:** every tool returns a ready-to-display **markdown table** *and* the same data as **structured JSON**.
|
||||
3. **Architecture:** Approach A — a `codeburn mcp` subcommand on the existing CLI (no second package), reusing the existing aggregation; run as a **long-lived in-process stdio server**.
|
||||
4. **Tool surface:** **2 tools** — `get_usage` and `get_savings`. (`compare_periods` cut — no reusable backend, overlap-incoherent.)
|
||||
5. **Privacy:** project/session names **hashed by default** (`project-<6hex>`); real names only when `include_project_names: true`. Absolute paths never exposed.
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
### Process model
|
||||
- `codeburn mcp` starts a **resident, in-process** MCP server over **stdio** (`StdioServerTransport`). Not exec-per-call — a resident process is required so the existing in-process session cache (180s TTL, `src/parser.ts`) amortizes across tool calls. Measured cost otherwise: `--period all` is ~17.6s **even warm** when the process exits between calls.
|
||||
- **First line of the `mcp` action:** `console.log = console.error`. The aggregation path is stdout-clean today (writes go to stderr), but the global `preAction` hook and `runOptimize` contain stdout `console.log`s; reassigning immunizes the JSON-RPC stream against any present or future stdout write. (Verified: `scanAndDetect`, `parseAllSessions`, providers, caches, `buildMenubarPayload` do not write to stdout.)
|
||||
- Pre-warm `today` usage on boot so the first interactive call is fast.
|
||||
|
||||
### Modules
|
||||
- **`src/usage-aggregator.ts`** *(new)* — extracted from the inline logic in the `status` handler (`src/main.ts` ~476–760):
|
||||
- `buildUsage(period, opts): Promise<MenubarPayload>` — **cheap path**, no optimize pass.
|
||||
- `buildSavings(period, opts): Promise<MenubarPayload>` — adds `scanAndDetect` (optimize findings + retry tax + routing waste).
|
||||
- `opts: { provider?: string; project?: string[]; exclude?: string[]; range?: ResolvedRange }`. The `provider` and project/range filters are **required** for parity — the `status` body forks on `isAllProviders` and resolves a range from `day/days/from/to` before `period`. `status --format menubar-json` is refactored to call these (one shared path).
|
||||
- **`src/mcp/server.ts`** *(new)* — builds `McpServer`, registers the 2 tools, connects `StdioServerTransport`, owns the in-flight coalescing map and the empty-state messages.
|
||||
- **`src/mcp/tables.ts`** *(new)* — compact markdown renderers per slice, reusing `format.ts` and `models-report.ts` where they already render tables.
|
||||
- **`src/mcp/redact.ts`** *(new)* — stable pseudonym hashing for project/session names; applied unless the caller passes `include_project_names: true`.
|
||||
- **`src/main.ts`** — add `.command('mcp')` that dynamic-`import()`s `./mcp/server.js` and starts it.
|
||||
|
||||
### Dependencies & build
|
||||
- Add to **`dependencies`**: `@modelcontextprotocol/sdk@^1.29.0` (v1 line; import paths `@modelcontextprotocol/sdk/server/mcp.js` and `/server/stdio.js`) and `zod@^3.25` (NOT transitive — it's a peer dep of the SDK and absent from the lockfile today).
|
||||
- Add to **`tsup.config.ts`**: `external: ['@modelcontextprotocol/sdk', 'zod']`. The current config bundles all deps (`splitting: false`, no `external`), so without this a dynamic import would inline the SDK (~MBs) into `dist/main.js`. Externalizing keeps `dist` small and makes the dynamic import a real lazy load from `node_modules`. (`files: ["dist"]` means externalized deps must be runtime `dependencies`.)
|
||||
- Pin the SDK major: a separately-named v2 package exists with different import paths; `^1.29.0` keeps the v1 paths valid.
|
||||
|
||||
## 4. Tool Surface
|
||||
|
||||
Period enum (LLM-clear names, mapped internally): `today→today`, `last_7_days→week`, `last_30_days→30days`, `month_to_date→month`, `last_6_months→all`. Documented: `last_6_months` is the maximum window (codeburn's "all" = ~6 months); history is summarized, not dumped.
|
||||
|
||||
Both tools carry annotations `{ readOnlyHint: true, openWorldHint: false, idempotentHint: true, title }`, a zod `inputSchema` and `outputSchema`, and return `{ content: [{ type:'text', text: <markdown table> }], structuredContent: <object matching outputSchema> }`.
|
||||
|
||||
### 4.1 `get_usage`
|
||||
- **title:** "CodeBurn — usage & cost"
|
||||
- **description (agent-facing):** "Show AI coding token spend and usage for a period. Omit `by` for a headline summary; set `by` to break it down by project, model, task, or provider. Fast (does not run the deeper savings analysis). Data is local to this machine and current as of the last scan."
|
||||
- **inputSchema:**
|
||||
- `period?: enum` (default `today`)
|
||||
- `by?: "project" | "model" | "task" | "provider"`
|
||||
- `limit?: number` (default 20, max 100) — row cap for breakdowns
|
||||
- `include_project_names?: boolean` (default `false`)
|
||||
- **Behavior:** no `by` → headline (cost, calls, sessions, input/output tokens, cache-hit %, one-shot rate) + top-N models/projects/tasks. With `by` → one ranked table for that dimension (`project→topProjects`, `model→topModels`, `task→topActivities`, `provider→providers` cost map). Uses `buildUsage` (cheap path).
|
||||
- **outputSchema:** the relevant subset of `MenubarPayload.current` (typed).
|
||||
|
||||
### 4.2 `get_savings`
|
||||
- **title:** "CodeBurn — savings opportunities"
|
||||
- **description (agent-facing):** "Find ways to reduce AI coding cost for a period: optimization findings, retry tax (money spent re-doing work), and routing waste (what you'd have saved on a cheaper model). Runs a deeper analysis, so it is slower than get_usage."
|
||||
- **inputSchema:** `period?: enum` (default `last_7_days` — never default to the slow `last_6_months`), `include_project_names?: boolean` (default `false`).
|
||||
- **Behavior:** runs `buildSavings`; returns optimize findings (title, impact, $ saved), retry tax (total + by model), routing waste (total savings, baseline model, by model).
|
||||
- **outputSchema:** `{ optimize, retryTax, routingWaste }` (typed).
|
||||
|
||||
### 4.3 Server `instructions`
|
||||
> "CodeBurn exposes local AI-coding spend data. Use `get_usage` for spend/usage and breakdowns (fast); use `get_savings` to find cost reductions (slower). Project names are pseudonymized unless you pass `include_project_names: true`. All data is read locally from this machine; `last_6_months` is the widest window. Numbers reflect the most recent scan and may lag the current in-flight session by a short interval."
|
||||
|
||||
## 5. Data Flow
|
||||
|
||||
```
|
||||
agent tool call
|
||||
→ zod inputSchema validation (invalid params → protocol error, auto)
|
||||
→ in-flight coalesce on {kind, period, opts-hash} (parallel callers share one scan)
|
||||
→ buildUsage / buildSavings(period, {provider:'all', ...})
|
||||
→ parseAllSessions with PER-PROVIDER allSettled isolation → degraded[]
|
||||
→ existing 180s in-process session cache amortizes
|
||||
→ redact project/session names unless include_project_names
|
||||
→ render markdown table + build structuredContent (validated vs outputSchema)
|
||||
→ return { content:[{type:'text',text}], structuredContent } (isError:true on failure)
|
||||
```
|
||||
|
||||
## 6. Privacy / Redaction
|
||||
- Name-bearing fields — `topProjects[].name`, `topSessions[].project`, `topProjects[].sessionDetails` — are replaced with stable pseudonyms `project-<6hex(sha256(name))>` unless `include_project_names: true`.
|
||||
- Absolute paths are never emitted (they aren't in the payload today; the MCP must not enrich with them).
|
||||
- Rationale: an MCP is an egress surface to possibly-remote/cloud agents; codeburn's brand is "data never leaves your machine." Hashing keeps breakdowns coherent (stable pseudonyms) without leaking identity; local users who want real names opt in per call.
|
||||
|
||||
## 7. Performance
|
||||
- **Two paths:** `get_usage` uses the cheap aggregation (`--no-optimize` seam already exists; ~3s `today`, ~5s `all`). `get_savings` runs the optimize pass (~+13s) — isolated to the one tool that needs it. Without this split, every tool paid the 13s tax.
|
||||
- **Resident process** + existing 180s session cache → warm calls are cheap.
|
||||
- **In-flight coalescing:** concurrent calls for the same `{kind, period, opts}` await a single scan (agents fire tools in parallel).
|
||||
- **Pre-warm** `today` usage on boot.
|
||||
- **Token discipline:** breakdowns capped at `limit` (default 20); history is summarized (totals + top-N), never the full 365-day array; tables are compact.
|
||||
|
||||
## 8. Error Handling
|
||||
- Invalid params → zod → MCP protocol error (auto).
|
||||
- No data for period (fresh install) → `isError: false` with a friendly "no usage recorded for <period> yet — run some coding sessions" message (not a zero-filled table).
|
||||
- One provider fails to parse → isolate via `allSettled`, return partial data, list skipped providers in `degraded[]` and a table footer note.
|
||||
- Aggregation throw / payload-shape mismatch → `isError: true` with a clear message (incl. version-skew hint); never hang the transport.
|
||||
|
||||
## 9. Testing
|
||||
- **Parity:** `buildUsage('today', {provider:'all'})` deep-equals current `status --format menubar-json --period today` (plus one more period). Guards the extraction.
|
||||
- **Per-tool:** fixture payload → expected markdown table (snapshot) + structuredContent keys; `by` each dimension; redaction on/off (no real names/paths leak when off; pseudonyms stable); empty-state message.
|
||||
- **MCP protocol smoke:** in-memory transport — `listTools` returns the 2 tools with annotations + outputSchema; call each; assert result shape (`content` + `structuredContent`) and `isError` paths; concurrent identical calls coalesce to one scan.
|
||||
- **Build:** assert SDK + zod are externalized (absent from `dist`) and declared in `dependencies`.
|
||||
|
||||
## 10. Out of Scope (v1 — YAGNI)
|
||||
- HTTP/SSE transport (stdio only).
|
||||
- `compare_periods` (agent can diff two `get_usage` calls; backend is net-new and overlap-incoherent).
|
||||
- Write/mutating tools, auth, multi-machine/remote data.
|
||||
- MCP **prompts** and **resources** primitives (tools-only v1; revisit if a "cost-review" prompt template proves useful).
|
||||
- README "MCP" section and MCP-registry listings (lobehub/Smithery/mcp.so) — follow-up tasks, not code.
|
||||
|
||||
## 11. Provenance — review findings incorporated
|
||||
Hardened by a validator + devil's-advocate pass:
|
||||
- **BLOCKER** in-process resident server (caches don't help exec-per-call) — §3, §7.
|
||||
- **BLOCKER** split cheap vs optimize path (optimize ≈ 70% of cost) — §7.
|
||||
- **BLOCKER** redact names by default (raw repo names + dated spend would egress) — §6.
|
||||
- **MAJOR** `buildUsage/buildSavings` signature carries provider/project/range for parity — §3.
|
||||
- **MAJOR** tsup `external` + deps in `dependencies`; pin SDK `^1.29.0`; add `zod` explicitly — §3.
|
||||
- **MAJOR** per-provider `allSettled` isolation + `degraded[]` — §5, §8.
|
||||
- **MAJOR** in-flight coalescing — §5, §7.
|
||||
- **MAJOR** drop `compare_periods`; rename period enums; default `today` — §4, §10.
|
||||
- **MINOR** `console.log→console.error` stdout guard; friendly empty-state; run compiled `dist` not `tsx`; validate payload shape; `last_6_months` is the real max — §3, §8, §4.
|
||||
Reference in New Issue
Block a user