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..3508ec94 --- /dev/null +++ b/.changeset/mcp-payment-audit.md @@ -0,0 +1,55 @@ +--- +'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, 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 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 +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 3937aedc..81d5aea1 100644 --- a/.env.example +++ b/.env.example @@ -202,6 +202,68 @@ 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= +# 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. # --- MCP server (npm run mcp:server) --- # The agent's own x402 payer keys, used to answer a 402 challenge when paid_call # fetches a gated resource. These spend money, so keep them out of any other diff --git a/README.md b/README.md index 13334c3b..0485c02d 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,9 @@ The GraphQL schema is exported as a canonical Schema Definition Language (SDL) a ## 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": @@ -346,6 +349,151 @@ Or interactively at [http://localhost:3002/graphiql](http://localhost:3002/graph > **Note:** the current `GATED_ROUTES` map in [`src/middleware/x402.ts`](src/middleware/x402.ts) gates `/price`, `/pools`, and `/candles` only — `/graphql` is not gated. If you intend price data from GraphQL to require the same payment as REST, extend `GATED_ROUTES` (or add a per-resolver guard). +## MCP server + +`npm run mcp:server` starts the Bazaar MCP server ([src/mcp/server.ts](src/mcp/server.ts), wired up in [examples/mcp-server/run.ts](examples/mcp-server/run.ts)). It advertises: + +| Tool | What it does | Spends? | +| --- | --- | --- | +| `bazaar_search` | Reads a Bazaar base URL's `/discovery/resources` or `/discovery/search?q=` and lists what is available. | No | +| `paid_call` | Fetches an x402-gated URL and handles the `402 → sign → retry` loop. | **Yes — it signs with a real Stellar key** | +| `payment_budget` | Reports the configured limits and what is left of today's budget. | No | + +`payment_budget` appears whenever a spend budget is configured, which the example always sets. Every one of them is served over **either** transport, and the tool list, schemas and error codes are identical whichever you pick. Which transport runs is selected by configuration; **stdio is the default**, so an existing setup needs no changes. + +The server signs with a real Stellar key, so it needs `MCP_AGENT_SECRET_KEY_TESTNET` +and `MCP_AGENT_SECRET_KEY_MAINNET` in the environment — keep them in `.env` +(`run.ts` loads it with `dotenv`) rather than in a shell history line or a +client config file that gets shared. Never commit them. Optional spend +controls: `MCP_MAX_PAYMENT_PRICE` (per call, default `$1.00`), +`MCP_DAILY_SPEND_CEILING` (default `$10.00`) and `MCP_SPEND_STATE_FILE` — the +running total survives a restart, because a restart does not refund what the +previous process spent. + +### stdio (default) + +For a client that spawns the server itself as a child process — Claude +Desktop, Cursor, or the runnable [agent example](examples/mcp-server/agent.ts) +(`npm run mcp:agent`, which prints discovery and a paid call end to end). + +```bash +npm run mcp:server # reads MCP_TRANSPORT=stdio, talks JSON-RPC on stdin/stdout +``` + +Point a client at the command — the keys come from `.env`, so nothing secret +has to be written into client config. This is the same invocation +[agent.ts](examples/mcp-server/agent.ts) uses: + +```json +{ + "mcpServers": { + "lens-bazaar": { + "command": "npx", + "args": ["tsx", "examples/mcp-server/run.ts"] + } + } +} +``` + +Because the process is spawned by the client, there is no port, no token and +no remote attack surface: whoever can spawn it can spend, so keep it to +your own machine. + +### Streamable HTTP + +For a client that connects over the network instead of spawning a process — +a hosted agent, a shared dev box, a container. Set `MCP_TRANSPORT=http`: + +```bash +# MCP_AGENT_SECRET_KEY_TESTNET / MCP_AGENT_SECRET_KEY_MAINNET live in .env +export MCP_TRANSPORT=http +export MCP_HTTP_AUTH_TOKEN="$(openssl rand -hex 32)" # required, see below +export MCP_HTTP_HOST=127.0.0.1 # default +export MCP_HTTP_PORT=3003 # default +export MCP_HTTP_PATH=/mcp # default +npm run mcp:server +# [mcp-server] Listening at http://127.0.0.1:3003/mcp +``` + +**Authentication is mandatory and fails closed.** Without a non-empty +`MCP_HTTP_AUTH_TOKEN` the process exits before it builds a signer, so an +unauthenticated remote server with `paid_call` on its tool list can never come +up. Every request must then carry it as a bearer token; anything else gets a +JSON-RPC error with `ERR_UNAUTHORIZED` and is rejected before the payment path +is reached: + +```bash +curl -sS -X POST http://127.0.0.1:3003/mcp \ + -H "Authorization: Bearer $MCP_HTTP_AUTH_TOKEN" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' +``` + +A client connects the same way: + +```ts +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; + +const transport = new StreamableHTTPClientTransport(new URL('http://127.0.0.1:3003/mcp'), { + requestInit: { headers: { authorization: `Bearer ${process.env.MCP_HTTP_AUTH_TOKEN}` } }, +}); + +const client = new Client({ name: 'lens-agent', version: '1.0.0' }, { capabilities: {} }); +await client.connect(transport); +``` + +Notes for this transport: + +- It binds `127.0.0.1` unless you set `MCP_HTTP_HOST` deliberately. Do not + expose it on a routable address over plain HTTP: the bearer token and every + paid result cross the wire, so terminate TLS in a reverse proxy and forward + to loopback. +- The token is a credential — the bearer who holds it can spend. Keep it out + of URLs, logs, and shell history; rotate it if it leaks. +- Requests are stateless: no `Mcp-Session-Id` is issued and each `POST` is + served by a fresh transport, so a client cannot hold a session open across + requests. Every MCP message must be a `POST` (`GET` returns `405`, which + spec-compliant clients treat as "no server-initiated stream"). Bodies are + capped at 1 MiB. +- The daily spend ceiling is shared across everything this process serves, so + one HTTP client cannot spend past a budget another one already used up. + +#### Payment spend metrics + +What the agent spent is scrapeable from the same listener, at `GET /metrics`, +guarded by the same bearer token — it is operator data about a key that moves +money, so there is no anonymous scrape: + +```bash +curl -sS http://127.0.0.1:3003/metrics \ + -H "Authorization: Bearer $MCP_HTTP_AUTH_TOKEN" +``` + +| Metric | Type | Labels | +| --- | --- | --- | +| `mcp_payment_attempts_total` | Counter | `host`, `network`, `outcome` | +| `mcp_payment_amount_usd_total` | Counter | `host`, `network` | +| `mcp_payment_unquantified_total` | Counter | `host`, `network` | + +A refused payment is an attempt (`outcome="failure"`) and adds nothing to +`mcp_payment_amount_usd_total`. `host` may only be a value from +`MCP_METRIC_HOSTS` and `network` only one from `MCP_METRIC_NETWORKS` (default +`stellar:testnet,stellar:pubnet` — the networks this server signs for): +Prometheus series are permanent, so anything outside an allow-list is reported +as `unknown` instead of letting one caller mint a time series per URL an agent +tries. Amounts that are not a USD money string cannot join the USD sum, so they +are counted in `mcp_payment_unquantified_total` rather than dropped silently. +Per-payment detail — full URL, transaction hash, asset, error code — is in the +`MCP_PAYMENT_LEDGER_FILE` audit trail, which keeps the real host whatever the +allow-list says. + +`MCP_HTTP_PATH` cannot be `/metrics`; the process exits at boot rather than +answering MCP and Prometheus on one path. Under **stdio** there is no listener +to scrape, so read the running total from the `payment_budget` tool or the +audit file. + ## 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. @@ -531,6 +679,23 @@ 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 | +| `MCP_METRIC_NETWORKS` | Network ids allowed to become a Prometheus `network` label; others report as `unknown` | `stellar:testnet,stellar:pubnet` | No | + *\*Required if enabling x402 payment gating.* ## Stack diff --git a/examples/mcp-server/run.ts b/examples/mcp-server/run.ts index ef9da239..cb7754a2 100644 --- a/examples/mcp-server/run.ts +++ b/examples/mcp-server/run.ts @@ -1,10 +1,53 @@ import 'dotenv/config'; import { BazaarMcpServer } from '../../src/mcp/server'; +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'; 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 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() { + // 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 +61,30 @@ 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 labelHosts = metricHosts(); + const labelNetworks = metricNetworks(); + const paymentLedger = new PaymentLedger({ + filePath: ledgerFile, + metricHosts: labelHosts, + metricNetworks: labelNetworks, + }); + + const fetchWithPayment = wrapFetchWithPaymentFromConfig(paymentLedger.instrumentFetch(globalThis.fetch), { + spendControls: { + maxAmountPerPayment: maxPrice, + }, schemes: [ { network: 'stellar:testnet', @@ -33,6 +97,19 @@ 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, + budget, + paymentLedger // paid_call spends from the keys above, so the hosts it may pay for are the // operator's decision. Unset means no host is payable at all (#404). const paidCallAllowlist = (process.env.MCP_PAID_CALL_ALLOWED_HOSTS || '') @@ -45,8 +122,37 @@ async function main() { paidCallAllowlist }); - 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 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...'); + const handle = await server.runHttp(transportConfig); + const address = handle.server.address(); + const host = address && typeof address === 'object' ? address.address : transportConfig.host; + const port = address && typeof address === 'object' ? address.port : transportConfig.port; + console.error(`[mcp-server] Listening at ${formatHttpUrl(host, port, transportConfig.path)}`); + console.error('[mcp-server] Bearer token authentication is required for every request'); + console.error( + `[mcp-server] Payment spend metrics: GET ${formatHttpUrl(host, port, DEFAULT_MCP_METRICS_PATH)} with the same bearer token` + ); + return; + } + + console.error('[mcp-server] Starting Bazaar MCP server on stdio...'); + console.error( + '[mcp-server] stdio serves no HTTP listener, so the spend metrics of this process cannot be scraped; read the running total with the payment_budget tool or from the audit file above.' + ); console.error( paidCallAllowlist.length > 0 ? `[mcp-server] paid_call may pay these allow-listed origins: ${paidCallAllowlist.join(', ')}` 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 c9a76967..76ef9ff6 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', // Refused before any request was made, for two different reasons: // ERR_URL_BLOCKED the target resolves somewhere we will never fetch // (private, loopback, link-local, metadata) — #488 @@ -22,4 +24,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..c5a1895e --- /dev/null +++ b/src/mcp/config.ts @@ -0,0 +1,98 @@ +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'; +} + +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; +} + +/** + * 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(); + + 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 804b0791..bd76dba6 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1,7 +1,19 @@ +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, 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 { checkPaidCallTarget, parsePaidCallAllowlist, type PaidCallAllowRule } from './paidCallAllowlist.js'; import { fetchWithTimeout } from '../fetch.js'; import { config } from '../config.js'; @@ -10,6 +22,143 @@ import { ssrfSafeFetch, SsrfError, type SsrfFetchOptions } from '../net/ssrf.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')); + }); + }); * Origins (or URL prefixes) `paid_call` may pay for. Operator configuration, * never a tool argument: an empty or absent list — the default — means * `paid_call` pays nothing. See ./paidCallAllowlist.ts. @@ -26,6 +175,23 @@ export interface McpServerConfig { 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.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( private paidCallRules: PaidCallAllowRule[]; private bazaarFetchOptions: SsrfFetchOptions; @@ -58,12 +224,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', @@ -87,6 +253,9 @@ export class BazaarMcpServer { name: 'paid_call', 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.'), 'This tool spends real USDC from the server operator\'s key, so it only accepts urls whose ' + 'origin the operator allow-listed at startup; anything else fails with ERR_HOST_NOT_ALLOWED ' + 'before a request is sent. The allow-list is server configuration and cannot be widened by an ' + @@ -107,10 +276,25 @@ export class BazaarMcpServer { additionalProperties: false, }, }, + ...(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; @@ -146,10 +330,45 @@ 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; + // Gate before the fetch: fetchWithPayment answers a 402 by paying, so // by the time a request leaves this process the money is already spent. const target = checkPaidCallTarget(url, this.paidCallRules); @@ -159,14 +378,33 @@ export class BazaarMcpServer { 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 { @@ -174,11 +412,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); } } @@ -187,34 +438,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 this.createErrorResponse('ERR_INTERNAL_FAILURE', e.message || 'An unknown error occurred during the paid call.'); + + return 'ERR_INTERNAL_FAILURE'; } - private createErrorResponse(code: McpErrorCode, reason: string) { + 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.'); + } + } + + 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) }], @@ -226,4 +534,159 @@ 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. + * `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(); + 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); + // 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(() => { + 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) { + 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; + } + + // 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(() => {}); + } + } + + /** + * 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 new file mode 100644 index 00000000..51da794f --- /dev/null +++ b/tests/mcp-http.test.ts @@ -0,0 +1,523 @@ +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 { + 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(() => ({ + 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, + 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', + 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/); + }); + + 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 { + 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']); + }); +}); + +/** + * 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/); + }); +}); diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index 7d71be0d..c5213ae3 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -1,9 +1,119 @@ -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', + }), + }); +} + +/** + * 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 { 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 }); const mockFetch = vi.fn().mockRejectedValue(new Error('Transaction signature expired. Signature expiration ledger 123 is less than current ledger 125')); const server = new BazaarMcpServer({ fetchWithPayment: mockFetch, paidCallAllowlist: ['example.com'] }); @@ -29,37 +139,76 @@ describe('BazaarMcpServer', () => { }); 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 { callTool } = createHarness( + vi.fn().mockRejectedValue( + paymentPayloadFailure('op_no_trust: The account does not have a trustline for this asset') + ) + ); const mockFetch = vi.fn().mockRejectedValue(new Error('op_no_trust: The account does not have a trustline for this asset')); const server = new BazaarMcpServer({ fetchWithPayment: mockFetch, paidCallAllowlist: ['example.com'] }); - let handler: any = null; - (server as any).server.setRequestHandler = (schema: any, fn: any) => { - if (schema === CallToolRequestSchema) { - handler = fn; - } - }; - (server as any).setupHandlers(); + const response = await callTool('paid_call', { url: PAID_URL }); - const response = await handler({ - params: { - name: 'paid_call', - arguments: { - url: 'http://example.com/paid' - } - } + expect(response.isError).toBe(true); + 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 () => { + // A rejected payment is not an exception: wrapFetchWithPayment resolves + // with the seller's second 402 response after the payment loop fails + // (node_modules/@x402/fetch/dist/esm/index.mjs:87), so this is a real + // Response, exactly as the transport hands it over. + const { callTool } = createHarness( + vi.fn().mockResolvedValue(new Response('Payment Required', { status: 402 })) + ); + + const response = await callTool('paid_call', { url: PAID_URL }); expect(response.isError).toBe(true); - 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_PAYMENT_REJECTED, + reason: 'Payment required but was rejected or failed.', + }); }); - it('should map 402 rejected error to ERR_PAYMENT_REJECTED', async () => { + it('should return successfully on a successful call', async () => { + const body = { success: true, data: 'hello' }; + const { callTool } = createHarness(vi.fn().mockResolvedValue(jsonResponse(body))); + + const response = await callTool('paid_call', { url: PAID_URL }); + + expect(response.isError).toBeFalsy(); + expect(JSON.parse(response.content[0].text)).toEqual(body); + }); +}); + +describe('bazaar_search', () => { + beforeEach(() => { + mockFetchWithTimeout.mockReset(); + }); + + it('lists resources from /discovery/resources when no query is given', async () => { + const body = { + resources: [ + { + name: 'Mock Price Feed', + description: 'Price data for XLM', + url: 'https://bazaar.example/api/v1/price', + }, + ], const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 402, @@ -74,21 +223,202 @@ describe('BazaarMcpServer', () => { handler = fn; } }; - (server as any).setupHandlers(); + mockFetchWithTimeout.mockResolvedValue(jsonResponse(body)); + const { callTool, fetchWithPayment } = createHarness(); - const response = await handler({ - params: { - name: 'paid_call', - arguments: { - url: 'http://example.com/paid' - } - } + const response = await callTool('bazaar_search', { url: 'https://bazaar.example/' }); + + // A trailing slash on the base URL must not produce a double slash. + 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); - 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_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'] }, + }, + }, it('should return successfully on a successful call', async () => { const mockFetch = vi.fn().mockResolvedValue({ @@ -105,20 +435,575 @@ describe('BazaarMcpServer', () => { handler = fn; } }; - (server as any).setupHandlers(); + const fetchWithPayment = vi.fn().mockRejectedValue(failure); + const { callTool } = createHarness(fetchWithPayment); - const response = await handler({ - params: { - name: 'paid_call', - arguments: { - url: 'http://example.com/paid' - } + 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(); - const parsedText = JSON.parse(response.content[0].text); - expect(parsedText.success).toBe(true); + 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); }); });