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']); + }); +});