From 95df99dce61d26398e1f2e99ea72ed45fe8527f6 Mon Sep 17 00:00:00 2001 From: AGWAM001 <281425994+AGWAM001@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:33:21 -0700 Subject: [PATCH] feat(mcp): record and meter what every paid call spent paid_call moved money from a live key and left no receipt: the wrapped x402 fetch returns a plain Response, and the price only exists on the request the transport makes. PaymentLedger appends one record per attempt to MCP_PAYMENT_LEDGER_FILE - failed and rejected calls included, since a payment can settle on a call that errored - and never reads the signing key or the signed envelope out of the payment header. Each record says whether its spend is confirmed (settled), unprovable (possible) or excluded (none), so the trail reads as an upper bound instead of inventing certainty a seller never confirmed. An audit file that cannot be created, or that a record cannot reach, makes paid_call refuse further calls rather than spend unrecorded. The same records count into mcp_payment_attempts_total, mcp_payment_amount_usd_total and mcp_payment_unquantified_total. Their host label may only take a value from the paid_call allow-list - the same list that decides what can be paid at all - and their network label only the two networks this signer can use, so no caller mints permanent time series with the URLs it tries. totalSpend() gives a cumulative ceiling the running sum to read. --- .changeset/mcp-payment-audit.md | 24 ++ .env.example | 20 ++ README.md | 6 + examples/mcp-server/README.md | 61 +++++ examples/mcp-server/run.ts | 33 ++- src/mcp/ledger.ts | 472 ++++++++++++++++++++++++++++++++ src/mcp/server.ts | 114 +++++++- tests/mcp.test.ts | 366 ++++++++++++++++++++++++- 8 files changed, 1074 insertions(+), 22 deletions(-) create mode 100644 .changeset/mcp-payment-audit.md create mode 100644 src/mcp/ledger.ts diff --git a/.changeset/mcp-payment-audit.md b/.changeset/mcp-payment-audit.md new file mode 100644 index 00000000..7404e2d9 --- /dev/null +++ b/.changeset/mcp-payment-audit.md @@ -0,0 +1,24 @@ +--- +'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. diff --git a/.env.example b/.env.example index 3937aedc..e3627e80 100644 --- a/.env.example +++ b/.env.example @@ -217,6 +217,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. # ─── Snapshot retention ──────────────────────────────────────────────────── diff --git a/README.md b/README.md index 13334c3b..61e1281a 100644 --- a/README.md +++ b/README.md @@ -222,6 +222,12 @@ data through two tools: `bazaar_search` and `paid_call`. The server signs x402 payment challenges with Stellar secret keys on both `stellar:testnet` and `stellar:pubnet`. +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 ef9da239..fae19149 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 { PaymentLedger, defaultPaymentLedgerFile, metricHostsFromPaidCallAllowlist } from '../../src/mcp/ledger'; import { wrapFetchWithPaymentFromConfig } from '@x402/fetch'; import { ExactStellarScheme } from '@x402/stellar/exact/client'; import { createEd25519Signer } from '@x402/stellar'; @@ -19,7 +20,25 @@ async function main() { const testnetClient = new ExactStellarScheme(testnetSigner); const pubnetClient = new ExactStellarScheme(pubnetSigner); - const fetchWithPayment = wrapFetchWithPaymentFromConfig(globalThis.fetch, { + // 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), + }); + + const fetchWithPayment = wrapFetchWithPaymentFromConfig(paymentLedger.instrumentFetch(globalThis.fetch), { maxPrice: process.env.MCP_MAX_PAYMENT_PRICE || '$1.00', schemes: [ { @@ -33,20 +52,16 @@ async function main() { ] }); - // 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); - const server = new BazaarMcpServer({ fetchWithPayment, - paidCallAllowlist + paidCallAllowlist, + paymentLedger }); console.error('[mcp-server] Starting Bazaar MCP server on stdio...'); 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( paidCallAllowlist.length > 0 ? `[mcp-server] paid_call may pay these allow-listed origins: ${paidCallAllowlist.join(', ')}` diff --git a/src/mcp/ledger.ts b/src/mcp/ledger.ts new file mode 100644 index 00000000..2ef67bdc --- /dev/null +++ b/src/mcp/ledger.ts @@ -0,0 +1,472 @@ +import { appendFileSync, mkdirSync } from 'fs'; +import { homedir } from 'os'; +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'; + +export type PaymentOutcome = 'success' | 'failure'; + +/** + * What the ledger can vouch for about the money itself, as distinct from the + * outcome of the HTTP call. A call can fail after the payment settled, and a + * call can succeed on a seller that never echoes its settlement. + * + * - `settled`: the settlement response confirmed the transaction. + * - `possible`: a payment was offered and nothing proves it failed. + * - `none`: nothing moved, or nothing was ever offered. + */ +export type PaymentSpend = 'settled' | 'possible' | 'none'; + +/** One line of the audit trail. Field names are stable; new fields are additive. */ +export interface PaymentRecord { + readonly timestamp: string; + /** Full URL as called. Never shortened, never masked. */ + readonly url: string; + readonly host: string; + readonly method: string; + /** Amount exactly as the seller offered it, e.g. `$0.10` or an atomic-unit string. */ + readonly amount: string | null; + readonly asset: string | null; + readonly network: string | null; + readonly transactionHash: string | null; + readonly outcome: PaymentOutcome; + readonly spend: PaymentSpend; + readonly errorCode: McpErrorCode | null; +} + +export interface SpendSummary { + readonly attempts: number; + /** Confirmed spend, in integer nanodollars, as a decimal string for JSON safety. */ + readonly settledNanodollars: string; + /** + * Spend that cannot be ruled out, in integer nanodollars. A ceiling must add + * this to {@link SpendSummary.settledNanodollars} to stay an upper bound: the + * ledger records what is known, it does not prove what is absent. + */ + readonly possibleNanodollars: string; + /** + * Recorded payments whose offer was not a USD money string, so they contribute + * nothing to the totals above. A non-zero count means the totals understate + * what was spent. + */ + readonly unquantifiedPayments: number; +} + +export interface PaymentLedgerConfig { + /** + * Append-only audit file. Omit to keep records in memory only, which is what + * tests and embedders that do not want a file on disk get by default. + */ + filePath?: string; + /** + * 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}. + */ + metricNetworks?: readonly string[]; + 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; + +/** + * Caps on values that originate outside this process. The audit trail is written + * with appendFileSync and read back by humans and tools, so an over-long or + * newline-bearing field from a seller must not be able to forge extra records. + */ +const MAX_URL_CHARS = 2048; +const MAX_FIELD_CHARS = 256; + +const paymentAttemptsTotal = new Counter({ + name: 'mcp_payment_attempts_total', + help: 'Paid calls attempted through the MCP server, by host, network and outcome', + labelNames: ['host', 'network', 'outcome'], + registers: [register], +}); + +const paymentAmountUsdTotal = new Counter({ + name: 'mcp_payment_amount_usd_total', + help: 'USD value of payments the MCP server made, by host and network. Only payments priced in USD are counted; see mcp_payment_unquantified_total.', + labelNames: ['host', 'network'], + registers: [register], +}); + +const paymentUnquantifiedTotal = new Counter({ + name: 'mcp_payment_unquantified_total', + help: 'Payments recorded that could not be priced in USD, by host and network, so the USD total is known to understate spend', + labelNames: ['host', 'network'], + registers: [register], +}); + +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(); + if (!trimmed) return null; + return trimmed.length > maxChars ? `${trimmed.slice(0, maxChars)}...[truncated]` : trimmed; +} + +function hostOf(url: string): string { + try { + return new URL(url).host; + } catch { + // A paid call can still be attempted with a URL this process cannot parse; + // the record must show what was asked for rather than drop the attempt. + return UNKNOWN_LABEL; + } +} + +function decodeJsonHeader(value: string | null): Record | null { + if (!value) return null; + try { + const json = Buffer.from(value.trim().replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'); + const parsed: unknown = JSON.parse(json); + return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : null; + } catch { + return null; + } +} + +function readJsonHeader(response: Response | undefined, names: readonly string[]): Record | null { + const headers = response?.headers; + // A real Response always carries Headers. A stand-in that does not must not be + // able to throw inside record() and turn a paid call that worked into an error: + // the audit trail is written around the money, not in its path. + if (!headers || typeof headers.get !== 'function') { + return null; + } + for (const name of names) { + const decoded = decodeJsonHeader(headers.get(name)); + if (decoded) { + return decoded; + } + } + return null; +} + +/** Headers a resource server uses to echo the settlement of a payment it accepted. */ +function settleHeader(response: Response | undefined): Record | null { + return readJsonHeader(response, ['payment-response', 'x-payment-response']); +} + +/** + * The accepted offer, read out of the payment header this client sent. + * + * `accepted` is the seller's own requirements echoed back, so it carries the + * price, asset and network. Everything else in this header — above all + * `payload`, the signed transaction envelope — is deliberately not read here + * and must never reach a record, a metric or the audit file. + */ +function acceptedHeader(headers: Headers): Record | null { + const encoded = headers.get('payment-signature') ?? headers.get('x-payment'); + const payload = decodeJsonHeader(encoded); + if (!payload) return null; + const accepted = payload.accepted; + if (accepted !== null && typeof accepted === 'object' && !Array.isArray(accepted)) { + return accepted as Record; + } + // x402 v1 payloads carry the offer at the top level and do not include an amount. + return payload; +} + +interface PaymentObservation { + readonly amount: string | null; + readonly asset: string | null; + readonly network: string | null; +} + +function observePayment(headers: Headers): PaymentObservation | null { + const accepted = acceptedHeader(headers); + if (!accepted) return null; + return { + amount: boundedText(accepted.amount, MAX_FIELD_CHARS), + asset: boundedText(accepted.asset, MAX_FIELD_CHARS), + network: boundedText(accepted.network, MAX_FIELD_CHARS), + }; +} + +/** + * Durable, ordered record of every paid call, plus the Prometheus series that + * make that spend visible from outside the process. + * + * The record is written where the payment can actually be seen. A wrapped + * x402 fetch hands back a plain Response, and the price the agent agreed to is + * only present on the request the transport makes, so + * {@link PaymentLedger.instrumentFetch} observes the transport and the record + * is assembled from both sides: the offer from the request, the settlement from + * the response. + */ +export class PaymentLedger { + /** + * False when a durable audit trail was asked for and cannot be had — the file + * could not be created, or a record failed to reach it. An in-memory ledger + * (no filePath) never claims to be durable and stays true, because there was + * nothing to break. + */ + durable: boolean; + + private readonly filePath?: string; + private readonly metricHosts: readonly string[]; + private readonly metricNetworks: readonly string[]; + private readonly now: () => Date; + private readonly observations = new WeakMap(); + private readonly log: PaymentRecord[] = []; + private settledNanodollars = 0n; + private possibleNanodollars = 0n; + private unquantifiedPayments = 0; + private warnedAboutFile = false; + + constructor(config: PaymentLedgerConfig = {}) { + this.filePath = config.filePath; + this.metricHosts = config.metricHosts ?? []; + this.metricNetworks = config.metricNetworks ?? DEFAULT_PAYMENT_METRIC_NETWORKS; + this.now = config.now ?? (() => new Date()); + this.durable = true; + if (this.filePath) { + try { + mkdirSync(dirname(this.filePath), { recursive: true }); + } catch (e) { + this.durable = false; + this.warnFile( + `the payment ledger file at ${this.filePath} could not be created: ${(e as Error).message}. ` + + 'Paid calls are refused until the audit trail can be written.' + ); + } + } + } + + /** + * Wraps the transport fetch so that the offer on each payment leg is recorded + * against the Response that leg produces. Pass the result as the fetch given + * to `wrapFetchWithPayment`; the x402 loop and the response the tool returns + * are untouched. + */ + instrumentFetch(baseFetch: typeof fetch): typeof fetch { + return async (input, init) => { + // Only the headers are read, so the request is passed to the transport + // untouched: cloning it would risk consuming a body on the way. + const headers = input instanceof Request ? input.headers : new Headers(init?.headers); + const observation = observePayment(headers); + const response = await baseFetch(input, init); + if (observation) { + this.observations.set(response, observation); + } + return response; + }; + } + + /** + * Writes the record for one paid call. Called for the outcome of every attempt + * that reached the payment transport, whether or not it settled, and whether or + * not the seller said anything about settlement. + * + * @param paymentMayHaveSettled the caller's verdict for an attempt that produced + * no Response — a transport that threw part-way through the payment loop. The + * ledger cannot see the offer in that case, so it takes this rather than + * concluding nothing was spent. + */ + record(input: { + url: string; + method: string; + outcome: PaymentOutcome; + errorCode?: McpErrorCode | null; + response?: Response; + paymentMayHaveSettled?: boolean; + }): PaymentRecord { + const response = input.response; + const observed = response ? this.observations.get(response) : undefined; + const settle = response ? settleHeader(response) : null; + const settled = settle ? settle.success === true : null; + const amount = observed?.amount ?? boundedText(settle?.amount, MAX_FIELD_CHARS) ?? null; + const network = observed?.network ?? boundedText(settle?.network, MAX_FIELD_CHARS) ?? null; + const offerWasSent = observed !== undefined || settle !== null || input.paymentMayHaveSettled === true; + + // Nothing that proves the money moved is enough to call it unspent: an + // explicit settlement failure and a rejected 402 are the only outs. + let spend: PaymentSpend; + if (settled === true) { + spend = 'settled'; + } else if (settled === false || response?.status === 402) { + spend = 'none'; + } else { + spend = offerWasSent ? 'possible' : 'none'; + } + + const record: PaymentRecord = { + timestamp: this.now().toISOString(), + url: boundedText(input.url, MAX_URL_CHARS) ?? String(input.url ?? ''), + host: boundedText(hostOf(input.url), MAX_FIELD_CHARS) ?? UNKNOWN_LABEL, + method: boundedText(input.method, MAX_FIELD_CHARS) ?? 'GET', + amount, + asset: observed?.asset ?? null, + network, + transactionHash: boundedText(settle?.transaction, MAX_FIELD_CHARS), + outcome: input.outcome, + spend, + errorCode: input.errorCode ?? null, + }; + + this.log.push(record); + this.bookTotals(record); + this.appendToFile(record); + this.exportMetrics(record); + return record; + } + + /** Every record taken so far, oldest first. */ + records(): readonly PaymentRecord[] { + return this.log; + } + + /** + * 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. + */ + totalSpend(): SpendSummary { + return { + attempts: this.log.length, + settledNanodollars: this.settledNanodollars.toString(), + possibleNanodollars: this.possibleNanodollars.toString(), + unquantifiedPayments: this.unquantifiedPayments, + }; + } + + private bookTotals(record: PaymentRecord): void { + if (record.spend === 'none') { + return; + } + let nanodollars: bigint | null = null; + if (record.amount !== null) { + try { + nanodollars = usdOfferToNanodollars(record.amount); + } catch { + // Not a USD money string: an atomic-unit offer, or something a seller + // invented. It is recorded verbatim but cannot join a USD total. + nanodollars = null; + } + } + if (nanodollars === null) { + this.unquantifiedPayments++; + return; + } + if (record.spend === 'settled') { + this.settledNanodollars += nanodollars; + } else { + this.possibleNanodollars += nanodollars; + } + } + + private appendToFile(record: PaymentRecord): void { + if (!this.filePath) { + return; + } + try { + appendFileSync(this.filePath, `${JSON.stringify(record)}\n`, 'utf8'); + } catch (e) { + this.durable = false; + this.warnFile( + `a payment record could not be written to ${this.filePath}: ${(e as Error).message}. ` + + 'Paid calls are refused from here on: the audit trail on disk has a gap and a durable one is required.' + ); + } + } + + private warnFile(reason: string): void { + if (this.warnedAboutFile) { + return; + } + this.warnedAboutFile = true; + console.error(`[mcp-ledger] ${reason}`); + } + + 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; + + paymentAttemptsTotal.inc({ host, network, outcome: record.outcome }); + if (record.spend === 'settled' && record.amount !== null) { + let nanodollars: bigint | null = null; + try { + nanodollars = usdOfferToNanodollars(record.amount); + } catch { + nanodollars = null; + } + if (nanodollars !== null) { + paymentAmountUsdTotal.inc({ host, network }, Number(nanodollars) / Number(NANODOLLARS_PER_USD)); + } + } + if (record.spend !== 'none' && !isUsdAmount(record.amount)) { + paymentUnquantifiedTotal.inc({ host, network }); + } + } +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 804b0791..05bc9f65 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -3,6 +3,7 @@ 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 { checkPaidCallTarget, parsePaidCallAllowlist, type PaidCallAllowRule } from './paidCallAllowlist.js'; +import { PaymentLedger } from './ledger.js'; import { fetchWithTimeout } from '../fetch.js'; import { config } from '../config.js'; import { ssrfSafeFetch, SsrfError, type SsrfFetchOptions } from '../net/ssrf.js'; @@ -21,6 +22,11 @@ export interface McpServerConfig { * tests can drive DNS resolution and the transport without real sockets. */ 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; } export class BazaarMcpServer { @@ -28,6 +34,8 @@ export class BazaarMcpServer { private fetchWithPayment: typeof fetch; private paidCallRules: PaidCallAllowRule[]; private bazaarFetchOptions: SsrfFetchOptions; + /** The audit trail this server writes to. Public so embedders can read spend without parsing the file. */ + readonly paymentLedger: PaymentLedger; constructor(config_: McpServerConfig) { this.fetchWithPayment = config_.fetchWithPayment; @@ -42,6 +50,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), @@ -157,13 +171,40 @@ 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 }); if (!res.ok) { if (res.status === 402) { + // The seller rejected the payment, so nothing settled. + this.paymentLedger.record({ + url, + method, + outcome: 'failure', + errorCode: 'ERR_PAYMENT_REJECTED', + response: res, + }); 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.paymentLedger.record({ + url, + method, + outcome: 'failure', + errorCode: 'ERR_BAD_REQUEST', + response: res, + paymentMayHaveSettled: true, + }); return this.createErrorResponse('ERR_BAD_REQUEST', `Request failed with status ${res.status}`); } @@ -174,11 +215,22 @@ export class BazaarMcpServer { } catch { parsed = text; } + this.paymentLedger.record({ url, method, outcome: 'success', response: res }); return { content: [{ type: 'text', text: typeof parsed === 'string' ? parsed : JSON.stringify(parsed, null, 2) }], }; } catch (e: any) { + const mayHaveSettled = !this.paymentCouldNotHaveSettled(e); + // No Response reached this process, so the ledger cannot read the offer + // or a settlement header; the caller's verdict is all the record can hold. + this.paymentLedger.record({ + url, + method, + outcome: 'failure', + errorCode: this.classifyPaidCallError(e), + paymentMayHaveSettled: mayHaveSettled, + }); return this.handlePaidCallError(e); } } @@ -187,26 +239,66 @@ export class BazaarMcpServer { }); } - private handlePaidCallError(e: any) { - const msg = String(e.message || e).toLowerCase(); - + /** + * 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') + ); + } + + /** + * The error code a thrown paid call maps to. Kept separate from the response so + * the ledger records the same code the agent is shown. + */ + private classifyPaidCallError(e: any): McpErrorCode { + const msg = String(e?.message || e).toLowerCase(); + if (msg.includes('signatureexpirationledger') || msg.includes('signature expired')) { - return this.createErrorResponse('ERR_EXPIRED_AUTHORISATION', 'The transaction signature expired before it could be submitted.'); + return 'ERR_EXPIRED_AUTHORISATION'; } - + if (msg.includes('trustline') || msg.includes('op_no_trust')) { - return this.createErrorResponse('ERR_MISSING_TRUSTLINE', 'Missing trustline for the required asset.'); + return 'ERR_MISSING_TRUSTLINE'; } - + if (msg.includes('insufficient') || msg.includes('underfunded')) { - return this.createErrorResponse('ERR_INSUFFICIENT_BALANCE', 'Insufficient balance to complete the payment.'); + return 'ERR_INSUFFICIENT_BALANCE'; } if (msg.includes('fetch') || msg.includes('network')) { - return this.createErrorResponse('ERR_SERVICE_UNREACHABLE', 'Failed to reach the service: ' + e.message); + return 'ERR_SERVICE_UNREACHABLE'; + } + + return 'ERR_INTERNAL_FAILURE'; + } + + private handlePaidCallError(e: any) { + const code = this.classifyPaidCallError(e); + + switch (code) { + case 'ERR_EXPIRED_AUTHORISATION': + return this.createErrorResponse(code, 'The transaction signature expired before it could be submitted.'); + case 'ERR_MISSING_TRUSTLINE': + return this.createErrorResponse(code, 'Missing trustline for the required asset.'); + case 'ERR_INSUFFICIENT_BALANCE': + return this.createErrorResponse(code, 'Insufficient balance to complete the payment.'); + case 'ERR_SERVICE_UNREACHABLE': + return this.createErrorResponse(code, 'Failed to reach the service: ' + e.message); + default: + return this.createErrorResponse(code, e.message || 'An unknown error occurred during the paid call.'); } - - return this.createErrorResponse('ERR_INTERNAL_FAILURE', e.message || 'An unknown error occurred during the paid call.'); } private createErrorResponse(code: McpErrorCode, reason: string) { diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index 7d71be0d..38178550 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -1,5 +1,11 @@ -import { describe, it, expect, vi } from 'vitest'; -import { BazaarMcpServer } from '../src/mcp/server'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { BazaarMcpServer, type McpServerConfig } from '../src/mcp/server'; +import { PaymentLedger, metricHostsFromPaidCallAllowlist } from '../src/mcp/ledger'; +import { MCP_ERROR_CODES } from '../src/mcp/codes'; +import { register } from '../src/metrics'; import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'; describe('BazaarMcpServer', () => { @@ -200,3 +206,359 @@ describe('BazaarMcpServer bazaar_search SSRF guard (#407)', () => { expect(JSON.parse(response.content[0].text).resources[0].name).toBe('x'); }); }); + +/** + * x402 v2 payment header as the client's transport sends it: the seller's own + * requirements echoed under `accepted`, plus `payload`, the signed envelope. + * `payload` is the only thing here the ledger must never read, so it carries a + * marker no record may contain. + */ +const PAYMENT_OFFER = { + x402Version: 2, + resource: 'https://ledger-offer.test/api/v1/price', + accepted: { + scheme: 'exact', + network: 'stellar:testnet', + asset: 'native', + amount: '$0.10', + }, + payload: { txEnvelopeXdr: 'DECOY-SIGNED-ENVELOPE-MUST-NOT-BE-RECORDED' }, +}; + +/** Settlement header a resource server returns for a payment it accepted. */ +const SETTLEMENT = { + success: true, + network: 'stellar:testnet', + // A fixture, not a ledger entry: 64 hex characters written in full, never shortened. + transaction: '0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c4b5a69788796a5b4c3d2e1f0', +}; + +const ENVELOPE_MARKER = 'DECOY-SIGNED-ENVELOPE-MUST-NOT-BE-RECORDED'; + +function base64Json(value: unknown): string { + return Buffer.from(JSON.stringify(value)).toString('base64'); +} + +function settledResponse(body: unknown, settle: unknown = SETTLEMENT) { + return new Response(JSON.stringify(body), { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'payment-response': base64Json(settle), + }, + }); +} + +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; + } + }); + + function tempLedgerFile(name: string): string { + ledgerDir = mkdtempSync(join(tmpdir(), 'lens-mcp-ledger-')); + return join(ledgerDir, name); + } + + /** + * 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 }; + } + + /** + * Wires the ledger the way run.ts does: the ledger wraps the transport's fetch, + * and a stand-in for wrapFetchWithPayment adds the payment header that the real + * 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) { + 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); + } + + 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 response = await callTool('paid_call', { url: PAID_URL }); + + expect(response.isError).toBe(true); + expect(ledger.records()).toHaveLength(1); + const [record] = ledger.records(); + expect(record).toMatchObject({ + url: PAID_URL, + host: 'ledger-audit.test', + method: 'GET', + outcome: 'failure', + spend: 'none', + errorCode: MCP_ERROR_CODES.ERR_PAYMENT_REJECTED, + transactionHash: null, + }); + expect(Date.parse(record.timestamp)).not.toBeNaN(); + }); + + it('records every paid call, whatever the transport did with it', async () => { + const transport = vi + .fn() + .mockResolvedValueOnce(settledResponse({ price: '1.00' })) + .mockResolvedValueOnce(new Response('Payment Required', { status: 402 })) + .mockResolvedValueOnce(new Response('Internal Server Error', { status: 500 })) + .mockRejectedValueOnce(new Error('fetch failed')); + const { ledger, callTool } = createHarness(transport); + + 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' }); + + 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' }))); + + await callTool('paid_call', { url: PAID_URL }); + + expect(ledger.records()[0]).toMatchObject({ + outcome: 'success', + spend: 'settled', + transactionHash: SETTLEMENT.transaction, + network: 'stellar:testnet', + }); + }); + + it('reads the price off the payment header the transport sends', async () => { + const ledger = new PaymentLedger(); + const { callTool } = createPaidTransport(ledger, () => settledResponse({ price: '1.00' })); + + const response = await callTool('paid_call', { url: 'https://ledger-offer.test/api/v1/price' }); + + expect(response.isError).toBeFalsy(); + expect(ledger.records()[0]).toMatchObject({ + amount: '$0.10', + asset: 'native', + network: 'stellar:testnet', + outcome: 'success', + // The envelope settled, so the offer price is what was spent. + spend: 'settled', + }); + }); + + it('records no secret material from the payment header', async () => { + const filePath = tempLedgerFile('payments.jsonl'); + const ledger = new PaymentLedger({ filePath }); + 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'); + for (const stored of [record, file]) { + expect(stored).not.toContain(ENVELOPE_MARKER); + expect(stored).not.toContain('txEnvelopeXdr'); + expect(stored).not.toContain('payload'); + } + // The offer is recorded; the signature that carried it is not. + expect(record).toContain('$0.10'); + }); + + it('appends one line per attempt to the audit file, oldest first', async () => { + const filePath = tempLedgerFile('payments.jsonl'); + const ledger = new PaymentLedger({ filePath }); + const fetchWithPayment = vi + .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 lines = readFileSync(filePath, 'utf8').trimEnd().split('\n').map((line) => JSON.parse(line)); + expect(lines).toHaveLength(2); + expect(lines.map((line) => line.outcome)).toEqual(['success', 'failure']); + 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); + + 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_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); + }); + + it('sums settled spend and flags spend it cannot price', async () => { + const ledger = new PaymentLedger(); + const instrumented = ledger.instrumentFetch( + vi + .fn() + .mockResolvedValueOnce(settledResponse({ price: '1.00' })) + .mockResolvedValueOnce(settledResponse({ price: '1.00' })) as unknown as typeof fetch + ); + const transport = vi.fn(async (url: string, init?: RequestInit) => { + const amount = url.includes('unpriced') ? 'usdc:1000000' : '$0.10'; + 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); + + await callTool('paid_call', { url: 'https://ledger-sum.test/api/v1/price' }); + await callTool('paid_call', { url: 'https://ledger-sum.test/api/v1/unpriced' }); + + // $0.10 settled as nanodollars; the asset-prefixed offer is recorded verbatim + // but cannot join a USD total, and the ledger says so instead of quietly + // undercounting what the agent spent. + expect(ledger.totalSpend()).toEqual({ + attempts: 2, + settledNanodollars: '100000000', + possibleNanodollars: '0', + unquantifiedPayments: 1, + }); + expect(ledger.records()[1].amount).toBe('usdc:1000000'); + }); + + it('exports spend as metrics with a bounded label set', async () => { + const ledger = new PaymentLedger({ + metricHosts: ['ledger-metrics.test'], + metricNetworks: ['stellar:testnet'], + }); + 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' }); + + const attempts = await register.getSingleMetricAsString('mcp_payment_attempts_total'); + expect(attempts).toMatch(/mcp_payment_attempts_total\{[^}]*host="ledger-metrics\.test"[^}]*network="stellar:testnet"[^}]*outcome="success"[^}]*\} 1/); + 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 })); + const { ledger, callTool } = createHarness(fetchWithPayment, new PaymentLedger(), { + bazaarFetch: { 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(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']); + }); +});