diff --git a/.env.example b/.env.example index e0b43b1c..b9ea2303 100644 --- a/.env.example +++ b/.env.example @@ -387,6 +387,10 @@ FACILITATOR_SECRET_KEY= FACILITATOR_FEE_STROOPS_TESTNET=50000 FACILITATOR_FEE_STROOPS_MAINNET=50000 +# --- MCP server (`npm run mcp:server`) --- +# Stellar secret keys the MCP agent signs x402 payments with, one per network. +# Both are required to start the server. Leave these empty in a committed file — +# the mainnet key moves real money. # --- Price Plausibility Sanity Bounds --- # Lower and upper bounds for sanity-checking derived venue prices. # Derived spot prices outside this range are rejected with a log warning and metric increment. @@ -418,8 +422,47 @@ MCP_HTTP_HOST=127.0.0.1 MCP_HTTP_PORT=3003 # Path the HTTP transport answers MCP POSTs on (default: /mcp). MCP_HTTP_PATH=/mcp +# Most a single payment may cost. Enforced as the x402 client's per-payment cap +# (spendControls.maxAmountPerPayment), so a call priced higher is refused before +# any money moves (default: $1.00). # Ceiling per paid_call, enforced by the x402 fetch wrapper. MCP_MAX_PAYMENT_PRICE=$1.00 +# Cumulative ceiling on what paid calls may cost in one UTC day. The per-call cap +# above only bounds one mistake; without this, an agent in a retry loop can pay +# that amount over and over. Once a further call's worst case no longer fits the +# remaining budget, paid_call is refused with ERR_BUDGET_EXCEEDED without +# attempting payment. Read live with the payment_budget tool (default: $10.00). +MCP_DAILY_SPEND_CEILING=$10.00 +# Where the day's running total is kept, so a server restart does not refill the +# budget: the total is per UTC day and read back from this file at startup, and +# the window still resets at 00:00 UTC. If the file is missing it starts at zero; +# if it is unreadable or unwritable the budget is treated as spent and paid calls +# are refused until it is fixed (fail closed). Keep it off ephemeral filesystems. +MCP_SPEND_STATE_FILE= +# Where the payment audit trail is appended, one JSON record per paid call: +# timestamp, full URL, host, method, price, asset, network, transaction hash and +# the outcome. Never the secret key and never the signed payment envelope. A call +# that failed is recorded too, since a payment can settle on a call that errored. +# If this file cannot be created or written, paid calls are refused: a tool that +# moves money does not get to spend without a record (fail closed). Keep it off +# ephemeral filesystems (default: ~/.lens-mcp/payments.jsonl). +MCP_PAYMENT_LEDGER_FILE= +# Comma-separated hosts allowed to become the `host` label of the spend metrics. +# Prometheus series are permanent, so labelling by whatever URL an agent asks for +# lets one caller mint unbounded time series; hosts outside this list are reported +# as `unknown`, and an empty value reports every host as `unknown`. The audit file +# above always keeps the real host regardless (e.g. bazaar.example,price.feed). +MCP_METRIC_HOSTS= +# Comma-separated network ids allowed to become the `network` label of the spend +# metrics. Same bounded-series rule as above. Unset keeps the networks this agent +# signs for, stellar:testnet and stellar:pubnet; setting it to an empty value +# reports every network as `unknown`, so the default is spelled out here rather +# than left blank for an operator copying this file. +MCP_METRIC_NETWORKS=stellar:testnet,stellar:pubnet +# With MCP_TRANSPORT=http the registry above is scrapeable from the MCP server +# itself: GET /metrics on MCP_HTTP_PORT, requiring the same MCP_HTTP_AUTH_TOKEN +# bearer token. stdio starts no listener, so there the running total is what the +# payment_budget tool reports and what the audit file holds. # Comma-separated origins (or URL prefixes) paid_call may pay for, e.g. # "https://api.lens.example,localhost:3000". An entry with no scheme matches # both http and https; an entry with a path only allows that path and what sits diff --git a/README.md b/README.md index ee38250f..e4a43c56 100644 --- a/README.md +++ b/README.md @@ -620,6 +620,150 @@ Or interactively at [http://localhost:3002/graphiql](http://localhost:3002/graph > **Note:** the current `GATED_ROUTES` map in [`src/middleware/x402.ts`](src/middleware/x402.ts) gates `/price`, `/pools`, and `/candles` only — `/graphql` is not gated. If you intend price data from GraphQL to require the same payment as REST, extend `GATED_ROUTES` (or add a per-resolver guard). +## MCP server + +`npm run mcp:server` starts the Bazaar MCP server ([src/mcp/server.ts](src/mcp/server.ts), wired up in [examples/mcp-server/run.ts](examples/mcp-server/run.ts)). It advertises: + +| Tool | What it does | Spends? | +| --- | --- | --- | +| `bazaar_search` | Reads a Bazaar base URL's `/discovery/resources` or `/discovery/search?q=` and lists what is available. | No | +| `paid_call` | Fetches an x402-gated URL and handles the `402 → sign → retry` loop. | **Yes — it signs with a real Stellar key** | +| `payment_budget` | Reports the configured limits and what is left of today's budget. | No | + +`payment_budget` appears whenever a spend budget is configured, which the example always sets. Every one of them is served over **either** transport, and the tool list, schemas and error codes are identical whichever you pick. Which transport runs is selected by configuration; **stdio is the default**, so an existing setup needs no changes. + +The server signs with a real Stellar key, so it needs `MCP_AGENT_SECRET_KEY_TESTNET` +and `MCP_AGENT_SECRET_KEY_MAINNET` in the environment — keep them in `.env` +(`run.ts` loads it with `dotenv`) rather than in a shell history line or a +client config file that gets shared. Never commit them. Optional spend +controls: `MCP_MAX_PAYMENT_PRICE` (per call, default `$1.00`), +`MCP_DAILY_SPEND_CEILING` (default `$10.00`) and `MCP_SPEND_STATE_FILE` — the +running total survives a restart, because a restart does not refund what the +previous process spent. + +### stdio (default) + +For a client that spawns the server itself as a child process — Claude +Desktop, Cursor, or the runnable [agent example](examples/mcp-server/agent.ts) +(`npm run mcp:agent`, which prints discovery and a paid call end to end). + +```bash +npm run mcp:server # reads MCP_TRANSPORT=stdio, talks JSON-RPC on stdin/stdout +``` + +Point a client at the command — the keys come from `.env`, so nothing secret +has to be written into client config. This is the same invocation +[agent.ts](examples/mcp-server/agent.ts) uses: + +```json +{ + "mcpServers": { + "lens-bazaar": { + "command": "npx", + "args": ["tsx", "examples/mcp-server/run.ts"] + } + } +} +``` + +Because the process is spawned by the client, there is no port, no token and +no remote attack surface: whoever can spawn it can spend, so keep it to +your own machine. + +### Streamable HTTP + +For a client that connects over the network instead of spawning a process — +a hosted agent, a shared dev box, a container. Set `MCP_TRANSPORT=http`: + +```bash +# MCP_AGENT_SECRET_KEY_TESTNET / MCP_AGENT_SECRET_KEY_MAINNET live in .env +export MCP_TRANSPORT=http +export MCP_HTTP_AUTH_TOKEN="$(openssl rand -hex 32)" # required, see below +export MCP_HTTP_HOST=127.0.0.1 # default +export MCP_HTTP_PORT=3003 # default +export MCP_HTTP_PATH=/mcp # default +npm run mcp:server +# [mcp-server] Listening at http://127.0.0.1:3003/mcp +``` + +**Authentication is mandatory and fails closed.** Without a non-empty +`MCP_HTTP_AUTH_TOKEN` the process exits before it builds a signer, so an +unauthenticated remote server with `paid_call` on its tool list can never come +up. Every request must then carry it as a bearer token; anything else gets a +JSON-RPC error with `ERR_UNAUTHORIZED` and is rejected before the payment path +is reached: + +```bash +curl -sS -X POST http://127.0.0.1:3003/mcp \ + -H "Authorization: Bearer $MCP_HTTP_AUTH_TOKEN" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' +``` + +A client connects the same way: + +```ts +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; + +const transport = new StreamableHTTPClientTransport(new URL('http://127.0.0.1:3003/mcp'), { + requestInit: { headers: { authorization: `Bearer ${process.env.MCP_HTTP_AUTH_TOKEN}` } }, +}); + +const client = new Client({ name: 'lens-agent', version: '1.0.0' }, { capabilities: {} }); +await client.connect(transport); +``` + +Notes for this transport: + +- It binds `127.0.0.1` unless you set `MCP_HTTP_HOST` deliberately. Do not + expose it on a routable address over plain HTTP: the bearer token and every + paid result cross the wire, so terminate TLS in a reverse proxy and forward + to loopback. +- The token is a credential — the bearer who holds it can spend. Keep it out + of URLs, logs, and shell history; rotate it if it leaks. +- Requests are stateless: no `Mcp-Session-Id` is issued and each `POST` is + served by a fresh transport, so a client cannot hold a session open across + requests. Every MCP message must be a `POST` (`GET` returns `405`, which + spec-compliant clients treat as "no server-initiated stream"). Bodies are + capped at 1 MiB. +- The daily spend ceiling is shared across everything this process serves, so + one HTTP client cannot spend past a budget another one already used up. + +#### Payment spend metrics + +What the agent spent is scrapeable from the same listener, at `GET /metrics`, +guarded by the same bearer token — it is operator data about a key that moves +money, so there is no anonymous scrape: + +```bash +curl -sS http://127.0.0.1:3003/metrics \ + -H "Authorization: Bearer $MCP_HTTP_AUTH_TOKEN" +``` + +| Metric | Type | Labels | +| --- | --- | --- | +| `mcp_payment_attempts_total` | Counter | `host`, `network`, `outcome` | +| `mcp_payment_amount_usd_total` | Counter | `host`, `network` | +| `mcp_payment_unquantified_total` | Counter | `host`, `network` | + +A refused payment is an attempt (`outcome="failure"`) and adds nothing to +`mcp_payment_amount_usd_total`. `host` may only be a value from +`MCP_METRIC_HOSTS` and `network` only one from `MCP_METRIC_NETWORKS` (default +`stellar:testnet,stellar:pubnet` — the networks this server signs for): +Prometheus series are permanent, so anything outside an allow-list is reported +as `unknown` instead of letting one caller mint a time series per URL an agent +tries. Amounts that are not a USD money string cannot join the USD sum, so they +are counted in `mcp_payment_unquantified_total` rather than dropped silently. +Per-payment detail — full URL, transaction hash, asset, error code — is in the +`MCP_PAYMENT_LEDGER_FILE` audit trail, which keeps the real host whatever the +allow-list says. + +`MCP_HTTP_PATH` cannot be `/metrics`; the process exits at boot rather than +answering MCP and Prometheus on one path. Under **stdio** there is no listener +to scrape, so read the running total from the `payment_budget` tool or the +audit file. ## TypeScript Client A typed TypeScript client is generated from [`openapi.yaml`](openapi.yaml) with @@ -876,6 +1020,23 @@ Callers should parse the `Retry-After` header or `retryAfter` field and apply ex | `ORACLE_PAYMENT_ADDRESS_TESTNET` / `ORACLE_PAYMENT_ADDRESS_MAINNET` | Per-network override for the address above | - | No | | `X402_FACILITATOR_URL` | x402 facilitator service URL | - | No | +### MCP server + +| Variable | Description | Default | Required | +|---|---|---|---| +| `MCP_AGENT_SECRET_KEY_TESTNET` / `MCP_AGENT_SECRET_KEY_MAINNET` | The keys `paid_call` signs payments with, per network | - | **Yes** | +| `MCP_TRANSPORT` | `stdio` or `http` | `stdio` | No | +| `MCP_HTTP_AUTH_TOKEN` | Bearer token every HTTP request must present | - | **Yes, if `MCP_TRANSPORT=http`** | +| `MCP_HTTP_HOST` | HTTP bind address | `127.0.0.1` | No | +| `MCP_HTTP_PORT` | HTTP port | `3003` | No | +| `MCP_HTTP_PATH` | MCP endpoint path | `/mcp` | No | +| `MCP_MAX_PAYMENT_PRICE` | Ceiling for a single `paid_call` | `$1.00` | No | +| `MCP_DAILY_SPEND_CEILING` | Cumulative ceiling per day, persisted across restarts | `$10.00` | No | +| `MCP_SPEND_STATE_FILE` | Where the daily running total is kept | platform default | No | +| `MCP_PAYMENT_LEDGER_FILE` | Where the payment audit trail is appended | platform default | No | +| `MCP_METRIC_HOSTS` | Hosts allowed to become a Prometheus `host` label; others report as `unknown` | - | No | +| `MCP_METRIC_NETWORKS` | Network ids allowed to become a Prometheus `network` label; others report as `unknown` | `stellar:testnet,stellar:pubnet` | No | + *\*Required if enabling x402 payment gating.* ## Stack diff --git a/examples/mcp-server/run.ts b/examples/mcp-server/run.ts index ba3297a6..af159166 100644 --- a/examples/mcp-server/run.ts +++ b/examples/mcp-server/run.ts @@ -7,6 +7,8 @@ import { parseMetricLabelAllowList, resolveMcpTransportConfig, } from '../../src/mcp/config'; +import { createLensPriceQueries } from '../../src/mcp/priceQueries'; +import { activeNetwork } from '../../src/config'; import { SpendBudget, defaultSpendStateFile } from '../../src/mcp/budget'; import { PaymentLedger, defaultPaymentLedgerFile } from '../../src/mcp/ledger'; import { wrapFetchWithPaymentFromConfig } from '@x402/fetch'; @@ -106,6 +108,18 @@ async function main() { stateFilePath: process.env.MCP_SPEND_STATE_FILE || defaultSpendStateFile(), }); + // 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, + // Lens's own aggregated prices, read from the same Postgres the API serves. + priceQueries: createLensPriceQueries(), const server = new BazaarMcpServer({ fetchWithPayment, budget, @@ -113,6 +127,18 @@ async function main() { }); console.error('[mcp-server] Agent configured for stellar:testnet and stellar:pubnet'); + console.error(`[mcp-server] lens_get_price serves aggregated prices on ${activeNetwork}`); + console.error(`[mcp-server] Spend limits: ${budget.describeLimits()}`); + // The audit file records where money went, never the key that signed it. + console.error(`[mcp-server] Payment audit trail: ${ledgerFile}`); + console.error( + `[mcp-server] Spend metric labels — host: ${formatLabelDomain(labelHosts)}; network: ${formatLabelDomain(labelNetworks)}` + ); + if (!labelHosts.length) { + console.error( + '[mcp-server] MCP_METRIC_HOSTS is empty, so every spend series is labelled host="unknown". Set it to the nodes this agent pays to get per-host spend.' + ); + } console.error(`[mcp-server] Spend limits: ${budget.describeLimits()}`); // The audit file records where money went, never the key that signed it. console.error(`[mcp-server] Payment audit trail: ${ledgerFile}`); @@ -139,10 +165,25 @@ async function main() { return; } + if (transportConfig.transport === 'http') { + console.error('[mcp-server] Starting Bazaar MCP server on streamable HTTP...'); + const handle = await server.runHttp(transportConfig); + const address = handle.server.address(); + const host = address && typeof address === 'object' ? address.address : transportConfig.host; + const port = address && typeof address === 'object' ? address.port : transportConfig.port; + console.error(`[mcp-server] Listening at ${formatHttpUrl(host, port, transportConfig.path)}`); + console.error('[mcp-server] Bearer token authentication is required for every request'); + console.error( + `[mcp-server] Payment spend metrics: GET ${formatHttpUrl(host, port, DEFAULT_MCP_METRICS_PATH)} with the same bearer token` + ); + return; + } + console.error('[mcp-server] Starting Bazaar MCP server on stdio...'); console.error( '[mcp-server] stdio serves no HTTP listener, so the spend metrics of this process cannot be scraped; read the running total with the payment_budget tool or from the audit file above.' ); + await server.run(); } diff --git a/package-lock.json b/package-lock.json index cfe90959..cd2a1698 100644 --- a/package-lock.json +++ b/package-lock.json @@ -340,6 +340,27 @@ } }, "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/core/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { "version": "1.9.2", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", @@ -352,6 +373,13 @@ } }, "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, "version": "1.9.2", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", @@ -1544,6 +1572,71 @@ "linux" ] }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", + "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", + "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", + "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", + "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", + "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { "version": "3.0.3", "cpu": [ @@ -1556,6 +1649,9 @@ ] }, "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", "version": "1.2.5", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.5.tgz", "integrity": "sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA==", @@ -1563,6 +1659,7 @@ "license": "MIT", "optional": true, "dependencies": { + "@tybys/wasm-util": "^0.10.1" "@tybys/wasm-util": "^0.10.3" }, "engines": { @@ -1573,6 +1670,8 @@ "url": "https://github.com/sponsors/Brooooooklyn" }, "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } @@ -1786,6 +1885,269 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.16.tgz", + "integrity": "sha512-+tHktCHWV8BDQSjemUqm/Jl/TPk3QObCTIjmdDy/nlupcujZghmKK2962LYrqFpWu+ai01AN/REOH3NEpqvYQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.16.tgz", + "integrity": "sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.16.tgz", + "integrity": "sha512-EKwI1tSrLs7YVw+JPJT/G2dJQ1jl9qlTTTEG0V2Ok/RdOenRfBw2PQdLPyjhIu58ocdBfP7vIRN/pvMsPxs/AQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.16.tgz", + "integrity": "sha512-Uknladnb3Sxqu6SEcqBldQyJUpk8NleooZEc0MbRBJ4inEhRYWZX0NJu12vNf2mqAq7gsofAxHrGghiUYjhaLQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.16.tgz", + "integrity": "sha512-FIb8+uG49sZBtLTn+zt1AJ20TqVcqWeSIyoVt0or7uAWesgKaHbiBh6OpA/k9v0LTt+PTrb1Lao133kP4uVxkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.16.tgz", + "integrity": "sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.16.tgz", + "integrity": "sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.16.tgz", + "integrity": "sha512-3Q2KQxnC8IJOLqXmUMoYwyIPZU9hzRbnHaoV3Euz+VVnjZKcY8ktnNP8T9R4/GGQtb27C/UYKABxesKWb8lsvQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.9.2", + "@emnapi/runtime": "1.9.2", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.16.tgz", + "integrity": "sha512-tj7XRemQcOcFwv7qhpUxMTBbI5mWMlE4c1Omhg5+h8GuLXzyj8HviYgR+bB2DMDgRqUE+jiDleqSCRjx4aYk/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.16.tgz", + "integrity": "sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.16.tgz", + "integrity": "sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.16.tgz", + "integrity": "sha512-r/OmdR00HmD4i79Z//xO06uEPOq5hRXdhw7nzkxQxwSavs3PSHa1ijntdpOiZ2mzOQ3fVVu8C1M19FoNM+dMUQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.16.tgz", + "integrity": "sha512-KcRE5w8h0OnjUatG8pldyD14/CQ5Phs1oxfR+3pKDjboHRo9+MkqQaiIZlZRpsxC15paeXme/I127tUa9TXJ6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.16.tgz", + "integrity": "sha512-bT0guA1bpxEJ/ZhTRniQf7rNF8ybvXOuWbNIeLABaV5NGjx4EtOWBTSRGWFU9ZWVkPOZ+HNFP8RMcBokBiZ0Kg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@rolldown/binding-linux-arm64-gnu": { "version": "1.0.0-rc.16", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.16.tgz", @@ -2024,6 +2386,9 @@ } }, "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", "version": "0.10.4", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz", "integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==", @@ -3574,6 +3939,20 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "license": "MIT", diff --git a/src/mcp/codes.ts b/src/mcp/codes.ts index 4f9f185f..49e3de34 100644 --- a/src/mcp/codes.ts +++ b/src/mcp/codes.ts @@ -6,6 +6,8 @@ export const MCP_ERROR_CODES = { ERR_SERVICE_UNREACHABLE: 'ERR_SERVICE_UNREACHABLE', ERR_NO_RESULTS: 'ERR_NO_RESULTS', ERR_BAD_REQUEST: 'ERR_BAD_REQUEST', + ERR_UNAUTHORIZED: 'ERR_UNAUTHORIZED', + ERR_BUDGET_EXCEEDED: 'ERR_BUDGET_EXCEEDED', ERR_INVALID_ASSET: 'ERR_INVALID_ASSET', ERR_PAIR_NOT_WATCHED: 'ERR_PAIR_NOT_WATCHED', ERR_ASSET_ISSUER_MISMATCH: 'ERR_ASSET_ISSUER_MISMATCH', diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 2ba415a2..eead3074 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -17,6 +17,7 @@ import { McpError, } from '@modelcontextprotocol/sdk/types.js'; import { MCP_ERROR_CODES, McpErrorCode, classifyPaidCallError } from './codes.js'; +import { DEFAULT_MCP_HTTP_HOST, DEFAULT_MCP_HTTP_PORT, DEFAULT_MCP_HTTP_PATH, DEFAULT_MCP_METRICS_PATH, normalizeMcpHttpPath } from './config.js'; import { DEFAULT_MCP_HTTP_HOST, DEFAULT_MCP_HTTP_PORT, @@ -274,6 +275,8 @@ export class BazaarMcpServer { private bazaarFetchOptions: SsrfFetchOptions; private priceQueries?: LensPriceQueries; private budget?: SpendBudget; + /** The audit trail this server writes to. Public so embedders can read spend without parsing the file. */ + readonly paymentLedger: PaymentLedger; private limits: McpLimits; private resources: LensResourceDescriptor[]; /** The audit trail this server writes to. Public so embedders can read spend without parsing the file. */ @@ -530,7 +533,7 @@ export class BazaarMcpServer { return this.createErrorResponse('ERR_SERVICE_UNREACHABLE', `Upstream ${upstream} returned a body that is not valid JSON`); } - const hasResources = (d: unknown): d is { resources: unknown[] } => + const hasResources = (d: unknown): d is { resources: unknown[] } => typeof d === 'object' && d !== null && 'resources' in d && Array.isArray((d as Record).resources); if ((Array.isArray(data) && data.length === 0) || (hasResources(data) && data.resources.length === 0)) { @@ -562,11 +565,24 @@ export class BazaarMcpServer { if (request.params.name === 'payment_budget') { if (!this.budget) { - return this.createErrorResponse('ERR_BAD_REQUEST', 'This server has no spend budget configured.'); + return this.createErrorResponse( + 'ERR_BAD_REQUEST', + 'This server has no spend budget configured.' + ); } // recordedSpend is what the ledger actually saw, as distinct from the // budget's worst-case reservations: the ceiling holds per-call maxima, // this is the priced offer of each attempt that reached the transport. + const reported = { + ...this.budget.snapshot(), + recordedSpend: this.paymentLedger.totalSpend(), + }; + return this.createBoundedResult(reported, { + maxBytes: this.limits.maxResultBytes, + maxItems: Number.MAX_SAFE_INTEGER, + offset: 0, + notice: `The spend budget was truncated to fit a ${this.limits.maxResultBytes}-byte tool output limit.`, + }); return this.createBoundedResult( { ...this.budget.snapshot(), recordedSpend: this.paymentLedger.totalSpend() }, { @@ -602,11 +618,12 @@ export class BazaarMcpServer { return this.createErrorResponse('ERR_BUDGET_EXCEEDED', decision.reason, { budget: decision.snapshot }); } const reservationId = decision?.allowed ? decision.reservationId : undefined; + // Gate before the fetch: fetchWithPayment answers a 402 by paying, so // by the time a request leaves this process the money is already spent. - const target = checkPaidCallTarget(url, this.paidCallRules); - if (!target.allowed) { - return this.createErrorResponse(target.code, target.reason); + const paidTarget = checkPaidCallTarget(url, this.paidCallRules); + if (!paidTarget.allowed) { + return this.createErrorResponse(paidTarget.code, paidTarget.reason); } try { @@ -668,6 +685,7 @@ export class BazaarMcpServer { return this.createBoundedResult(parsed, budget); } catch (e: any) { + const code = classifyPaidCallError(e).code; const code = this.classifyPaidCallFailure(e); const mayHaveSettled = !this.paymentCouldNotHaveSettled(e); this.settleReservation(reservationId, mayHaveSettled); @@ -861,6 +879,9 @@ export class BazaarMcpServer { private async handleHttpRequest( req: IncomingMessage, res: ServerResponse, + httpConfig: { authToken: string; path: string } + ): Promise { + if (!hasValidBearerToken(req.headers.authorization, httpConfig.authToken)) { settings: { authToken: string; path: string } ): Promise { if (!hasValidBearerToken(req.headers.authorization, settings.authToken)) { @@ -883,6 +904,7 @@ export class BazaarMcpServer { return; } + if (pathname !== httpConfig.path) { if (pathname !== settings.path) { if (pathname === DEFAULT_MCP_METRICS_PATH) { if (req.method !== 'GET') { diff --git a/tests/mcp-http.test.ts b/tests/mcp-http.test.ts index 1d04ce4a..287cbcbd 100644 --- a/tests/mcp-http.test.ts +++ b/tests/mcp-http.test.ts @@ -34,6 +34,9 @@ vi.mock('../src/fetch', () => ({ */ const TEST_AUTH_TOKEN = 'lens-mcp-http-test-token-4d1f8b6e2a97'; const PAID_URL = 'https://bazaar.example/api/v1/price'; +// paid_call is gated by the operator allow-list (#404); these are the only +// hosts the paid calls below are allowed to reach. +const ALLOWED_PAID_HOSTS = ['bazaar.example', 'spend-export.test']; /** * `paid_call` pays only for hosts the operator allow-listed (#404), and in these @@ -63,6 +66,9 @@ async function startHttp( ): Promise { const server = new BazaarMcpServer({ fetchWithPayment: fetchWithPayment as unknown as typeof fetch, + ...(budget ? { budget } : {}), + ...(paymentLedger ? { paymentLedger } : {}), + paidCallAllowlist: ALLOWED_PAID_HOSTS, paidCallAllowlist: PAID_CALL_ALLOWLIST, ...(budget ? { budget } : {}), ...(paymentLedger ? { paymentLedger } : {}), @@ -341,6 +347,7 @@ describe('MCP HTTP transport parity', () => { // same SDK routing, without spawning a process to pipe stdin/stdout. const directServer = new BazaarMcpServer({ fetchWithPayment: directFetch as unknown as typeof fetch, + paidCallAllowlist: ALLOWED_PAID_HOSTS, paidCallAllowlist: PAID_CALL_ALLOWLIST, }); const [directClientTransport, directServerTransport] = InMemoryTransport.createLinkedPair(); @@ -356,6 +363,10 @@ describe('MCP HTTP transport parity', () => { expect(httpTools.tools.map((tool: any) => tool.name)).toEqual(['bazaar_search', 'paid_call']); expect(httpPaidCall).toEqual(directPaidCall); expect(JSON.parse((httpPaidCall.content as any)[0].text)).toEqual({ asset: 'XLM', price: '0.15' }); + expect(httpFetch).toHaveBeenCalledWith(PAID_URL, { + method: 'GET', + signal: expect.any(AbortSignal), + }); // objectContaining, not an exact match: the request also carries an // AbortSignal deadline. expect(httpFetch).toHaveBeenCalledWith(PAID_URL, expect.objectContaining({ method: 'GET' })); diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index 9187d6df..f9bc373d 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -1,4 +1,12 @@ import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { register } from '../src/metrics'; +import { BazaarMcpServer, SERVER_VERSION, SERVER_NAME } from '../src/mcp/server'; +import { SpendBudget, parseUsdToNanodollars } from '../src/mcp/budget'; +import { PaymentLedger } from '../src/mcp/ledger'; +import { MCP_ERROR_CODES } from '../src/mcp/codes'; import { BazaarMcpServer, SERVER_VERSION, SERVER_NAME } from '../src/mcp/server'; import { CallToolRequestSchema, @@ -7,8 +15,119 @@ import { ToolSchema, } from '@modelcontextprotocol/sdk/types.js'; import { PAID_CALL_HTTP_METHODS } from '../src/mcp/toolSchemas'; -import { readFileSync } from 'fs'; -import { join } from 'path'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + ListToolsResultSchema, + ToolSchema, +} from '@modelcontextprotocol/sdk/types.js'; + +const { mockFetchWithTimeout } = vi.hoisted(() => ({ + mockFetchWithTimeout: vi.fn(), +})); + +// bazaar_search fetches through src/fetch's timeout wrapper rather than the +// injected payment fetch, so the module has to be mocked to keep discovery off +// the network. Mocking it also keeps src/config (and with it @stellar/stellar-sdk +// and the env-dependent network resolution) out of this file's import graph. +vi.mock('../src/fetch', () => ({ + fetchWithTimeout: mockFetchWithTimeout, +})); + +type CapturedHandler = (request: any) => Promise; + +/** + * Hosts this file's fixtures point at. The merged server gates paid_call on an + * operator allow-list (#404) and bazaar_search on the SSRF policy (#407), so + * the harness stands in for that operator configuration: every fixture host is + * payable and resolves to a TEST-NET-3 address that is not blocked. + */ +const HARNESS_HOSTS = [ + 'bazaar.example', + 'ledger-audit.test', + 'ledger-offer.test', + 'ledger-sum.test', + 'ledger-metrics.test', + 'not-allow-listed.test', +]; + +/** + * The SDK Server only routes a request once a transport is connected, so the + * handlers are captured by swapping setRequestHandler for a recorder and + * re-running setupHandlers() — the same approach the tests below already use. + */ +function createHarness( + fetchWithPayment: Mock = vi.fn(), + budget?: SpendBudget, + paymentLedger?: PaymentLedger +) { + const server = new BazaarMcpServer({ + fetchWithPayment: fetchWithPayment as unknown as typeof fetch, + ...(budget ? { budget } : {}), + ...(paymentLedger ? { paymentLedger } : {}), + paidCallAllowlist: HARNESS_HOSTS, + bazaarFetch: { + allowedHosts: HARNESS_HOSTS, + lookup: async () => ['93.184.216.34'], + }, + }); + + const handlers = new Map(); + (server as any).server.setRequestHandler = (schema: unknown, fn: CapturedHandler) => { + handlers.set(schema, fn); + }; + (server as any).setupHandlers(); + + const callTool = handlers.get(CallToolRequestSchema); + const listTools = handlers.get(ListToolsRequestSchema); + if (!callTool || !listTools) { + throw new Error('BazaarMcpServer did not register both a ListTools and a CallTool handler'); + } + + return { + fetchWithPayment, + ledger: server.paymentLedger, + listTools: () => listTools({}), + callTool: (name: string, args?: Record) => + callTool({ params: { name, arguments: args } }), + }; +} + +function jsonResponse(body: unknown) { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); +} + +/** undici's real shape for a connection-level failure. */ +function transportFailure() { + return new TypeError('fetch failed', { + cause: Object.assign(new Error('connect ECONNREFUSED 203.0.113.10:443'), { + code: 'ECONNREFUSED', + }), + }); +} + +/** + * The exact chain fetchWithPayment runs when the wallet cannot build the + * payment: ExactStellarScheme.createPaymentPayload simulates the transfer and + * handleSimulationResult throws + * "Stellar simulation failed with error message: " + * (node_modules/@x402/stellar/dist/esm/chunk-JRF5X5WU.mjs:26), and @x402/fetch + * rethrows any payload error as "Failed to create payment payload: " + * (node_modules/@x402/fetch/dist/esm/index.mjs:47). Chain the two frames so the + * classifier sees what it really sees; simulation.error itself is network + * text, carried here verbatim from the fixtures this file already used. + */ +function paymentPayloadFailure(simulationError: string) { + const simulation = new Error(`Stellar simulation failed with error message: ${simulationError}`); + return new Error(`Failed to create payment payload: ${simulation.message}`, { cause: simulation }); +} + +function parseFailure(result: { success: boolean; error?: unknown }) { + return result.success ? '' : String(result.error); +} const pkg = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf8')) as { version: string }; @@ -23,122 +142,866 @@ describe('BazaarMcpServer — server identity', () => { }); describe('BazaarMcpServer', () => { - it('should map signature expiration ledger error to ERR_EXPIRED_AUTHORISATION', async () => { - const mockFetch = vi.fn().mockRejectedValue(new Error('Transaction signature expired. Signature expiration ledger 123 is less than current ledger 125')); - const server = new BazaarMcpServer({ fetchWithPayment: mockFetch, paidCallAllowlist: ['example.com'] }); + const PAID_URL = 'https://bazaar.example/api/v1/price'; - // The SDK server doesn't expose the router directly without a client, but we can test the handlePaidCallError method directly, - // or by overriding setRequestHandler to capture our handler. - let handler: any = null; - (server as any).server.setRequestHandler = (schema: any, fn: any) => { - if (schema === CallToolRequestSchema) { - handler = fn; - } - }; - - // Re-setup handlers to capture the handler - (server as any).setupHandlers(); + it('should map signature expiration ledger error to ERR_EXPIRED_AUTHORISATION', async () => { + const failure = paymentPayloadFailure( + 'Transaction signature expired. Signature expiration ledger 123 is less than current ledger 125' + ); + const { callTool } = createHarness(vi.fn().mockRejectedValue(failure)); - const response = await handler({ - params: { - name: 'paid_call', - arguments: { - url: 'http://example.com/paid' - } - } - }); + const response = await callTool('paid_call', { url: PAID_URL }); expect(response.isError).toBe(true); - const content = JSON.parse(response.content[0].text); - expect(content.code).toBe('ERR_EXPIRED_AUTHORISATION'); + // The #480 classifier carries the original message as the reason; the code + // is the contract, matched from a structured signal or the text fallback. + expect(JSON.parse(response.content[0].text)).toEqual({ + isError: true, + code: MCP_ERROR_CODES.ERR_EXPIRED_AUTHORISATION, + reason: failure.message, + }); }); it('should map missing trustline error to ERR_MISSING_TRUSTLINE', async () => { - const mockFetch = vi.fn().mockRejectedValue(new Error('op_no_trust: The account does not have a trustline for this asset')); - const server = new BazaarMcpServer({ fetchWithPayment: mockFetch, paidCallAllowlist: ['example.com'] }); - - let handler: any = null; - (server as any).server.setRequestHandler = (schema: any, fn: any) => { - if (schema === CallToolRequestSchema) { - handler = fn; - } - }; - (server as any).setupHandlers(); + const failure = paymentPayloadFailure('op_no_trust: The account does not have a trustline for this asset'); + const { callTool } = createHarness(vi.fn().mockRejectedValue(failure)); - const response = await handler({ - params: { - name: 'paid_call', - arguments: { - url: 'http://example.com/paid' - } - } - }); + const response = await callTool('paid_call', { url: PAID_URL }); expect(response.isError).toBe(true); - const content = JSON.parse(response.content[0].text); - expect(content.code).toBe('ERR_MISSING_TRUSTLINE'); + expect(JSON.parse(response.content[0].text)).toEqual({ + isError: true, + code: MCP_ERROR_CODES.ERR_MISSING_TRUSTLINE, + reason: failure.message, + }); }); it('should map 402 rejected error to ERR_PAYMENT_REJECTED', async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: false, - status: 402, - text: async () => 'Payment Required' + // A rejected payment is not an exception: wrapFetchWithPayment resolves + // with the seller's second 402 response after the payment loop fails + // (node_modules/@x402/fetch/dist/esm/index.mjs:87), so this is a real + // Response, exactly as the transport hands it over. + const { callTool } = createHarness( + vi.fn().mockResolvedValue(new Response('Payment Required', { status: 402 })) + ); + + const response = await callTool('paid_call', { url: PAID_URL }); + + expect(response.isError).toBe(true); + expect(JSON.parse(response.content[0].text)).toEqual({ + isError: true, + code: MCP_ERROR_CODES.ERR_PAYMENT_REJECTED, + reason: 'Payment required but was rejected or failed.', }); - - const server = new BazaarMcpServer({ fetchWithPayment: mockFetch, paidCallAllowlist: ['example.com'] }); + }); - let handler: any = null; - (server as any).server.setRequestHandler = (schema: any, fn: any) => { - if (schema === CallToolRequestSchema) { - handler = fn; - } + it('should return successfully on a successful call', async () => { + const body = { success: true, data: 'hello' }; + const { callTool } = createHarness(vi.fn().mockResolvedValue(jsonResponse(body))); + + const response = await callTool('paid_call', { url: PAID_URL }); + + expect(response.isError).toBeFalsy(); + expect(JSON.parse(response.content[0].text)).toEqual(body); + }); +}); + +describe('bazaar_search', () => { + beforeEach(() => { + mockFetchWithTimeout.mockReset(); + }); + + it('lists resources from /discovery/resources when no query is given', async () => { + const body = { + resources: [ + { + name: 'Mock Price Feed', + description: 'Price data for XLM', + url: 'https://bazaar.example/api/v1/price', + }, + ], }; - (server as any).setupHandlers(); + mockFetchWithTimeout.mockResolvedValue(jsonResponse(body)); + const { callTool, fetchWithPayment } = createHarness(); - const response = await handler({ - params: { - name: 'paid_call', - arguments: { - url: 'http://example.com/paid' - } - } + const response = await callTool('bazaar_search', { url: 'https://bazaar.example/' }); + + // A trailing slash on the base URL must not produce a double slash. + // Discovery now goes through ssrfSafeFetch, which passes a fetch init + // (manual redirect handling) alongside the URL. + expect(mockFetchWithTimeout).toHaveBeenCalledWith( + 'https://bazaar.example/discovery/resources?limit=50&offset=0', + expect.objectContaining({ redirect: 'manual' }) + ); + // Discovery must never spend: only paid_call may reach the payment signer. + expect(fetchWithPayment).not.toHaveBeenCalled(); + expect(response.isError).toBeFalsy(); + expect(JSON.parse(response.content[0].text)).toEqual(body); + }); + + it('routes a query to /discovery/search with the query URL-encoded', async () => { + const body = { + resources: [{ name: 'Mock Price Feed', url: 'https://bazaar.example/api/v1/price' }], + }; + mockFetchWithTimeout.mockResolvedValue(jsonResponse(body)); + const { callTool } = createHarness(); + + const response = await callTool('bazaar_search', { + url: 'https://bazaar.example', + query: 'XLM/USD spot', }); + expect(mockFetchWithTimeout).toHaveBeenCalledWith( + 'https://bazaar.example/discovery/search?q=XLM%2FUSD+spot&limit=50&offset=0', + expect.objectContaining({ redirect: 'manual' }) + ); + expect(response.isError).toBeFalsy(); + expect(JSON.parse(response.content[0].text)).toEqual(body); + }); + + it('maps an empty resources array to ERR_NO_RESULTS', async () => { + mockFetchWithTimeout.mockResolvedValue(jsonResponse({ resources: [] })); + const { callTool } = createHarness(); + + const response = await callTool('bazaar_search', { url: 'https://bazaar.example' }); + expect(response.isError).toBe(true); - const content = JSON.parse(response.content[0].text); - expect(content.code).toBe('ERR_PAYMENT_REJECTED'); + expect(JSON.parse(response.content[0].text)).toEqual({ + isError: true, + code: MCP_ERROR_CODES.ERR_NO_RESULTS, + reason: 'No resources found', + }); }); - - it('should return successfully on a successful call', async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - text: async () => JSON.stringify({ success: true, data: "hello" }) + + it('maps an empty top-level array to ERR_NO_RESULTS', async () => { + mockFetchWithTimeout.mockResolvedValue(jsonResponse([])); + const { callTool } = createHarness(); + + const response = await callTool('bazaar_search', { + url: 'https://bazaar.example', + query: 'nothing indexed yet', }); - - const server = new BazaarMcpServer({ fetchWithPayment: mockFetch, paidCallAllowlist: ['example.com'] }); - let handler: any = null; - (server as any).server.setRequestHandler = (schema: any, fn: any) => { - if (schema === CallToolRequestSchema) { - handler = fn; - } - }; - (server as any).setupHandlers(); + expect(response.isError).toBe(true); + expect(JSON.parse(response.content[0].text)).toEqual({ + isError: true, + code: MCP_ERROR_CODES.ERR_NO_RESULTS, + reason: 'No resources found', + }); + }); - const response = await handler({ - params: { - name: 'paid_call', - arguments: { - url: 'http://example.com/paid' - } - } + it('maps a non-2xx response to ERR_SERVICE_UNREACHABLE with the status in the reason', async () => { + mockFetchWithTimeout.mockResolvedValue(new Response('Service Unavailable', { status: 503 })); + const { callTool } = createHarness(); + + const response = await callTool('bazaar_search', { url: 'https://bazaar.example' }); + + expect(response.isError).toBe(true); + expect(JSON.parse(response.content[0].text)).toEqual({ + isError: true, + code: MCP_ERROR_CODES.ERR_SERVICE_UNREACHABLE, + reason: 'Upstream bazaar.example returned HTTP 503', }); + }); - expect(response.isError).toBeFalsy(); - const parsedText = JSON.parse(response.content[0].text); - expect(parsedText.success).toBe(true); + it('maps a 200 carrying an unparseable body to ERR_SERVICE_UNREACHABLE', async () => { + // A gateway can answer 200 with HTML. The catch-all cannot tell a transport + // failure from a bad payload, so both surface as unreachable. + mockFetchWithTimeout.mockResolvedValue( + new Response('502 Bad Gateway', { + status: 200, + headers: { 'Content-Type': 'text/html' }, + }) + ); + const { callTool } = createHarness(); + + const response = await callTool('bazaar_search', { url: 'https://bazaar.example' }); + + expect(response.isError).toBe(true); + const payload = JSON.parse(response.content[0].text); + expect(payload.code).toBe(MCP_ERROR_CODES.ERR_SERVICE_UNREACHABLE); + // undici's JSON parse message is version-dependent; only assert it is carried through. + expect(typeof payload.reason).toBe('string'); + expect(payload.reason.length).toBeGreaterThan(0); + }); + + it('maps a transport failure to ERR_SERVICE_UNREACHABLE', async () => { + mockFetchWithTimeout.mockRejectedValue(transportFailure()); + const { callTool } = createHarness(); + + const response = await callTool('bazaar_search', { url: 'https://bazaar.example' }); + + expect(response.isError).toBe(true); + expect(JSON.parse(response.content[0].text)).toEqual({ + isError: true, + code: MCP_ERROR_CODES.ERR_SERVICE_UNREACHABLE, + reason: 'Upstream bazaar.example failed: fetch failed', + }); + }); +}); + +describe('paid_call upstream failures', () => { + it('maps a transport failure to an unclassified internal failure', async () => { + // The #480 classifier only sees the top-level message, and "fetch failed" + // carries no structured signal: undici hides the ECONNREFUSED in `cause`, + // which the classifier does not read. The honest verdict is unclassified. + const failure = transportFailure(); + const fetchWithPayment = vi.fn().mockRejectedValue(failure); + const { callTool } = createHarness(fetchWithPayment); + + const response = await callTool('paid_call', { url: 'https://bazaar.example/api/v1/price' }); + + expect(response.isError).toBe(true); + expect(JSON.parse(response.content[0].text)).toEqual({ + isError: true, + code: MCP_ERROR_CODES.ERR_INTERNAL_FAILURE, + reason: failure.message, + }); + }); + + it('maps an unmatched non-402 status to ERR_BAD_REQUEST', async () => { + const fetchWithPayment = vi + .fn() + .mockResolvedValue(new Response('Internal Server Error', { status: 500 })); + const { callTool } = createHarness(fetchWithPayment); + + const response = await callTool('paid_call', { url: 'https://bazaar.example/api/v1/price' }); + + expect(response.isError).toBe(true); + expect(JSON.parse(response.content[0].text)).toEqual({ + isError: true, + code: MCP_ERROR_CODES.ERR_BAD_REQUEST, + reason: 'Request failed with status 500', + }); + }); + + it('falls through to ERR_INTERNAL_FAILURE and keeps the upstream reason verbatim', async () => { + // @x402/fetch wraps whatever ExactStellarScheme threw while building the + // payment; this one is its asset-address validation. + const reason = + 'Failed to create payment payload: Invalid Stellar asset address: not-a-stellar-asset'; + const fetchWithPayment = vi.fn().mockRejectedValue(new Error(reason)); + const { callTool } = createHarness(fetchWithPayment); + + const response = await callTool('paid_call', { url: 'https://bazaar.example/api/v1/price' }); + + expect(response.isError).toBe(true); + expect(JSON.parse(response.content[0].text)).toEqual({ + isError: true, + code: MCP_ERROR_CODES.ERR_INTERNAL_FAILURE, + reason, + }); + }); + + it('reports an unregistered scheme client as unclassified, not as an unreachable service', async () => { + // @x402/core throws this when no client matches the payment requirements. + // The old substring matcher keyed on "network" in the message and blamed the + // remote service; #480 removed that match, so a local misconfiguration now + // reads as what it is — nothing structured to classify. + const message = 'No client registered for scheme: exact and network: stellar:pubnet'; + const fetchWithPayment = vi.fn().mockRejectedValue(new Error(message)); + const { callTool } = createHarness(fetchWithPayment); + + const response = await callTool('paid_call', { url: 'https://bazaar.example/api/v1/price' }); + + expect(response.isError).toBe(true); + expect(JSON.parse(response.content[0].text)).toEqual({ + isError: true, + code: MCP_ERROR_CODES.ERR_INTERNAL_FAILURE, + reason: message, + }); + }); + + it('cannot see a trustline failure that lives in the structured result codes', async () => { + // Horizon reports why a transaction failed in extras.result_codes, not in the + // message, and handlePaidCallError only reads e.message — so op_no_trust is + // lost. Plain-string fixtures hide this; the real error object does not. + const failure: any = new Error('Transaction submission failed'); + failure.response = { + status: 400, + data: { + title: 'Transaction Failed', + status: 400, + detail: 'The transaction failed when submitted to the ledger.', + extras: { + result_codes: { transaction: 'tx_failed', operations: ['op_no_trust'] }, + }, + }, + }; + const fetchWithPayment = vi.fn().mockRejectedValue(failure); + const { callTool } = createHarness(fetchWithPayment); + + const response = await callTool('paid_call', { url: 'https://bazaar.example/api/v1/price' }); + + expect(response.isError).toBe(true); + const payload = JSON.parse(response.content[0].text); + expect(payload.code).not.toBe(MCP_ERROR_CODES.ERR_MISSING_TRUSTLINE); + expect(payload).toEqual({ + isError: true, + code: MCP_ERROR_CODES.ERR_INTERNAL_FAILURE, + reason: 'Transaction submission failed', + }); + }); +}); + +describe('ListTools', () => { + it('advertises exactly bazaar_search and paid_call', async () => { + const { listTools } = createHarness(); + + const result = await listTools(); + + expect(result.tools.map((tool: any) => tool.name)).toEqual(['bazaar_search', 'paid_call']); + }); + + it('returns a result a client can parse with the SDK ListTools schema', async () => { + const { listTools } = createHarness(); + + const result = await listTools(); + const parsed = ListToolsResultSchema.safeParse(result); + + expect(parsed.success, parseFailure(parsed)).toBe(true); + }); + + it('declares a valid inputSchema for every advertised tool', async () => { + const { listTools } = createHarness(); + + const { tools } = await listTools(); + expect(tools).toHaveLength(2); + + for (const tool of tools) { + const parsed = ToolSchema.safeParse(tool); + expect(parsed.success, `${tool.name}: ${parseFailure(parsed)}`).toBe(true); + expect(tool.inputSchema.type, `${tool.name}: MCP requires an object inputSchema`).toBe( + 'object' + ); + + const declared = Object.keys(tool.inputSchema.properties ?? {}); + // Both tools read arguments.url unconditionally; an agent that omits it + // would otherwise get a request to the literal string "undefined". + expect(tool.inputSchema.required, `${tool.name}: url must be required`).toContain('url'); + for (const required of tool.inputSchema.required ?? []) { + expect(declared, `${tool.name}: "${required}" is required but undeclared`).toContain( + required + ); + } + } + }); +}); + +describe('unknown tool', () => { + it('rejects instead of returning a best-effort result', async () => { + const { callTool } = createHarness(); + + // The handler throws, so the SDK surfaces this as a JSON-RPC error rather + // than a structured McpErrorResponse — the agent gets no error code. + await expect(callTool('bazaar_refund')).rejects.toThrow('Unknown tool: bazaar_refund'); + }); +}); + +describe('cumulative spend budget', () => { + const PAID_URL = 'https://bazaar.example/api/v1/price'; + + let tempDir: string | undefined; + + beforeEach(() => { + tempDir = undefined; + }); + + /** + * Each call must hand back a fresh Response: a Response body can only be read + * once, and reusing one instance would fail the second call for a reason that + * has nothing to do with the budget. + */ + function cheapPaidCall(): Mock { + return vi.fn().mockImplementation(() => Promise.resolve(jsonResponse({ price: '1.00' }))); + } + + function spendStateFile(): string { + tempDir = mkdtempSync(join(tmpdir(), 'lens-mcp-budget-')); + return join(tempDir, 'spend-state.json'); + } + + afterEach(() => { + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('refuses the call that would take the day total past the ceiling, without paying for it', async () => { + // Three calls at the $1.00 per-call maximum fill the $3.00 ceiling; each one is + // individually inside the per-call limit, which is the whole point of the issue. + const fetchWithPayment = cheapPaidCall(); + const budget = new SpendBudget({ perCallMax: '$1.00', dailyCeiling: '$3.00' }); + const { callTool } = createHarness(fetchWithPayment, budget); + + for (let call = 1; call <= 3; call++) { + const response = await callTool('paid_call', { url: PAID_URL }); + expect(response.isError, `call ${call} should still fit the ceiling`).toBeFalsy(); + } + expect(budget.snapshot().spentToday).toBe('$3.00'); + expect(budget.snapshot().remainingToday).toBe('$0.00'); + + const refusal = await callTool('paid_call', { url: PAID_URL }); + + expect(refusal.isError).toBe(true); + const payload = JSON.parse(refusal.content[0].text); + expect(payload.code).toBe(MCP_ERROR_CODES.ERR_BUDGET_EXCEEDED); + expect(payload.reason).toContain('Daily spend ceiling reached'); + // The agent has to be able to tell "out of budget" from "broken", and to see + // what is left, from the refusal alone. + expect(payload.details.budget).toMatchObject({ + currency: 'USD', + perCallMax: '$1.00', + dailyCeiling: '$3.00', + spentToday: '$3.00', + remainingToday: '$0.00', + state: 'ok', + }); + // Refusing must not spend: the ceiling gate runs before the payment fetch. + expect(fetchWithPayment).toHaveBeenCalledTimes(3); + }); + + it('refuses a call whose worst case no longer fits, instead of overspending the remainder', async () => { + const fetchWithPayment = cheapPaidCall(); + const budget = new SpendBudget({ perCallMax: '$1.00', dailyCeiling: '$2.50' }); + const { callTool } = createHarness(fetchWithPayment, budget); + + expect((await callTool('paid_call', { url: PAID_URL })).isError).toBeFalsy(); + await callTool('paid_call', { url: PAID_URL }); + expect(budget.snapshot().remainingToday).toBe('$0.50'); + + // $0.50 is left, but a call can cost up to $1.00, so the reservation cannot fit. + const refusal = await callTool('paid_call', { url: PAID_URL }); + + expect(refusal.isError).toBe(true); + expect(JSON.parse(refusal.content[0].text).code).toBe(MCP_ERROR_CODES.ERR_BUDGET_EXCEEDED); + expect(fetchWithPayment).toHaveBeenCalledTimes(2); + expect(budget.snapshot().spentToday).toBe('$2.00'); + }); + + it('resumes the day total after a restart, so a restart does not refill the budget', async () => { + const file = spendStateFile(); + const options = { perCallMax: '$1.00', dailyCeiling: '$2.00', stateFilePath: file }; + + const firstProcess = createHarness(cheapPaidCall(), new SpendBudget(options)); + await firstProcess.callTool('paid_call', { url: PAID_URL }); + await firstProcess.callTool('paid_call', { url: PAID_URL }); + + // A fresh SpendBudget is what a server restart builds: same configuration, same file. + const restarted = new SpendBudget(options); + expect(restarted.snapshot().spentToday).toBe('$2.00'); + + const fetchWithPayment = cheapPaidCall(); + const secondProcess = createHarness(fetchWithPayment, restarted); + const refusal = await secondProcess.callTool('paid_call', { url: PAID_URL }); + + expect(JSON.parse(refusal.content[0].text).code).toBe(MCP_ERROR_CODES.ERR_BUDGET_EXCEEDED); + expect(fetchWithPayment).not.toHaveBeenCalled(); + expect(JSON.parse(readFileSync(file, 'utf8'))).toMatchObject({ version: 1, spentNanodollars: '2000000000' }); + }); + + it('starts a fresh window at UTC midnight', async () => { + let current = new Date('2026-10-17T23:30:00.000Z'); + const file = spendStateFile(); + const budget = new SpendBudget({ + perCallMax: '$1.00', + dailyCeiling: '$1.00', + stateFilePath: file, + now: () => current, + }); + const { callTool } = createHarness(cheapPaidCall(), budget); + + expect((await callTool('paid_call', { url: PAID_URL })).isError).toBeFalsy(); + const sameDay = await callTool('paid_call', { url: PAID_URL }); + expect(JSON.parse(sameDay.content[0].text).code).toBe(MCP_ERROR_CODES.ERR_BUDGET_EXCEEDED); + + current = new Date('2026-10-18T00:00:00.000Z'); + + expect((await callTool('paid_call', { url: PAID_URL })).isError).toBeFalsy(); + expect(budget.snapshot()).toMatchObject({ day: '2026-10-18', spentToday: '$1.00', resetsAt: '2026-10-19T00:00:00.000Z' }); + expect(JSON.parse(readFileSync(file, 'utf8'))).toMatchObject({ day: '2026-10-18' }); + }); + + it('does not charge the budget when the seller rejects the payment', async () => { + const fetchWithPayment = vi.fn().mockResolvedValue(new Response('Payment Required', { status: 402 })); + const budget = new SpendBudget({ perCallMax: '$1.00', dailyCeiling: '$1.00' }); + const { callTool } = createHarness(fetchWithPayment, budget); + + const rejection = await callTool('paid_call', { url: PAID_URL }); + + expect(JSON.parse(rejection.content[0].text).code).toBe(MCP_ERROR_CODES.ERR_PAYMENT_REJECTED); + expect(budget.snapshot().spentToday).toBe('$0.00'); + // Rejected money never settled, so the call must be repeatable within the ceiling. + expect(budget.authorize().allowed).toBe(true); + }); + + it('treats an unreadable state file as fully spent rather than as a clean slate', async () => { + const file = spendStateFile(); + writeFileSync(file, 'not json at all', 'utf8'); + // The budget degrades while loading, so the spy has to be in place first. + const logged = vi.spyOn(console, 'error').mockImplementation(() => {}); + const fetchWithPayment = cheapPaidCall(); + const budget = new SpendBudget({ perCallMax: '$1.00', dailyCeiling: '$10.00', stateFilePath: file }); + const { callTool } = createHarness(fetchWithPayment, budget); + + const refusal = await callTool('paid_call', { url: PAID_URL }); + + expect(JSON.parse(refusal.content[0].text).code).toBe(MCP_ERROR_CODES.ERR_BUDGET_EXCEEDED); + expect(JSON.parse(refusal.content[0].text).details.budget.state).toBe('unavailable'); + expect(fetchWithPayment).not.toHaveBeenCalled(); + expect(logged).toHaveBeenCalledTimes(1); + logged.mockRestore(); + }); + + it('shows both limits in the tool description and the live budget as a tool', async () => { + const budget = new SpendBudget({ perCallMax: '$1.00', dailyCeiling: '$10.00' }); + const { listTools, callTool } = createHarness(cheapPaidCall(), budget); + + const { tools } = await listTools(); + const paidCallTool = tools.find((tool: any) => tool.name === 'paid_call'); + expect(paidCallTool.description).toContain('capped per call at $1.00'); + expect(paidCallTool.description).toContain('$10.00 per UTC day'); + expect(paidCallTool.description).toContain('ERR_BUDGET_EXCEEDED'); + + const budgetTool = tools.find((tool: any) => tool.name === 'payment_budget'); + expect(budgetTool).toBeDefined(); + expect(ToolSchema.safeParse(budgetTool).success).toBe(true); + + const reported = await callTool('payment_budget'); + expect(reported.isError).toBeFalsy(); + expect(JSON.parse(reported.content[0].text)).toMatchObject({ + currency: 'USD', + perCallMax: '$1.00', + dailyCeiling: '$10.00', + spentToday: '$0.00', + reservedToday: '$0.00', + remainingToday: '$10.00', + state: 'ok', + }); + + await callTool('paid_call', { url: PAID_URL }); + expect(JSON.parse((await callTool('payment_budget')).content[0].text).remainingToday).toBe('$9.00'); + }); + + it('refuses to start with limits that could never allow a call', () => { + expect(() => new SpendBudget({ perCallMax: '$10.00', dailyCeiling: '$1.00' })).toThrow(/exceeds dailyCeiling/); + expect(() => new SpendBudget({ perCallMax: 'free', dailyCeiling: '$1.00' })).toThrow(/Invalid USD amount/); + expect(() => new SpendBudget({ perCallMax: '-$1.00', dailyCeiling: '$10.00' })).toThrow(/Invalid USD amount/); + }); + + it('parses money strings into exact integer nanodollars', () => { + expect(parseUsdToNanodollars('$1.00')).toBe(1_000_000_000n); + expect(parseUsdToNanodollars('$0.10')).toBe(100_000_000n); + expect(parseUsdToNanodollars('0')).toBe(0n); + expect(() => parseUsdToNanodollars('$1.0000000000')).toThrow(/more than 9 decimal places/); + }); +}); + +/** + * 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', () => { + const PAID_URL = 'https://ledger-audit.test/api/v1/price'; + + let ledgerDir: string | undefined; + + beforeEach(() => { + ledgerDir = undefined; + mockFetchWithTimeout.mockReset(); + }); + + afterEach(() => { + if (ledgerDir) { + rmSync(ledgerDir, { recursive: true, force: true }); + } + }); + + function tempLedgerFile(name: string): string { + ledgerDir = mkdtempSync(join(tmpdir(), 'lens-mcp-ledger-')); + return join(ledgerDir, name); + } + + /** + * 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) { + 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(PAYMENT_OFFER) } }) + ); + return createHarness(transport as unknown as Mock, undefined, ledger); + } + + it('records a failed payment as an attempt', async () => { + const fetchWithPayment = vi.fn().mockResolvedValue(new Response('Payment Required', { status: 402 })); + const { callTool, ledger } = createHarness(fetchWithPayment); + + const response = await callTool('paid_call', { url: PAID_URL }); + + expect(response.isError).toBe(true); + const [record] = ledger.records(); + expect(ledger.records()).toHaveLength(1); + 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 that reaches the transport, and refuses the rest before spending', async () => { + const ledger = new PaymentLedger(); + 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(transportFailure()); + const { callTool } = createHarness(transport, undefined, ledger); + + await callTool('paid_call', { url: PAID_URL, method: 'POST' }); + await callTool('paid_call', { url: PAID_URL }); + await callTool('paid_call', { url: 'https://ledger-audit.test/api/v1/other' }); + await callTool('paid_call', { url: PAID_URL }); + + expect(ledger.records().map((record) => [record.outcome, record.errorCode])).toEqual([ + ['success', null], + ['failure', MCP_ERROR_CODES.ERR_PAYMENT_REJECTED], + ['failure', MCP_ERROR_CODES.ERR_BAD_REQUEST], + ['failure', MCP_ERROR_CODES.ERR_INTERNAL_FAILURE], + ]); + // The method the agent asked for, and one call that threw before any + // response existed — which cannot be proved unspent. + expect(ledger.records()[0].method).toBe('POST'); + expect(ledger.records()[3].spend).toBe('possible'); + + // A url this process cannot even parse is refused by the paid_call + // allow-list gate (#404) before a request is sent, so there is nothing + // to audit: the refusal is the answer, and the ledger stays untouched. + const refusal = await callTool('paid_call', { url: 'not a url at all' }); + expect(JSON.parse(refusal.content[0].text).code).toBe(MCP_ERROR_CODES.ERR_BAD_REQUEST); + expect(ledger.records()).toHaveLength(4); + }); + + it('records the transaction hash the seller echoes on settlement', async () => { + const ledger = new PaymentLedger(); + const { callTool } = createHarness(vi.fn().mockResolvedValue(settledResponse({ price: '1.00' })), undefined, ledger); + + 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 ledger = new PaymentLedger({ filePath: tempLedgerFile('payments.jsonl') }); + const { callTool } = createPaidTransport(ledger, () => settledResponse({ price: '1.00' })); + + await callTool('paid_call', { url: 'https://ledger-offer.test/api/v1/price' }); + + const record = JSON.stringify(ledger.records()[0]); + const file = readFileSync(ledgerDir + '/payments.jsonl', '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, undefined, ledger); + + await callTool('paid_call', { url: PAID_URL }); + await callTool('paid_call', { url: PAID_URL }); + await callTool('bazaar_search', { url: 'https://ledger-audit.test' }); + + 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 to spend once the audit trail cannot be written', async () => { + const ledger = new PaymentLedger({ filePath: tempLedgerFile('payments.jsonl') }); + const fetchWithPayment = vi.fn().mockResolvedValue(settledResponse({ price: '1.00' })); + const { callTool } = createHarness(fetchWithPayment, undefined, ledger); + + // Stand in for a write that failed mid-run; the ledger marks itself not durable. + ledger.durable = 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); + expect(fetchWithPayment).not.toHaveBeenCalled(); + }); + + 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 Mock, undefined, 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://not-allow-listed.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('not-allow-listed.test'); + + const settled = await register.getSingleMetricAsString('mcp_payment_amount_usd_total'); + expect(settled).toMatch(/mcp_payment_amount_usd_total\{[^}]*host="ledger-metrics\.test"[^}]*\} 0\.1/); + expect(settled).not.toContain('not-allow-listed.test'); + }); + + it('surfaces the running total to the budget tool', async () => { + const ledger = new PaymentLedger(); + const instrumented = ledger.instrumentFetch( + vi.fn().mockResolvedValue(settledResponse({ price: '1.00' })) as unknown as typeof fetch + ); + const transport = vi.fn(async (url: string, init?: RequestInit) => + instrumented(url, { ...init, headers: { 'payment-signature': base64Json(PAYMENT_OFFER) } }) + ); + const budget = new SpendBudget({ + perCallMax: '$1.00', + dailyCeiling: '$10.00', + stateFilePath: tempLedgerFile('spend-state.json'), + }); + const { callTool } = createHarness(transport as unknown as Mock, budget, ledger); + + await callTool('paid_call', { url: 'https://ledger-offer.test/api/v1/price' }); + + const reported = JSON.parse((await callTool('payment_budget')).content[0].text); + expect(reported.recordedSpend).toEqual({ + attempts: 1, + settledNanodollars: '100000000', + possibleNanodollars: '0', + unquantifiedPayments: 0, + }); + // The two views disagree on purpose: the budget can only hold the worst case + // it reserved before paying, while the ledger reports the price it actually saw. + expect(reported.spentToday).toBe('$1.00'); + }); + + it('records nothing for discovery, which never pays', async () => { + mockFetchWithTimeout.mockResolvedValue(jsonResponse({ resources: [{ name: 'Mock Price Feed', url: PAID_URL }] })); + const { callTool, ledger } = createHarness(); + + await callTool('bazaar_search', { url: 'https://ledger-audit.test' }); + + expect(ledger.records()).toEqual([]); + expect(ledger.totalSpend().attempts).toBe(0); }); it('should return structured ERR_UNKNOWN_TOOL error with valid tool names when unknown tool is called', async () => {