@budgetary/mcp
v0.13.0
Published
Model Context Protocol server for Budgetary: a portable pre-flight token-spend estimate tool for any MCP-capable host.
Readme
@budgetary/mcp
A single Model Context Protocol server that gives any MCP-capable host — Claude Code, Cursor, GitHub Copilot, Codex, and others — a pre-flight, probabilistic token-spend estimate for a coding task before you run it, and a best-effort, never-fabricated way to measure what the task actually cost. The forecast is a probability; the measurement is a count. Where there is no firm basis to forecast a particular task, it says so instead of guessing. Build it once; add it everywhere. It replaces the previously-planned per-host extensions.
The server exposes exactly one model-invokable tool, estimate. It talks to the hosted Budgetary API at https://api.budgetary.tools.
Install — one command per host
Pass your key as BUDGETARY_API_KEY and tag the host with BUDGETARY_HOST so ledger entries distinguish where the estimate came from. Always include -y with npx so the launch never blocks on an install prompt.
Claude Code
claude mcp add budgetary \
--env BUDGETARY_API_KEY=bg_test_... \
--env BUDGETARY_HOST=claude-code \
-- npx -y @budgetary/mcpAutomatic actuals need the plugin, not just this command.
claude mcp addwires the estimate tool only. The session-end hook that submits real actuals is wired by the bundled Claude Code plugin (via its manifest), so with a bareclaude mcp addyou 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 doctorreports 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 taggedBUDGETARY_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:
{
"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) 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)
{
"mcpServers": {
"budgetary": {
"command": "npx",
"args": ["-y", "@budgetary/mcp"],
"env": {
"BUDGETARY_API_KEY": "bg_test_...",
"BUDGETARY_HOST": "cursor"
}
}
}
}GitHub Copilot (VS Code) — .vscode/mcp.json
VS Code uses the servers key (not mcpServers) and can prompt for secrets via inputs:
{
"inputs": [
{ "id": "budgetary-key", "type": "promptString", "description": "Budgetary API key", "password": true }
],
"servers": {
"budgetary": {
"command": "npx",
"args": ["-y", "@budgetary/mcp"],
"env": {
"BUDGETARY_API_KEY": "${input:budgetary-key}",
"BUDGETARY_HOST": "copilot"
}
}
}
}Codex
codex mcp add budgetary \
--env BUDGETARY_API_KEY=bg_test_... \
--env BUDGETARY_HOST=codex \
-- npx -y @budgetary/mcp…or in ~/.codex/config.toml:
[mcp_servers.budgetary]
command = "npx"
args = ["-y", "@budgetary/mcp"]
[mcp_servers.budgetary.env]
BUDGETARY_API_KEY = "bg_test_..."
BUDGETARY_HOST = "codex"From the MCP registry (one click)
This server is listed in the MCP registry as io.github.thriftell/budgetary. The listing advertises the npm package above and nothing else. A host that installs from it 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 an explicit value always wins over anything the server detects on its own. Left blank, the host is still recorded as mcp — but the one-time notice about unsubmitted runs no longer depends on the variable: the MCP handshake already carries the host's own name, so a Claude Code install that never set it is still told, once, when nothing here is submitting its finished runs. 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 remote endpoint (hand-configured only)
A Streamable-HTTP endpoint exists at https://api.budgetary.tools/mcp. The registry listing no longer advertises it, because a listing entry carries a URL and nothing else: there is no field for the API key tools/call requires, and no room to tell you any of what follows at the moment you would be choosing it. Configure it by hand or not at all.
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.
Authentication is by Authorization: Bearer bg_…; without it initialize and tools/list still answer, and tools/call returns authentication_failed. If your host caches an older copy of the listing, it may still show the remote as an install option — the withdrawal only governs the current entry.
API key setup
The server resolves the key in this order:
BUDGETARY_API_KEYin the server's environment (set via the host config above).~/.budgetary/config.json→{ "api_key": "bg_...", "base_url"? }.
If neither is set, the estimate tool returns short configure-your-key guidance instead of an error — it never throws and never crashes the host.
mkdir -p ~/.budgetary
# Write the key with an EDITOR so it never lands in your shell history:
"${EDITOR:-nano}" ~/.budgetary/config.json # add: { "api_key": "bg_test_..." }
chmod 600 ~/.budgetary/config.json # owner-onlyPrefer an editor over
echo '{...}' > config.json, which records the secret in your shell history, andchmod 600the file so it isn't world-readable.
Key prefixes denote the environment:
bg_test_— the free testing tier. Works immediately for development.bg_live_— production. A live key must be on an active plan; if it isn't, the API returns 403 and the tool says "Your Budgetary key isn't on an active plan." (This is distinct from a 401, which means the key itself was rejected.)
The API key never appears in a tool result, in pending.json, or in any log line.
Optional: tag the language you're working in
You can optionally tag each estimate with the language you're working in, so your estimate history is grouped by language. Set it in the server's environment:
--env BUDGETARY_LANGUAGE=TypeScriptor add a language field to ~/.budgetary/config.json (the environment variable wins if both are set):
{ "api_key": "bg_test_...", "language": "TypeScript" }It's a free-form display name — TypeScript, Python, Go, and so on — that the server tidies up; you don't need an exact spelling. Like BUDGETARY_HOST, it is a benign tag you declare in the environment: the language model never sets it and it is never guessed from your task description. There is intentionally no language argument on the estimate tool. If you set nothing, the estimate is simply recorded without a language — it's never required, and it never changes the estimate itself.
A plain stdio MCP server only sees the messages your host sends it, not which file you have open, so this declared value (one per host/session) is the signal it can rely on. Hosts that expose no language at all just record the estimate without one.
Optional: BUDGETARY_SOURCE — an operator's label for a batch of runs
Ordinary users have nothing to set here, and nothing changes if you don't. This exists for operators who drive this client from an automated harness (a benchmark, a load test, a scripted evaluation) and want those runs labelled so they can tell them apart from real ones afterwards.
Set it in the environment of the process you launch for that batch, so its lifetime is the batch:
BUDGETARY_SOURCE=my-harness-run <the command your harness runs>Do not put this one in your MCP host config. Unlike
BUDGETARY_HOSTandBUDGETARY_LANGUAGE, this label should not go inclaude mcp add --env,~/.claude.json, or.mcp.json. Those are machine-wide and permanent: a label you set there for one batch silently outlives it, and every ordinary session on that machine is labelled with it afterwards — undetectably, because the rows still look perfectly normal.~/.budgetary/config.jsonis not read for this variable either, on purpose — of these three, onlyBUDGETARY_LANGUAGEhas aconfig.jsonfallback at all. A label that describes a run should not outlive the run.
Each actuals submission carries this label. It defaults to mcp_client. It is an opaque string — the client attaches no meaning to it, validates only its shape (up to 64 characters of A–Z a–z 0–9 . _ -), and ignores anything malformed, falling back to the default rather than failing your submission. (Run with BUDGETARY_DEBUG=1 to have it say so on stderr when it rejects a label; otherwise a typo is silent.)
Two things it is deliberately not:
- It does not change how your data is treated. It is a label, not a setting. Setting it (or not) grants nothing, unlocks nothing, and alters nothing about what is recorded or how it is used.
- It is not a per-task field, and the model never sets it. Like
BUDGETARY_HOSTandBUDGETARY_LANGUAGE, it is declared in the environment. There is intentionally nosourceargument on theestimatetool.
The label is resolved once, when the estimate is made, and stored on that estimate's pending entry — so if a submission has to be retried later (from a different session, with a different environment), it still reports the label of the run that actually happened.
Actuals — automatic where possible, manual otherwise, never fabricated
A pre-flight estimate is only half the loop; calibration needs the realized token counts after the run. How those are recorded depends on what the host exposes:
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 aspytestorgo 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:npx @budgetary/mcp on-session-end --transcript ~/.claude/projects/<project>/<session-id>.jsonlRun 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
--successor--failedto declare how the run turned out — with neither, the outcome is left unreported (see below). Better still, wire the hook above 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:
npx @budgetary/mcp on-session-end --transcript ~/.codex/sessions/rollout-<ts>-<uuid>.jsonlRun it from the directory you estimated in.
Declaring the outcome is optional, and there is no default.
--success/--failedsay whether the run met its objective, and they are forwarded verbatim — but only a caller that actually checked has anything to declare, so with neither flag the field is omitted and the server records the outcome as not observed. That is a third state: it does not mean the run failed, and it does not mean it succeeded. Don't guess in either direction — a wrong verdict is permanent (only the first submission for an estimate is stored, and there is no update call), while the token counts you did measure submit either way.A harness that spawned the session itself — and therefore observed how the run ended — can declare that too, with
--censoring <category>: exactly one ofnatural,harness_watchdog,operative_cap,kill_switch(the API contract's closed vocabulary). The value is matched exactly and forwarded verbatim; anything else is omitted from the submission — never normalized into a category, and never an error. Don't pass it for a run whose ending you didn't observe: an absent field honestly records "unknown", while a guessednaturalis a false claim the server has no way to detect.Cursor / Copilot / other hosts — manual. These hosts do not hand a third-party server the token totals of a completed agent run, and the language model does not know them either. So you record them yourself when you have a moment:
npx @budgetary/mcp report-actualIt shows this project's most recent pending estimate and prompts you for the input/output token counts (read them from your host's usage UI, grouped numbers like
48,000are fine), whether the task succeeded, an optional duration, and — if you observed it — how the run ended (a cap, a watchdog, a deliberate abort, or on its own). Pressing Enter on that last question records nothing for it: "not sure" is a first-class answer, and an unobserved ending is never turned into a value.
To see which estimates still await actuals at any time (read-only, no server call):
npx @budgetary/mcp pendingThe model never supplies token counts. The only model-invokable tool is
estimate. Actuals are submitted only from (a) the session-end hook reading a real transcript, or (b) the human-enteredreport-actualcommand. A fabricated actual would poison calibration, so there is deliberately no tool a model can call to write counts.
What you get back for a recorded run
Recording a run returns a measured breakdown of where its tokens went — exploration, generation, testing, retries, other, each with its share of the measured total — and the API's answer to "was that normal for a task like this?". Both are computed and reported by the API and printed exactly as received; where it reports none, nothing is printed rather than a guess. The breakdown is a measurement of that run's own counts, so it is exact and needs nothing to compare it against; the verdict may honestly be insufficient_data, which is that question answered truthfully — not an error.
Where it appears depends on how the run was submitted:
report-actual/on-session-end --transcript— printed in your terminal, directly below the submit confirmation.- The session-end hook — a hook's output never reaches you (the host sends it to a debug log), so the summary is buffered locally and shown beneath your next
estimate, stamped with the id of the run it measures. Shown once, never repeated.
Dashboard
For the predicted-vs-actual calibration dashboard, install Budgetary (budgetary.budgetary-vscode) from Open VSX — it runs in Cursor and other VS Code forks unchanged. This server does not re-implement it.
Privacy
Your first successful estimate on a machine appends a one-time note pointing here, and npx @budgetary/mcp doctor repeats a short form of it on every run. That note is one call late and says so: by the time anything can be rendered, estimate has already sent the task text. It is a disclosure and a pointer — it asks for nothing, gates nothing, and grants nothing. This section is the account it points at.
Only these things leave your machine, and only to https://api.budgetary.tools:
- The task description you pass to
estimate, verbatim. - If the model supplied one, the target model identifier it named (e.g.
claude-opus-4-7) — the optionalmodelargument of theestimatetool, omitted when absent. - The host tag —
BUDGETARY_HOSTif you set it, otherwise the constantmcp. It says which MCP host the call came from and nothing else. - A per-call request id, a fresh random UUID generated for each
estimateso a retried call is not counted twice. It is derived from nothing about you, your machine, or your task. - If you set it, the language tag you declared (e.g.
TypeScript) — a benign label, the same kind of thing as the host name. Never sent unless you opt in viaBUDGETARY_LANGUAGEor the configlanguagefield. - After a run, the token counts (
tokens_in,tokens_out) and a duration; asuccessflag only when the outcome was actually observed, and never otherwise; and, when one was declared, how the run ended — one ofnatural,harness_watchdog,operative_cap,kill_switch, and nothing else. - A constant client label —
mcp_clientunless an operator overrode it withBUDGETARY_SOURCE(see above). It says which client sent the row and nothing else: it is a fixed string, derived from no part of you, your machine, or your task. - On Claude Code, a behavior trace: per step, the host tool name (e.g.
Read,Bash), its token count, a redacted descriptor of what it acted on, and whether it succeeded. The descriptor exposes a program name in the clear only when it is a common, non-sensitive tool (e.g.pytest,npm run) — a pasted credential or a private script name is never shown, only its salted digest; everything after the program (paths, arguments, the rest of the command) always lives inside the digest, or a bare path digest for a file tool. Custom/internal tool names (e.g. an org's private MCP tool) are reported generically asmcp:other, never verbatim. No file contents, absolute paths, command arguments, or output ever leave the machine — only an allowlisted program name and an opaque key. SetBUDGETARY_TRACE_TARGET=offto drop the descriptor entirely (the trace falls back to tool names + token counts); any value other than an explicit1/true/on/yesis treated as off.
Nothing else is transmitted. Both the descriptor's digest and the project_id attached to each estimate are salted, non-reversible hashes (HMAC-SHA256): the descriptor digest with a fresh per-submission salt, and project_id with a machine-local install salt persisted at ~/.budgetary/install-salt. The salts never leave the machine, so the server gets a stable key it cannot reverse back to a command or path. The pending store lives at ~/.budgetary/pending.json, shared byte-for-byte with the first-party Claude Code and Codex clients, so configuring once covers every host. The measured summary the API returns for a submitted run is buffered locally at ~/.budgetary/measured.json until an estimate shows it — at most a handful of recent records, dropped after a week; it is a copy of a response you already received, and nothing in it is ever sent anywhere.
Reference
For the v1 API contract — endpoints, error codes, scenario labels — see docs/api-contract.md.
Licensed under Apache-2.0.
