Sign In

@budgetary/mcp

Package Overview
Dependencies
Maintainers
1
Versions
21
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@budgetary/mcp - npm Package Compare versions

Comparing version
0.12.0
to
0.13.0
dist/chunk-O5H2WSVL.js

Sorry, the diff of this file is too big to display

+146
import {
MAX_TRANSCRIPT_BYTES,
MeasuredStore,
PendingStore,
capTrace,
entryBinding,
findProvenTranscript,
isTranscriptDir,
measuredFilePath,
pendingFilePath,
persistedCounts,
readTranscriptUsage,
submitActuals
} from "./chunk-O5H2WSVL.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 measured = new MeasuredStore({
path: measuredFilePath(args.home),
logger,
now: args.now ?? (() => /* @__PURE__ */ new Date())
});
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,
// ★★ No `success`. This path runs in a LATER session, against a PREVIOUS
// session's pending entry, and every gate it passes proves only that the
// earlier session ENDED: the serving process is gone and the transcript is
// complete and stable. None of that observes whether the task WORKED — a
// session that was opened, estimated and abandoned satisfies all of it. A
// constant is not a measurement, and picking either constant is an
// affirmative false claim about every reconciled run: `true` records
// abandoned work as achievement, `false` strands genuine successes
// permanently (the server stores only the first submission's value). The
// counts here ARE measured from the run's own transcript and still submit;
// the outcome is left unobserved, which is the only honest record.
// 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(measuredCounts) {
try {
const outcome = await submitActuals({
store,
client,
entry: args.entry,
counts: measuredCounts,
logger,
measured
});
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
};
+1
-1

@@ -12,3 +12,3 @@ import {

submitActuals
} from "./chunk-XVFRGAHR.js";
} from "./chunk-O5H2WSVL.js";
export {

@@ -15,0 +15,0 @@ MAX_ATTEMPTS,

{
"name": "@budgetary/mcp",
"version": "0.12.0",
"version": "0.13.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",

# @budgetary/mcp
A single [Model Context Protocol](https://modelcontextprotocol.io) 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 record what the task actually cost. Build it once; add it everywhere. It replaces the previously-planned per-host extensions.
A single [Model Context Protocol](https://modelcontextprotocol.io) 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.

@@ -241,7 +241,12 @@ The server exposes exactly one model-invokable tool, `estimate`. It talks to the hosted Budgetary API at `https://api.budgetary.tools`.

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`.
- 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 optional `model` argument of the `estimate` tool, omitted when absent.
- The **host tag** — `BUDGETARY_HOST` if you set it, otherwise the constant `mcp`. It says which MCP host the call came from and nothing else.
- A per-call **request id**, a fresh random UUID generated for each `estimate` so 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 via `BUDGETARY_LANGUAGE` or the config `language` field.
- After a run, the **token counts** (`tokens_in`, `tokens_out`), a `success` flag, and a duration.
- After a run, the **token counts** (`tokens_in`, `tokens_out`) and a duration; a `success` flag **only when the outcome was actually observed**, and never otherwise; and, when one was declared, how the run **ended** — one of `natural`, `harness_watchdog`, `operative_cap`, `kill_switch`, and nothing else.
- A constant **client label** — `mcp_client` unless an operator overrode it with `BUDGETARY_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.

@@ -248,0 +253,0 @@ - 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 as `mcp: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. Set `BUDGETARY_TRACE_TARGET=off` to drop the descriptor entirely (the trace falls back to tool names + token counts); any value other than an explicit `1`/`true`/`on`/`yes` is treated as off.

Sorry, the diff of this file is too big to display

import {
MAX_TRANSCRIPT_BYTES,
MeasuredStore,
PendingStore,
capTrace,
entryBinding,
findProvenTranscript,
isTranscriptDir,
measuredFilePath,
pendingFilePath,
persistedCounts,
readTranscriptUsage,
submitActuals
} from "./chunk-XVFRGAHR.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 measured = new MeasuredStore({
path: measuredFilePath(args.home),
logger,
now: args.now ?? (() => /* @__PURE__ */ new Date())
});
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,
// ★★ No `success`. This path runs in a LATER session, against a PREVIOUS
// session's pending entry, and every gate it passes proves only that the
// earlier session ENDED: the serving process is gone and the transcript is
// complete and stable. None of that observes whether the task WORKED — a
// session that was opened, estimated and abandoned satisfies all of it. A
// constant is not a measurement, and picking either constant is an
// affirmative false claim about every reconciled run: `true` records
// abandoned work as achievement, `false` strands genuine successes
// permanently (the server stores only the first submission's value). The
// counts here ARE measured from the run's own transcript and still submit;
// the outcome is left unobserved, which is the only honest record.
// 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(measuredCounts) {
try {
const outcome = await submitActuals({
store,
client,
entry: args.entry,
counts: measuredCounts,
logger,
measured
});
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
};

Sorry, the diff of this file is too big to display