diff --git a/.changeset/mcp-payment-audit.md b/.changeset/mcp-payment-audit.md index 3508ec94..8aae9e9b 100644 --- a/.changeset/mcp-payment-audit.md +++ b/.changeset/mcp-payment-audit.md @@ -1,4 +1,27 @@ --- +'lens': minor +--- + +Record what every `paid_call` cost (#406). The server spent from a live key with +no receipt: `wrapFetchWithPayment` hands back a plain `Response`, the price is +only visible on the request the transport makes, and nothing said afterwards how +much left the account. `PaymentLedger` appends one JSON record per attempt — +timestamp, full URL, host, method, price, asset, network, settlement hash, +outcome, spend, error code — to `MCP_PAYMENT_LEDGER_FILE` +(default `~/.lens-mcp/payments.jsonl`), failed and rejected calls included, +since a payment can settle on a call that errored. Each record labels its spend +`settled`, `possible` or `none` so the trail reads as an upper bound instead of +inventing certainty a seller never confirmed. Neither the secret key nor the +signed envelope is ever read out of the payment header. An audit trail that +cannot be written fails closed: `paid_call` refuses further calls rather than +spending unrecorded. The same records are exported as +`mcp_payment_attempts_total`, `mcp_payment_amount_usd_total` and +`mcp_payment_unquantified_total`, whose `host` label may only take a value from +the paid-call allow-list and whose `network` label only the two networks this +signer can use, so no caller mints time series with the URLs it tries — and +`totalSpend()` gives a cumulative ceiling the running sum to read. The stdio +server has no HTTP listener, so those series are registered but not yet +scrapeable from its process. 'lens': patch --- diff --git a/.env.example b/.env.example index e0b43b1c..b370436d 100644 --- a/.env.example +++ b/.env.example @@ -427,6 +427,26 @@ MCP_MAX_PAYMENT_PRICE=$1.00 # refused with ERR_HOST_NOT_ALLOWED before a request is sent. This is a # prompt-injection guard — text an agent reads must not choose where money goes. MCP_PAID_CALL_ALLOWED_HOSTS= +# Where the audit trail of every paid call is appended, one JSON record per line: +# timestamp, full URL, host, method, price, asset, network, transaction hash, +# outcome, spend and the error code. Never the secret key, and never the signed +# payment envelope. A call that FAILED is recorded too — a payment can settle on +# a call that errored — and each record says whether the spend is confirmed +# (`settled`), merely possible (`possible`), or excluded (`none`). +# If this file cannot be created or written, paid_call refuses to run: a tool that +# moves money does not get to spend without leaving a record (fail closed). Keep it +# off ephemeral filesystems, and back it up like any other financial record +# (default: ~/.lens-mcp/payments.jsonl). +MCP_PAYMENT_LEDGER_FILE= +# The same spend is exported as Prometheus counters +# (mcp_payment_attempts_total, mcp_payment_amount_usd_total, +# mcp_payment_unquantified_total). Their host label can only ever take a value +# from MCP_PAID_CALL_ALLOWED_HOSTS above, and their network label only +# stellar:testnet / stellar:pubnet, so an agent looping over URLs cannot mint +# unbounded time series; anything else is labelled `unknown`. There is no separate +# knob for this. The audit file always keeps the real host regardless. +# Note that npm run mcp:server serves stdio only, so these series are registered +# but not scrapeable from that process until an HTTP transport exists. # Path to the OpenAPI document served by the MCP `lens://openapi.json` resource. # Leave unset to serve the openapi.json at the package root; set it only when the # document lives elsewhere, e.g. mounted separately in a container. diff --git a/README.md b/README.md index ee38250f..d847c7e3 100644 --- a/README.md +++ b/README.md @@ -354,6 +354,12 @@ const unsubscribe = client.subscribe( `payment_budget` appears whenever a spend budget is configured, which the example always sets, and `lens_get_price` only where the server can reach Lens's own price data. Every one of them is served over **either** transport, and the tool list, schemas and error codes are identical whichever you pick. Which transport runs is selected by configuration; **stdio is the default**, so an existing setup needs no changes. +Every paid call — failed ones included — is appended to a durable audit trail and +counted in spend metrics whose labels are bounded to the operator's allow-list, so +an operator can always answer what the agent spent and where. The trail never +holds the signing key or a signed payment, and a spend that cannot be recorded is +refused rather than made silently. + **⚠️ Warning:** the mainnet key signs with real XLM. Read the configuration, spending warning, and worked example before running. diff --git a/examples/mcp-server/README.md b/examples/mcp-server/README.md index bada952f..cfb14931 100644 --- a/examples/mcp-server/README.md +++ b/examples/mcp-server/README.md @@ -29,6 +29,7 @@ paid call from the mainnet account. | `MCP_AGENT_SECRET_KEY_TESTNET` | Yes | Stellar secret key used to sign payments on `stellar:testnet` | | `MCP_AGENT_SECRET_KEY_MAINNET` | Yes | Stellar secret key used to sign payments on `stellar:pubnet` (mainnet) — **real money** | | `MCP_MAX_PAYMENT_PRICE` | No | Maximum price the server will pay per request (default: `$1.00`) | +| `MCP_PAYMENT_LEDGER_FILE` | No | Where the audit trail of every paid call is appended (default: `~/.lens-mcp/payments.jsonl`) | Both keys are required: `run.ts` exits with an error if either is unset. @@ -69,6 +70,7 @@ The server starts on stdio and logs: ``` [mcp-server] Starting Bazaar MCP server on stdio... [mcp-server] Agent configured for stellar:testnet and stellar:pubnet +[mcp-server] Payment audit trail: /home/you/.lens-mcp/payments.jsonl ``` In another terminal, run the agent example, which connects an MCP client, @@ -184,9 +186,68 @@ An agent is the least able caller to notice a wrong answer: the server prefers returning a structured error over guessing a best-effort result, so always check `isError` before acting on a tool response. +## Payment audit trail + +`paid_call` moves money from a live key, so every attempt through it is +recorded — including the ones that failed, because a payment can settle on a +call that errored. One JSON object per line is appended to +`MCP_PAYMENT_LEDGER_FILE` (default `~/.lens-mcp/payments.jsonl`): + +| Field | What it holds | +|---|---| +| `timestamp` | ISO-8601 time the call finished | +| `url` | The full URL as called — never shortened or masked | +| `host`, `method` | The URL's host, and the HTTP method used | +| `amount`, `asset`, `network` | The price exactly as the seller offered it, with its asset and network, or `null` | +| `transactionHash` | The settlement hash the seller echoed back, or `null` | +| `outcome` | `success` or `failure` for the HTTP call | +| `spend` | `settled` (confirmed by the seller), `possible` (a payment was offered and nothing proves it failed) or `none` | +| `errorCode` | The MCP error code the agent was shown, or `null` | + +Two things are never recorded: the signing secret key, and the signed payment +envelope. The ledger reads a price, an asset and a network off the payment +header and nothing else from it, so the trail can be backed up or shared without +turning it into a key store. + +`transactionHash` is `null` more often than you would hope — it appears only when +the resource server echoes its settlement back in a `payment-response` header. A +record with `spend: "possible"` and no hash means "we cannot rule out that this +paid": read the trail as an upper bound, not as a statement of what certainly +happened. `PaymentLedger.totalSpend()` returns the same accounting as integer +nanodollars in decimal strings, plus a count of payments it could not price in +USD, so you know when the sums understate spend. + +**The trail is mandatory.** If the file cannot be created, or a record cannot be +appended to it, `paid_call` refuses further calls with `ERR_INTERNAL_FAILURE` +rather than spending unrecorded money. Put it somewhere durable and writable. + +### Spend metrics + +The same records feed three counters on the shared registry ([`src/metrics.ts`](../../src/metrics.ts)): + +| Series | Labels | Counts | +|---|---|---| +| `mcp_payment_attempts_total` | `host`, `network`, `outcome` | every paid call attempted | +| `mcp_payment_amount_usd_total` | `host`, `network` | USD value of payments confirmed settled | +| `mcp_payment_unquantified_total` | `host`, `network` | payments that could not be priced in USD | + +Both label sets are bounded on purpose: Prometheus series are permanent, so a +`host` label taken from whatever URL an agent names would let one caller mint +unbounded time series. `host` may only take a value from +`MCP_PAID_CALL_ALLOWED_HOSTS` — the same list that decides what can be paid at +all, so it already bounds every host that can appear in a record — and `network` +only `stellar:testnet` or `stellar:pubnet`, the two networks this server's signer +can use. Anything outside those domains is labelled `unknown`; the audit file +keeps the real host regardless. + +`npm run mcp:server` is a stdio process with no HTTP listener, so these series +are registered but not scrapeable from it. Read `register` from an embedder that +does serve metrics, or wait for an HTTP transport for the MCP server. + ## Related - MCP server implementation: [`src/mcp/server.ts`](../../src/mcp/server.ts) +- Payment audit ledger: [`src/mcp/ledger.ts`](../../src/mcp/ledger.ts) - Error codes: [`src/mcp/codes.ts`](../../src/mcp/codes.ts) - Tests: [`tests/mcp.test.ts`](../../tests/mcp.test.ts) - Repo README MCP section: [`README.md`](../../README.md#mcp-model-context-protocol) diff --git a/examples/mcp-server/run.ts b/examples/mcp-server/run.ts index ba3297a6..0e54395b 100644 --- a/examples/mcp-server/run.ts +++ b/examples/mcp-server/run.ts @@ -1,5 +1,8 @@ import 'dotenv/config'; import { BazaarMcpServer } from '../../src/mcp/server'; +import { PaymentLedger, defaultPaymentLedgerFile, metricHostsFromPaidCallAllowlist } from '../../src/mcp/ledger'; +import { createLensPriceQueries } from '../../src/mcp/priceQueries'; +import { activeNetwork } from '../../src/config'; import { DEFAULT_MCP_METRICS_PATH, DEFAULT_MCP_METRIC_NETWORKS, @@ -61,6 +64,24 @@ async function main() { const testnetClient = new ExactStellarScheme(testnetSigner); const pubnetClient = new ExactStellarScheme(pubnetSigner); + + // paid_call spends from the keys above, so the hosts it may pay for are the + // operator's decision. Unset means no host is payable at all (#404). + const paidCallAllowlist = (process.env.MCP_PAID_CALL_ALLOWED_HOSTS || '') + .split(',') + .map(entry => entry.trim()) + .filter(Boolean); + + // The audit trail is built before the transport so it can observe the payment + // the transport makes: a wrapped x402 fetch hands back a plain Response and the + // price only appears on the request, so the offer has to be read at the boundary. + // Its `host` metric label takes the same allow-list that gates spending, so an + // agent cannot mint time series with the URLs it tries. + const ledgerFile = process.env.MCP_PAYMENT_LEDGER_FILE || defaultPaymentLedgerFile(); + const paymentLedger = new PaymentLedger({ + filePath: ledgerFile, + metricHosts: metricHostsFromPaidCallAllowlist(paidCallAllowlist), + }); // 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 @@ -109,10 +130,17 @@ async function main() { const server = new BazaarMcpServer({ fetchWithPayment, budget, + paidCallAllowlist, + // Lens's own aggregated prices, read from the same Postgres the API serves. + priceQueries: createLensPriceQueries(), paymentLedger }); console.error('[mcp-server] Agent configured for stellar:testnet and stellar:pubnet'); + // The audit file records where money went, never the key that signed it. + console.error(`[mcp-server] Payment audit trail: ${ledgerFile}`); + console.error(`[mcp-server] lens_get_price serves aggregated prices on ${activeNetwork}`); + // Limits and file paths only: the signing key is never logged. console.error(`[mcp-server] Spend limits: ${budget.describeLimits()}`); // The audit file records where money went, never the key that signed it. console.error(`[mcp-server] Payment audit trail: ${ledgerFile}`); diff --git a/src/mcp/ledger.ts b/src/mcp/ledger.ts index cbf64308..8a81e630 100644 --- a/src/mcp/ledger.ts +++ b/src/mcp/ledger.ts @@ -4,6 +4,7 @@ import { dirname, join } from 'path'; import { Counter } from 'prom-client'; import { register } from '../metrics.js'; import type { McpErrorCode } from './codes.js'; +import { parsePaidCallAllowlist } from './paidCallAllowlist.js'; import { parseUsdToNanodollars } from './budget.js'; export type PaymentOutcome = 'success' | 'failure'; @@ -64,6 +65,16 @@ export interface PaymentLedgerConfig { * Hosts allowed to appear as the `host` label of a Prometheus series. * * Prometheus series are permanent, so a `host` label taken from whatever URL + * an agent asks for lets one caller mint unbounded time series. Pass the + * origins from {@link metricHostsFromPaidCallAllowlist} to use the operator's + * paid_call allow-list as the label domain; anything else records as `unknown`. + * The durable record always keeps the real host regardless of this list. + */ + metricHosts?: readonly string[]; + /** + * Networks allowed to appear as the `network` label. Anything else is + * `unknown`. Defaults to {@link DEFAULT_PAYMENT_METRIC_NETWORKS}. + */ * an agent asks for lets one caller mint unbounded time series. The default * of an empty list therefore labels every series `unknown`; pass the server's * allow-listed host set to get per-host series. The durable record always keeps @@ -75,6 +86,13 @@ export interface PaymentLedgerConfig { now?: () => Date; } +/** + * The network ids this server's signer can pay on, so they are the whole + * `network` label domain. A label taken from whatever a seller echoes in a + * header would mint a permanent time series per distinct string. + */ +export const DEFAULT_PAYMENT_METRIC_NETWORKS: readonly string[] = ['stellar:testnet', 'stellar:pubnet']; + const UNKNOWN_LABEL = 'unknown'; const NANODOLLARS_PER_USD = 1_000_000_000n; @@ -111,6 +129,49 @@ export function defaultPaymentLedgerFile(): string { return join(homedir(), '.lens-mcp', 'payments.jsonl'); } +/** + * The `host` label domain, taken from the same operator configuration that + * decides which hosts `paid_call` may pay for. A call to anything else is + * refused before a request is sent, so this set already bounds every host that + * can appear in a record — no second allow-list to keep in sync. + * + * Entries that do not parse are dropped here as they are by the gate itself, + * which matches nothing rather than matching everything. + */ +export function metricHostsFromPaidCallAllowlist(entries: readonly string[]): string[] { + return [...new Set(parsePaidCallAllowlist(entries).rules.map((rule) => rule.origin))]; +} + +/** + * 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, which + * is how a payment priced in asset units is told apart from one priced in dollars. + */ +function usdOfferToNanodollars(money: string): bigint { + const cleaned = String(money).trim().replace(/^\$/, ''); + const match = /^(\d+)(?:\.(\d+))?$/.exec(cleaned); + if (!match) { + throw new Error(`Not a USD amount: ${JSON.stringify(money)}`); + } + const fraction = match[2] ?? ''; + if (fraction.length > 9) { + throw new Error(`USD amount ${JSON.stringify(money)} has more than 9 decimal places`); + } + return BigInt(match[1]) * NANODOLLARS_PER_USD + BigInt(fraction.padEnd(9, '0') || '0'); +} + +function isUsdAmount(amount: string | null): boolean { + if (amount === null) { + return false; + } + try { + usdOfferToNanodollars(amount); + return true; + } catch { + return false; + } +} + function boundedText(value: unknown, maxChars: number): string | null { if (typeof value !== 'string') return null; const trimmed = value.trim(); @@ -233,6 +294,7 @@ export class PaymentLedger { constructor(config: PaymentLedgerConfig = {}) { this.filePath = config.filePath; this.metricHosts = config.metricHosts ?? []; + this.metricNetworks = config.metricNetworks ?? DEFAULT_PAYMENT_METRIC_NETWORKS; this.metricNetworks = config.metricNetworks ?? []; this.now = config.now ?? (() => new Date()); this.durable = true; @@ -333,6 +395,10 @@ export class PaymentLedger { } /** + * The running total of recorded spend: what the ledger saw settle, what it + * cannot rule out, and how much of the trail it could not price in USD. This + * is the measurement a cumulative ceiling should read, as distinct from the + * worst case it reserves before a price is known. * The running total of recorded spend. This is what a cumulative ceiling reads * instead of guessing from a per-call maximum: `settledNanodollars` is what the * ledger saw settle, and `possibleNanodollars` is what it cannot rule out. @@ -353,6 +419,7 @@ export class PaymentLedger { let nanodollars: bigint | null = null; if (record.amount !== null) { try { + nanodollars = usdOfferToNanodollars(record.amount); nanodollars = parseUsdToNanodollars(record.amount); } catch { // Not a USD money string: an atomic-unit offer, or something a seller @@ -396,12 +463,15 @@ export class PaymentLedger { private exportMetrics(record: PaymentRecord): void { const host = this.metricHosts.includes(record.host) ? record.host : UNKNOWN_LABEL; + const network = + record.network !== null && this.metricNetworks.includes(record.network) ? record.network : UNKNOWN_LABEL; const network = record.network !== null && this.metricNetworks.includes(record.network) ? record.network : UNKNOWN_LABEL; paymentAttemptsTotal.inc({ host, network, outcome: record.outcome }); if (record.spend === 'settled' && record.amount !== null) { let nanodollars: bigint | null = null; try { + nanodollars = usdOfferToNanodollars(record.amount); nanodollars = parseUsdToNanodollars(record.amount); } catch { nanodollars = null; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 28dd30c1..9ad1ea52 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -28,6 +28,7 @@ import { SpendBudget } from './budget.js'; import { PaymentLedger } from './ledger.js'; import { getMetrics, register } from '../metrics.js'; import { checkPaidCallTarget, parsePaidCallAllowlist, type PaidCallAllowRule } from './paidCallAllowlist.js'; +import { PaymentLedger } from './ledger.js'; import { LENS_PRICE_TOOL_NAME, lensPriceToolDefinition, readLensPrice, type LensPriceQueries } from './priceTool.js'; import { BAZAAR_SEARCH_ARGUMENTS_SCHEMA, @@ -125,6 +126,10 @@ export interface McpServerConfig { */ bazaarFetch?: Partial; /** + * Audit trail of what every paid call cost. Defaults to a ledger kept in + * memory; pass one with a file path to keep the record past the process. + */ + paymentLedger?: PaymentLedger; * Bounds on what a tool result may cost the agent in context (#416), from the * `MCP_*` env by default. Overridable so a test can drive the limits without * touching process state. @@ -274,6 +279,8 @@ export class BazaarMcpServer { private bazaarFetchOptions: SsrfFetchOptions; private priceQueries?: LensPriceQueries; private budget?: SpendBudget; + /** The audit trail this server writes to. Public so embedders can read spend without parsing the file. */ + readonly paymentLedger: PaymentLedger; private limits: McpLimits; private resources: LensResourceDescriptor[]; /** The audit trail this server writes to. Public so embedders can read spend without parsing the file. */ @@ -296,6 +303,12 @@ export class BazaarMcpServer { console.error(`[mcp-server] Ignoring unreadable paid_call allow-list entries: ${allowlist.rejected.join(', ')}`); } + // The paid_call allow-list is both the set of hosts that can ever be recorded + // and the domain the `host` metric label may take, so a caller looping over + // URLs cannot mint time series with it. + this.paymentLedger = + config_.paymentLedger ?? new PaymentLedger({ metricHosts: [...new Set(this.paidCallRules.map((rule) => rule.origin))] }); + this.bazaarFetchOptions = { allowedHosts: config.outbound.allowedHosts, fetchImpl: (input, init) => fetchWithTimeout(input, init), @@ -614,6 +627,16 @@ export class BazaarMcpServer { return this.createErrorResponse(target.code, target.reason); } + // Fail closed: this tool moves money, and a spend that leaves no audit + // trail is worse than a refused call. Only reachable when a durable + // ledger was configured and its file became unwritable. + if (!this.paymentLedger.durable) { + return this.createErrorResponse( + 'ERR_INTERNAL_FAILURE', + 'The payment audit trail could not be written, so this server refuses to spend. Fix or point MCP_PAYMENT_LEDGER_FILE at a writable path.' + ); + } + try { const res = await this.fetchWithPayment(url, { method, signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS) }); @@ -638,6 +661,7 @@ export class BazaarMcpServer { outcome: 'failure', errorCode: 'ERR_BAD_REQUEST', response: res, + paymentMayHaveSettled: true, }); return this.createErrorResponse('ERR_BAD_REQUEST', `Request failed with status ${res.status}`); } @@ -670,9 +694,19 @@ export class BazaarMcpServer { // answer. parsed = body.text; } + this.paymentLedger.record({ url, method, outcome: 'success', response: res }); + + return { + content: [{ type: 'text', text: typeof parsed === 'string' ? parsed : JSON.stringify(parsed, null, 2) }], + }; return this.createBoundedResult(parsed, budget); } catch (e: any) { + const mayHaveSettled = !this.paymentCouldNotHaveSettled(e); + this.settleReservation(reservationId, mayHaveSettled); + // Mirror handlePaidCallError so the ledger records the code the agent is shown; + // an AbortError has no signal for the shared classifier, only the caller does. + const code = isTimeoutError(e) ? 'ERR_SERVICE_UNREACHABLE' : classifyPaidCallError(e).code; const code = this.classifyPaidCallFailure(e); const mayHaveSettled = !this.paymentCouldNotHaveSettled(e); this.settleReservation(reservationId, mayHaveSettled); diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index 9882a30d..eaef343e 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -1,3 +1,6 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { BazaarMcpServer, SERVER_VERSION, SERVER_NAME, type McpServerConfig } from '../src/mcp/server'; +import { CallToolRequestSchema, ListToolsRequestSchema, ToolSchema } from '@modelcontextprotocol/sdk/types.js'; import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; import { BazaarMcpServer, SERVER_VERSION, SERVER_NAME } from '../src/mcp/server'; import { @@ -8,7 +11,9 @@ import { } from '@modelcontextprotocol/sdk/types.js'; import { PAID_CALL_HTTP_METHODS } from '../src/mcp/toolSchemas'; import { readFileSync } from 'fs'; -import { join } from 'path'; +import { dirname, join } from 'path'; +import { PaymentLedger, metricHostsFromPaidCallAllowlist } from '../src/mcp/ledger'; +import { register } from '../src/metrics'; const pkg = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf8')) as { version: string }; @@ -1349,6 +1354,17 @@ function settledResponse(body: unknown, settle: unknown = SETTLEMENT) { }); } +describe('payment audit ledger (#406)', () => { + const PAID_URL = 'https://ledger-audit.test/api/v1/price'; + // Every host this block pays, so the paid_call gate lets all of them through. + const ALLOW_HOSTS = ['ledger-audit.test', 'ledger-offer.test', 'ledger-sum.test', 'ledger-metrics.test', 'other-metrics.test']; + + let ledgerDir: string | undefined; + + afterEach(() => { + if (ledgerDir) { + rmSync(ledgerDir, { recursive: true, force: true }); + ledgerDir = undefined; describe('payment audit ledger', () => { const PAID_URL = 'https://ledger-audit.test/api/v1/price'; @@ -1370,6 +1386,31 @@ describe('payment audit ledger', () => { } /** + * Same capture-the-handler seam the rest of this file uses, with a ledger handed + * to the server the way run.ts hands it. + */ + function createHarness( + fetchWithPayment: any, + ledger = new PaymentLedger(), + extra: Partial = {} + ) { + const server = new BazaarMcpServer({ + fetchWithPayment, + paidCallAllowlist: ALLOW_HOSTS, + paymentLedger: ledger, + ...extra, + }); + + let handler: any = null; + (server as any).server.setRequestHandler = (schema: any, fn: any) => { + if (schema === CallToolRequestSchema) { + handler = fn; + } + }; + (server as any).setupHandlers(); + + const callTool = (name: string, args: Record = {}) => handler({ params: { name, arguments: args } }); + return { server, ledger, callTool }; * Discovery goes through the SSRF-safe fetch, so it needs a lookup and a * transport of its own to stay off DNS and sockets. */ @@ -1387,11 +1428,15 @@ describe('payment audit ledger', () => { * scheme builds. The price is only observable there — the wrapped fetch hands * back a plain Response with no amount on it. */ + function createPaidTransport(ledger: PaymentLedger, respond: () => Response, offer = PAYMENT_OFFER) { function createPaidTransport(ledger: PaymentLedger, respond: () => Response) { const instrumented = ledger.instrumentFetch( vi.fn().mockImplementation(async () => respond()) as unknown as typeof fetch ); const transport = vi.fn(async (url: string, init?: RequestInit) => + instrumented(url, { ...init, headers: { 'payment-signature': base64Json(offer) } }) + ); + return createHarness(transport as unknown as any, ledger); instrumented(url, { ...init, headers: { 'payment-signature': base64Json(PAYMENT_OFFER) } }) ); return createHarness(transport as unknown as Mock, undefined, ledger); @@ -1399,11 +1444,14 @@ describe('payment audit ledger', () => { it('records a failed payment as an attempt', async () => { const fetchWithPayment = vi.fn().mockResolvedValue(new Response('Payment Required', { status: 402 })); + const { ledger, callTool } = createHarness(fetchWithPayment); const { callTool, ledger } = createHarness(fetchWithPayment); const response = await callTool('paid_call', { url: PAID_URL }); expect(response.isError).toBe(true); + expect(ledger.records()).toHaveLength(1); + const [record] = ledger.records(); const [record] = ledger.records(); expect(ledger.records()).toHaveLength(1); expect(record).toMatchObject({ @@ -1425,18 +1473,35 @@ describe('payment audit ledger', () => { .mockResolvedValueOnce(settledResponse({ price: '1.00' })) .mockResolvedValueOnce(new Response('Payment Required', { status: 402 })) .mockResolvedValueOnce(new Response('Internal Server Error', { status: 500 })) + // Node's connection-level failure. The shared classifier reads the errno in + // the message; a bare "fetch failed" carries no signal and is unclassified. + .mockRejectedValueOnce(Object.assign(new Error('connect ECONNREFUSED 203.0.113.10:443'), { code: 'ECONNREFUSED' })); + const { ledger, callTool } = createHarness(transport); .mockRejectedValueOnce(transportFailure()); const { callTool } = createHarness(transport, undefined, ledger); await callTool('paid_call', { url: PAID_URL, method: 'POST' }); await callTool('paid_call', { url: PAID_URL }); await callTool('paid_call', { url: 'https://ledger-audit.test/api/v1/other' }); + await callTool('paid_call', { url: 'https://ledger-audit.test/api/v1/throws' }); await callTool('paid_call', { url: PAID_URL }); expect(ledger.records().map((record) => [record.outcome, record.errorCode])).toEqual([ ['success', null], ['failure', MCP_ERROR_CODES.ERR_PAYMENT_REJECTED], ['failure', MCP_ERROR_CODES.ERR_BAD_REQUEST], + ['failure', MCP_ERROR_CODES.ERR_SERVICE_UNREACHABLE], + ]); + expect(ledger.records()[0].method).toBe('POST'); + // A 500 after the payment loop ran, and a transport that threw mid-flight, + // are both failures that cannot be proved unspent. Only the refused 402 — + // where the seller said no before taking anything — is recorded as none. + expect(ledger.records().map((record) => record.spend)).toEqual(['settled', 'none', 'possible', 'possible']); + expect(ledger.totalSpend().attempts).toBe(4); + }); + + it('records the transaction hash the seller echoes on settlement', async () => { + const { ledger, callTool } = createHarness(vi.fn().mockResolvedValue(settledResponse({ price: '1.00' }))); ['failure', MCP_ERROR_CODES.ERR_INTERNAL_FAILURE], ]); // The method the agent asked for, carried through to the record. @@ -1483,12 +1548,15 @@ describe('payment audit ledger', () => { }); it('records no secret material from the payment header', async () => { + const filePath = tempLedgerFile('payments.jsonl'); + const ledger = new PaymentLedger({ filePath }); const ledger = new PaymentLedger({ filePath: tempLedgerFile('payments.jsonl') }); const { callTool } = createPaidTransport(ledger, () => settledResponse({ price: '1.00' })); await callTool('paid_call', { url: 'https://ledger-offer.test/api/v1/price' }); const record = JSON.stringify(ledger.records()[0]); + const file = readFileSync(filePath, 'utf8'); const file = readFileSync(ledgerDir + '/payments.jsonl', 'utf8'); for (const stored of [record, file]) { expect(stored).not.toContain(ENVELOPE_MARKER); @@ -1506,6 +1574,10 @@ describe('payment audit ledger', () => { .fn() .mockResolvedValueOnce(settledResponse({ price: '1.00' })) .mockResolvedValueOnce(new Response('Payment Required', { status: 402 })); + const { callTool } = createHarness(fetchWithPayment, ledger); + + await callTool('paid_call', { url: PAID_URL }); + await callTool('paid_call', { url: PAID_URL }); const { callTool } = createHarness(fetchWithPayment, undefined, ledger, discoveryStub()); await callTool('paid_call', { url: PAID_URL }); @@ -1518,6 +1590,17 @@ describe('payment audit ledger', () => { expect(lines[0]).toMatchObject({ transactionHash: SETTLEMENT.transaction, spend: 'settled' }); }); + it('refuses every paid call when the audit file cannot be created', async () => { + // A regular file where the ledger's parent directory should be: mkdirSync + // fails for real rather than a flag we set to make the test pass. + const insideTrackedDir = tempLedgerFile('ignored.jsonl'); + const blocker = join(dirname(insideTrackedDir), 'not-a-directory'); + writeFileSync(blocker, ''); + const ledger = new PaymentLedger({ filePath: join(blocker, 'payments.jsonl') }); + const fetchWithPayment = vi.fn().mockResolvedValue(settledResponse({ price: '1.00' })); + const { callTool } = createHarness(fetchWithPayment, ledger); + + expect(ledger.durable).toBe(false); it('refuses to spend once the audit trail cannot be written', async () => { const ledger = new PaymentLedger({ filePath: tempLedgerFile('payments.jsonl') }); const fetchWithPayment = vi.fn().mockResolvedValue(settledResponse({ price: '1.00' })); @@ -1530,6 +1613,34 @@ describe('payment audit ledger', () => { expect(refusal.isError).toBe(true); expect(JSON.parse(refusal.content[0].text).code).toBe(MCP_ERROR_CODES.ERR_INTERNAL_FAILURE); + // Refused before the request: this server will not spend money it cannot record. + expect(fetchWithPayment).not.toHaveBeenCalled(); + expect(ledger.records()).toEqual([]); + }); + + it('stops spending once a record fails to reach the audit file', async () => { + const filePath = tempLedgerFile('payments.jsonl'); + const ledger = new PaymentLedger({ filePath }); + const fetchWithPayment = vi.fn().mockResolvedValue(settledResponse({ price: '1.00' })); + const { callTool } = createHarness(fetchWithPayment, ledger); + + await callTool('paid_call', { url: PAID_URL }); + expect(readFileSync(filePath, 'utf8')).toContain(PAID_URL); + + // The trail disappears between calls, so the next append cannot land. + rmSync(dirname(filePath), { recursive: true, force: true }); + ledgerDir = undefined; + + await callTool('paid_call', { url: PAID_URL }); + // That second call is the gap itself: it spent, then found nowhere to record + // it. Nothing can be written about a write that failed, which is why every + // call after it is refused rather than spent. + expect(ledger.durable).toBe(false); + expect(fetchWithPayment).toHaveBeenCalledTimes(2); + + const refusal = await callTool('paid_call', { url: PAID_URL }); + expect(JSON.parse(refusal.content[0].text).code).toBe(MCP_ERROR_CODES.ERR_INTERNAL_FAILURE); + expect(fetchWithPayment).toHaveBeenCalledTimes(2); expect(fetchWithPayment).not.toHaveBeenCalled(); }); @@ -1546,6 +1657,7 @@ describe('payment audit ledger', () => { const offer = { ...PAYMENT_OFFER, accepted: { ...PAYMENT_OFFER.accepted, amount } }; return instrumented(url, { ...init, headers: { 'payment-signature': base64Json(offer) } }); }); + const { callTool } = createHarness(transport as unknown as any, ledger); const { callTool } = createHarness(transport as unknown as Mock, undefined, ledger); await callTool('paid_call', { url: 'https://ledger-sum.test/api/v1/price' }); @@ -1571,6 +1683,7 @@ describe('payment audit ledger', () => { const { callTool } = createPaidTransport(ledger, () => settledResponse({ price: '1.00' })); await callTool('paid_call', { url: 'https://ledger-metrics.test/api/v1/price' }); + await callTool('paid_call', { url: 'https://other-metrics.test/api/v1/price' }); await callTool('paid_call', { url: 'https://not-allow-listed.test/api/v1/price' }); const attempts = await register.getSingleMetricAsString('mcp_payment_attempts_total'); @@ -1578,6 +1691,41 @@ describe('payment audit ledger', () => { expect(attempts).toMatch(/host="unknown"/); // A host outside the allow-list never becomes a series of its own: an agent // that loops over URLs must not be able to mint unbounded time series. + expect(attempts).not.toContain('other-metrics.test'); + + const settled = await register.getSingleMetricAsString('mcp_payment_amount_usd_total'); + expect(settled).toMatch(/mcp_payment_amount_usd_total\{[^}]*host="ledger-metrics\.test"[^}]*network="stellar:testnet"[^}]*\} 0\.1/); + expect(settled).not.toContain('other-metrics.test'); + }); + + it('records nothing for a host the allow-list refused, or for discovery', async () => { + const fetchWithPayment = vi.fn().mockResolvedValue(settledResponse({ price: '1.00' })); + // Discovery is stubbed all the way down: the SSRF guard would otherwise do a + // real DNS lookup for this host, and a spend test has no business reaching + // for the network. + const discovery = vi.fn( + async () => + new Response(JSON.stringify({ resources: [{ name: 'x' }] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + const { ledger, callTool } = createHarness(fetchWithPayment, new PaymentLedger(), { + bazaarFetch: { + allowedHosts: ['ledger-audit.test'], + lookup: async () => ['93.184.216.34'], + fetchImpl: discovery, + }, + }); + + const refusal = await callTool('paid_call', { url: 'https://never-allow-listed.test/api/v1/price' }); + + expect(JSON.parse(refusal.content[0].text).code).toBe(MCP_ERROR_CODES.ERR_HOST_NOT_ALLOWED); + expect(fetchWithPayment).not.toHaveBeenCalled(); + + // Discovery reads a catalog and never pays, so it has no place in a spend trail. + const search = await callTool('bazaar_search', { url: 'https://ledger-audit.test' }); + expect(search.isError).toBeFalsy(); expect(attempts).not.toContain('not-allow-listed.test'); const settled = await register.getSingleMetricAsString('mcp_payment_amount_usd_total'); @@ -1622,4 +1770,18 @@ describe('payment audit ledger', () => { expect(ledger.records()).toEqual([]); expect(ledger.totalSpend().attempts).toBe(0); }); + + it('takes its host label domain from the paid_call allow-list', () => { + // Same set that gates spending, deduplicated, with entries the gate itself + // rejects left out rather than allowed to label a series. + expect( + metricHostsFromPaidCallAllowlist([ + 'https://api.lens.example', + 'api.lens.example', + 'localhost:3000', + 'https://api.lens.example/v1/price', + 'not a url', + ]) + ).toEqual(['api.lens.example', 'localhost:3000']); + }); });