@budgetary/mcp
Advanced tools
Sorry, the diff of this file is too big to display
| import { | ||
| MAX_TRANSCRIPT_BYTES, | ||
| PendingStore, | ||
| capTrace, | ||
| entryBinding, | ||
| findProvenTranscript, | ||
| isTranscriptDir, | ||
| pendingFilePath, | ||
| persistedCounts, | ||
| readTranscriptUsage, | ||
| submitActuals | ||
| } from "./chunk-FR4HH2QS.js"; | ||
| // src/reconcile.ts | ||
| import { readdirSync, readFileSync, statSync } from "fs"; | ||
| import { BudgetaryClient } from "@budgetary/sdk"; | ||
| var RECONCILE_MAX_RETRIES = 0; | ||
| function fileContains(path, needle) { | ||
| try { | ||
| const st = statSync(path); | ||
| if (!st.isFile() || st.size > MAX_TRANSCRIPT_BYTES) return false; | ||
| return readFileSync(path, "utf8").includes(needle); | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
| function listDir(dir) { | ||
| try { | ||
| return readdirSync(dir); | ||
| } catch { | ||
| return []; | ||
| } | ||
| } | ||
| function mtimeMs(path) { | ||
| try { | ||
| return statSync(path).mtimeMs; | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
| function fingerprint(path) { | ||
| try { | ||
| const st = statSync(path); | ||
| return `${st.size}:${st.mtimeMs}`; | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
| async function reconcileEntry(args) { | ||
| const logger = args.logger ?? { warn: () => { | ||
| } }; | ||
| const now = (args.now ?? (() => /* @__PURE__ */ new Date()))(); | ||
| const binding = entryBinding(args.entry); | ||
| if (binding === null) return "no-binding"; | ||
| const store = new PendingStore({ | ||
| path: pendingFilePath(args.home), | ||
| logger | ||
| }); | ||
| const factory = args.clientFactory ?? ((opts) => new BudgetaryClient(opts)); | ||
| const client = factory({ | ||
| apiKey: args.apiKey, | ||
| baseUrl: args.baseUrl, | ||
| maxRetries: RECONCILE_MAX_RETRIES | ||
| }); | ||
| const persisted = persistedCounts(args.entry); | ||
| if (persisted !== null) { | ||
| return finish(await trySubmit(persisted)); | ||
| } | ||
| if (!isTranscriptDir(binding.transcriptDir, args.home)) return "no-transcript"; | ||
| const path = findProvenTranscript( | ||
| binding, | ||
| { listDir, contains: fileContains, mtimeMs }, | ||
| Date.parse(args.entry.created_at) | ||
| ); | ||
| if (path === null) return "no-transcript"; | ||
| const before = fingerprint(path); | ||
| if (before === null) return "no-transcript"; | ||
| const usage = (args.readUsage ?? readTranscriptUsage)(path, { target: false }); | ||
| if (fingerprint(path) !== before) return "transcript-changed"; | ||
| if (usage === null) return "no-usage"; | ||
| const trace = capTrace(usage.trace) ?? void 0; | ||
| const counts = { | ||
| tokensIn: usage.tokensIn, | ||
| tokensOut: usage.tokensOut, | ||
| // We submit ONLY for a session whose serving process is gone and whose | ||
| // transcript is complete and stable — i.e. one that ran to termination, | ||
| // which is the same condition the hook path encodes as success (it counts | ||
| // `clear` / `logout` / `prompt_input_exit`, the normal endings). The hook's | ||
| // conservative default of `false` is not available to us: it keys off a | ||
| // termination reason only the host can report, and defaulting to it would | ||
| // stamp `false` on EVERY reconciled run — a systematic label, not a | ||
| // measurement. See the PR for the divergence this leaves. | ||
| success: true, | ||
| // Measured, not inferred from this process's clock. The hook can bound the | ||
| // run with a real session-end moment; we cannot, so the transcript's own | ||
| // last-write time is used as the end bound. Using `now` here would report | ||
| // the age of the ENTRY (up to a full day) as the run's duration. | ||
| durationMs: durationFromTranscript(path, args.entry, now), | ||
| ...trace ? { trace } : {} | ||
| }; | ||
| return finish(await trySubmit(counts)); | ||
| async function trySubmit(measured) { | ||
| try { | ||
| const outcome = await submitActuals({ | ||
| store, | ||
| client, | ||
| entry: args.entry, | ||
| counts: measured, | ||
| logger | ||
| }); | ||
| return outcome.submitted; | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
| function finish(submitted) { | ||
| return submitted ? "submitted" : "not-submitted"; | ||
| } | ||
| } | ||
| function durationFromTranscript(path, entry, now) { | ||
| const created = Date.parse(entry.created_at); | ||
| if (!Number.isFinite(created)) return 0; | ||
| let endMs; | ||
| try { | ||
| endMs = statSync(path).mtimeMs; | ||
| } catch { | ||
| return 0; | ||
| } | ||
| if (!Number.isFinite(endMs) || endMs < created || endMs > now.getTime() + 1e3) { | ||
| return 0; | ||
| } | ||
| return Math.max(0, Math.round(endMs - created)); | ||
| } | ||
| export { | ||
| reconcileEntry | ||
| }; |
+17
-1
@@ -76,2 +76,10 @@ import { ActualsTraceStep, BudgetaryClientOptions, BudgetaryClient } from '@budgetary/sdk'; | ||
| source?: string; | ||
| /** The session the serving MCP server was SPAWNED in — a HINT, stale after a `/clear`. */ | ||
| session_id?: string; | ||
| /** The host's id for the estimate call itself — the PROOF of which transcript is ours. */ | ||
| tool_use_id?: string; | ||
| /** Absolute path of this project's Claude Code transcript directory. */ | ||
| transcript_dir?: string; | ||
| /** The MCP server process that served the estimate; its death means the session ended. */ | ||
| owner_pid?: number; | ||
| } | ||
@@ -187,2 +195,10 @@ interface PendingStoreFile { | ||
| } | ||
| /** | ||
| * The measured counts persisted on a prior FAILED submit, or `null` when the | ||
| * entry is fresh (never submitted) or its persisted fields are absent/corrupt. | ||
| * Re-validates every field so a partial/garbage write is ignored (fall back to a | ||
| * fresh read) rather than submitting a fabricated count — the store keeps v1 | ||
| * files readable precisely because these are checked here, not trusted on read. | ||
| */ | ||
| declare function persistedCounts(entry: PendingEntry): ActualCounts | null; | ||
| /** The outcome of one {@link submitActuals} call, so callers can report it honestly. */ | ||
@@ -347,2 +363,2 @@ interface SubmitOutcome { | ||
| export { type ActualCounts, type AutoActualsArgs, MAX_ATTEMPTS, type ManualActualsArgs, type PendingListArgs, type PendingWriter, type RolloutActualsArgs, type SessionEndPayload, type SubmitActualsArgs, type SubmitOutcome, breadcrumbForecastVsActual, describeAge, runAutoActuals, runManualActuals, runPendingList, runRolloutActuals, submitActuals }; | ||
| export { type ActualCounts, type AutoActualsArgs, MAX_ATTEMPTS, type ManualActualsArgs, type PendingListArgs, type PendingWriter, type RolloutActualsArgs, type SessionEndPayload, type SubmitActualsArgs, type SubmitOutcome, breadcrumbForecastVsActual, describeAge, persistedCounts, runAutoActuals, runManualActuals, runPendingList, runRolloutActuals, submitActuals }; |
+3
-1
@@ -5,2 +5,3 @@ import { | ||
| describeAge, | ||
| persistedCounts, | ||
| runAutoActuals, | ||
@@ -11,3 +12,3 @@ runManualActuals, | ||
| submitActuals | ||
| } from "./chunk-NPFO6PIQ.js"; | ||
| } from "./chunk-FR4HH2QS.js"; | ||
| export { | ||
@@ -17,2 +18,3 @@ MAX_ATTEMPTS, | ||
| describeAge, | ||
| persistedCounts, | ||
| runAutoActuals, | ||
@@ -19,0 +21,0 @@ runManualActuals, |
+8
-0
@@ -21,2 +21,10 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js'; | ||
| signal?: AbortSignal; | ||
| /** | ||
| * The host's id for THIS tool call, threaded from the MCP request's | ||
| * `params._meta["claudecode/toolUseId"]`. Host-supplied, never model-supplied. | ||
| * Stamped on the pending entry so a LATER estimate can prove which session's | ||
| * transcript this run belongs to. Absent on any host that does not send it — | ||
| * the run is then simply not reconcilable. | ||
| */ | ||
| toolUseId?: string; | ||
| } | ||
@@ -23,0 +31,0 @@ interface EstimateToolResult { |
+1
-1
| { | ||
| "name": "@budgetary/mcp", | ||
| "version": "0.7.0", | ||
| "version": "0.9.0", | ||
| "description": "Model Context Protocol server for Budgetary: a portable pre-flight token-spend estimate tool for any MCP-capable host.", | ||
@@ -5,0 +5,0 @@ "mcpName": "io.github.thriftell/budgetary", |
+44
-2
@@ -20,4 +20,31 @@ # @budgetary/mcp | ||
| > **Automatic actuals need the plugin, not just this command.** `claude mcp add` wires the **estimate tool** only. The session-end hook that submits real actuals is wired by the bundled [Claude Code plugin](../claude-code/README.md) (via its manifest), so with a bare `claude mcp add` you get estimates but record actuals **manually** (`npx @budgetary/mcp report-actual`), the same as any other host. Install the plugin for the automatic loop. | ||
| > **Automatic actuals need the plugin, not just this command.** `claude mcp add` wires the **estimate tool** only. The session-end hook that submits real actuals is wired by the bundled [Claude Code plugin](../claude-code/README.md) (via its manifest), so with a bare `claude mcp add` you get estimates but record actuals **manually**, the same as any other host. Install the plugin for the automatic loop — or add the hook yourself (below). | ||
| > | ||
| > You do not have to have read this to find out: `npx @budgetary/mcp doctor` reports whether an automatic session-end submission has ever **run** on this machine, and prints the exact hook to add if none has. On an install tagged `BUDGETARY_HOST=claude-code` (as the command above does), the server also says so once, on its first estimate. Neither ever writes to your Claude Code configuration — nothing in this package reads or edits it. | ||
| To wire the hook without the plugin, add this to `~/.claude/settings.json` yourself: | ||
| ```json | ||
| { | ||
| "hooks": { | ||
| "SessionEnd": [ | ||
| { | ||
| "matcher": "", | ||
| "hooks": [ | ||
| { | ||
| "type": "command", | ||
| "command": "npx -y @budgetary/mcp on-session-end", | ||
| "timeout": 30 | ||
| } | ||
| ] | ||
| } | ||
| ] | ||
| } | ||
| } | ||
| ``` | ||
| Put your key in `~/.budgetary/config.json` (see [API key setup](#api-key-setup)) rather than interpolating it into the command, so it stays out of the process list. | ||
| The hook proves itself on use: after your next session ends, `npx @budgetary/mcp doctor` shows that run under `Last auto:`. Until then `doctor` still reports that no automatic submission has been recorded — that is expected, not a sign the edit failed. Nothing in this package reads your Claude Code configuration, so it cannot confirm the hook any earlier. | ||
| ### Cursor — `.cursor/mcp.json` (project) or `~/.cursor/mcp.json` (global) | ||
@@ -83,2 +110,10 @@ | ||
| ### From the MCP registry (one click) | ||
| This server is listed in the [MCP registry](https://registry.modelcontextprotocol.io) as `io.github.thriftell/budgetary`. A host that installs from the listing prompts you for the fields the listing declares — `BUDGETARY_API_KEY`, which is required, and `BUDGETARY_HOST`, which is optional. | ||
| **Set `BUDGETARY_HOST` anyway.** It is what tags your estimates with the host they came from, and on `claude-code` it is what lets the server tell you, once, when nothing here is submitting your finished runs. Left blank, the host is recorded as `mcp` and that notice never appears — the same as any hand-written config that omits it. If the listing your client reads is an older one that offers no such field, add it to that server's entry in your host's own MCP config, exactly as in the sections above. | ||
| > **The listing also offers a remote endpoint, `https://api.budgetary.tools/mcp`. It estimates only.** There is no local process on that path — no pending store, no session-end hook, no transcript to read — so nothing there can measure what a run actually cost, and the endpoint deliberately has no tool that would accept a count, because it would have to be told one rather than measure it. An estimate made through it is never closed out by an actual, by any route. Install the npm package above if you want your runs to count. | ||
| ## API key setup | ||
@@ -152,3 +187,10 @@ | ||
| - **Claude Code (with the plugin) — automatic.** This host writes a real session transcript. The plugin's session-end hook reads the true `tokens_in + tokens_out` (cache-read tokens **excluded**) and submits them — together with a short **behavior trace**: which tools the run used (`Read`, `Edit`, `Bash`, …), roughly how many tokens each, and two raw measurements per step — *which command it ran* (a **redacted** descriptor: a common program name such as `pytest` or `go test`, plus a **salted, non-reversible** digest of the rest — never a raw path, argument, or command) and *whether it succeeded*. The trace is measured from the transcript, never model-supplied, and any field that can't be read reliably is simply omitted; the total still submits. No human action needed. (A bare `claude mcp add` **without** the plugin has no session-end hook, so it is manual like the hosts below.) | ||
| - **Claude Code (with the plugin) — automatic.** This host writes a real session transcript. The plugin's session-end hook reads the true `tokens_in + tokens_out` (cache-read tokens **excluded**) and submits them — together with a short **behavior trace**: which tools the run used (`Read`, `Edit`, `Bash`, …), roughly how many tokens each, and two raw measurements per step — *which command it ran* (a **redacted** descriptor: a common program name such as `pytest` or `go test`, plus a **salted, non-reversible** digest of the rest — never a raw path, argument, or command) and *whether it succeeded*. The trace is measured from the transcript, never model-supplied, and any field that can't be read reliably is simply omitted; the total still submits. No human action needed. | ||
| - **Claude Code (bare `claude mcp add`, no plugin) — manual, from the transcript.** Without the plugin there is no session-end hook, so nothing submits for you — but Claude Code writes the same real transcript either way, so you never have to *type* counts. After a session ends, submit its measured totals (and the same behavior trace) with: | ||
| ```bash | ||
| npx @budgetary/mcp on-session-end --transcript ~/.claude/projects/<project>/<session-id>.jsonl | ||
| ``` | ||
| Run it from the directory you estimated in, and name a **finished** session — a transcript still being written is incomplete, and the first submission for an estimate is the one that counts. Add `--failed` if the task didn't complete. Better still, wire the hook [above](#claude-code) and stop doing this by hand. | ||
| - **Codex — manual, from the rollout.** Codex ships no session-end hook, but it writes a rollout transcript. After a session, submit its real counts: | ||
@@ -155,0 +197,0 @@ |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
721248
3.12%11
10%19032
2.55%237
21.54%21
16.67%