@budgetary/mcp
Advanced tools
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 | ||
| }; |
+1
-1
@@ -12,3 +12,3 @@ import { | ||
| submitActuals | ||
| } from "./chunk-MUGGEPSD.js"; | ||
| } from "./chunk-XVFRGAHR.js"; | ||
| export { | ||
@@ -15,0 +15,0 @@ MAX_ATTEMPTS, |
+37
-2
@@ -6,2 +6,15 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js'; | ||
| /** | ||
| * The client identity from the MCP `initialize` handshake, as the SDK's | ||
| * `Server.getClientVersion()` surfaces it. Host-supplied on a protocol channel | ||
| * established before the model ever spoke — the model cannot see, set, or | ||
| * influence it. `name` is typed `unknown` on purpose: the SDK validates it as | ||
| * `z.string()` and nothing else (empty, huge, control bytes and ANSI escapes | ||
| * all parse), so every reader must prove the type and then compare it against | ||
| * {@link attestedHost}'s frozen allowlist and discard it. The raw value is | ||
| * never rendered, logged, stored, or interpolated. | ||
| */ | ||
| interface HandshakeClientInfo { | ||
| name?: unknown; | ||
| } | ||
| interface EstimateToolArgs { | ||
@@ -30,2 +43,11 @@ query: string; | ||
| toolUseId?: string; | ||
| /** | ||
| * The connected client's self-declared identity, read from | ||
| * `Server.getClientVersion()` INSIDE the tools/call handler and threaded | ||
| * here — the same host-supplied seam as {@link toolUseId}. `undefined` | ||
| * whenever the SDK has nothing (its return type is | ||
| * `Implementation | undefined` and nothing enforces initialize-first), and | ||
| * absence asserts nothing. | ||
| */ | ||
| clientInfo?: HandshakeClientInfo; | ||
| } | ||
@@ -67,2 +89,10 @@ interface EstimateToolResult { | ||
| signal?: AbortSignal; | ||
| /** | ||
| * The client identity from the MCP `initialize` handshake, as | ||
| * `Server.getClientVersion()` returned it AT THIS CALL — supplied by | ||
| * {@link buildServer} from inside the tools/call handler, never captured | ||
| * earlier (see the note there). `undefined` is an ordinary, reachable answer | ||
| * (nothing enforces initialize-first) and asserts nothing downstream. | ||
| */ | ||
| clientInfo?: HandshakeClientInfo; | ||
| } | ||
@@ -78,2 +108,7 @@ /** | ||
| declare function handleCallTool(request: CallToolRequest, deps?: CallToolDeps): Promise<CallToolResult>; | ||
| /** Injectable dependencies for {@link buildServer} (tests). */ | ||
| interface BuildServerDeps { | ||
| /** Override the estimate tool (tests); defaults to {@link runEstimateTool}. */ | ||
| runEstimate?: typeof runEstimateTool; | ||
| } | ||
| /** | ||
@@ -87,3 +122,3 @@ * Build the MCP server with the single model-invokable `estimate` tool. | ||
| */ | ||
| declare function buildServer(): Server; | ||
| declare function buildServer(deps?: BuildServerDeps): Server; | ||
| /** | ||
@@ -191,2 +226,2 @@ * Seams for {@link runStdioServer} (tests only). `connect` defaults to wiring the | ||
| export { type CallToolDeps, type OnSessionEndArgs, type OnSessionEndDeps, type RunStdioServerDeps, SERVER_VERSION, TOOLS, TOOL_NAME, buildServer, handleCallTool, main, parseOnSessionEndArgs, parseReportActualArgs, runOnSessionEndCli, runStdioServer }; | ||
| export { type BuildServerDeps, type CallToolDeps, type OnSessionEndArgs, type OnSessionEndDeps, type RunStdioServerDeps, SERVER_VERSION, TOOLS, TOOL_NAME, buildServer, handleCallTool, main, parseOnSessionEndArgs, parseReportActualArgs, runOnSessionEndCli, runStdioServer }; |
+1
-1
| { | ||
| "name": "@budgetary/mcp", | ||
| "version": "0.11.0", | ||
| "version": "0.12.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", |
+10
-4
@@ -111,8 +111,14 @@ # @budgetary/mcp | ||
| 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. | ||
| This server is listed in the [MCP registry](https://registry.modelcontextprotocol.io) 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 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. | ||
| **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 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. | ||
| ### 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 | ||
@@ -171,3 +177,3 @@ | ||
| > **Do not put this one in your MCP host config.** Unlike `BUDGETARY_HOST` and `BUDGETARY_LANGUAGE`, this label should *not* go in `claude mcp add --env`, `~/.claude.json`, `.mcp.json`, or `~/.budgetary/config.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.json` is not even read for this variable, on purpose. A label that describes *a run* should not outlive the run. | ||
| > **Do not put this one in your MCP host config.** Unlike `BUDGETARY_HOST` and `BUDGETARY_LANGUAGE`, this label should *not* go in `claude 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.json` is not read for this variable either, on purpose — of these three, only `BUDGETARY_LANGUAGE` has a `config.json` fallback at all. A label that describes *a run* should not outlive the run. | ||
@@ -174,0 +180,0 @@ 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.) |
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-MUGGEPSD.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
757450
0.42%19774
0.25%256
2.4%