From 233762910b789f6371ec8e9ed7bd5535209aa4a7 Mon Sep 17 00:00:00 2001 From: AGWAM001 <281425994+AGWAM001@users.noreply.github.com> Date: Sat, 17 Oct 2026 23:04:11 -0700 Subject: [PATCH] fix(mcp): bound cumulative daily paid-call spend, not just per call (#405) A per-call cap answers "how much can one mistake cost"; an operator needs "how much can today cost", and an agent in a retry loop could previously pay the per-call maximum without limit. SpendBudget adds a per-UTC-day ceiling with worst-case reservation before each call, refuses with ERR_BUDGET_EXCEEDED (carrying the live snapshot in details.budget) before the payment fetch, exposes both limits in the paid_call description plus a payment_budget tool, and persists the day's total so a restart does not refill the budget. Unreadable or unwritable state fails closed. Also fixes MCP_MAX_PAYMENT_PRICE being passed as maxPrice, which is not an x402 client config option and bounded nothing; it is now spendControls.maxAmountPerPayment. --- .changeset/mcp-spend-ceiling.md | 43 +++++ .env.example | 20 +++ examples/mcp-server/run.ts | 27 ++- src/mcp/budget.ts | 309 ++++++++++++++++++++++++++++++++ src/mcp/codes.ts | 3 + src/mcp/server.ts | 96 +++++++++- tests/mcp.test.ts | 247 ++++++++++++++++++++++++- 7 files changed, 736 insertions(+), 9 deletions(-) create mode 100644 .changeset/mcp-spend-ceiling.md create mode 100644 src/mcp/budget.ts diff --git a/.changeset/mcp-spend-ceiling.md b/.changeset/mcp-spend-ceiling.md new file mode 100644 index 00000000..8043cb6d --- /dev/null +++ b/.changeset/mcp-spend-ceiling.md @@ -0,0 +1,43 @@ +--- +'lens': patch +--- + +The MCP server's spend cap is now cumulative, not just per call. `maxPrice` +bounded a single payment, so an agent in a retry loop — or one told to "try each +of these endpoints" — could pay that amount arbitrarily many times: every +payment individually inside the limit, the sum unbounded. A per-call limit +answers "how much can one mistake cost"; an operator's question is "how much can +today cost". + +`SpendBudget` (`src/mcp/budget.ts`) adds a ceiling on the total paid by +`paid_call` within one UTC day, configured with `MCP_DAILY_SPEND_CEILING` +(default `$10.00`) alongside the per-call `MCP_MAX_PAYMENT_PRICE`. The day window +was chosen over a sliding 24 hours because it answers the operator's question +directly, needs one stored number rather than one entry per payment, and rolls +over from the wall clock — so a restarted server resumes the same day's total and +resets it after 00:00 UTC. + +Before a call is made the budget reserves its worst case, the per-call maximum, +and refuses when that no longer fits what is left. It refuses with the new +`ERR_BUDGET_EXCEEDED` code carrying the spent, ceiling, remaining and reset time +in `details.budget`, so an agent can report being out of budget instead of +looking broken; the refusal happens before the payment fetch, so no money moves. +Both limits are in the `paid_call` description and the live totals are readable +without spending, through the new `payment_budget` tool. The running total +persists to `MCP_SPEND_STATE_FILE` (default `~/.lens-mcp/spend-state.json`) and is +read back at startup, because a restart does not refund what the previous process +already spent. + +Accounting is deliberately conservative: the wrapped x402 transport exposes only +a plain `Response`, so a paid call that could have settled is booked at the +per-call maximum and a rejection that proves nothing settled is not booked at all. +Recorded spend is an upper bound, which means the ceiling is reached early rather +than late — and an unreadable or unwritable state file is treated as fully spent, +not as a clean slate. + +Also fixed while wiring this up: `MCP_MAX_PAYMENT_PRICE` was passed to +`wrapFetchWithPaymentFromConfig` as `maxPrice`, which is not an option of the +x402 client config and was silently ignored — every payment was capped by the +library default of `$1` whatever the operator asked for. It is now passed as +`spendControls.maxAmountPerPayment`, so the per-call limit the budget reserves +against is the one actually enforced. diff --git a/.env.example b/.env.example index 41e4465f..3482cae2 100644 --- a/.env.example +++ b/.env.example @@ -191,3 +191,23 @@ FACILITATOR_FEE_STROOPS_MAINNET=50000 # against a table that has never been pruned cannot hold a long lock or blow # the WAL. Default 1000. SNAPSHOT_PRUNE_BATCH_SIZE=1000 + + +# --- MCP agent spending limits --- +# Most a single payment may cost. Enforced as the x402 client's per-payment cap +# (spendControls.maxAmountPerPayment), so a call priced higher is refused before +# any money moves (default: $1.00). +MCP_MAX_PAYMENT_PRICE=$1.00 +# Cumulative ceiling on what paid calls may cost in one UTC day. The per-call cap +# above only bounds one mistake; without this, an agent in a retry loop can pay +# that amount over and over. Once a further call's worst case no longer fits the +# remaining budget, paid_call is refused with ERR_BUDGET_EXCEEDED without +# attempting payment. Read live with the payment_budget tool (default: $10.00). +MCP_DAILY_SPEND_CEILING=$10.00 +# Where the day's running total is kept, so a server restart does not refill the +# budget: the total is per UTC day and read back from this file at startup, and +# the window still resets at 00:00 UTC. If the file is missing it starts at zero; +# if it is unreadable or unwritable the budget is treated as spent and paid calls +# are refused until it is fixed (fail closed). Keep it off ephemeral filesystems +# (default: ~/.lens-mcp/spend-state.json). +MCP_SPEND_STATE_FILE= diff --git a/examples/mcp-server/run.ts b/examples/mcp-server/run.ts index 2b971525..d35fff64 100644 --- a/examples/mcp-server/run.ts +++ b/examples/mcp-server/run.ts @@ -1,5 +1,6 @@ import 'dotenv/config'; import { BazaarMcpServer } from '../../src/mcp/server'; +import { SpendBudget, defaultSpendStateFile } from '../../src/mcp/budget'; import { wrapFetchWithPaymentFromConfig } from '@x402/fetch'; import { ExactStellarScheme } from '@x402/stellar/exact/client'; import { createEd25519Signer } from '@x402/stellar'; @@ -19,8 +20,18 @@ async function main() { const testnetClient = new ExactStellarScheme(testnetSigner); const pubnetClient = new ExactStellarScheme(pubnetSigner); + // A single payment is capped by spendControls.maxAmountPerPayment. The value is + // passed through to the budget so the cumulative ceiling can reserve each call at + // its worst case; leaving spendControls unset would let the library default of $1 + // differ from whatever MCP_MAX_PAYMENT_PRICE says. maxPrice is not a field of the + // x402 client config, so setting it here previously bounded nothing. + const maxPrice = process.env.MCP_MAX_PAYMENT_PRICE || '$1.00'; + const dailyCeiling = process.env.MCP_DAILY_SPEND_CEILING || '$10.00'; + const fetchWithPayment = wrapFetchWithPaymentFromConfig(globalThis.fetch, { - maxPrice: process.env.MCP_MAX_PAYMENT_PRICE || '$1.00', + spendControls: { + maxAmountPerPayment: maxPrice, + }, schemes: [ { network: 'stellar:testnet', @@ -33,12 +44,24 @@ async function main() { ] }); + // The running total outlives the process: this server signs with a real key, and a + // restart does not refund what the previous process already spent. Point + // MCP_SPEND_STATE_FILE somewhere durable if the default location is not. + const budget = new SpendBudget({ + perCallMax: maxPrice, + dailyCeiling, + stateFilePath: process.env.MCP_SPEND_STATE_FILE || defaultSpendStateFile(), + }); + const server = new BazaarMcpServer({ - fetchWithPayment + fetchWithPayment, + budget }); console.error('[mcp-server] Starting Bazaar MCP server on stdio...'); console.error('[mcp-server] Agent configured for stellar:testnet and stellar:pubnet'); + // Limits and file paths only: the signing key is never logged. + console.error(`[mcp-server] Spend limits: ${budget.describeLimits()}`); await server.run(); } diff --git a/src/mcp/budget.ts b/src/mcp/budget.ts new file mode 100644 index 00000000..7eeffeb5 --- /dev/null +++ b/src/mcp/budget.ts @@ -0,0 +1,309 @@ +import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs'; +import { homedir } from 'os'; +import { dirname, join } from 'path'; + +const NANODOLLARS_PER_USD = 1_000_000_000n; + +export function defaultSpendStateFile(): string { + return join(homedir(), '.lens-mcp', 'spend-state.json'); +} + +/** + * Parses a USD money string such as '$1.00', '1.00' or '$0.05' into integer + * nanodollars. Throws on anything that is not a non-negative USD amount: a + * malformed ceiling must stop the server at boot rather than leave it running + * with an unintended budget. + */ +export function parseUsdToNanodollars(money: string): bigint { + const cleaned = String(money).trim().replace(/^\$/, ''); + const match = /^(\d+)(?:\.(\d+))?$/.exec(cleaned); + if (!match) { + throw new Error(`Invalid USD amount: ${JSON.stringify(money)}. Expected a non-negative amount such as "$1.00".`); + } + const fraction = match[2] ?? ''; + if (fraction.length > 9) { + throw new Error(`USD amount ${JSON.stringify(money)} has more than 9 decimal places; nanodollar precision is the finest the budget can track.`); + } + return BigInt(match[1]) * NANODOLLARS_PER_USD + BigInt(fraction.padEnd(9, '0') || '0'); +} + +function formatUsd(nanodollars: bigint): string { + const whole = nanodollars / NANODOLLARS_PER_USD; + const fraction = (nanodollars % NANODOLLARS_PER_USD).toString().padStart(9, '0').replace(/0+$/, ''); + // Show cents at minimum; keep finer precision only when the amount actually has it. + return `$${whole}.${fraction.padEnd(2, '0')}`; +} + +function utcDayOf(now: Date): string { + return now.toISOString().slice(0, 10); +} + +function nextUtcMidnightIso(day: string): string { + return new Date(Date.parse(`${day}T00:00:00.000Z`) + 86_400_000).toISOString(); +} + +export interface BudgetSnapshot { + currency: 'USD'; + window: 'utc_day'; + /** Current UTC day this window covers, e.g. '2026-10-17'. */ + day: string; + perCallMax: string; + dailyCeiling: string; + spentToday: string; + /** Worst-case value of paid calls that are in flight right now. */ + reservedToday: string; + remainingToday: string; + resetsAt: string; + /** 'unavailable' once state could not be read or written; paid calls are then refused. */ + state: 'ok' | 'unavailable'; +} + +export interface SpendBudgetOptions { + /** Ceiling for a single payment, e.g. '$1.00'. */ + perCallMax: string; + /** Cumulative ceiling for the current UTC day, e.g. '$10.00'. */ + dailyCeiling: string; + /** Where the running total is persisted. Omit to keep it in memory only. */ + stateFilePath?: string; + now?: () => Date; +} + +export type BudgetDecision = + | { allowed: true; reservationId: number; snapshot: BudgetSnapshot } + | { allowed: false; snapshot: BudgetSnapshot; reason: string }; + +interface PersistedSpendState { + version: 1; + day: string; + spentNanodollars: string; +} + +/** + * Cumulative spend ceiling for paid calls, per UTC day. + * + * A per-call maximum only bounds what one mistake can cost; this bounds what a + * day of mistakes can cost. The window is a UTC calendar day rather than a + * sliding 24 hours because it answers the operator's question directly ("how + * much can today cost"), needs a single stored number instead of one entry per + * payment, and rolls over from the wall clock, so a restarted server resumes + * the same day's total and resets it after 00:00 UTC. + * + * Accounting is conservative. A payment's real price is only known inside the + * x402 transport, which hands back a plain Response, so a paid call that could + * have settled debits the per-call maximum. Recorded spend is therefore an + * upper bound: the ceiling is never reached late, only early. + */ +export class SpendBudget { + readonly perCallMaxNanodollars: bigint; + readonly dailyCeilingNanodollars: bigint; + + private readonly stateFilePath?: string; + private readonly now: () => Date; + private day: string; + private spentNanodollars = 0n; + private reservedNanodollars = 0n; + private readonly holds = new Map(); + private nextHoldId = 1; + private state: 'ok' | 'unavailable' = 'ok'; + private stateUnavailableReason = ''; + + constructor(options: SpendBudgetOptions) { + this.now = options.now ?? (() => new Date()); + this.perCallMaxNanodollars = parseUsdToNanodollars(options.perCallMax); + this.dailyCeilingNanodollars = parseUsdToNanodollars(options.dailyCeiling); + if (this.perCallMaxNanodollars <= 0n) { + throw new Error(`perCallMax must be greater than zero, got ${JSON.stringify(options.perCallMax)}.`); + } + if (this.perCallMaxNanodollars > this.dailyCeilingNanodollars) { + throw new Error( + `perCallMax ${JSON.stringify(options.perCallMax)} exceeds dailyCeiling ${JSON.stringify(options.dailyCeiling)}; ` + + 'no paid call would ever be allowed. Raise the ceiling or lower the per-call maximum.' + ); + } + this.day = utcDayOf(this.now()); + this.stateFilePath = options.stateFilePath; + if (this.stateFilePath) { + this.loadState(); + } + } + + /** + * Decides whether a paid call may start, and holds its worst case against the + * ceiling while it is in flight. Refuses when the remaining budget cannot + * cover a call priced at the per-call maximum. + */ + authorize(): BudgetDecision { + this.rollWindowIfNeeded(); + if (this.state === 'unavailable') { + return { + allowed: false, + snapshot: this.snapshot(), + reason: + `Refusing a paid call because the spend budget cannot be trusted (${this.stateUnavailableReason}). ` + + 'Fix the state file, then restart the server to resume paid calls.', + }; + } + if (this.spentNanodollars + this.reservedNanodollars + this.perCallMaxNanodollars > this.dailyCeilingNanodollars) { + return { + allowed: false, + snapshot: this.snapshot(), + reason: + `Daily spend ceiling reached: ${formatUsd(this.spentNanodollars)} of ${formatUsd(this.dailyCeilingNanodollars)} spent today, ` + + `leaving ${formatUsd(this.remainingNanodollars())} of a ceiling that must cover a call of up to ${formatUsd(this.perCallMaxNanodollars)}. ` + + `The window resets at ${nextUtcMidnightIso(this.day)}.`, + }; + } + const reservationId = this.nextHoldId++; + this.holds.set(reservationId, this.perCallMaxNanodollars); + this.reservedNanodollars += this.perCallMaxNanodollars; + return { allowed: true, reservationId, snapshot: this.snapshot() }; + } + + /** Books the held worst case as spent. Call when a payment may have settled. */ + commit(reservationId: number): void { + const hold = this.holds.get(reservationId); + if (!hold) { + return; + } + this.holds.delete(reservationId); + this.reservedNanodollars -= hold; + const today = utcDayOf(this.now()); + if (today !== this.day) { + // The window closed while the call was in flight, so the payment belongs to the + // window that has just ended and must not be booked against the new day. + this.day = today; + this.spentNanodollars = 0n; + this.holds.clear(); + this.reservedNanodollars = 0n; + this.writeState(); + return; + } + this.spentNanodollars += hold; + this.writeState(); + } + + /** Drops a hold when no payment settled, so the budget is not consumed. */ + release(reservationId: number): void { + const hold = this.holds.get(reservationId); + if (!hold) { + return; + } + this.holds.delete(reservationId); + this.reservedNanodollars -= hold; + } + + snapshot(): BudgetSnapshot { + return { + currency: 'USD', + window: 'utc_day', + day: this.day, + perCallMax: formatUsd(this.perCallMaxNanodollars), + dailyCeiling: formatUsd(this.dailyCeilingNanodollars), + spentToday: formatUsd(this.spentNanodollars), + reservedToday: formatUsd(this.reservedNanodollars), + remainingToday: formatUsd(this.remainingNanodollars()), + resetsAt: nextUtcMidnightIso(this.day), + state: this.state, + }; + } + + /** Text for a tool description so an agent can reason about cost before calling. */ + describeLimits(): string { + const snapshot = this.snapshot(); + return ( + `Payments are capped per call at ${snapshot.perCallMax} and cumulatively at ` + + `${snapshot.dailyCeiling} per UTC day (${snapshot.day}, resetting at ${snapshot.resetsAt}). ` + + `Recorded spend so far today: ${snapshot.spentToday}; remaining: ${snapshot.remainingToday}. ` + + `A call that cannot fit its per-call maximum inside the remaining budget is refused with ` + + `ERR_BUDGET_EXCEEDED before any payment is attempted.` + ); + } + + private remainingNanodollars(): bigint { + const remaining = this.dailyCeilingNanodollars - this.spentNanodollars; + return remaining > 0n ? remaining : 0n; + } + + private rollWindowIfNeeded(): void { + const today = utcDayOf(this.now()); + if (today === this.day) { + return; + } + this.day = today; + this.spentNanodollars = 0n; + this.holds.clear(); + this.reservedNanodollars = 0n; + this.writeState(); + } + + private loadState(): void { + const path = this.stateFilePath!; + let raw: string; + try { + raw = readFileSync(path, 'utf8'); + } catch (e) { + if ((e as NodeJS.ErrnoException).code === 'ENOENT') { + this.writeState(); + return; + } + this.degrade(`the spend state file at ${path} could not be read: ${(e as Error).message}`); + return; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + this.degrade(`the spend state file at ${path} is not valid JSON`); + return; + } + + const state = parsed as Partial; + const isValidDay = typeof state.day === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(state.day); + const isValidSpend = typeof state.spentNanodollars === 'string' && /^\d+$/.test(state.spentNanodollars); + if (state.version !== 1 || !isValidDay || !isValidSpend) { + this.degrade(`the spend state file at ${path} does not contain a recognised budget state`); + return; + } + + const spentNanodollars = BigInt(state.spentNanodollars as string); + if (state.day === utcDayOf(this.now())) { + this.day = state.day as string; + this.spentNanodollars = spentNanodollars; + } else { + this.day = utcDayOf(this.now()); + this.spentNanodollars = 0n; + this.writeState(); + } + } + + private writeState(): void { + if (!this.stateFilePath) { + return; + } + const payload: PersistedSpendState = { + version: 1, + day: this.day, + spentNanodollars: this.spentNanodollars.toString(), + }; + const tempPath = `${this.stateFilePath}.tmp-${process.pid}`; + try { + mkdirSync(dirname(this.stateFilePath), { recursive: true }); + writeFileSync(tempPath, JSON.stringify(payload), 'utf8'); + renameSync(tempPath, this.stateFilePath); + } catch (e) { + this.degrade(`the spend state file at ${this.stateFilePath} could not be written: ${(e as Error).message}`); + } + } + + /** + * Fails closed. With unreadable or unwritable state the running total is + * unknown, and an unknown total against a money ceiling is assumed spent. + */ + private degrade(reason: string): void { + this.state = 'unavailable'; + this.stateUnavailableReason = reason; + this.spentNanodollars = this.dailyCeilingNanodollars; + console.error(`[mcp-budget] ${reason}. Refusing paid calls until the state is recoverable.`); + } +} diff --git a/src/mcp/codes.ts b/src/mcp/codes.ts index a9631225..0d28b391 100644 --- a/src/mcp/codes.ts +++ b/src/mcp/codes.ts @@ -6,6 +6,7 @@ export const MCP_ERROR_CODES = { ERR_SERVICE_UNREACHABLE: 'ERR_SERVICE_UNREACHABLE', ERR_NO_RESULTS: 'ERR_NO_RESULTS', ERR_BAD_REQUEST: 'ERR_BAD_REQUEST', + ERR_BUDGET_EXCEEDED: 'ERR_BUDGET_EXCEEDED', ERR_INTERNAL_FAILURE: 'ERR_INTERNAL_FAILURE' } as const; @@ -15,4 +16,6 @@ export interface McpErrorResponse { isError: true; code: McpErrorCode; reason: string; + /** Structured context for the caller, e.g. the spend budget behind ERR_BUDGET_EXCEEDED. */ + details?: Record; } diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 3f69c5b1..634446e3 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -2,18 +2,28 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import { MCP_ERROR_CODES, McpErrorCode } from './codes.js'; +import { SpendBudget } from './budget.js'; import { fetchWithTimeout } from '../fetch.js'; export interface McpServerConfig { fetchWithPayment: typeof fetch; + /** + * Cumulative ceiling over paid calls. Optional so a server can still run with + * per-call limits only, but leaving it out means nothing bounds a day: the + * per-payment cap only answers how much one mistake can cost, so a caller in a + * retry loop can pay it again and again without limit. + */ + budget?: SpendBudget; } export class BazaarMcpServer { private server: Server; private fetchWithPayment: typeof fetch; + private budget?: SpendBudget; constructor(config: McpServerConfig) { this.fetchWithPayment = config.fetchWithPayment; + this.budget = config.budget; this.server = new Server( { name: 'bazaar-mcp-server', @@ -52,7 +62,11 @@ export class BazaarMcpServer { }, { name: 'paid_call', - description: 'Make a paid API call to a Bazaar resource, handling the 402 loop automatically.', + description: + 'Make a paid API call to a Bazaar resource, handling the 402 loop automatically. ' + + (this.budget + ? this.budget.describeLimits() + : 'No cumulative spend ceiling is configured for this server; payments are bounded per call only.'), inputSchema: { type: 'object', properties: { @@ -68,6 +82,21 @@ export class BazaarMcpServer { required: ['url'], }, }, + ...(this.budget + ? [ + { + name: 'payment_budget', + description: + "Report the payment limits this server enforces and how much of today's budget is left, " + + 'without making a paid call. Read this before spending to plan cost.', + inputSchema: { + type: 'object', + properties: {}, + required: [], + }, + }, + ] + : []), ], })); @@ -104,20 +133,40 @@ export class BazaarMcpServer { } } + if (request.params.name === 'payment_budget') { + if (!this.budget) { + return this.createErrorResponse('ERR_BAD_REQUEST', 'This server has no spend budget configured.'); + } + return { + content: [{ type: 'text', text: JSON.stringify(this.budget.snapshot(), null, 2) }], + }; + } + if (request.params.name === 'paid_call') { const url = String(request.params.arguments?.url); const method = request.params.arguments?.method ? String(request.params.arguments?.method) : 'GET'; - + + const decision = this.budget?.authorize(); + if (decision && !decision.allowed) { + return this.createErrorResponse('ERR_BUDGET_EXCEEDED', decision.reason, { budget: decision.snapshot }); + } + const reservationId = decision?.allowed ? decision.reservationId : undefined; + try { const res = await this.fetchWithPayment(url, { method }); - + if (!res.ok) { if (res.status === 402) { + // The seller rejected the payment, so nothing settled and the budget is untouched. + this.settleReservation(reservationId, false); return this.createErrorResponse('ERR_PAYMENT_REJECTED', 'Payment required but was rejected or failed.'); } + // The resource failed after the payment loop ran; the payment may have settled anyway. + this.settleReservation(reservationId, true); return this.createErrorResponse('ERR_BAD_REQUEST', `Request failed with status ${res.status}`); } - + + this.settleReservation(reservationId, true); const text = await res.text(); let parsed; try { @@ -130,6 +179,7 @@ export class BazaarMcpServer { content: [{ type: 'text', text: typeof parsed === 'string' ? parsed : JSON.stringify(parsed, null, 2) }], }; } catch (e: any) { + this.settleReservation(reservationId, !this.paymentCouldNotHaveSettled(e)); return this.handlePaidCallError(e); } } @@ -160,12 +210,48 @@ export class BazaarMcpServer { return this.createErrorResponse('ERR_INTERNAL_FAILURE', e.message || 'An unknown error occurred during the paid call.'); } - private createErrorResponse(code: McpErrorCode, reason: string) { + /** + * Books or drops the worst-case hold taken by authorize(). `mayHaveSettled` + * is true unless the failure proves no payment went through; anything that + * cannot be proven is counted, so the ceiling stays an upper bound. + */ + private settleReservation(reservationId: number | undefined, mayHaveSettled: boolean): void { + if (reservationId === undefined || !this.budget) { + return; + } + if (mayHaveSettled) { + this.budget.commit(reservationId); + } else { + this.budget.release(reservationId); + } + } + + /** + * True when an error proves the payment never settled: the transport failed + * before offering payment, or the wallet could not fund or submit it. A + * network failure mid-flight is not proof, and neither is an unknown error. + */ + private paymentCouldNotHaveSettled(e: any): boolean { + const msg = String(e?.message || e).toLowerCase(); + return ( + msg.includes('failed to parse payment requirements') || + msg.includes('failed to create payment payload') || + msg.includes('signatureexpirationledger') || + msg.includes('signature expired') || + msg.includes('trustline') || + msg.includes('op_no_trust') || + msg.includes('insufficient') || + msg.includes('underfunded') + ); + } + + private createErrorResponse(code: McpErrorCode, reason: string, details?: Record) { // For MCP, returning isError: true with a JSON payload is the standard way to return tool errors. const payload = { isError: true, code, reason, + ...(details ? { details } : {}), }; return { content: [{ type: 'text', text: JSON.stringify(payload) }], diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index 8b979ad6..8181d69e 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -1,6 +1,15 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, afterEach, type Mock } from 'vitest'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { BazaarMcpServer } from '../src/mcp/server'; -import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'; +import { SpendBudget, parseUsdToNanodollars } from '../src/mcp/budget'; +import { MCP_ERROR_CODES } from '../src/mcp/codes'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + ToolSchema, +} from '@modelcontextprotocol/sdk/types.js'; describe('BazaarMcpServer', () => { it('should map signature expiration ledger error to ERR_EXPIRED_AUTHORISATION', async () => { @@ -121,3 +130,237 @@ describe('BazaarMcpServer', () => { expect(parsedText.success).toBe(true); }); }); + +const PAID_URL = 'http://example.com/paid'; + +type CapturedHandler = (request: any) => Promise; + +/** + * The SDK Server only routes once a transport is connected, so the handlers are + * captured by swapping setRequestHandler for a recorder and re-running + * setupHandlers(), which is what the tests above already do inline. + */ +function createBudgetHarness(fetchWithPayment: Mock, budget: SpendBudget) { + const server = new BazaarMcpServer({ + fetchWithPayment: fetchWithPayment as unknown as typeof fetch, + budget, + }); + + const handlers = new Map(); + (server as any).server.setRequestHandler = (schema: unknown, fn: CapturedHandler) => { + handlers.set(schema, fn); + }; + (server as any).setupHandlers(); + + const callTool = handlers.get(CallToolRequestSchema); + const listTools = handlers.get(ListToolsRequestSchema); + if (!callTool || !listTools) { + throw new Error('BazaarMcpServer did not register both a ListTools and a CallTool handler'); + } + + return { + listTools: () => listTools({}), + callTool: (name: string, args?: Record) => callTool({ params: { name, arguments: args } }), + }; +} + +/** + * Each call must hand back a fresh Response: a Response body can only be read + * once, and reusing one instance would fail the second call for a reason that + * has nothing to do with the budget. + */ +function cheapPaidCall(): Mock { + return vi + .fn() + .mockImplementation(() => + Promise.resolve(new Response(JSON.stringify({ price: '1.00' }), { status: 200 })) + ); +} + +describe('cumulative spend budget', () => { + let tempDir: string | undefined; + + function spendStateFile(): string { + tempDir = mkdtempSync(join(tmpdir(), 'lens-mcp-budget-')); + return join(tempDir, 'spend-state.json'); + } + + afterEach(() => { + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('refuses the call that would take the day total past the ceiling, without paying for it', async () => { + // Three calls at the $1.00 per-call maximum fill the $3.00 ceiling; each one is + // individually inside the per-call limit, which is the whole point of the issue. + const fetchWithPayment = cheapPaidCall(); + const budget = new SpendBudget({ perCallMax: '$1.00', dailyCeiling: '$3.00' }); + const { callTool } = createBudgetHarness(fetchWithPayment, budget); + + for (let call = 1; call <= 3; call++) { + const response = await callTool('paid_call', { url: PAID_URL }); + expect(response.isError, `call ${call} should still fit the ceiling`).toBeFalsy(); + } + expect(budget.snapshot().spentToday).toBe('$3.00'); + expect(budget.snapshot().remainingToday).toBe('$0.00'); + + const refusal = await callTool('paid_call', { url: PAID_URL }); + + expect(refusal.isError).toBe(true); + const payload = JSON.parse(refusal.content[0].text); + expect(payload.code).toBe(MCP_ERROR_CODES.ERR_BUDGET_EXCEEDED); + expect(payload.reason).toContain('Daily spend ceiling reached'); + // The agent has to be able to tell "out of budget" from "broken", and to see + // what is left, from the refusal alone. + expect(payload.details.budget).toMatchObject({ + currency: 'USD', + perCallMax: '$1.00', + dailyCeiling: '$3.00', + spentToday: '$3.00', + remainingToday: '$0.00', + state: 'ok', + }); + // Refusing must not spend: the ceiling gate runs before the payment fetch. + expect(fetchWithPayment).toHaveBeenCalledTimes(3); + }); + + it('refuses a call whose worst case no longer fits, instead of overspending the remainder', async () => { + const fetchWithPayment = cheapPaidCall(); + const budget = new SpendBudget({ perCallMax: '$1.00', dailyCeiling: '$2.50' }); + const { callTool } = createBudgetHarness(fetchWithPayment, budget); + + expect((await callTool('paid_call', { url: PAID_URL })).isError).toBeFalsy(); + await callTool('paid_call', { url: PAID_URL }); + expect(budget.snapshot().remainingToday).toBe('$0.50'); + + // $0.50 is left, but a call can cost up to $1.00, so the reservation cannot fit. + const refusal = await callTool('paid_call', { url: PAID_URL }); + + expect(refusal.isError).toBe(true); + expect(JSON.parse(refusal.content[0].text).code).toBe(MCP_ERROR_CODES.ERR_BUDGET_EXCEEDED); + expect(fetchWithPayment).toHaveBeenCalledTimes(2); + expect(budget.snapshot().spentToday).toBe('$2.00'); + }); + + it('resumes the day total after a restart, so a restart does not refill the budget', async () => { + const file = spendStateFile(); + const options = { perCallMax: '$1.00', dailyCeiling: '$2.00', stateFilePath: file }; + + const firstProcess = createBudgetHarness(cheapPaidCall(), new SpendBudget(options)); + await firstProcess.callTool('paid_call', { url: PAID_URL }); + await firstProcess.callTool('paid_call', { url: PAID_URL }); + + // A fresh SpendBudget is what a server restart builds: same configuration, same file. + const restarted = new SpendBudget(options); + expect(restarted.snapshot().spentToday).toBe('$2.00'); + + const fetchWithPayment = cheapPaidCall(); + const secondProcess = createBudgetHarness(fetchWithPayment, restarted); + const refusal = await secondProcess.callTool('paid_call', { url: PAID_URL }); + + expect(JSON.parse(refusal.content[0].text).code).toBe(MCP_ERROR_CODES.ERR_BUDGET_EXCEEDED); + expect(fetchWithPayment).not.toHaveBeenCalled(); + expect(JSON.parse(readFileSync(file, 'utf8'))).toMatchObject({ version: 1, spentNanodollars: '2000000000' }); + }); + + it('starts a fresh window at UTC midnight', async () => { + let current = new Date('2026-10-17T23:30:00.000Z'); + const file = spendStateFile(); + const budget = new SpendBudget({ + perCallMax: '$1.00', + dailyCeiling: '$1.00', + stateFilePath: file, + now: () => current, + }); + const { callTool } = createBudgetHarness(cheapPaidCall(), budget); + + expect((await callTool('paid_call', { url: PAID_URL })).isError).toBeFalsy(); + const sameDay = await callTool('paid_call', { url: PAID_URL }); + expect(JSON.parse(sameDay.content[0].text).code).toBe(MCP_ERROR_CODES.ERR_BUDGET_EXCEEDED); + + current = new Date('2026-10-18T00:00:00.000Z'); + + expect((await callTool('paid_call', { url: PAID_URL })).isError).toBeFalsy(); + expect(budget.snapshot()).toMatchObject({ + day: '2026-10-18', + spentToday: '$1.00', + resetsAt: '2026-10-19T00:00:00.000Z', + }); + expect(JSON.parse(readFileSync(file, 'utf8'))).toMatchObject({ day: '2026-10-18' }); + }); + + it('does not charge the budget when the seller rejects the payment', async () => { + const fetchWithPayment = vi.fn().mockResolvedValue(new Response('Payment Required', { status: 402 })); + const budget = new SpendBudget({ perCallMax: '$1.00', dailyCeiling: '$1.00' }); + const { callTool } = createBudgetHarness(fetchWithPayment, budget); + + const rejection = await callTool('paid_call', { url: PAID_URL }); + + expect(JSON.parse(rejection.content[0].text).code).toBe(MCP_ERROR_CODES.ERR_PAYMENT_REJECTED); + expect(budget.snapshot().spentToday).toBe('$0.00'); + // Rejected money never settled, so the call must be repeatable within the ceiling. + expect(budget.authorize().allowed).toBe(true); + }); + + it('treats an unreadable state file as fully spent rather than as a clean slate', async () => { + const file = spendStateFile(); + writeFileSync(file, 'not json at all', 'utf8'); + // The budget degrades while loading, so the spy has to be in place first. + const logged = vi.spyOn(console, 'error').mockImplementation(() => {}); + const fetchWithPayment = cheapPaidCall(); + const budget = new SpendBudget({ perCallMax: '$1.00', dailyCeiling: '$10.00', stateFilePath: file }); + const { callTool } = createBudgetHarness(fetchWithPayment, budget); + + const refusal = await callTool('paid_call', { url: PAID_URL }); + + expect(JSON.parse(refusal.content[0].text).code).toBe(MCP_ERROR_CODES.ERR_BUDGET_EXCEEDED); + expect(JSON.parse(refusal.content[0].text).details.budget.state).toBe('unavailable'); + expect(fetchWithPayment).not.toHaveBeenCalled(); + expect(logged).toHaveBeenCalledTimes(1); + logged.mockRestore(); + }); + + it('shows both limits in the tool description and the live budget as a tool', async () => { + const budget = new SpendBudget({ perCallMax: '$1.00', dailyCeiling: '$10.00' }); + const { listTools, callTool } = createBudgetHarness(cheapPaidCall(), budget); + + const { tools } = await listTools(); + const paidCallTool = tools.find((tool: any) => tool.name === 'paid_call'); + expect(paidCallTool.description).toContain('capped per call at $1.00'); + expect(paidCallTool.description).toContain('$10.00 per UTC day'); + expect(paidCallTool.description).toContain('ERR_BUDGET_EXCEEDED'); + + const budgetTool = tools.find((tool: any) => tool.name === 'payment_budget'); + expect(budgetTool).toBeDefined(); + expect(ToolSchema.safeParse(budgetTool).success).toBe(true); + + const reported = await callTool('payment_budget'); + expect(reported.isError).toBeFalsy(); + expect(JSON.parse(reported.content[0].text)).toMatchObject({ + currency: 'USD', + perCallMax: '$1.00', + dailyCeiling: '$10.00', + spentToday: '$0.00', + reservedToday: '$0.00', + remainingToday: '$10.00', + state: 'ok', + }); + + await callTool('paid_call', { url: PAID_URL }); + expect(JSON.parse((await callTool('payment_budget')).content[0].text).remainingToday).toBe('$9.00'); + }); + + it('refuses to start with limits that could never allow a call', () => { + expect(() => new SpendBudget({ perCallMax: '$10.00', dailyCeiling: '$1.00' })).toThrow(/exceeds dailyCeiling/); + expect(() => new SpendBudget({ perCallMax: 'free', dailyCeiling: '$1.00' })).toThrow(/Invalid USD amount/); + expect(() => new SpendBudget({ perCallMax: '-$1.00', dailyCeiling: '$10.00' })).toThrow(/Invalid USD amount/); + }); + + it('parses money strings into exact integer nanodollars', () => { + expect(parseUsdToNanodollars('$1.00')).toBe(1_000_000_000n); + expect(parseUsdToNanodollars('$0.10')).toBe(100_000_000n); + expect(parseUsdToNanodollars('0')).toBe(0n); + expect(() => parseUsdToNanodollars('$1.0000000000')).toThrow(/more than 9 decimal places/); + }); +});