From a588327c9eaa5a9e998a05ed94fb13521c056c8e Mon Sep 17 00:00:00 2001 From: AGWAM001 <281425994+AGWAM001@users.noreply.github.com> Date: Sat, 17 Oct 2026 23:01:14 -0700 Subject: [PATCH 1/3] feat(mcp): bound cumulative spend, audit payments, serve over HTTP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit paid_call signs with a real mainnet key, and maxPrice only bounds one mistake — a caller in a retry loop could spend without limit. Add a cumulative USD ceiling that reserves each call's maximum before the payment runs and settles it after, record every attempt in an audit ledger, and refuse to spend at all when a durable ledger was asked for and cannot be written. The same tools are also served over Streamable HTTP, which refuses to start without a bearer token since it exposes paid_call to the network. Cover #421: bazaar_search, ListTools and the unknown-tool path had no tests, and the simulated upstream errors were plain strings that hid how handlePaidCallError classifies on lowercased message substrings. Fixtures now use the shapes that @x402/fetch, @x402/core and @x402/stellar really throw, including one Horizon error whose op_no_trust signal is invisible to that matcher. --- .changeset/mcp-http-transport.md | 42 ++ .changeset/mcp-payment-audit.md | 46 ++ .changeset/mcp-spend-ceiling.md | 43 ++ .env.example | 53 ++ README.md | 127 +++++ examples/mcp-server/run.ts | 91 +++- src/mcp/budget.ts | 309 +++++++++++ src/mcp/codes.ts | 4 + src/mcp/config.ts | 69 +++ src/mcp/ledger.ts | 429 +++++++++++++++ src/mcp/server.ts | 468 ++++++++++++++++- tests/mcp-http.test.ts | 405 ++++++++++++++ tests/mcp.test.ts | 871 ++++++++++++++++++++++++++++++- 13 files changed, 2926 insertions(+), 31 deletions(-) create mode 100644 .changeset/mcp-http-transport.md create mode 100644 .changeset/mcp-payment-audit.md create mode 100644 .changeset/mcp-spend-ceiling.md create mode 100644 src/mcp/budget.ts create mode 100644 src/mcp/config.ts create mode 100644 src/mcp/ledger.ts create mode 100644 tests/mcp-http.test.ts diff --git a/.changeset/mcp-http-transport.md b/.changeset/mcp-http-transport.md new file mode 100644 index 00000000..3fbacc6a --- /dev/null +++ b/.changeset/mcp-http-transport.md @@ -0,0 +1,42 @@ +--- +'lens': minor +--- + +The MCP server can now serve Streamable HTTP, selected with `MCP_TRANSPORT=http`. +Until then stdio was the only transport, so the server could only be used by a +client able to launch this process on the same machine — which rules out every +hosted agent, and means the agent's secret key has to be installed on each +machine that wants to pay. + +HTTP reaches the network, so it is only allowed to start authenticated. +`resolveMcpTransportConfig` (`src/mcp/config.ts`) requires a non-empty +`MCP_HTTP_AUTH_TOKEN` before anything else happens, and `runHttp()` throws again +if handed a blank one, so an unauthenticated remote server with `paid_call` in +its tool list cannot be brought up by configuration or by an embedding caller. +The example entrypoint resolves the transport before it builds a signer: a +server that cannot authenticate callers never reaches the point where it can +spend. Every request must then present `Authorization: Bearer `, compared +over SHA-256 digests with `timingSafeEqual`; a missing, malformed or wrong token +is refused with `401`, `www-authenticate: Bearer realm="lens-mcp"` and the new +`ERR_UNAUTHORIZED` code, before the body is read and before any tool runs. The +token is never echoed back into an error message or a log line. + +It binds `127.0.0.1` unless `MCP_HTTP_HOST` says otherwise, answers MCP only on +`MCP_HTTP_PATH` (default `/mcp`), rejects anything else with `404` without +revealing whether the path exists, and requires `POST` — `GET` gets `405`, which +spec-compliant clients read as "no server-initiated stream" rather than an +error. Bodies are capped at 1 MiB and must be `application/json`, so a caller +cannot make the payment path stream an unbounded request. + +Both transports register the same tools from the same code path, and share +one spend budget and payment ledger per process, so a remote client cannot spend +past a ceiling a local one already used. State is deliberately not shared +across requests: the SDK's stateless Streamable HTTP transport is single-use — a +second request on one instance throws, because message ids from different +clients would collide — so each `POST` gets its own protocol server and +transport. + +stdio remains the default: with `MCP_TRANSPORT` unset, or set to `stdio`, the +server talks to stdin and stdout exactly as before. An unrecognised value throws +instead of quietly falling back, because a server that silently ignored the +transport it was told to serve would be hard to trust with a key. diff --git a/.changeset/mcp-payment-audit.md b/.changeset/mcp-payment-audit.md new file mode 100644 index 00000000..071473be --- /dev/null +++ b/.changeset/mcp-payment-audit.md @@ -0,0 +1,46 @@ +--- +'lens': patch +--- + +Every `paid_call` now leaves a record of what it cost. A successful call used to +return the response body and nothing else: no log line, metric or persisted trace +of which URL was paid, how much, on which network, with which transaction. For a +tool that signs with a real key, the audit trail is not an enhancement — without +it there is no answer to "where did today's money go", and the cumulative ceiling +had no measurement of spend to read. + +`PaymentLedger` (`src/mcp/ledger.ts`) writes one JSON line per attempt to +`MCP_PAYMENT_LEDGER_FILE` (default `~/.lens-mcp/payments.jsonl`): timestamp, full +URL, host, method, price, asset, network, transaction hash, the call's outcome, +what the ledger can vouch for about the money (`settled`, `possible` or `none`) +and the error code if the call failed. A failed call is recorded too, because a +payment can settle on a call that then errors — `outcome` and `spend` are separate +fields so "the call failed" never implies "nothing left the account". + +The price is not on the `Response` the x402 client hands back; it is only visible +on the payment header the transport puts on the request. So the ledger wraps that +transport's fetch (`instrumentFetch`) and correlates the offer with the response +it produced. The settlement header is read for the transaction hash and for +whether the seller confirmed it. + +`mcp_payment_attempts_total` (labels `host`, `network`, `outcome`) and +`mcp_payment_amount_usd_total` (labels `host`, `network`) export the same spend to +Prometheus, on the shared registry. Labels come from `MCP_METRIC_HOSTS`: series are +permanent, so labelling by whatever URL an agent asks for would let one caller mint +unbounded time series, and anything outside the allow-list is reported as `unknown`. +The audit file always keeps the real host. Amounts that are not a USD money string +are recorded verbatim but cannot join the USD sum, so `mcp_payment_unquantified_total` +and `totalSpend().unquantifiedPayments` make that understatement visible instead of +silent. + +No secret material is recorded. Only the seller's echoed `accepted` requirements are +read out of the payment header; the signed envelope alongside it never reaches a +record, a metric or the file, and every header field is length-capped so a seller +cannot forge extra lines into the trail. + +Accounting fails closed in both directions: if the audit file cannot be created or a +record cannot be written, `paid_call` is refused with `ERR_INTERNAL_FAILURE` rather +than spending unrecorded. And `payment_budget` now reports `recordedSpend` beside the +budget's own worst-case reservations, so the ceiling has an actual measurement to +read: the budget holds the per-call maximum because it cannot see the price, while +the ledger reports the price it did see. diff --git a/.changeset/mcp-spend-ceiling.md b/.changeset/mcp-spend-ceiling.md new file mode 100644 index 00000000..8043cb6d --- /dev/null +++ b/.changeset/mcp-spend-ceiling.md @@ -0,0 +1,43 @@ +--- +'lens': patch +--- + +The MCP server's spend cap is now cumulative, not just per call. `maxPrice` +bounded a single payment, so an agent in a retry loop — or one told to "try each +of these endpoints" — could pay that amount arbitrarily many times: every +payment individually inside the limit, the sum unbounded. A per-call limit +answers "how much can one mistake cost"; an operator's question is "how much can +today cost". + +`SpendBudget` (`src/mcp/budget.ts`) adds a ceiling on the total paid by +`paid_call` within one UTC day, configured with `MCP_DAILY_SPEND_CEILING` +(default `$10.00`) alongside the per-call `MCP_MAX_PAYMENT_PRICE`. The day window +was chosen over a sliding 24 hours because it answers the operator's question +directly, needs one stored number rather than one entry per payment, and rolls +over from the wall clock — so a restarted server resumes the same day's total and +resets it after 00:00 UTC. + +Before a call is made the budget reserves its worst case, the per-call maximum, +and refuses when that no longer fits what is left. It refuses with the new +`ERR_BUDGET_EXCEEDED` code carrying the spent, ceiling, remaining and reset time +in `details.budget`, so an agent can report being out of budget instead of +looking broken; the refusal happens before the payment fetch, so no money moves. +Both limits are in the `paid_call` description and the live totals are readable +without spending, through the new `payment_budget` tool. The running total +persists to `MCP_SPEND_STATE_FILE` (default `~/.lens-mcp/spend-state.json`) and is +read back at startup, because a restart does not refund what the previous process +already spent. + +Accounting is deliberately conservative: the wrapped x402 transport exposes only +a plain `Response`, so a paid call that could have settled is booked at the +per-call maximum and a rejection that proves nothing settled is not booked at all. +Recorded spend is an upper bound, which means the ceiling is reached early rather +than late — and an unreadable or unwritable state file is treated as fully spent, +not as a clean slate. + +Also fixed while wiring this up: `MCP_MAX_PAYMENT_PRICE` was passed to +`wrapFetchWithPaymentFromConfig` as `maxPrice`, which is not an option of the +x402 client config and was silently ignored — every payment was capped by the +library default of `$1` whatever the operator asked for. It is now passed as +`spendControls.maxAmountPerPayment`, so the per-call limit the budget reserves +against is the one actually enforced. diff --git a/.env.example b/.env.example index 41e4465f..1d4a0fae 100644 --- a/.env.example +++ b/.env.example @@ -184,6 +184,59 @@ 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. +MCP_AGENT_SECRET_KEY_TESTNET= +MCP_AGENT_SECRET_KEY_MAINNET= +# Transport to serve: "stdio" (default) or "http". stdio speaks over the process's +# stdin/stdout and only works for a client that can launch this process on the +# same machine; "http" serves Streamable HTTP so remote/hosted clients can connect. +MCP_TRANSPORT=stdio +# Required when MCP_TRANSPORT=http, and refused without it. Every request must +# carry `Authorization: Bearer `. Generate a strong random value +# (e.g. `openssl rand -hex 32`); it authorises paid_call spending, so treat it +# as a credential, and never put it in a URL. +MCP_HTTP_AUTH_TOKEN= +# Bind address for the HTTP transport (default: 127.0.0.1). Reachability beyond +# loopback is only safe behind a TLS-terminating reverse proxy. +MCP_HTTP_HOST=127.0.0.1 +# Port for the HTTP transport (default: 3003; the REST API listens on PORT=3002). +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). +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= + # ─── Snapshot retention ──────────────────────────────────────────────────── # Rows removed by one DELETE during a retention run. The job drains in batches diff --git a/README.md b/README.md index 8351617e..182b4fe6 100644 --- a/README.md +++ b/README.md @@ -322,6 +322,117 @@ 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. + ## Documentation Detailed system design and data flow diagrams can be found in the [Architecture Overview](docs/architecture.md). The API specification is available in [OpenAPI 3.0 format](openapi.yaml) and is auto-published to GitHub Pages at https://miracle656.github.io/lens/openapi.json. @@ -507,6 +618,22 @@ curl -s http://localhost:3002/price/XLM/USDC | jq '{price, ammPrice, lastUpdated | `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 | + *\*Required if enabling x402 payment gating.* ## Stack diff --git a/examples/mcp-server/run.ts b/examples/mcp-server/run.ts index 2b971525..9a1ad426 100644 --- a/examples/mcp-server/run.ts +++ b/examples/mcp-server/run.ts @@ -1,10 +1,41 @@ import 'dotenv/config'; import { BazaarMcpServer } from '../../src/mcp/server'; +import { resolveMcpTransportConfig, McpTransportConfig } from '../../src/mcp/config'; +import { SpendBudget, defaultSpendStateFile } from '../../src/mcp/budget'; +import { PaymentLedger, defaultPaymentLedgerFile } from '../../src/mcp/ledger'; import { wrapFetchWithPaymentFromConfig } from '@x402/fetch'; import { ExactStellarScheme } from '@x402/stellar/exact/client'; import { createEd25519Signer } from '@x402/stellar'; +function formatHttpUrl(host: string, port: number, path: string): string { + const hostPart = host.includes(':') ? `[${host}]` : host; + return `http://${hostPart}:${port}${path}`; +} + +/** + * Hosts allowed to become the `host` label of a Prometheus series. Series are + * permanent, so labelling by whatever URL an agent asks for lets one caller + * mint unbounded time series; anything outside this list is reported as + * `unknown`. The audit file always keeps the real host regardless. + */ +function parseMetricHosts(raw: string | undefined): string[] { + return (raw ?? '') + .split(',') + .map((value) => value.trim()) + .filter(Boolean); +} + async function main() { + // Fail closed before a signer exists: an HTTP server that cannot authenticate + // callers must never reach the point where it can spend. + let transportConfig: McpTransportConfig; + try { + transportConfig = resolveMcpTransportConfig(); + } catch (e: any) { + console.error(`[mcp-server] Error: ${e.message}`); + process.exit(1); + } + const testnetKey = process.env.MCP_AGENT_SECRET_KEY_TESTNET; const mainnetKey = process.env.MCP_AGENT_SECRET_KEY_MAINNET; @@ -18,9 +49,27 @@ async function main() { const testnetClient = new ExactStellarScheme(testnetSigner); const pubnetClient = new ExactStellarScheme(pubnetSigner); - - const fetchWithPayment = wrapFetchWithPaymentFromConfig(globalThis.fetch, { - maxPrice: process.env.MCP_MAX_PAYMENT_PRICE || '$1.00', + + // A single payment is capped by spendControls.maxAmountPerPayment. The value is + // passed through to the budget so the cumulative ceiling can reserve each call at + // its worst case; leaving spendControls unset would let the library default of $1 + // differ from whatever MCP_MAX_PAYMENT_PRICE says. + const maxPrice = process.env.MCP_MAX_PAYMENT_PRICE || '$1.00'; + const dailyCeiling = process.env.MCP_DAILY_SPEND_CEILING || '$10.00'; + + // 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. + const ledgerFile = process.env.MCP_PAYMENT_LEDGER_FILE || defaultPaymentLedgerFile(); + const paymentLedger = new PaymentLedger({ + filePath: ledgerFile, + metricHosts: parseMetricHosts(process.env.MCP_METRIC_HOSTS), + }); + + const fetchWithPayment = wrapFetchWithPaymentFromConfig(paymentLedger.instrumentFetch(globalThis.fetch), { + spendControls: { + maxAmountPerPayment: maxPrice, + }, schemes: [ { network: 'stellar:testnet', @@ -33,13 +82,43 @@ async function main() { ] }); + // The running total outlives the process: this server signs with a real key, and a + // restart does not refund what the previous process already spent. Point + // MCP_SPEND_STATE_FILE somewhere durable if the default location is not. + const budget = new SpendBudget({ + perCallMax: maxPrice, + dailyCeiling, + stateFilePath: process.env.MCP_SPEND_STATE_FILE || defaultSpendStateFile(), + }); + const server = new BazaarMcpServer({ - fetchWithPayment + fetchWithPayment, + budget, + paymentLedger }); - console.error('[mcp-server] Starting Bazaar MCP server on stdio...'); console.error('[mcp-server] Agent configured for stellar:testnet and stellar:pubnet'); - + 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 metrics are labelled by host: ${ + parseMetricHosts(process.env.MCP_METRIC_HOSTS).join(', ') || 'every host is reported as unknown' + }` + ); + + 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'); + return; + } + + console.error('[mcp-server] Starting Bazaar MCP server on stdio...'); await server.run(); } diff --git a/src/mcp/budget.ts b/src/mcp/budget.ts new file mode 100644 index 00000000..7eeffeb5 --- /dev/null +++ b/src/mcp/budget.ts @@ -0,0 +1,309 @@ +import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs'; +import { homedir } from 'os'; +import { dirname, join } from 'path'; + +const NANODOLLARS_PER_USD = 1_000_000_000n; + +export function defaultSpendStateFile(): string { + return join(homedir(), '.lens-mcp', 'spend-state.json'); +} + +/** + * Parses a USD money string such as '$1.00', '1.00' or '$0.05' into integer + * nanodollars. Throws on anything that is not a non-negative USD amount: a + * malformed ceiling must stop the server at boot rather than leave it running + * with an unintended budget. + */ +export function parseUsdToNanodollars(money: string): bigint { + const cleaned = String(money).trim().replace(/^\$/, ''); + const match = /^(\d+)(?:\.(\d+))?$/.exec(cleaned); + if (!match) { + throw new Error(`Invalid USD amount: ${JSON.stringify(money)}. Expected a non-negative amount such as "$1.00".`); + } + const fraction = match[2] ?? ''; + if (fraction.length > 9) { + throw new Error(`USD amount ${JSON.stringify(money)} has more than 9 decimal places; nanodollar precision is the finest the budget can track.`); + } + return BigInt(match[1]) * NANODOLLARS_PER_USD + BigInt(fraction.padEnd(9, '0') || '0'); +} + +function formatUsd(nanodollars: bigint): string { + const whole = nanodollars / NANODOLLARS_PER_USD; + const fraction = (nanodollars % NANODOLLARS_PER_USD).toString().padStart(9, '0').replace(/0+$/, ''); + // Show cents at minimum; keep finer precision only when the amount actually has it. + return `$${whole}.${fraction.padEnd(2, '0')}`; +} + +function utcDayOf(now: Date): string { + return now.toISOString().slice(0, 10); +} + +function nextUtcMidnightIso(day: string): string { + return new Date(Date.parse(`${day}T00:00:00.000Z`) + 86_400_000).toISOString(); +} + +export interface BudgetSnapshot { + currency: 'USD'; + window: 'utc_day'; + /** Current UTC day this window covers, e.g. '2026-10-17'. */ + day: string; + perCallMax: string; + dailyCeiling: string; + spentToday: string; + /** Worst-case value of paid calls that are in flight right now. */ + reservedToday: string; + remainingToday: string; + resetsAt: string; + /** 'unavailable' once state could not be read or written; paid calls are then refused. */ + state: 'ok' | 'unavailable'; +} + +export interface SpendBudgetOptions { + /** Ceiling for a single payment, e.g. '$1.00'. */ + perCallMax: string; + /** Cumulative ceiling for the current UTC day, e.g. '$10.00'. */ + dailyCeiling: string; + /** Where the running total is persisted. Omit to keep it in memory only. */ + stateFilePath?: string; + now?: () => Date; +} + +export type BudgetDecision = + | { allowed: true; reservationId: number; snapshot: BudgetSnapshot } + | { allowed: false; snapshot: BudgetSnapshot; reason: string }; + +interface PersistedSpendState { + version: 1; + day: string; + spentNanodollars: string; +} + +/** + * Cumulative spend ceiling for paid calls, per UTC day. + * + * A per-call maximum only bounds what one mistake can cost; this bounds what a + * day of mistakes can cost. The window is a UTC calendar day rather than a + * sliding 24 hours because it answers the operator's question directly ("how + * much can today cost"), needs a single stored number instead of one entry per + * payment, and rolls over from the wall clock, so a restarted server resumes + * the same day's total and resets it after 00:00 UTC. + * + * Accounting is conservative. A payment's real price is only known inside the + * x402 transport, which hands back a plain Response, so a paid call that could + * have settled debits the per-call maximum. Recorded spend is therefore an + * upper bound: the ceiling is never reached late, only early. + */ +export class SpendBudget { + readonly perCallMaxNanodollars: bigint; + readonly dailyCeilingNanodollars: bigint; + + private readonly stateFilePath?: string; + private readonly now: () => Date; + private day: string; + private spentNanodollars = 0n; + private reservedNanodollars = 0n; + private readonly holds = new Map(); + private nextHoldId = 1; + private state: 'ok' | 'unavailable' = 'ok'; + private stateUnavailableReason = ''; + + constructor(options: SpendBudgetOptions) { + this.now = options.now ?? (() => new Date()); + this.perCallMaxNanodollars = parseUsdToNanodollars(options.perCallMax); + this.dailyCeilingNanodollars = parseUsdToNanodollars(options.dailyCeiling); + if (this.perCallMaxNanodollars <= 0n) { + throw new Error(`perCallMax must be greater than zero, got ${JSON.stringify(options.perCallMax)}.`); + } + if (this.perCallMaxNanodollars > this.dailyCeilingNanodollars) { + throw new Error( + `perCallMax ${JSON.stringify(options.perCallMax)} exceeds dailyCeiling ${JSON.stringify(options.dailyCeiling)}; ` + + 'no paid call would ever be allowed. Raise the ceiling or lower the per-call maximum.' + ); + } + this.day = utcDayOf(this.now()); + this.stateFilePath = options.stateFilePath; + if (this.stateFilePath) { + this.loadState(); + } + } + + /** + * Decides whether a paid call may start, and holds its worst case against the + * ceiling while it is in flight. Refuses when the remaining budget cannot + * cover a call priced at the per-call maximum. + */ + authorize(): BudgetDecision { + this.rollWindowIfNeeded(); + if (this.state === 'unavailable') { + return { + allowed: false, + snapshot: this.snapshot(), + reason: + `Refusing a paid call because the spend budget cannot be trusted (${this.stateUnavailableReason}). ` + + 'Fix the state file, then restart the server to resume paid calls.', + }; + } + if (this.spentNanodollars + this.reservedNanodollars + this.perCallMaxNanodollars > this.dailyCeilingNanodollars) { + return { + allowed: false, + snapshot: this.snapshot(), + reason: + `Daily spend ceiling reached: ${formatUsd(this.spentNanodollars)} of ${formatUsd(this.dailyCeilingNanodollars)} spent today, ` + + `leaving ${formatUsd(this.remainingNanodollars())} of a ceiling that must cover a call of up to ${formatUsd(this.perCallMaxNanodollars)}. ` + + `The window resets at ${nextUtcMidnightIso(this.day)}.`, + }; + } + const reservationId = this.nextHoldId++; + this.holds.set(reservationId, this.perCallMaxNanodollars); + this.reservedNanodollars += this.perCallMaxNanodollars; + return { allowed: true, reservationId, snapshot: this.snapshot() }; + } + + /** Books the held worst case as spent. Call when a payment may have settled. */ + commit(reservationId: number): void { + const hold = this.holds.get(reservationId); + if (!hold) { + return; + } + this.holds.delete(reservationId); + this.reservedNanodollars -= hold; + const today = utcDayOf(this.now()); + if (today !== this.day) { + // The window closed while the call was in flight, so the payment belongs to the + // window that has just ended and must not be booked against the new day. + this.day = today; + this.spentNanodollars = 0n; + this.holds.clear(); + this.reservedNanodollars = 0n; + this.writeState(); + return; + } + this.spentNanodollars += hold; + this.writeState(); + } + + /** Drops a hold when no payment settled, so the budget is not consumed. */ + release(reservationId: number): void { + const hold = this.holds.get(reservationId); + if (!hold) { + return; + } + this.holds.delete(reservationId); + this.reservedNanodollars -= hold; + } + + snapshot(): BudgetSnapshot { + return { + currency: 'USD', + window: 'utc_day', + day: this.day, + perCallMax: formatUsd(this.perCallMaxNanodollars), + dailyCeiling: formatUsd(this.dailyCeilingNanodollars), + spentToday: formatUsd(this.spentNanodollars), + reservedToday: formatUsd(this.reservedNanodollars), + remainingToday: formatUsd(this.remainingNanodollars()), + resetsAt: nextUtcMidnightIso(this.day), + state: this.state, + }; + } + + /** Text for a tool description so an agent can reason about cost before calling. */ + describeLimits(): string { + const snapshot = this.snapshot(); + return ( + `Payments are capped per call at ${snapshot.perCallMax} and cumulatively at ` + + `${snapshot.dailyCeiling} per UTC day (${snapshot.day}, resetting at ${snapshot.resetsAt}). ` + + `Recorded spend so far today: ${snapshot.spentToday}; remaining: ${snapshot.remainingToday}. ` + + `A call that cannot fit its per-call maximum inside the remaining budget is refused with ` + + `ERR_BUDGET_EXCEEDED before any payment is attempted.` + ); + } + + private remainingNanodollars(): bigint { + const remaining = this.dailyCeilingNanodollars - this.spentNanodollars; + return remaining > 0n ? remaining : 0n; + } + + private rollWindowIfNeeded(): void { + const today = utcDayOf(this.now()); + if (today === this.day) { + return; + } + this.day = today; + this.spentNanodollars = 0n; + this.holds.clear(); + this.reservedNanodollars = 0n; + this.writeState(); + } + + private loadState(): void { + const path = this.stateFilePath!; + let raw: string; + try { + raw = readFileSync(path, 'utf8'); + } catch (e) { + if ((e as NodeJS.ErrnoException).code === 'ENOENT') { + this.writeState(); + return; + } + this.degrade(`the spend state file at ${path} could not be read: ${(e as Error).message}`); + return; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + this.degrade(`the spend state file at ${path} is not valid JSON`); + return; + } + + const state = parsed as Partial; + const isValidDay = typeof state.day === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(state.day); + const isValidSpend = typeof state.spentNanodollars === 'string' && /^\d+$/.test(state.spentNanodollars); + if (state.version !== 1 || !isValidDay || !isValidSpend) { + this.degrade(`the spend state file at ${path} does not contain a recognised budget state`); + return; + } + + const spentNanodollars = BigInt(state.spentNanodollars as string); + if (state.day === utcDayOf(this.now())) { + this.day = state.day as string; + this.spentNanodollars = spentNanodollars; + } else { + this.day = utcDayOf(this.now()); + this.spentNanodollars = 0n; + this.writeState(); + } + } + + private writeState(): void { + if (!this.stateFilePath) { + return; + } + const payload: PersistedSpendState = { + version: 1, + day: this.day, + spentNanodollars: this.spentNanodollars.toString(), + }; + const tempPath = `${this.stateFilePath}.tmp-${process.pid}`; + try { + mkdirSync(dirname(this.stateFilePath), { recursive: true }); + writeFileSync(tempPath, JSON.stringify(payload), 'utf8'); + renameSync(tempPath, this.stateFilePath); + } catch (e) { + this.degrade(`the spend state file at ${this.stateFilePath} could not be written: ${(e as Error).message}`); + } + } + + /** + * Fails closed. With unreadable or unwritable state the running total is + * unknown, and an unknown total against a money ceiling is assumed spent. + */ + private degrade(reason: string): void { + this.state = 'unavailable'; + this.stateUnavailableReason = reason; + this.spentNanodollars = this.dailyCeilingNanodollars; + console.error(`[mcp-budget] ${reason}. Refusing paid calls until the state is recoverable.`); + } +} diff --git a/src/mcp/codes.ts b/src/mcp/codes.ts index a9631225..ce44b6a0 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_BUDGET_EXCEEDED: 'ERR_BUDGET_EXCEEDED', + ERR_UNAUTHORIZED: 'ERR_UNAUTHORIZED', ERR_INTERNAL_FAILURE: 'ERR_INTERNAL_FAILURE' } as const; @@ -15,4 +17,6 @@ export interface McpErrorResponse { isError: true; code: McpErrorCode; reason: string; + /** Structured context for the caller, e.g. the spend budget behind ERR_BUDGET_EXCEEDED. */ + details?: Record; } diff --git a/src/mcp/config.ts b/src/mcp/config.ts new file mode 100644 index 00000000..2445daa8 --- /dev/null +++ b/src/mcp/config.ts @@ -0,0 +1,69 @@ +export const DEFAULT_MCP_HTTP_HOST = '127.0.0.1'; +export const DEFAULT_MCP_HTTP_PORT = 3003; +export const DEFAULT_MCP_HTTP_PATH = '/mcp'; + +export interface McpStdioTransportConfig { + transport: 'stdio'; +} + +export interface McpHttpTransportConfig { + transport: 'http'; + host: string; + port: number; + path: string; + authToken: string; +} + +export type McpTransportConfig = McpStdioTransportConfig | McpHttpTransportConfig; + +function parsePort(raw: string): number { + if (!/^\d+$/.test(raw)) { + throw new Error(`MCP_HTTP_PORT must be an integer between 0 and 65535 (received "${raw}")`); + } + const port = Number(raw); + if (port > 65535) { + throw new Error(`MCP_HTTP_PORT must be an integer between 0 and 65535 (received "${raw}")`); + } + return port; +} + +export function normalizeMcpHttpPath(raw: string): string { + const value = raw.trim(); + if (!value) return DEFAULT_MCP_HTTP_PATH; + if (/[?#\s]/.test(value)) { + throw new Error('MCP_HTTP_PATH must be a URL path without a query string or fragment'); + } + const withLeadingSlash = value.startsWith('/') ? value : `/${value}`; + return withLeadingSlash.length > 1 && withLeadingSlash.endsWith('/') + ? withLeadingSlash.slice(0, -1) + : withLeadingSlash; +} + +export function resolveMcpTransportConfig(env: NodeJS.ProcessEnv = process.env): McpTransportConfig { + const transport = (env.MCP_TRANSPORT ?? 'stdio').trim().toLowerCase(); + + if (!transport || transport === 'stdio') { + return { transport: 'stdio' }; + } + + if (transport !== 'http') { + throw new Error(`MCP_TRANSPORT must be "stdio" or "http" (received "${transport}")`); + } + + const authToken = env.MCP_HTTP_AUTH_TOKEN?.trim(); + if (!authToken) { + throw new Error( + 'MCP_HTTP_AUTH_TOKEN is required when MCP_TRANSPORT=http; refusing to start an unauthenticated remote MCP server' + ); + } + + const rawPort = env.MCP_HTTP_PORT?.trim(); + + return { + transport: 'http', + host: env.MCP_HTTP_HOST?.trim() || DEFAULT_MCP_HTTP_HOST, + port: rawPort ? parsePort(rawPort) : DEFAULT_MCP_HTTP_PORT, + path: normalizeMcpHttpPath(env.MCP_HTTP_PATH ?? DEFAULT_MCP_HTTP_PATH), + authToken, + }; +} diff --git a/src/mcp/ledger.ts b/src/mcp/ledger.ts new file mode 100644 index 00000000..cbf64308 --- /dev/null +++ b/src/mcp/ledger.ts @@ -0,0 +1,429 @@ +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 { parseUsdToNanodollars } from './budget.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. The default + * of an empty list therefore labels every series `unknown`; pass the server's + * allow-listed host set to get per-host series. The durable record always keeps + * the real host regardless of this list. + */ + metricHosts?: readonly string[]; + /** Networks allowed to appear as the `network` label. Anything else is `unknown`. */ + metricNetworks?: readonly string[]; + now?: () => Date; +} + +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'); +} + +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 ?? []; + 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. This is what a cumulative ceiling reads + * instead of guessing from a per-call maximum: `settledNanodollars` is what the + * ledger saw settle, and `possibleNanodollars` is what it cannot rule out. + */ + 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 = parseUsdToNanodollars(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 = parseUsdToNanodollars(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 }); + } + } +} + +function isUsdAmount(amount: string | null): boolean { + if (amount === null) { + return false; + } + try { + parseUsdToNanodollars(amount); + return true; + } catch { + return false; + } +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 3f69c5b1..63348801 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1,20 +1,182 @@ +import { createHash, timingSafeEqual } from 'node:crypto'; +import { + createServer, + type IncomingMessage, + type Server as HttpServer, + type ServerResponse, +} from 'node:http'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import { MCP_ERROR_CODES, McpErrorCode } from './codes.js'; +import { DEFAULT_MCP_HTTP_HOST, DEFAULT_MCP_HTTP_PORT, DEFAULT_MCP_HTTP_PATH, normalizeMcpHttpPath } from './config.js'; +import { SpendBudget } from './budget.js'; +import { PaymentLedger } from './ledger.js'; import { fetchWithTimeout } from '../fetch.js'; export interface McpServerConfig { fetchWithPayment: typeof fetch; + /** + * Cumulative spend ceiling for paid calls. Without it the per-call maximum + * only bounds one mistake, not a day of retries, so a caller in a loop can + * spend without limit. + */ + budget?: SpendBudget; + /** + * 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 interface McpHttpServerOptions { + authToken: string; + host?: string; + port?: number; + path?: string; +} + +export interface McpHttpServerHandle { + readonly server: HttpServer; + close(): Promise; +} + +const MAX_HTTP_BODY_BYTES = 1024 * 1024; + +const RPC_ERROR_CODES = { + UNAUTHORIZED: -32001, + NOT_FOUND: -32004, + METHOD_NOT_ALLOWED: -32005, + PAYLOAD_TOO_LARGE: -32006, + UNSUPPORTED_MEDIA_TYPE: -32007, + PARSE_ERROR: -32700, + INVALID_REQUEST: -32600, + INTERNAL_ERROR: -32603, +} as const; + +class HttpRequestError extends Error { + constructor( + readonly status: number, + readonly rpcCode: number, + message: string + ) { + super(message); + } +} + +function sendJsonRpcError( + res: ServerResponse, + status: number, + rpcCode: number, + message: string, + data?: Record, + headers: Record = {} +): void { + if (res.headersSent) { + res.end(); + return; + } + res.writeHead(status, { + 'content-type': 'application/json; charset=utf-8', + ...headers, + }); + res.end( + JSON.stringify({ + jsonrpc: '2.0', + error: { code: rpcCode, message, ...(data ? { data } : {}) }, + id: null, + }) + ); +} + +function hasValidBearerToken(header: string | undefined, expectedToken: string): boolean { + if (typeof header !== 'string') return false; + const match = /^Bearer[ \t]+(.+)$/i.exec(header.trim()); + if (!match) return false; + + const providedToken = match[1].trim(); + if (!providedToken) return false; + + // Hash both sides so the comparison is constant-time and does not leak the + // expected token's length. + const providedDigest = createHash('sha256').update(providedToken).digest(); + const expectedDigest = createHash('sha256').update(expectedToken).digest(); + return timingSafeEqual(providedDigest, expectedDigest); +} + +function readJsonBody(req: IncomingMessage): Promise { + const contentType = req.headers['content-type'] ?? ''; + if (!/^application\/json(?:\s*;|$)/i.test(contentType)) { + return Promise.reject( + new HttpRequestError(415, RPC_ERROR_CODES.UNSUPPORTED_MEDIA_TYPE, 'Content-Type must be application/json') + ); + } + + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let size = 0; + let tooLarge = false; + + req.on('data', (chunk: Buffer) => { + if (tooLarge) return; + size += chunk.length; + if (size > MAX_HTTP_BODY_BYTES) { + tooLarge = true; + chunks.length = 0; + return; + } + chunks.push(chunk); + }); + + req.on('end', () => { + if (tooLarge) { + reject( + new HttpRequestError( + 413, + RPC_ERROR_CODES.PAYLOAD_TOO_LARGE, + `Request body exceeds ${MAX_HTTP_BODY_BYTES} bytes` + ) + ); + return; + } + if (size === 0) { + reject(new HttpRequestError(400, RPC_ERROR_CODES.INVALID_REQUEST, 'Request body is required')); + return; + } + try { + resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); + } catch { + reject(new HttpRequestError(400, RPC_ERROR_CODES.PARSE_ERROR, 'Request body must be valid JSON')); + } + }); + + req.on('error', () => { + reject(new HttpRequestError(400, RPC_ERROR_CODES.INVALID_REQUEST, 'Failed to read request body')); + }); + }); } export class BazaarMcpServer { private server: Server; private fetchWithPayment: typeof fetch; + private budget?: SpendBudget; + /** 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; - this.server = new Server( + this.budget = config.budget; + this.paymentLedger = config.paymentLedger ?? new PaymentLedger(); + this.server = this.createProtocolServer(); + } + + /** + * A protocol server with this instance's tools registered. Streamable HTTP in + * stateless mode needs one per request — see runHttp(). + */ + private createProtocolServer(): Server { + const server = new Server( { name: 'bazaar-mcp-server', version: '1.0.0', @@ -25,12 +187,12 @@ export class BazaarMcpServer { }, } ); - - this.setupHandlers(); + this.setupHandlers(server); + return server; } - private setupHandlers() { - this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ + private setupHandlers(target: Server = this.server) { + target.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: 'bazaar_search', @@ -52,7 +214,11 @@ export class BazaarMcpServer { }, { name: 'paid_call', - description: 'Make a paid API call to a Bazaar resource, handling the 402 loop automatically.', + description: + 'Make a paid API call to a Bazaar resource, handling the 402 loop automatically. ' + + (this.budget + ? this.budget.describeLimits() + : 'No cumulative spend ceiling is configured for this server; payments are bounded per call only.'), inputSchema: { type: 'object', properties: { @@ -68,10 +234,25 @@ export class BazaarMcpServer { required: ['url'], }, }, + ...(this.budget + ? [ + { + name: 'payment_budget', + description: + "Report the payment limits this server enforces and how much of today's budget is left, " + + 'without making a paid call. Read this before spending to plan cost.', + inputSchema: { + type: 'object', + properties: {}, + required: [], + }, + }, + ] + : []), ], })); - this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + target.setRequestHandler(CallToolRequestSchema, async (request) => { if (request.params.name === 'bazaar_search') { const url = String(request.params.arguments?.url); const query = request.params.arguments?.query ? String(request.params.arguments?.query) : undefined; @@ -104,20 +285,74 @@ 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.' + ); + } + // 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 { + content: [{ type: 'text', text: JSON.stringify(reported, null, 2) }], + }; + } + if (request.params.name === 'paid_call') { const url = String(request.params.arguments?.url); const method = request.params.arguments?.method ? String(request.params.arguments?.method) : 'GET'; - + + // 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.' + ); + } + + const decision = this.budget?.authorize(); + if (decision && !decision.allowed) { + return this.createErrorResponse('ERR_BUDGET_EXCEEDED', decision.reason, { budget: decision.snapshot }); + } + const reservationId = decision?.allowed ? decision.reservationId : undefined; + try { const res = await this.fetchWithPayment(url, { method }); - + if (!res.ok) { if (res.status === 402) { + // The seller rejected the payment, so nothing settled and the budget is untouched. + this.settleReservation(reservationId, false); + 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.settleReservation(reservationId, true); + this.paymentLedger.record({ + url, + method, + outcome: 'failure', + errorCode: 'ERR_BAD_REQUEST', + response: res, + }); return this.createErrorResponse('ERR_BAD_REQUEST', `Request failed with status ${res.status}`); } - + + this.settleReservation(reservationId, true); const text = await res.text(); let parsed; try { @@ -125,11 +360,24 @@ 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 code = this.classifyPaidCallError(e); + const mayHaveSettled = !this.paymentCouldNotHaveSettled(e); + this.settleReservation(reservationId, mayHaveSettled); + // 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: code, + paymentMayHaveSettled: mayHaveSettled, + }); return this.handlePaidCallError(e); } } @@ -138,34 +386,91 @@ export class BazaarMcpServer { }); } - private handlePaidCallError(e: any) { - const msg = String(e.message || e).toLowerCase(); - + /** + * Books or drops the worst-case hold taken by authorize(). `mayHaveSettled` + * is true unless the failure proves no payment went through; anything that + * cannot be proven is counted, so the ceiling stays an upper bound. + */ + private settleReservation(reservationId: number | undefined, mayHaveSettled: boolean): void { + if (reservationId === undefined || !this.budget) { + return; + } + if (mayHaveSettled) { + this.budget.commit(reservationId); + } else { + this.budget.release(reservationId); + } + } + + /** + * True when an error proves the payment never settled: the transport failed + * before offering payment, or the wallet could not fund or submit it. A + * network failure mid-flight is not proof, and neither is an unknown error. + */ + private paymentCouldNotHaveSettled(e: any): boolean { + const msg = String(e?.message || e).toLowerCase(); + return ( + msg.includes('failed to parse payment requirements') || + msg.includes('failed to create payment payload') || + msg.includes('signatureexpirationledger') || + msg.includes('signature expired') || + msg.includes('trustline') || + msg.includes('op_no_trust') || + msg.includes('insufficient') || + msg.includes('underfunded') + ); + } + + /** + * 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) { + private createErrorResponse(code: McpErrorCode, reason: string, details?: Record) { // For MCP, returning isError: true with a JSON payload is the standard way to return tool errors. const payload = { isError: true, code, reason, + ...(details ? { details } : {}), }; return { content: [{ type: 'text', text: JSON.stringify(payload) }], @@ -177,4 +482,121 @@ export class BazaarMcpServer { const transport = new StdioServerTransport(); await this.server.connect(transport); } + + /** + * Serve the same tools over Streamable HTTP. Bindings default to loopback and + * a non-empty bearer token is mandatory: this transport exposes paid_call to + * anywhere on the network, so it must never start unauthenticated. + */ + async runHttp(options: McpHttpServerOptions): Promise { + const authToken = options.authToken?.trim(); + if (!authToken) { + throw new Error('The HTTP transport requires a non-empty auth token; refusing to start an unauthenticated MCP server'); + } + + const host = options.host?.trim() || DEFAULT_MCP_HTTP_HOST; + const port = options.port ?? DEFAULT_MCP_HTTP_PORT; + if (!Number.isInteger(port) || port < 0 || port > 65535) { + throw new Error(`MCP HTTP port must be an integer between 0 and 65535 (received "${port}")`); + } + const path = normalizeMcpHttpPath(options.path ?? DEFAULT_MCP_HTTP_PATH); + + const httpServer = createServer((req, res) => { + void this.handleHttpRequest(req, res, { authToken, path }).catch(() => { + sendJsonRpcError(res, 500, RPC_ERROR_CODES.INTERNAL_ERROR, 'Internal error'); + }); + }); + + await new Promise((resolve, reject) => { + httpServer.once('error', reject); + httpServer.listen(port, host, () => { + httpServer.off('error', reject); + resolve(); + }); + }); + + return { + server: httpServer, + close: async () => { + await new Promise((resolve, reject) => { + httpServer.close((err) => (err ? reject(err) : resolve())); + httpServer.closeIdleConnections(); + }); + }, + }; + } + + private async handleHttpRequest( + req: IncomingMessage, + res: ServerResponse, + config: { authToken: string; path: string } + ): Promise { + if (!hasValidBearerToken(req.headers.authorization, config.authToken)) { + sendJsonRpcError( + res, + 401, + RPC_ERROR_CODES.UNAUTHORIZED, + 'Missing or invalid bearer token', + { code: MCP_ERROR_CODES.ERR_UNAUTHORIZED }, + { 'www-authenticate': 'Bearer realm="lens-mcp"' } + ); + return; + } + + let pathname: string; + try { + pathname = new URL(req.url ?? '/', 'http://localhost').pathname; + } catch { + sendJsonRpcError(res, 400, RPC_ERROR_CODES.INVALID_REQUEST, 'Malformed request URL'); + return; + } + + if (pathname !== config.path) { + sendJsonRpcError(res, 404, RPC_ERROR_CODES.NOT_FOUND, 'Not found'); + return; + } + + // Stateless transport: POST is the only MCP method it can answer. GET + // (server-initiated SSE) and DELETE (session termination) are 405 by spec. + if (req.method !== 'POST') { + sendJsonRpcError( + res, + 405, + RPC_ERROR_CODES.METHOD_NOT_ALLOWED, + 'Method not allowed; send MCP requests as POST', + undefined, + { allow: 'POST' } + ); + return; + } + + let parsedBody: unknown; + try { + parsedBody = await readJsonBody(req); + } catch (e) { + if (e instanceof HttpRequestError) { + sendJsonRpcError(res, e.status, e.rpcCode, e.message); + return; + } + throw e; + } + + // A stateless Streamable HTTP transport is single-use: the SDK rejects a + // second request on the same instance because message ids from different + // clients would collide. Each request therefore gets its own protocol + // server, registered from the same tools and sharing this instance's + // budget and payment ledger, so the tools match stdio exactly. + const server = this.createProtocolServer(); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + }); + + try { + await server.connect(transport); + await transport.handleRequest(req, res, parsedBody); + } finally { + await server.close().catch(() => {}); + } + } } diff --git a/tests/mcp-http.test.ts b/tests/mcp-http.test.ts new file mode 100644 index 00000000..98cab3f5 --- /dev/null +++ b/tests/mcp-http.test.ts @@ -0,0 +1,405 @@ +import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'; +import type { AddressInfo } from 'node:net'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { BazaarMcpServer, type McpHttpServerHandle } from '../src/mcp/server'; +import { resolveMcpTransportConfig } from '../src/mcp/config'; +import { SpendBudget } from '../src/mcp/budget'; +import { MCP_ERROR_CODES } from '../src/mcp/codes'; + +const { mockFetchWithTimeout } = vi.hoisted(() => ({ + mockFetchWithTimeout: vi.fn(), +})); + +// bazaar_search fetches through src/fetch, which also pulls src/config and its +// env-dependent network resolution. Mocking it keeps this file offline and +// independent of the ambient environment. +vi.mock('../src/fetch', () => ({ + fetchWithTimeout: mockFetchWithTimeout, +})); + +/** + * A test-only shared secret. It is an opaque bearer value with no relation to + * any Stellar key, and it never leaves the process. + */ +const TEST_AUTH_TOKEN = 'lens-mcp-http-test-token-4d1f8b6e2a97'; +const PAID_URL = 'https://bazaar.example/api/v1/price'; + +const openHandles: McpHttpServerHandle[] = []; + +afterEach(async () => { + while (openHandles.length) { + await openHandles.pop()!.close(); + } +}); + +function mcpUrl(handle: McpHttpServerHandle, path = '/mcp'): string { + const address = handle.server.address() as AddressInfo; + return `http://127.0.0.1:${address.port}${path}`; +} + +async function startHttp( + fetchWithPayment: Mock = vi.fn(), + budget?: SpendBudget +): Promise { + const server = new BazaarMcpServer({ + fetchWithPayment: fetchWithPayment as unknown as typeof fetch, + ...(budget ? { budget } : {}), + }); + const handle = await server.runHttp({ + host: '127.0.0.1', + port: 0, + path: '/mcp', + authToken: TEST_AUTH_TOKEN, + }); + openHandles.push(handle); + return handle; +} + +function postJsonRpc( + handle: McpHttpServerHandle, + message: Record, + headers: Record = {} +): Promise { + return fetch(mcpUrl(handle), { + method: 'POST', + headers: { + accept: 'application/json, text/event-stream', + 'content-type': 'application/json', + ...headers, + }, + body: JSON.stringify(message), + }); +} + +function initializeMessage(id: number): Record { + return { + jsonrpc: '2.0', + id, + method: 'initialize', + params: { + protocolVersion: '2025-03-26', + capabilities: {}, + clientInfo: { name: 'lens-test', version: '1.0.0' }, + }, + }; +} + +async function connectClient(handle: McpHttpServerHandle, token = TEST_AUTH_TOKEN): Promise { + const client = new Client({ name: 'lens-http-test', version: '1.0.0' }, { capabilities: {} }); + await client.connect( + new StreamableHTTPClientTransport(new URL(mcpUrl(handle)), { + requestInit: { headers: { authorization: `Bearer ${token}` } }, + }) + ); + return client; +} + +function paidCallResponse() { + return new Response(JSON.stringify({ asset: 'XLM', price: '0.15' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); +} + +const budgetTempDirs: string[] = []; + +/** + * A budget writing to its own throwaway state file, so a parity assertion + * cannot be polluted by spend recorded under another test's file. + */ +function makeBudget(label: string): SpendBudget { + const dir = mkdtempSync(join(tmpdir(), `lens-mcp-http-${label}-`)); + budgetTempDirs.push(dir); + return new SpendBudget({ + perCallMax: '$1.00', + dailyCeiling: '$10.00', + stateFilePath: join(dir, 'spend-state.json'), + }); +} + +afterEach(() => { + while (budgetTempDirs.length) { + rmSync(budgetTempDirs.pop()!, { recursive: true, force: true }); + } +}); + +describe('MCP transport configuration', () => { + it('keeps stdio the default when nothing is configured', () => { + expect(resolveMcpTransportConfig({})).toEqual({ transport: 'stdio' }); + expect(resolveMcpTransportConfig({ MCP_TRANSPORT: 'stdio' })).toEqual({ transport: 'stdio' }); + }); + + it('selects http with loopback defaults once a key is configured', () => { + expect(resolveMcpTransportConfig({ MCP_TRANSPORT: 'http', MCP_HTTP_AUTH_TOKEN: TEST_AUTH_TOKEN })).toEqual({ + transport: 'http', + host: '127.0.0.1', + port: 3003, + path: '/mcp', + authToken: TEST_AUTH_TOKEN, + }); + }); + + it('honours host, port and path overrides', () => { + expect( + resolveMcpTransportConfig({ + MCP_TRANSPORT: 'http', + MCP_HTTP_AUTH_TOKEN: TEST_AUTH_TOKEN, + MCP_HTTP_HOST: '0.0.0.0', + MCP_HTTP_PORT: '8123', + MCP_HTTP_PATH: 'mcp/', + }) + ).toMatchObject({ host: '0.0.0.0', port: 8123, path: '/mcp' }); + }); + + it('refuses to select http without a key', () => { + expect(() => resolveMcpTransportConfig({ MCP_TRANSPORT: 'http' })).toThrow(/MCP_HTTP_AUTH_TOKEN/); + expect(() => resolveMcpTransportConfig({ MCP_TRANSPORT: 'http', MCP_HTTP_AUTH_TOKEN: ' ' })).toThrow( + /MCP_HTTP_AUTH_TOKEN/ + ); + }); + + it('rejects an unknown transport instead of silently falling back', () => { + expect(() => resolveMcpTransportConfig({ MCP_TRANSPORT: 'websockets' })).toThrow(/MCP_TRANSPORT/); + }); + + it('rejects a port outside the TCP range', () => { + const base = { MCP_TRANSPORT: 'http', MCP_HTTP_AUTH_TOKEN: TEST_AUTH_TOKEN }; + expect(() => resolveMcpTransportConfig({ ...base, MCP_HTTP_PORT: '70000' })).toThrow(/MCP_HTTP_PORT/); + expect(() => resolveMcpTransportConfig({ ...base, MCP_HTTP_PORT: 'not-a-port' })).toThrow(/MCP_HTTP_PORT/); + }); + + it('never echoes the auth token into a configuration error', () => { + let message = ''; + try { + resolveMcpTransportConfig({ + MCP_TRANSPORT: 'http', + MCP_HTTP_AUTH_TOKEN: TEST_AUTH_TOKEN, + MCP_HTTP_PORT: '70000', + }); + } catch (e: any) { + message = e.message; + } + expect(message).toContain('MCP_HTTP_PORT'); + expect(message).not.toContain(TEST_AUTH_TOKEN); + }); +}); + +describe('MCP HTTP transport authentication', () => { + it('refuses to start without a key, even when a caller asks for http', async () => { + const server = new BazaarMcpServer({ fetchWithPayment: vi.fn() as unknown as typeof fetch }); + + await expect(server.runHttp({ authToken: '' })).rejects.toThrow(/non-empty auth token/); + await expect(server.runHttp({ authToken: ' ' })).rejects.toThrow(/non-empty auth token/); + }); + + it('refuses an unauthenticated request before any tool can run', async () => { + const fetchWithPayment = vi.fn().mockImplementation(paidCallResponse); + const handle = await startHttp(fetchWithPayment); + + const res = await postJsonRpc(handle, { jsonrpc: '2.0', id: 1, method: 'tools/list' }); + + expect(res.status).toBe(401); + expect(res.headers.get('www-authenticate')).toBe('Bearer realm="lens-mcp"'); + expect(await res.json()).toMatchObject({ + jsonrpc: '2.0', + error: { + message: 'Missing or invalid bearer token', + data: { code: MCP_ERROR_CODES.ERR_UNAUTHORIZED }, + }, + }); + expect(fetchWithPayment).not.toHaveBeenCalled(); + }); + + it.each([ + ['a wrong token', `Bearer ${TEST_AUTH_TOKEN}-wrong`], + ['the token without a scheme', TEST_AUTH_TOKEN], + ['the wrong scheme', `Basic ${Buffer.from(TEST_AUTH_TOKEN).toString('base64')}`], + ])('refuses %s', async (_label, authorization) => { + const handle = await startHttp(); + + const res = await postJsonRpc(handle, initializeMessage(1), { authorization }); + + expect(res.status).toBe(401); + }); + + it('refuses a non-MCP path without revealing whether it exists', async () => { + const handle = await startHttp(); + + const unauthenticated = await fetch(mcpUrl(handle, '/admin'), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{}', + }); + const authenticated = await fetch(mcpUrl(handle, '/admin'), { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${TEST_AUTH_TOKEN}` }, + body: '{}', + }); + + expect(unauthenticated.status).toBe(401); + expect(authenticated.status).toBe(404); + }); + + it('answers GET with 405 because a stateless transport cannot hold an SSE stream', async () => { + const handle = await startHttp(); + + const res = await fetch(mcpUrl(handle), { + method: 'GET', + headers: { authorization: `Bearer ${TEST_AUTH_TOKEN}`, accept: 'text/event-stream' }, + }); + + expect(res.status).toBe(405); + expect(res.headers.get('allow')).toBe('POST'); + }); + + it('rejects a body that is not JSON with a structured parse error', async () => { + const handle = await startHttp(); + + const res = await fetch(mcpUrl(handle), { + method: 'POST', + headers: { + accept: 'application/json, text/event-stream', + 'content-type': 'application/json', + authorization: `Bearer ${TEST_AUTH_TOKEN}`, + }, + body: '{ not json', + }); + + expect(res.status).toBe(400); + expect(await res.json()).toMatchObject({ error: { code: -32700 } }); + }); + + it('rejects a non-JSON content type', async () => { + const handle = await startHttp(); + + const res = await fetch(mcpUrl(handle), { + method: 'POST', + headers: { authorization: `Bearer ${TEST_AUTH_TOKEN}`, 'content-type': 'text/plain' }, + body: 'hello', + }); + + expect(res.status).toBe(415); + }); +}); + +describe('MCP HTTP transport parity', () => { + it('exposes the same tools and the same paid_call result as a directly connected transport', async () => { + const httpFetch = vi.fn().mockImplementation(paidCallResponse); + const directFetch = vi.fn().mockImplementation(paidCallResponse); + + const handle = await startHttp(httpFetch); + const httpClient = await connectClient(handle); + const httpTools = await httpClient.listTools(); + const httpPaidCall = await httpClient.callTool({ name: 'paid_call', arguments: { url: PAID_URL } }); + await httpClient.close(); + + // InMemoryTransport stands in for stdio: the same BazaarMcpServer and the + // same SDK routing, without spawning a process to pipe stdin/stdout. + const directServer = new BazaarMcpServer({ fetchWithPayment: directFetch as unknown as typeof fetch }); + const [directClientTransport, directServerTransport] = InMemoryTransport.createLinkedPair(); + await (directServer as any).server.connect(directServerTransport); + const directClient = new Client({ name: 'lens-direct-test', version: '1.0.0' }, { capabilities: {} }); + await directClient.connect(directClientTransport); + const directTools = await directClient.listTools(); + const directPaidCall = await directClient.callTool({ name: 'paid_call', arguments: { url: PAID_URL } }); + await directClient.close(); + await (directServer as any).server.close(); + + expect(httpTools).toEqual(directTools); + 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' }); + }); + + it('advertises the budget tool over HTTP exactly as it does in-process', async () => { + // payment_budget only exists when a budget is configured, which is the + // shape the real npm run mcp:server takes. A parity check that never sets a + // budget would pass while HTTP silently dropped the conditional tool. + const httpBudget = makeBudget('http'); + const directBudget = makeBudget('direct'); + + const handle = await startHttp(vi.fn(), httpBudget); + const httpClient = await connectClient(handle); + const httpNames = (await httpClient.listTools()).tools.map((tool: any) => tool.name); + await httpClient.close(); + + const directServer = new BazaarMcpServer({ + fetchWithPayment: vi.fn() as unknown as typeof fetch, + budget: directBudget, + }); + const [directClientTransport, directServerTransport] = InMemoryTransport.createLinkedPair(); + await (directServer as any).server.connect(directServerTransport); + const directClient = new Client({ name: 'lens-budget-test', version: '1.0.0' }, { capabilities: {} }); + await directClient.connect(directClientTransport); + const directNames = (await directClient.listTools()).tools.map((tool: any) => tool.name); + await directClient.close(); + await (directServer as any).server.close(); + + expect(httpNames).toEqual(directNames); + expect(httpNames).toEqual(['bazaar_search', 'paid_call', 'payment_budget']); + }); + + it('keeps a deterministic tool error deterministic across the wire', async () => { + const handle = await startHttp( + vi.fn().mockResolvedValue(new Response('Payment Required', { status: 402 })) + ); + const client = await connectClient(handle); + + const result = await client.callTool({ name: 'paid_call', arguments: { url: PAID_URL } }); + await client.close(); + + expect(result.isError).toBe(true); + expect(JSON.parse((result.content as any)[0].text)).toMatchObject({ + isError: true, + code: MCP_ERROR_CODES.ERR_PAYMENT_REJECTED, + }); + }); + + it('refuses a real SDK client that presents no token', async () => { + const handle = await startHttp(); + const client = new Client({ name: 'lens-unauthenticated-test', version: '1.0.0' }, { capabilities: {} }); + + await expect( + client.connect(new StreamableHTTPClientTransport(new URL(mcpUrl(handle)))) + ).rejects.toThrow(); + }); + + it('serves a full initialize / tools/list handshake over POST', async () => { + const handle = await startHttp(); + + const initialized = await postJsonRpc( + handle, + initializeMessage(1), + { authorization: `Bearer ${TEST_AUTH_TOKEN}` } + ); + expect(initialized.status).toBe(200); + expect(await initialized.json()).toMatchObject({ jsonrpc: '2.0', id: 1 }); + + const listed = await postJsonRpc(handle, { jsonrpc: '2.0', id: 2, method: 'tools/list' }, { + authorization: `Bearer ${TEST_AUTH_TOKEN}`, + }); + expect(listed.status).toBe(200); + const body = await listed.json(); + expect(body.result.tools.map((tool: any) => tool.name)).toEqual(['bazaar_search', 'paid_call']); + }); + + it('answers tools/list without an initialize handshake, as the README curl does', async () => { + // Stateless mode issues no Mcp-Session-Id, so there is no session to + // initialize first. The documented one-shot curl relies on this. + const handle = await startHttp(); + + const listed = await postJsonRpc(handle, { jsonrpc: '2.0', id: 1, method: 'tools/list' }, { + authorization: `Bearer ${TEST_AUTH_TOKEN}`, + }); + + expect(listed.status).toBe(200); + const body = await listed.json(); + expect(body.result.tools.map((tool: any) => tool.name)).toEqual(['bazaar_search', 'paid_call']); + }); +}); diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index 8b979ad6..546b39e8 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -1,6 +1,89 @@ -import { describe, it, expect, vi } from 'vitest'; +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 } from '../src/mcp/server'; -import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'; +import { SpendBudget, parseUsdToNanodollars } from '../src/mcp/budget'; +import { PaymentLedger } from '../src/mcp/ledger'; +import { MCP_ERROR_CODES } from '../src/mcp/codes'; +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; + +/** + * 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 } : {}), + }); + + 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', + }), + }); +} + +function parseFailure(result: { success: boolean; error?: unknown }) { + return result.success ? '' : String(result.error); +} describe('BazaarMcpServer', () => { it('should map signature expiration ledger error to ERR_EXPIRED_AUTHORISATION', async () => { @@ -121,3 +204,787 @@ describe('BazaarMcpServer', () => { expect(parsedText.success).toBe(true); }); }); + +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', + }, + ], + }; + mockFetchWithTimeout.mockResolvedValue(jsonResponse(body)); + const { callTool, fetchWithPayment } = createHarness(); + + const response = await callTool('bazaar_search', { url: 'https://bazaar.example/' }); + + // A trailing slash on the base URL must not produce a double slash. + expect(mockFetchWithTimeout).toHaveBeenCalledWith('https://bazaar.example/discovery/resources'); + // 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%20spot' + ); + 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); + expect(JSON.parse(response.content[0].text)).toEqual({ + isError: true, + code: MCP_ERROR_CODES.ERR_NO_RESULTS, + reason: 'No resources found', + }); + }); + + 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', + }); + + 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', + }); + }); + + 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: 'Service returned 503', + }); + }); + + 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: 'fetch failed', + }); + }); +}); + +describe('paid_call upstream failures', () => { + it('maps a transport failure to ERR_SERVICE_UNREACHABLE', async () => { + const fetchWithPayment = vi.fn().mockRejectedValue(transportFailure()); + 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_SERVICE_UNREACHABLE, + reason: 'Failed to reach the service: fetch failed', + }); + }); + + 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 ERR_SERVICE_UNREACHABLE because the matcher keys on "network"', async () => { + // @x402/core throws this when no client matches the payment requirements. It + // is local misconfiguration, yet the substring "network" in the message routes + // it to the unreachable-service branch, so the agent is pointed at the remote + // service instead of its own x402 config. + 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_SERVICE_UNREACHABLE, + reason: `Failed to reach the service: ${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, whatever the transport did with it', 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: 'not a url at all' }); + + 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], + ]); + // The method the agent asked for, and the URL as it was handed over — including + // one this process cannot parse, which is recorded rather than dropped. + expect(ledger.records()[0].method).toBe('POST'); + expect(ledger.records()[3]).toMatchObject({ url: 'not a url at all', host: 'unknown' }); + // A call that threw before any response existed cannot be proved unspent. + expect(ledger.records()[3].spend).toBe('possible'); + }); + + 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); + }); +}); From f9063258ccf66b1d9d75c1a45b433f740c91cfb0 Mon Sep 17 00:00:00 2001 From: AGWAM001 <281425994+AGWAM001@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:57:58 -0700 Subject: [PATCH 2/3] feat(mcp): scrape spend metrics, label them by network The payment ledger registered mcp_payment_* counters on the shared registry, but the MCP process serves no listener that reads it, so the spend of an agent was still only recoverable from the audit file. Serve GET /metrics from the MCP HTTP transport, on the same bearer token as paid_call, and refuse to start when MCP_HTTP_PATH would put the MCP handshake on that path. The network label was always unknown in the shipped server because run.ts only passed the host allow-list. Default the network domain to the two networks the signer is configured for, overridable with MCP_METRIC_NETWORKS, so a series per network exists without opening the label to whatever a seller echoes. --- .changeset/mcp-payment-audit.md | 13 +++- .env.example | 10 +++ README.md | 38 ++++++++++ examples/mcp-server/run.ts | 44 +++++++++--- src/mcp/config.ts | 29 ++++++++ src/mcp/server.ts | 41 ++++++++++- tests/mcp-http.test.ts | 122 +++++++++++++++++++++++++++++++- 7 files changed, 282 insertions(+), 15 deletions(-) diff --git a/.changeset/mcp-payment-audit.md b/.changeset/mcp-payment-audit.md index 071473be..3508ec94 100644 --- a/.changeset/mcp-payment-audit.md +++ b/.changeset/mcp-payment-audit.md @@ -25,9 +25,18 @@ whether the seller confirmed it. `mcp_payment_attempts_total` (labels `host`, `network`, `outcome`) and `mcp_payment_amount_usd_total` (labels `host`, `network`) export the same spend to -Prometheus, on the shared registry. Labels come from `MCP_METRIC_HOSTS`: series are +Prometheus on the shared registry, which `MCP_TRANSPORT=http` now serves from the +MCP process itself at `GET /metrics` — a counter nobody can scrape is not an +export. That route answers only to the same bearer token as the tools: what an +agent bought, on which host and for how much describes a key that moves money. +`MCP_HTTP_PATH=/metrics` is refused at boot rather than resolved by precedence, so +one path never has two meanings. Under stdio there is no listener, so the running +total there is what `payment_budget` reports and what the audit file holds. + +Labels come from `MCP_METRIC_HOSTS` and `MCP_METRIC_NETWORKS` (default +`stellar:testnet,stellar:pubnet`, the networks this agent signs for): series are permanent, so labelling by whatever URL an agent asks for would let one caller mint -unbounded time series, and anything outside the allow-list is reported as `unknown`. +unbounded time series, and anything outside an allow-list is reported as `unknown`. The audit file always keeps the real host. Amounts that are not a USD money string are recorded verbatim but cannot join the USD sum, so `mcp_payment_unquantified_total` and `totalSpend().unquantifiedPayments` make that understatement visible instead of diff --git a/.env.example b/.env.example index 1d4a0fae..e3808fd4 100644 --- a/.env.example +++ b/.env.example @@ -236,6 +236,16 @@ MCP_PAYMENT_LEDGER_FILE= # 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`, which is why it is commented out rather +# than left blank. +# MCP_METRIC_NETWORKS= +# 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. # ─── Snapshot retention ──────────────────────────────────────────────────── diff --git a/README.md b/README.md index 182b4fe6..954a45fb 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,9 @@ query { ## Observability Prometheus metrics are exposed on `GET /metrics` (public, no API key required). +The MCP server is a separate process with its own `GET /metrics` on its own +port, and that one does require its bearer token — see +[MCP server](#mcp-server). Alongside the ingestion and database metrics, Lens exports the three HTTP signals needed to answer "is the API healthy": @@ -433,6 +436,40 @@ Notes for this transport: - 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. + ## Documentation Detailed system design and data flow diagrams can be found in the [Architecture Overview](docs/architecture.md). The API specification is available in [OpenAPI 3.0 format](openapi.yaml) and is auto-published to GitHub Pages at https://miracle656.github.io/lens/openapi.json. @@ -633,6 +670,7 @@ curl -s http://localhost:3002/price/XLM/USDC | jq '{price, ammPrice, lastUpdated | `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.* diff --git a/examples/mcp-server/run.ts b/examples/mcp-server/run.ts index 9a1ad426..ba3297a6 100644 --- a/examples/mcp-server/run.ts +++ b/examples/mcp-server/run.ts @@ -1,6 +1,12 @@ import 'dotenv/config'; import { BazaarMcpServer } from '../../src/mcp/server'; -import { resolveMcpTransportConfig, McpTransportConfig } from '../../src/mcp/config'; +import { + DEFAULT_MCP_METRICS_PATH, + DEFAULT_MCP_METRIC_NETWORKS, + McpTransportConfig, + parseMetricLabelAllowList, + resolveMcpTransportConfig, +} from '../../src/mcp/config'; import { SpendBudget, defaultSpendStateFile } from '../../src/mcp/budget'; import { PaymentLedger, defaultPaymentLedgerFile } from '../../src/mcp/ledger'; import { wrapFetchWithPaymentFromConfig } from '@x402/fetch'; @@ -18,11 +24,17 @@ function formatHttpUrl(host: string, port: number, path: string): string { * mint unbounded time series; anything outside this list is reported as * `unknown`. The audit file always keeps the real host regardless. */ -function parseMetricHosts(raw: string | undefined): string[] { - return (raw ?? '') - .split(',') - .map((value) => value.trim()) - .filter(Boolean); +function metricHosts(): string[] { + return parseMetricLabelAllowList(process.env.MCP_METRIC_HOSTS, []); +} + +/** Networks allowed to carry the `network` label; see DEFAULT_MCP_METRIC_NETWORKS. */ +function metricNetworks(): string[] { + return parseMetricLabelAllowList(process.env.MCP_METRIC_NETWORKS, DEFAULT_MCP_METRIC_NETWORKS); +} + +function formatLabelDomain(labels: readonly string[]): string { + return labels.length ? labels.join(', ') : 'every value is reported as unknown'; } async function main() { @@ -61,9 +73,12 @@ async function main() { // 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. const ledgerFile = process.env.MCP_PAYMENT_LEDGER_FILE || defaultPaymentLedgerFile(); + const labelHosts = metricHosts(); + const labelNetworks = metricNetworks(); const paymentLedger = new PaymentLedger({ filePath: ledgerFile, - metricHosts: parseMetricHosts(process.env.MCP_METRIC_HOSTS), + metricHosts: labelHosts, + metricNetworks: labelNetworks, }); const fetchWithPayment = wrapFetchWithPaymentFromConfig(paymentLedger.instrumentFetch(globalThis.fetch), { @@ -102,10 +117,13 @@ async function main() { // 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 metrics are labelled by host: ${ - parseMetricHosts(process.env.MCP_METRIC_HOSTS).join(', ') || 'every host is reported as unknown' - }` + `[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.' + ); + } if (transportConfig.transport === 'http') { console.error('[mcp-server] Starting Bazaar MCP server on streamable HTTP...'); @@ -115,10 +133,16 @@ async function main() { 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/src/mcp/config.ts b/src/mcp/config.ts index 2445daa8..c5a1895e 100644 --- a/src/mcp/config.ts +++ b/src/mcp/config.ts @@ -1,6 +1,17 @@ export const DEFAULT_MCP_HTTP_HOST = '127.0.0.1'; export const DEFAULT_MCP_HTTP_PORT = 3003; export const DEFAULT_MCP_HTTP_PATH = '/mcp'; +/** Where the MCP HTTP transport exposes its Prometheus registry. */ +export const DEFAULT_MCP_METRICS_PATH = '/metrics'; + +/** + * The only network ids a payment made by this server can carry, so they are the + * whole `network` label domain. Bazaar resources are paid on Stellar, and + * examples/mcp-server/run.ts builds a signer per entry in that list; a label + * taken from whatever a seller echoes in a header would mint a permanent time + * series per distinct string. + */ +export const DEFAULT_MCP_METRIC_NETWORKS: readonly string[] = ['stellar:testnet', 'stellar:pubnet']; export interface McpStdioTransportConfig { transport: 'stdio'; @@ -39,6 +50,24 @@ export function normalizeMcpHttpPath(raw: string): string { : withLeadingSlash; } +/** + * Comma-separated allow-list of the values that may carry a Prometheus label, + * such as the hosts a `host` label may name. Series are permanent, so a label + * domain must come from configuration, never from a caller's input. + * + * An unset variable takes `fallback`; setting one to empty means "allow-list + * nothing", so every series is labelled `unknown`. + */ +export function parseMetricLabelAllowList(raw: string | undefined, fallback: readonly string[]): string[] { + if (raw === undefined) { + return [...fallback]; + } + return raw + .split(',') + .map((value) => value.trim()) + .filter(Boolean); +} + export function resolveMcpTransportConfig(env: NodeJS.ProcessEnv = process.env): McpTransportConfig { const transport = (env.MCP_TRANSPORT ?? 'stdio').trim().toLowerCase(); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 63348801..f7c2eff3 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -10,9 +10,10 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import { MCP_ERROR_CODES, McpErrorCode } from './codes.js'; -import { DEFAULT_MCP_HTTP_HOST, DEFAULT_MCP_HTTP_PORT, DEFAULT_MCP_HTTP_PATH, normalizeMcpHttpPath } from './config.js'; +import { DEFAULT_MCP_HTTP_HOST, DEFAULT_MCP_HTTP_PORT, DEFAULT_MCP_HTTP_PATH, DEFAULT_MCP_METRICS_PATH, normalizeMcpHttpPath } from './config.js'; import { SpendBudget } from './budget.js'; import { PaymentLedger } from './ledger.js'; +import { getMetrics, register } from '../metrics.js'; import { fetchWithTimeout } from '../fetch.js'; export interface McpServerConfig { @@ -487,6 +488,8 @@ export class BazaarMcpServer { * Serve the same tools over Streamable HTTP. Bindings default to loopback and * a non-empty bearer token is mandatory: this transport exposes paid_call to * anywhere on the network, so it must never start unauthenticated. + * `GET /metrics` answers on the same listener and the same token, which is how + * the spend PaymentLedger records leaves this process. */ async runHttp(options: McpHttpServerOptions): Promise { const authToken = options.authToken?.trim(); @@ -500,6 +503,14 @@ export class BazaarMcpServer { throw new Error(`MCP HTTP port must be an integer between 0 and 65535 (received "${port}")`); } const path = normalizeMcpHttpPath(options.path ?? DEFAULT_MCP_HTTP_PATH); + // The registry is scraped from a fixed path on this same listener. A server + // that answers MCP on that path could not tell a scraper from a client, so + // the collision stops the process at boot rather than breaking one of them. + if (path === DEFAULT_MCP_METRICS_PATH) { + throw new Error( + `The MCP endpoint cannot be served on ${DEFAULT_MCP_METRICS_PATH}; that path is the Prometheus registry. Set MCP_HTTP_PATH to another path.` + ); + } const httpServer = createServer((req, res) => { void this.handleHttpRequest(req, res, { authToken, path }).catch(() => { @@ -552,6 +563,21 @@ export class BazaarMcpServer { } if (pathname !== config.path) { + if (pathname === DEFAULT_MCP_METRICS_PATH) { + if (req.method !== 'GET') { + sendJsonRpcError( + res, + 405, + RPC_ERROR_CODES.METHOD_NOT_ALLOWED, + 'Method not allowed; read the metrics endpoint with GET', + undefined, + { allow: 'GET' } + ); + return; + } + await this.writeMetricsResponse(res); + return; + } sendJsonRpcError(res, 404, RPC_ERROR_CODES.NOT_FOUND, 'Not found'); return; } @@ -599,4 +625,17 @@ export class BazaarMcpServer { await server.close().catch(() => {}); } } + + /** + * Renders the Prometheus registry, which is where the spend recorded by + * {@link PaymentLedger} becomes visible from outside this process. It answers + * only to the same bearer token as the tools: what an agent bought, on which + * host and for how much is operator data about a key that moves money, not a + * public endpoint. + */ + private async writeMetricsResponse(res: ServerResponse): Promise { + const body = await getMetrics(); + res.writeHead(200, { 'content-type': register.contentType }); + res.end(body); + } } diff --git a/tests/mcp-http.test.ts b/tests/mcp-http.test.ts index 98cab3f5..51da794f 100644 --- a/tests/mcp-http.test.ts +++ b/tests/mcp-http.test.ts @@ -7,8 +7,14 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; import { BazaarMcpServer, type McpHttpServerHandle } from '../src/mcp/server'; -import { resolveMcpTransportConfig } from '../src/mcp/config'; +import { + DEFAULT_MCP_METRIC_NETWORKS, + DEFAULT_MCP_METRICS_PATH, + parseMetricLabelAllowList, + resolveMcpTransportConfig, +} from '../src/mcp/config'; import { SpendBudget } from '../src/mcp/budget'; +import { PaymentLedger } from '../src/mcp/ledger'; import { MCP_ERROR_CODES } from '../src/mcp/codes'; const { mockFetchWithTimeout } = vi.hoisted(() => ({ @@ -44,11 +50,13 @@ function mcpUrl(handle: McpHttpServerHandle, path = '/mcp'): string { async function startHttp( fetchWithPayment: Mock = vi.fn(), - budget?: SpendBudget + budget?: SpendBudget, + paymentLedger?: PaymentLedger ): Promise { const server = new BazaarMcpServer({ fetchWithPayment: fetchWithPayment as unknown as typeof fetch, ...(budget ? { budget } : {}), + ...(paymentLedger ? { paymentLedger } : {}), }); const handle = await server.runHttp({ host: '127.0.0.1', @@ -173,6 +181,28 @@ describe('MCP transport configuration', () => { expect(() => resolveMcpTransportConfig({ ...base, MCP_HTTP_PORT: 'not-a-port' })).toThrow(/MCP_HTTP_PORT/); }); + describe('Prometheus label allow-lists', () => { + it('takes the default domain when the variable is unset', () => { + expect(parseMetricLabelAllowList(undefined, DEFAULT_MCP_METRIC_NETWORKS)).toEqual([ + 'stellar:testnet', + 'stellar:pubnet', + ]); + expect(parseMetricLabelAllowList(undefined, [])).toEqual([]); + }); + + it('treats an explicitly empty value as allowing nothing', () => { + expect(parseMetricLabelAllowList('', DEFAULT_MCP_METRIC_NETWORKS)).toEqual([]); + expect(parseMetricLabelAllowList(' ', DEFAULT_MCP_METRIC_NETWORKS)).toEqual([]); + }); + + it('trims entries and drops blanks', () => { + expect(parseMetricLabelAllowList(' bazaar.example ,, price.feed ', [])).toEqual([ + 'bazaar.example', + 'price.feed', + ]); + }); + }); + it('never echoes the auth token into a configuration error', () => { let message = ''; try { @@ -403,3 +433,91 @@ describe('MCP HTTP transport parity', () => { expect(body.result.tools.map((tool: any) => tool.name)).toEqual(['bazaar_search', 'paid_call']); }); }); + +/** + * The spend PaymentLedger records only becomes an operational signal once some + * process can read it. `npm run mcp:server` starts no API server, so without + * this endpoint the counters exist in a registry nothing scrapes. + */ +describe('MCP spend metrics endpoint', () => { + const SPEND_HOST = 'spend-export.test'; + const PAID_SPEND_URL = `https://${SPEND_HOST}/api/v1/price`; + + /** A seller that accepted the payment and echoed its price and network. */ + function settledResponse() { + const settlement = { success: true, network: 'stellar:testnet', amount: '$0.10' }; + return new Response(JSON.stringify({ asset: 'XLM', price: '0.15' }), { + status: 200, + headers: { + 'content-type': 'application/json', + 'payment-response': Buffer.from(JSON.stringify(settlement)).toString('base64'), + }, + }); + } + + function scrape(handle: McpHttpServerHandle, headers: Record = {}) { + return fetch(mcpUrl(handle, DEFAULT_MCP_METRICS_PATH), { headers }); + } + + it('exports settled spend as counters labelled by host and network', async () => { + const ledger = new PaymentLedger({ metricHosts: [SPEND_HOST], metricNetworks: ['stellar:testnet'] }); + const fetchWithPayment = vi + .fn() + .mockResolvedValueOnce(settledResponse()) + .mockResolvedValue(new Response('Payment Required', { status: 402 })); + const handle = await startHttp(fetchWithPayment, undefined, ledger); + const client = await connectClient(handle); + + await client.callTool({ name: 'paid_call', arguments: { url: PAID_SPEND_URL } }); + // A rejected payment is an attempt that spent nothing, and the two series + // must say exactly that: counted under outcome="failure", absent from the sum. + await client.callTool({ name: 'paid_call', arguments: { url: PAID_SPEND_URL } }); + // A host outside the allow-list may never become a series of its own. + await client.callTool({ name: 'paid_call', arguments: { url: 'https://unbounded-label-check.test/api/v1/price' } }); + await client.close(); + + const res = await scrape(handle, { authorization: `Bearer ${TEST_AUTH_TOKEN}` }); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('text/plain'); + const body = await res.text(); + + expect(body).toMatch(/mcp_payment_attempts_total\{[^}]*host="spend-export\.test"[^}]*network="stellar:testnet"[^}]*outcome="success"[^}]*\} 1/); + expect(body).toMatch(/mcp_payment_attempts_total\{[^}]*host="spend-export\.test"[^}]*outcome="failure"[^}]*\} 1/); + expect(body).toMatch(/mcp_payment_amount_usd_total\{[^}]*host="spend-export\.test"[^}]*network="stellar:testnet"[^}]*\} 0\.1/); + expect(body).not.toContain('unbounded-label-check.test'); + // The bearer token that guards this endpoint is not a label, a help string + // or anything else the registry has a reason to echo. + expect(body).not.toContain(TEST_AUTH_TOKEN); + }); + + it('refuses an unauthenticated scrape of what the agent spent', async () => { + const handle = await startHttp(); + + const res = await scrape(handle); + + expect(res.status).toBe(401); + expect(res.headers.get('www-authenticate')).toBe('Bearer realm="lens-mcp"'); + expect(await res.text()).not.toContain('mcp_payment_attempts_total'); + }); + + it('answers a POST to the metrics path with 405 instead of treating it as MCP', async () => { + const handle = await startHttp(); + + const res = await fetch(mcpUrl(handle, DEFAULT_MCP_METRICS_PATH), { + method: 'POST', + headers: { authorization: `Bearer ${TEST_AUTH_TOKEN}`, 'content-type': 'application/json' }, + body: JSON.stringify(initializeMessage(1)), + }); + + expect(res.status).toBe(405); + expect(res.headers.get('allow')).toBe('GET'); + }); + + it('refuses to start when the MCP endpoint would sit on the metrics path', async () => { + const server = new BazaarMcpServer({ fetchWithPayment: vi.fn() as unknown as typeof fetch }); + + await expect( + server.runHttp({ host: '127.0.0.1', port: 0, path: DEFAULT_MCP_METRICS_PATH, authToken: TEST_AUTH_TOKEN }) + ).rejects.toThrow(/Prometheus registry/); + }); +}); From f698b36b2fa65ccf18fc513dcb8c7b24c7d58ec9 Mon Sep 17 00:00:00 2001 From: AGWAM001 <281425994+AGWAM001@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:05:02 -0700 Subject: [PATCH 3/3] test(mcp): give the four legacy paid_call tests the shapes the real libraries throw The original four fixtures were duck-typed stubs: bare Error strings and plain-object responses. Rewire them on the actual chain: the @x402/stellar simulation guard wrapped by @x402/fetch's payment-payload frame, and real Response objects for the 402 and success paths. Drop the hand-rolled handler captures for the file's createHarness. --- tests/mcp.test.ts | 157 ++++++++++++++++++---------------------------- 1 file changed, 61 insertions(+), 96 deletions(-) diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index 546b39e8..cd8e172c 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -81,127 +81,92 @@ function transportFailure() { }); } +/** + * 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); } describe('BazaarMcpServer', () => { + const PAID_URL = 'https://bazaar.example/api/v1/price'; + 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 }); - - // 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(); - - const response = await handler({ - params: { - name: 'paid_call', - arguments: { - url: 'http://example.com/paid' - } - } - }); + const { callTool } = createHarness( + vi.fn().mockRejectedValue( + paymentPayloadFailure( + 'Transaction signature expired. Signature expiration ledger 123 is less than current ledger 125' + ) + ) + ); + + 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'); + expect(JSON.parse(response.content[0].text)).toEqual({ + isError: true, + code: MCP_ERROR_CODES.ERR_EXPIRED_AUTHORISATION, + reason: 'The transaction signature expired before it could be submitted.', + }); }); 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 }); + const { callTool } = createHarness( + vi.fn().mockRejectedValue( + paymentPayloadFailure('op_no_trust: The account does not have a trustline for this asset') + ) + ); - let handler: any = null; - (server as any).server.setRequestHandler = (schema: any, fn: any) => { - if (schema === CallToolRequestSchema) { - handler = fn; - } - }; - (server as any).setupHandlers(); - - 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: 'Missing trustline for the required asset.', + }); }); it('should map 402 rejected error to ERR_PAYMENT_REJECTED', async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: false, - status: 402, - text: async () => 'Payment Required' - }); - - const server = new BazaarMcpServer({ fetchWithPayment: mockFetch }); + // 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 })) + ); - let handler: any = null; - (server as any).server.setRequestHandler = (schema: any, fn: any) => { - if (schema === CallToolRequestSchema) { - handler = fn; - } - }; - (server as any).setupHandlers(); - - 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_PAYMENT_REJECTED'); + 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.', + }); }); - + 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" }) - }); - - const server = new BazaarMcpServer({ fetchWithPayment: mockFetch }); + const body = { success: true, data: 'hello' }; + const { callTool } = createHarness(vi.fn().mockResolvedValue(jsonResponse(body))); - let handler: any = null; - (server as any).server.setRequestHandler = (schema: any, fn: any) => { - if (schema === CallToolRequestSchema) { - handler = fn; - } - }; - (server as any).setupHandlers(); - - 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).toBeFalsy(); - const parsedText = JSON.parse(response.content[0].text); - expect(parsedText.success).toBe(true); + expect(JSON.parse(response.content[0].text)).toEqual(body); }); });