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/.env.example b/.env.example index c7cdb189..2038d6fa 100644 --- a/.env.example +++ b/.env.example @@ -271,6 +271,10 @@ FACILITATOR_SECRET_KEY= FACILITATOR_FEE_STROOPS_TESTNET=50000 FACILITATOR_FEE_STROOPS_MAINNET=50000 +# --- MCP server (`npm run mcp:server`) --- +# Stellar secret keys the MCP agent signs x402 payments with, one per network. +# Both are required to start the server. Leave these empty in a committed file — +# the mainnet key moves real money. # --- Price Plausibility Sanity Bounds --- # Lower and upper bounds for sanity-checking derived venue prices. # Derived spot prices outside this range are rejected with a log warning and metric increment. @@ -283,8 +287,62 @@ PRICE_MAX_PLAUSIBLE=10000000000 # process and fund only what you are willing to lose. MCP_AGENT_SECRET_KEY_TESTNET= MCP_AGENT_SECRET_KEY_MAINNET= -# Ceiling per paid_call, enforced by the x402 fetch wrapper. +# 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`, so the default is spelled out here rather +# than left blank for an operator copying this file. +MCP_METRIC_NETWORKS=stellar:testnet,stellar:pubnet +# With MCP_TRANSPORT=http the registry above is scrapeable from the MCP server +# itself: GET /metrics on MCP_HTTP_PORT, requiring the same MCP_HTTP_AUTH_TOKEN +# bearer token. stdio starts no listener, so there the running total is what the +# payment_budget tool reports and what the audit file holds. # Comma-separated origins (or URL prefixes) paid_call may pay for, e.g. # "https://api.lens.example,localhost:3000". An entry with no scheme matches # both http and https; an entry with a path only allows that path and what sits diff --git a/README.md b/README.md index e6b4ae56..e1411aa5 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,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 HTTP request, latency, authentication and rate-limit signals: @@ -462,6 +465,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. @@ -680,6 +828,23 @@ Callers should parse the `Retry-After` header or `retryAfter` field and apply ex | `ORACLE_PAYMENT_ADDRESS_TESTNET` / `ORACLE_PAYMENT_ADDRESS_MAINNET` | Per-network override for the address above | - | No | | `X402_FACILITATOR_URL` | x402 facilitator service URL | - | No | +### MCP server + +| Variable | Description | Default | Required | +|---|---|---|---| +| `MCP_AGENT_SECRET_KEY_TESTNET` / `MCP_AGENT_SECRET_KEY_MAINNET` | The keys `paid_call` signs payments with, per network | - | **Yes** | +| `MCP_TRANSPORT` | `stdio` or `http` | `stdio` | No | +| `MCP_HTTP_AUTH_TOKEN` | Bearer token every HTTP request must present | - | **Yes, if `MCP_TRANSPORT=http`** | +| `MCP_HTTP_HOST` | HTTP bind address | `127.0.0.1` | No | +| `MCP_HTTP_PORT` | HTTP port | `3003` | No | +| `MCP_HTTP_PATH` | MCP endpoint path | `/mcp` | No | +| `MCP_MAX_PAYMENT_PRICE` | Ceiling for a single `paid_call` | `$1.00` | No | +| `MCP_DAILY_SPEND_CEILING` | Cumulative ceiling per day, persisted across restarts | `$10.00` | No | +| `MCP_SPEND_STATE_FILE` | Where the daily running total is kept | platform default | No | +| `MCP_PAYMENT_LEDGER_FILE` | Where the payment audit trail is appended | platform default | No | +| `MCP_METRIC_HOSTS` | Hosts allowed to become a Prometheus `host` label; others report as `unknown` | - | No | +| `MCP_METRIC_NETWORKS` | Network ids allowed to become a Prometheus `network` label; others report as `unknown` | `stellar:testnet,stellar:pubnet` | No | + *\*Required if enabling x402 payment gating.* ## Stack diff --git a/examples/mcp-server/run.ts b/examples/mcp-server/run.ts index 4d3bad5d..28b864ec 100644 --- a/examples/mcp-server/run.ts +++ b/examples/mcp-server/run.ts @@ -1,13 +1,55 @@ 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 { createLensPriceQueries } from '../../src/mcp/priceQueries'; import { activeNetwork } from '../../src/config'; import { SpendBudget, defaultSpendStateFile } from '../../src/mcp/budget'; +import { PaymentLedger, defaultPaymentLedgerFile } from '../../src/mcp/ledger'; import { wrapFetchWithPaymentFromConfig } from '@x402/fetch'; 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; @@ -21,16 +63,27 @@ async function main() { const testnetClient = new ExactStellarScheme(testnetSigner); const pubnetClient = new ExactStellarScheme(pubnetSigner); - + // 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. maxPrice is not a field of the - // x402 client config, so setting it here previously bounded nothing. + // 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'; - const fetchWithPayment = wrapFetchWithPaymentFromConfig(globalThis.fetch, { + // 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, }, @@ -55,9 +108,6 @@ async function main() { stateFilePath: process.env.MCP_SPEND_STATE_FILE || defaultSpendStateFile(), }); - const server = new BazaarMcpServer({ - fetchWithPayment, - budget // 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 || '') @@ -70,20 +120,48 @@ async function main() { paidCallAllowlist, // Lens's own aggregated prices, read from the same Postgres the API serves. priceQueries: createLensPriceQueries(), - paidCallAllowlist + budget, + paymentLedger }); - console.error('[mcp-server] Starting Bazaar MCP server on stdio...'); console.error('[mcp-server] Agent configured for stellar:testnet and stellar:pubnet'); console.error(`[mcp-server] lens_get_price serves aggregated prices on ${activeNetwork}`); - // Limits and file paths only: the signing key is never logged. console.error(`[mcp-server] Spend limits: ${budget.describeLimits()}`); + // The audit file records where money went, never the key that signed it. + console.error(`[mcp-server] Payment audit trail: ${ledgerFile}`); + console.error( + `[mcp-server] Spend metric labels — host: ${formatLabelDomain(labelHosts)}; network: ${formatLabelDomain(labelNetworks)}` + ); + if (!labelHosts.length) { + console.error( + '[mcp-server] MCP_METRIC_HOSTS is empty, so every spend series is labelled host="unknown". Set it to the nodes this agent pays to get per-host spend.' + ); + } console.error( paidCallAllowlist.length > 0 ? `[mcp-server] paid_call may pay these allow-listed origins: ${paidCallAllowlist.join(', ')}` : '[mcp-server] paid_call allow-list is empty: every paid call will be refused. Set MCP_PAID_CALL_ALLOWED_HOSTS to allow hosts.' ); + if (transportConfig.transport === 'http') { + console.error('[mcp-server] Starting Bazaar MCP server on streamable HTTP...'); + const handle = await server.runHttp(transportConfig); + const address = handle.server.address(); + const host = address && typeof address === 'object' ? address.address : transportConfig.host; + const port = address && typeof address === 'object' ? address.port : transportConfig.port; + console.error(`[mcp-server] Listening at ${formatHttpUrl(host, port, transportConfig.path)}`); + console.error('[mcp-server] Bearer token authentication is required for every request'); + console.error( + `[mcp-server] Payment spend metrics: GET ${formatHttpUrl(host, port, DEFAULT_MCP_METRICS_PATH)} with the same bearer token` + ); + return; + } + + console.error('[mcp-server] Starting Bazaar MCP server on stdio...'); + console.error( + '[mcp-server] stdio serves no HTTP listener, so the spend metrics of this process cannot be scraped; read the running total with the payment_budget tool or from the audit file above.' + ); + await server.run(); } diff --git a/package-lock.json b/package-lock.json index 255f4633..9b7dc56a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -312,6 +312,479 @@ "prettier": "^2.7.1" } }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/core/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/win32-x64": { "version": "0.27.4", "cpu": [ @@ -877,15 +1350,80 @@ } } }, - "node_modules/@modelcontextprotocol/sdk/node_modules/eventsource": { - "version": "3.0.7", + "node_modules/@modelcontextprotocol/sdk/node_modules/eventsource": { + "version": "3.0.7", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", + "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", + "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", + "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "cpu": [ + "arm" + ], "license": "MIT", - "dependencies": { - "eventsource-parser": "^3.0.1" - }, - "engines": { - "node": ">=18.0.0" - } + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", + "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", + "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { "version": "3.0.3", @@ -898,6 +1436,25 @@ "win32" ] }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, "node_modules/@noble/ed25519": { "version": "3.2.0", "license": "MIT", @@ -962,64 +1519,327 @@ "url": "https://github.com/sponsors/Boshen" } }, - "node_modules/@pinojs/redact": { - "version": "0.4.0", - "license": "MIT" - }, - "node_modules/@prisma/client": { - "version": "5.22.0", - "hasInstallScript": true, - "license": "Apache-2.0", - "engines": { - "node": ">=16.13" - }, - "peerDependencies": { - "prisma": "*" + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "license": "MIT" + }, + "node_modules/@prisma/client": { + "version": "5.22.0", + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.13" + }, + "peerDependencies": { + "prisma": "*" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + } + } + }, + "node_modules/@prisma/debug": { + "version": "5.22.0", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "5.22.0", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/fetch-engine": "5.22.0", + "@prisma/get-platform": "5.22.0" + } + }, + "node_modules/@prisma/engines-version": { + "version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "5.22.0", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/get-platform": "5.22.0" + } + }, + "node_modules/@prisma/get-platform": { + "version": "5.22.0", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.16.tgz", + "integrity": "sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.16.tgz", + "integrity": "sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.16.tgz", + "integrity": "sha512-r/OmdR00HmD4i79Z//xO06uEPOq5hRXdhw7nzkxQxwSavs3PSHa1ijntdpOiZ2mzOQ3fVVu8C1M19FoNM+dMUQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.16.tgz", + "integrity": "sha512-KcRE5w8h0OnjUatG8pldyD14/CQ5Phs1oxfR+3pKDjboHRo9+MkqQaiIZlZRpsxC15paeXme/I127tUa9TXJ6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.16.tgz", + "integrity": "sha512-bT0guA1bpxEJ/ZhTRniQf7rNF8ybvXOuWbNIeLABaV5NGjx4EtOWBTSRGWFU9ZWVkPOZ+HNFP8RMcBokBiZ0Kg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.16.tgz", + "integrity": "sha512-+tHktCHWV8BDQSjemUqm/Jl/TPk3QObCTIjmdDy/nlupcujZghmKK2962LYrqFpWu+ai01AN/REOH3NEpqvYQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.16.tgz", + "integrity": "sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.16.tgz", + "integrity": "sha512-EKwI1tSrLs7YVw+JPJT/G2dJQ1jl9qlTTTEG0V2Ok/RdOenRfBw2PQdLPyjhIu58ocdBfP7vIRN/pvMsPxs/AQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.16.tgz", + "integrity": "sha512-Uknladnb3Sxqu6SEcqBldQyJUpk8NleooZEc0MbRBJ4inEhRYWZX0NJu12vNf2mqAq7gsofAxHrGghiUYjhaLQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.16.tgz", + "integrity": "sha512-FIb8+uG49sZBtLTn+zt1AJ20TqVcqWeSIyoVt0or7uAWesgKaHbiBh6OpA/k9v0LTt+PTrb1Lao133kP4uVxkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.16.tgz", + "integrity": "sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.16.tgz", + "integrity": "sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.16.tgz", + "integrity": "sha512-3Q2KQxnC8IJOLqXmUMoYwyIPZU9hzRbnHaoV3Euz+VVnjZKcY8ktnNP8T9R4/GGQtb27C/UYKABxesKWb8lsvQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.9.2", + "@emnapi/runtime": "1.9.2", + "@napi-rs/wasm-runtime": "^1.1.4" }, - "peerDependenciesMeta": { - "prisma": { - "optional": true - } + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@prisma/debug": { - "version": "5.22.0", - "devOptional": true, - "license": "Apache-2.0" - }, - "node_modules/@prisma/engines": { - "version": "5.22.0", - "devOptional": true, - "hasInstallScript": true, - "license": "Apache-2.0", + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "license": "MIT", + "optional": true, "dependencies": { - "@prisma/debug": "5.22.0", - "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", - "@prisma/fetch-engine": "5.22.0", - "@prisma/get-platform": "5.22.0" + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" } }, - "node_modules/@prisma/engines-version": { - "version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", - "devOptional": true, - "license": "Apache-2.0" - }, - "node_modules/@prisma/fetch-engine": { - "version": "5.22.0", - "devOptional": true, - "license": "Apache-2.0", + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, "dependencies": { - "@prisma/debug": "5.22.0", - "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", - "@prisma/get-platform": "5.22.0" + "tslib": "^2.4.0" } }, - "node_modules/@prisma/get-platform": { - "version": "5.22.0", - "devOptional": true, - "license": "Apache-2.0", - "dependencies": { - "@prisma/debug": "5.22.0" + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.16.tgz", + "integrity": "sha512-tj7XRemQcOcFwv7qhpUxMTBbI5mWMlE4c1Omhg5+h8GuLXzyj8HviYgR+bB2DMDgRqUE+jiDleqSCRjx4aYk/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-win32-x64-msvc": { @@ -1086,6 +1906,17 @@ "node": ">=18.0.0" } }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "dev": true, @@ -2598,6 +3429,20 @@ "node": ">=6 <7 || >=8" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "license": "MIT", @@ -3190,6 +4035,216 @@ "lightningcss-win32-x64-msvc": "1.32.0" } }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/lightningcss-win32-x64-msvc": { "version": "1.32.0", "cpu": [ diff --git a/src/mcp/codes.ts b/src/mcp/codes.ts index caf9261d..67324ba4 100644 --- a/src/mcp/codes.ts +++ b/src/mcp/codes.ts @@ -6,10 +6,11 @@ export const MCP_ERROR_CODES = { ERR_SERVICE_UNREACHABLE: 'ERR_SERVICE_UNREACHABLE', ERR_NO_RESULTS: 'ERR_NO_RESULTS', ERR_BAD_REQUEST: 'ERR_BAD_REQUEST', + ERR_UNAUTHORIZED: 'ERR_UNAUTHORIZED', + ERR_BUDGET_EXCEEDED: 'ERR_BUDGET_EXCEEDED', ERR_INVALID_ASSET: 'ERR_INVALID_ASSET', ERR_PAIR_NOT_WATCHED: 'ERR_PAIR_NOT_WATCHED', ERR_ASSET_ISSUER_MISMATCH: 'ERR_ASSET_ISSUER_MISMATCH', - ERR_BUDGET_EXCEEDED: 'ERR_BUDGET_EXCEEDED', // A request that succeeded upstream and was still refused: the body came back // too large to hand to an agent without consuming its whole context. Not a // reachability answer (ERR_SERVICE_UNREACHABLE) and not a refusal to ask 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 0ab584e7..9febd376 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1,5 +1,13 @@ +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, @@ -9,7 +17,10 @@ import { McpError, } from '@modelcontextprotocol/sdk/types.js'; import { MCP_ERROR_CODES, McpErrorCode, classifyPaidCallError } from './codes.js'; +import { DEFAULT_MCP_HTTP_HOST, DEFAULT_MCP_HTTP_PORT, DEFAULT_MCP_HTTP_PATH, DEFAULT_MCP_METRICS_PATH, normalizeMcpHttpPath } from './config.js'; import { SpendBudget } from './budget.js'; +import { PaymentLedger } from './ledger.js'; +import { getMetrics, register } from '../metrics.js'; import { checkPaidCallTarget, parsePaidCallAllowlist, type PaidCallAllowRule } from './paidCallAllowlist.js'; import { LENS_PRICE_TOOL_NAME, lensPriceToolDefinition, readLensPrice, type LensPriceQueries } from './priceTool.js'; import { @@ -81,6 +92,11 @@ export interface McpServerConfig { * retry loop can pay it again and again 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; /** * Origins (or URL prefixes) `paid_call` may pay for. Operator configuration, * never a tool argument: an empty or absent list — the default — means @@ -115,6 +131,133 @@ export interface McpServerConfig { resources?: LensResourceDescriptor[]; } +export interface McpHttpServerOptions { + authToken: string; + host?: string; + port?: number; + path?: string; +} + +export interface McpHttpServerHandle { + readonly server: HttpServer; + close(): Promise; +} + +const MAX_HTTP_BODY_BYTES = 1024 * 1024; + +const RPC_ERROR_CODES = { + UNAUTHORIZED: -32001, + NOT_FOUND: -32004, + METHOD_NOT_ALLOWED: -32005, + PAYLOAD_TOO_LARGE: -32006, + UNSUPPORTED_MEDIA_TYPE: -32007, + PARSE_ERROR: -32700, + INVALID_REQUEST: -32600, + INTERNAL_ERROR: -32603, +} as const; + +class HttpRequestError extends Error { + constructor( + readonly status: number, + readonly rpcCode: number, + message: string + ) { + super(message); + } +} + +function sendJsonRpcError( + res: ServerResponse, + status: number, + rpcCode: number, + message: string, + data?: Record, + headers: Record = {} +): void { + if (res.headersSent) { + res.end(); + return; + } + res.writeHead(status, { + 'content-type': 'application/json; charset=utf-8', + ...headers, + }); + res.end( + JSON.stringify({ + jsonrpc: '2.0', + error: { code: rpcCode, message, ...(data ? { data } : {}) }, + id: null, + }) + ); +} + +function hasValidBearerToken(header: string | undefined, expectedToken: string): boolean { + if (typeof header !== 'string') return false; + const match = /^Bearer[ \t]+(.+)$/i.exec(header.trim()); + if (!match) return false; + + const providedToken = match[1].trim(); + if (!providedToken) return false; + + // Hash both sides so the comparison is constant-time and does not leak the + // expected token's length. + const providedDigest = createHash('sha256').update(providedToken).digest(); + const expectedDigest = createHash('sha256').update(expectedToken).digest(); + return timingSafeEqual(providedDigest, expectedDigest); +} + +function readJsonBody(req: IncomingMessage): Promise { + const contentType = req.headers['content-type'] ?? ''; + if (!/^application\/json(?:\s*;|$)/i.test(contentType)) { + return Promise.reject( + new HttpRequestError(415, RPC_ERROR_CODES.UNSUPPORTED_MEDIA_TYPE, 'Content-Type must be application/json') + ); + } + + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let size = 0; + let tooLarge = false; + + req.on('data', (chunk: Buffer) => { + if (tooLarge) return; + size += chunk.length; + if (size > MAX_HTTP_BODY_BYTES) { + tooLarge = true; + chunks.length = 0; + return; + } + chunks.push(chunk); + }); + + req.on('end', () => { + if (tooLarge) { + reject( + new HttpRequestError( + 413, + RPC_ERROR_CODES.PAYLOAD_TOO_LARGE, + `Request body exceeds ${MAX_HTTP_BODY_BYTES} bytes` + ) + ); + return; + } + if (size === 0) { + reject(new HttpRequestError(400, RPC_ERROR_CODES.INVALID_REQUEST, 'Request body is required')); + return; + } + try { + resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); + } catch { + reject(new HttpRequestError(400, RPC_ERROR_CODES.PARSE_ERROR, 'Request body must be valid JSON')); + } + }); + + req.on('error', () => { + reject(new HttpRequestError(400, RPC_ERROR_CODES.INVALID_REQUEST, 'Failed to read request body')); + }); + }); +} + export class BazaarMcpServer { private server: Server; private fetchWithPayment: typeof fetch; @@ -122,6 +265,8 @@ export class BazaarMcpServer { private bazaarFetchOptions: SsrfFetchOptions; private priceQueries?: LensPriceQueries; private budget?: SpendBudget; + /** The audit trail this server writes to. Public so embedders can read spend without parsing the file. */ + readonly paymentLedger: PaymentLedger; private limits: McpLimits; private resources: LensResourceDescriptor[]; @@ -129,6 +274,7 @@ export class BazaarMcpServer { this.fetchWithPayment = config_.fetchWithPayment; this.priceQueries = config_.priceQueries; this.budget = config_.budget; + this.paymentLedger = config_.paymentLedger ?? new PaymentLedger(); this.limits = { ...config.mcp, ...config_.limits }; // Two independent gates, deliberately separate: this one decides which @@ -148,7 +294,15 @@ export class BazaarMcpServer { }; this.resources = config_.resources ?? defaultResources({ serverName: SERVER_NAME, serverVersion: SERVER_VERSION }); - this.server = new Server( + this.server = this.createProtocolServer(); + } + + /** + * A protocol server with this instance's tools registered. Streamable HTTP in + * stateless mode needs one per request — see runHttp(). + */ + private createProtocolServer(): Server { + const server = new Server( { name: SERVER_NAME, version: SERVER_VERSION, @@ -160,12 +314,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 () => { const tools: Record[] = [ { name: 'bazaar_search', @@ -213,7 +367,7 @@ export class BazaarMcpServer { // Listing resolves each resource rather than returning a static catalogue, // so the freshness it reports is real. These payloads are small and cheap // to produce; a stale or invented `lastModified` would be worse than none. - this.server.setRequestHandler(ListResourcesRequestSchema, async () => ({ + target.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: this.resources.map((descriptor) => { const snapshot = snapshotResource(descriptor); return { @@ -233,7 +387,7 @@ export class BazaarMcpServer { }), })); - this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => { + target.setRequestHandler(ReadResourceRequestSchema, async (request) => { const uri = request.params.uri; const descriptor = this.resources.find((d) => d.uri === uri); // A refusal to read is a protocol error, not a tool result: unlike a tool @@ -273,7 +427,7 @@ export class BazaarMcpServer { }; }); - this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + target.setRequestHandler(CallToolRequestSchema, async (request) => { if (request.params.name === LENS_PRICE_TOOL_NAME) { if (!this.priceQueries) { return this.createErrorResponse( @@ -367,7 +521,7 @@ export class BazaarMcpServer { return this.createErrorResponse('ERR_SERVICE_UNREACHABLE', `Upstream ${upstream} returned a body that is not valid JSON`); } - const hasResources = (d: unknown): d is { resources: unknown[] } => + const hasResources = (d: unknown): d is { resources: unknown[] } => typeof d === 'object' && d !== null && 'resources' in d && Array.isArray((d as Record).resources); if ((Array.isArray(data) && data.length === 0) || (hasResources(data) && data.resources.length === 0)) { @@ -399,9 +553,19 @@ export class BazaarMcpServer { if (request.params.name === 'payment_budget') { if (!this.budget) { - return this.createErrorResponse('ERR_BAD_REQUEST', 'This server has no spend budget configured.'); + return this.createErrorResponse( + 'ERR_BAD_REQUEST', + 'This server has no spend budget configured.' + ); } - return this.createBoundedResult(this.budget.snapshot(), { + // recordedSpend is what the ledger actually saw, as distinct from the + // budget's worst-case reservations: the ceiling holds per-call maxima, + // this is the priced offer of each attempt that reached the transport. + const reported = { + ...this.budget.snapshot(), + recordedSpend: this.paymentLedger.totalSpend(), + }; + return this.createBoundedResult(reported, { maxBytes: this.limits.maxResultBytes, maxItems: Number.MAX_SAFE_INTEGER, offset: 0, @@ -418,16 +582,27 @@ export class BazaarMcpServer { const { url } = parsed.data; const method = parsed.data.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); - if (!target.allowed) { - return this.createErrorResponse(target.code, target.reason); + const paidTarget = checkPaidCallTarget(url, this.paidCallRules); + if (!paidTarget.allowed) { + return this.createErrorResponse(paidTarget.code, paidTarget.reason); } try { @@ -437,15 +612,30 @@ export class BazaarMcpServer { 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 body = await readBodyCapped(res, this.limits.maxUpstreamBytes); + this.paymentLedger.record({ url, method, outcome: 'success', response: res }); const budget: ResultBudget = { maxBytes: this.limits.maxResultBytes, @@ -474,7 +664,18 @@ export class BazaarMcpServer { return this.createBoundedResult(parsed, budget); } catch (e: any) { - this.settleReservation(reservationId, !this.paymentCouldNotHaveSettled(e)); + const code = classifyPaidCallError(e).code; + 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); } } @@ -585,4 +786,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, + httpConfig: { authToken: string; path: string } + ): Promise { + if (!hasValidBearerToken(req.headers.authorization, httpConfig.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 !== httpConfig.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..bfdd969d --- /dev/null +++ b/tests/mcp-http.test.ts @@ -0,0 +1,533 @@ +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'; +// paid_call is gated by the operator allow-list (#404); these are the only +// hosts the paid calls below are allowed to reach. +const ALLOWED_PAID_HOSTS = ['bazaar.example', 'spend-export.test']; + +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 } : {}), + paidCallAllowlist: ALLOWED_PAID_HOSTS, + }); + 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, + paidCallAllowlist: ALLOWED_PAID_HOSTS, + }); + 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', + signal: expect.any(AbortSignal), + }); + }); + + 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 5a029272..3b36f971 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -1,9 +1,126 @@ -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, SERVER_VERSION, SERVER_NAME } from '../src/mcp/server'; -import { CallToolRequestSchema, ListToolsRequestSchema, ToolSchema } 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 { PAID_CALL_HTTP_METHODS } from '../src/mcp/toolSchemas'; -import { readFileSync } from 'fs'; -import { join } from 'path'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + ListToolsResultSchema, + ToolSchema, +} from '@modelcontextprotocol/sdk/types.js'; + +const { mockFetchWithTimeout } = vi.hoisted(() => ({ + mockFetchWithTimeout: vi.fn(), +})); + +// bazaar_search fetches through src/fetch's timeout wrapper rather than the +// injected payment fetch, so the module has to be mocked to keep discovery off +// the network. Mocking it also keeps src/config (and with it @stellar/stellar-sdk +// and the env-dependent network resolution) out of this file's import graph. +vi.mock('../src/fetch', () => ({ + fetchWithTimeout: mockFetchWithTimeout, +})); + +type CapturedHandler = (request: any) => Promise; + +/** + * Hosts this file's fixtures point at. The merged server gates paid_call on an + * operator allow-list (#404) and bazaar_search on the SSRF policy (#407), so + * the harness stands in for that operator configuration: every fixture host is + * payable and resolves to a TEST-NET-3 address that is not blocked. + */ +const HARNESS_HOSTS = [ + 'bazaar.example', + 'ledger-audit.test', + 'ledger-offer.test', + 'ledger-sum.test', + 'ledger-metrics.test', + 'not-allow-listed.test', +]; + +/** + * The SDK Server only routes a request once a transport is connected, so the + * handlers are captured by swapping setRequestHandler for a recorder and + * re-running setupHandlers() — the same approach the tests below already use. + */ +function createHarness( + fetchWithPayment: Mock = vi.fn(), + budget?: SpendBudget, + paymentLedger?: PaymentLedger +) { + const server = new BazaarMcpServer({ + fetchWithPayment: fetchWithPayment as unknown as typeof fetch, + ...(budget ? { budget } : {}), + ...(paymentLedger ? { paymentLedger } : {}), + paidCallAllowlist: HARNESS_HOSTS, + bazaarFetch: { + allowedHosts: HARNESS_HOSTS, + lookup: async () => ['93.184.216.34'], + }, + }); + + const handlers = new Map(); + (server as any).server.setRequestHandler = (schema: unknown, fn: CapturedHandler) => { + handlers.set(schema, fn); + }; + (server as any).setupHandlers(); + + const callTool = handlers.get(CallToolRequestSchema); + const listTools = handlers.get(ListToolsRequestSchema); + if (!callTool || !listTools) { + throw new Error('BazaarMcpServer did not register both a ListTools and a CallTool handler'); + } + + return { + fetchWithPayment, + ledger: server.paymentLedger, + listTools: () => listTools({}), + callTool: (name: string, args?: Record) => + callTool({ params: { name, arguments: args } }), + }; +} + +function jsonResponse(body: unknown) { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); +} + +/** undici's real shape for a connection-level failure. */ +function transportFailure() { + return new TypeError('fetch failed', { + cause: Object.assign(new Error('connect ECONNREFUSED 203.0.113.10:443'), { + code: 'ECONNREFUSED', + }), + }); +} + +/** + * The exact chain fetchWithPayment runs when the wallet cannot build the + * payment: ExactStellarScheme.createPaymentPayload simulates the transfer and + * handleSimulationResult throws + * "Stellar simulation failed with error message: " + * (node_modules/@x402/stellar/dist/esm/chunk-JRF5X5WU.mjs:26), and @x402/fetch + * rethrows any payload error as "Failed to create payment payload: " + * (node_modules/@x402/fetch/dist/esm/index.mjs:47). Chain the two frames so the + * classifier sees what it really sees; simulation.error itself is network + * text, carried here verbatim from the fixtures this file already used. + */ +function paymentPayloadFailure(simulationError: string) { + const simulation = new Error(`Stellar simulation failed with error message: ${simulationError}`); + return new Error(`Failed to create payment payload: ${simulation.message}`, { cause: simulation }); +} + +function parseFailure(result: { success: boolean; error?: unknown }) { + return result.success ? '' : String(result.error); +} const pkg = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf8')) as { version: string }; @@ -18,122 +135,866 @@ describe('BazaarMcpServer — server identity', () => { }); describe('BazaarMcpServer', () => { + const PAID_URL = 'https://bazaar.example/api/v1/price'; + it('should map signature expiration ledger error to ERR_EXPIRED_AUTHORISATION', async () => { - const mockFetch = vi.fn().mockRejectedValue(new Error('Transaction signature expired. Signature expiration ledger 123 is less than current ledger 125')); - const server = new BazaarMcpServer({ fetchWithPayment: mockFetch, paidCallAllowlist: ['example.com'] }); + const failure = paymentPayloadFailure( + 'Transaction signature expired. Signature expiration ledger 123 is less than current ledger 125' + ); + const { callTool } = createHarness(vi.fn().mockRejectedValue(failure)); - // The SDK server doesn't expose the router directly without a client, but we can test the handlePaidCallError method directly, - // or by overriding setRequestHandler to capture our handler. - let handler: any = null; - (server as any).server.setRequestHandler = (schema: any, fn: any) => { - if (schema === CallToolRequestSchema) { - handler = fn; - } + const response = await callTool('paid_call', { url: PAID_URL }); + + expect(response.isError).toBe(true); + // The #480 classifier carries the original message as the reason; the code + // is the contract, matched from a structured signal or the text fallback. + expect(JSON.parse(response.content[0].text)).toEqual({ + isError: true, + code: MCP_ERROR_CODES.ERR_EXPIRED_AUTHORISATION, + reason: failure.message, + }); + }); + + it('should map missing trustline error to ERR_MISSING_TRUSTLINE', async () => { + const failure = paymentPayloadFailure('op_no_trust: The account does not have a trustline for this asset'); + const { callTool } = createHarness(vi.fn().mockRejectedValue(failure)); + + const response = await callTool('paid_call', { url: PAID_URL }); + + expect(response.isError).toBe(true); + expect(JSON.parse(response.content[0].text)).toEqual({ + isError: true, + code: MCP_ERROR_CODES.ERR_MISSING_TRUSTLINE, + reason: failure.message, + }); + }); + + 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); + expect(JSON.parse(response.content[0].text)).toEqual({ + isError: true, + code: MCP_ERROR_CODES.ERR_PAYMENT_REJECTED, + reason: 'Payment required but was rejected or failed.', + }); + }); + + it('should return successfully on a successful call', async () => { + const 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', + }, + ], }; - - // Re-setup handlers to capture the handler - (server as any).setupHandlers(); + mockFetchWithTimeout.mockResolvedValue(jsonResponse(body)); + const { callTool, fetchWithPayment } = createHarness(); - const response = await handler({ - params: { - name: 'paid_call', - arguments: { - url: 'http://example.com/paid' - } + const response = await callTool('bazaar_search', { url: 'https://bazaar.example/' }); + + // A trailing slash on the base URL must not produce a double slash. + // Discovery now goes through ssrfSafeFetch, which passes a fetch init + // (manual redirect handling) alongside the URL. + expect(mockFetchWithTimeout).toHaveBeenCalledWith( + 'https://bazaar.example/discovery/resources?limit=50&offset=0', + expect.objectContaining({ redirect: 'manual' }) + ); + // Discovery must never spend: only paid_call may reach the payment signer. + expect(fetchWithPayment).not.toHaveBeenCalled(); + expect(response.isError).toBeFalsy(); + expect(JSON.parse(response.content[0].text)).toEqual(body); + }); + + it('routes a query to /discovery/search with the query URL-encoded', async () => { + const body = { + resources: [{ name: 'Mock Price Feed', url: 'https://bazaar.example/api/v1/price' }], + }; + mockFetchWithTimeout.mockResolvedValue(jsonResponse(body)); + const { callTool } = createHarness(); + + const response = await callTool('bazaar_search', { + url: 'https://bazaar.example', + query: 'XLM/USD spot', + }); + + expect(mockFetchWithTimeout).toHaveBeenCalledWith( + 'https://bazaar.example/discovery/search?q=XLM%2FUSD+spot&limit=50&offset=0', + expect.objectContaining({ redirect: 'manual' }) + ); + expect(response.isError).toBeFalsy(); + expect(JSON.parse(response.content[0].text)).toEqual(body); + }); + + it('maps an empty resources array to ERR_NO_RESULTS', async () => { + mockFetchWithTimeout.mockResolvedValue(jsonResponse({ resources: [] })); + const { callTool } = createHarness(); + + const response = await callTool('bazaar_search', { url: 'https://bazaar.example' }); + + expect(response.isError).toBe(true); + 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: 'Upstream bazaar.example returned HTTP 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: 'Upstream bazaar.example failed: fetch failed', + }); + }); +}); + +describe('paid_call upstream failures', () => { + it('maps a transport failure to an unclassified internal failure', async () => { + // The #480 classifier only sees the top-level message, and "fetch failed" + // carries no structured signal: undici hides the ECONNREFUSED in `cause`, + // which the classifier does not read. The honest verdict is unclassified. + const failure = transportFailure(); + const fetchWithPayment = vi.fn().mockRejectedValue(failure); + const { callTool } = createHarness(fetchWithPayment); + + const response = await callTool('paid_call', { url: 'https://bazaar.example/api/v1/price' }); + + expect(response.isError).toBe(true); + expect(JSON.parse(response.content[0].text)).toEqual({ + isError: true, + code: MCP_ERROR_CODES.ERR_INTERNAL_FAILURE, + reason: failure.message, + }); + }); + + it('maps an unmatched non-402 status to ERR_BAD_REQUEST', async () => { + const fetchWithPayment = vi + .fn() + .mockResolvedValue(new Response('Internal Server Error', { status: 500 })); + const { callTool } = createHarness(fetchWithPayment); + + const response = await callTool('paid_call', { url: 'https://bazaar.example/api/v1/price' }); + + expect(response.isError).toBe(true); + expect(JSON.parse(response.content[0].text)).toEqual({ + isError: true, + code: MCP_ERROR_CODES.ERR_BAD_REQUEST, + reason: 'Request failed with status 500', + }); + }); + + it('falls through to ERR_INTERNAL_FAILURE and keeps the upstream reason verbatim', async () => { + // @x402/fetch wraps whatever ExactStellarScheme threw while building the + // payment; this one is its asset-address validation. + const reason = + 'Failed to create payment payload: Invalid Stellar asset address: not-a-stellar-asset'; + const fetchWithPayment = vi.fn().mockRejectedValue(new Error(reason)); + const { callTool } = createHarness(fetchWithPayment); + + const response = await callTool('paid_call', { url: 'https://bazaar.example/api/v1/price' }); + + expect(response.isError).toBe(true); + expect(JSON.parse(response.content[0].text)).toEqual({ + isError: true, + code: MCP_ERROR_CODES.ERR_INTERNAL_FAILURE, + reason, + }); + }); + + it('reports an unregistered scheme client as unclassified, not as an unreachable service', async () => { + // @x402/core throws this when no client matches the payment requirements. + // The old substring matcher keyed on "network" in the message and blamed the + // remote service; #480 removed that match, so a local misconfiguration now + // reads as what it is — nothing structured to classify. + const message = 'No client registered for scheme: exact and network: stellar:pubnet'; + const fetchWithPayment = vi.fn().mockRejectedValue(new Error(message)); + const { callTool } = createHarness(fetchWithPayment); + + const response = await callTool('paid_call', { url: 'https://bazaar.example/api/v1/price' }); + + expect(response.isError).toBe(true); + expect(JSON.parse(response.content[0].text)).toEqual({ + isError: true, + code: MCP_ERROR_CODES.ERR_INTERNAL_FAILURE, + reason: message, + }); + }); + + it('cannot see a trustline failure that lives in the structured result codes', async () => { + // Horizon reports why a transaction failed in extras.result_codes, not in the + // message, and handlePaidCallError only reads e.message — so op_no_trust is + // lost. Plain-string fixtures hide this; the real error object does not. + const failure: any = new Error('Transaction submission failed'); + failure.response = { + status: 400, + data: { + title: 'Transaction Failed', + status: 400, + detail: 'The transaction failed when submitted to the ledger.', + extras: { + result_codes: { transaction: 'tx_failed', operations: ['op_no_trust'] }, + }, + }, + }; + const fetchWithPayment = vi.fn().mockRejectedValue(failure); + const { callTool } = createHarness(fetchWithPayment); + + const response = await callTool('paid_call', { url: 'https://bazaar.example/api/v1/price' }); + + expect(response.isError).toBe(true); + const payload = JSON.parse(response.content[0].text); + expect(payload.code).not.toBe(MCP_ERROR_CODES.ERR_MISSING_TRUSTLINE); + expect(payload).toEqual({ + isError: true, + code: MCP_ERROR_CODES.ERR_INTERNAL_FAILURE, + reason: 'Transaction submission failed', + }); + }); +}); + +describe('ListTools', () => { + it('advertises exactly bazaar_search and paid_call', async () => { + const { listTools } = createHarness(); + + const result = await listTools(); + + expect(result.tools.map((tool: any) => tool.name)).toEqual(['bazaar_search', 'paid_call']); + }); + + it('returns a result a client can parse with the SDK ListTools schema', async () => { + const { listTools } = createHarness(); + + const result = await listTools(); + const parsed = ListToolsResultSchema.safeParse(result); + + expect(parsed.success, parseFailure(parsed)).toBe(true); + }); + + it('declares a valid inputSchema for every advertised tool', async () => { + const { listTools } = createHarness(); + + const { tools } = await listTools(); + expect(tools).toHaveLength(2); + + for (const tool of tools) { + const parsed = ToolSchema.safeParse(tool); + expect(parsed.success, `${tool.name}: ${parseFailure(parsed)}`).toBe(true); + expect(tool.inputSchema.type, `${tool.name}: MCP requires an object inputSchema`).toBe( + 'object' + ); + + const declared = Object.keys(tool.inputSchema.properties ?? {}); + // Both tools read arguments.url unconditionally; an agent that omits it + // would otherwise get a request to the literal string "undefined". + expect(tool.inputSchema.required, `${tool.name}: url must be required`).toContain('url'); + for (const required of tool.inputSchema.required ?? []) { + expect(declared, `${tool.name}: "${required}" is required but undeclared`).toContain( + required + ); } + } + }); +}); + +describe('unknown tool', () => { + it('rejects instead of returning a best-effort result', async () => { + const { callTool } = createHarness(); + + // The handler throws, so the SDK surfaces this as a JSON-RPC error rather + // than a structured McpErrorResponse — the agent gets no error code. + await expect(callTool('bazaar_refund')).rejects.toThrow('Unknown tool: bazaar_refund'); + }); +}); + +describe('cumulative spend budget', () => { + const PAID_URL = 'https://bazaar.example/api/v1/price'; + + let tempDir: string | undefined; + + beforeEach(() => { + tempDir = undefined; + }); + + /** + * Each call must hand back a fresh Response: a Response body can only be read + * once, and reusing one instance would fail the second call for a reason that + * has nothing to do with the budget. + */ + function cheapPaidCall(): Mock { + return vi.fn().mockImplementation(() => Promise.resolve(jsonResponse({ price: '1.00' }))); + } + + function spendStateFile(): string { + tempDir = mkdtempSync(join(tmpdir(), 'lens-mcp-budget-')); + return join(tempDir, 'spend-state.json'); + } + + afterEach(() => { + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('refuses the call that would take the day total past the ceiling, without paying for it', async () => { + // Three calls at the $1.00 per-call maximum fill the $3.00 ceiling; each one is + // individually inside the per-call limit, which is the whole point of the issue. + const fetchWithPayment = cheapPaidCall(); + const budget = new SpendBudget({ perCallMax: '$1.00', dailyCeiling: '$3.00' }); + const { callTool } = createHarness(fetchWithPayment, budget); + + for (let call = 1; call <= 3; call++) { + const response = await callTool('paid_call', { url: PAID_URL }); + expect(response.isError, `call ${call} should still fit the ceiling`).toBeFalsy(); + } + expect(budget.snapshot().spentToday).toBe('$3.00'); + expect(budget.snapshot().remainingToday).toBe('$0.00'); + + const refusal = await callTool('paid_call', { url: PAID_URL }); + + expect(refusal.isError).toBe(true); + const payload = JSON.parse(refusal.content[0].text); + expect(payload.code).toBe(MCP_ERROR_CODES.ERR_BUDGET_EXCEEDED); + expect(payload.reason).toContain('Daily spend ceiling reached'); + // The agent has to be able to tell "out of budget" from "broken", and to see + // what is left, from the refusal alone. + expect(payload.details.budget).toMatchObject({ + currency: 'USD', + perCallMax: '$1.00', + dailyCeiling: '$3.00', + spentToday: '$3.00', + remainingToday: '$0.00', + state: 'ok', + }); + // Refusing must not spend: the ceiling gate runs before the payment fetch. + expect(fetchWithPayment).toHaveBeenCalledTimes(3); + }); + + it('refuses a call whose worst case no longer fits, instead of overspending the remainder', async () => { + const fetchWithPayment = cheapPaidCall(); + const budget = new SpendBudget({ perCallMax: '$1.00', dailyCeiling: '$2.50' }); + const { callTool } = createHarness(fetchWithPayment, budget); + + expect((await callTool('paid_call', { url: PAID_URL })).isError).toBeFalsy(); + await callTool('paid_call', { url: PAID_URL }); + expect(budget.snapshot().remainingToday).toBe('$0.50'); + + // $0.50 is left, but a call can cost up to $1.00, so the reservation cannot fit. + const refusal = await callTool('paid_call', { url: PAID_URL }); + + expect(refusal.isError).toBe(true); + expect(JSON.parse(refusal.content[0].text).code).toBe(MCP_ERROR_CODES.ERR_BUDGET_EXCEEDED); + expect(fetchWithPayment).toHaveBeenCalledTimes(2); + expect(budget.snapshot().spentToday).toBe('$2.00'); + }); + + it('resumes the day total after a restart, so a restart does not refill the budget', async () => { + const file = spendStateFile(); + const options = { perCallMax: '$1.00', dailyCeiling: '$2.00', stateFilePath: file }; + + const firstProcess = createHarness(cheapPaidCall(), new SpendBudget(options)); + await firstProcess.callTool('paid_call', { url: PAID_URL }); + await firstProcess.callTool('paid_call', { url: PAID_URL }); + + // A fresh SpendBudget is what a server restart builds: same configuration, same file. + const restarted = new SpendBudget(options); + expect(restarted.snapshot().spentToday).toBe('$2.00'); + + const fetchWithPayment = cheapPaidCall(); + const secondProcess = createHarness(fetchWithPayment, restarted); + const refusal = await secondProcess.callTool('paid_call', { url: PAID_URL }); + + expect(JSON.parse(refusal.content[0].text).code).toBe(MCP_ERROR_CODES.ERR_BUDGET_EXCEEDED); + expect(fetchWithPayment).not.toHaveBeenCalled(); + expect(JSON.parse(readFileSync(file, 'utf8'))).toMatchObject({ version: 1, spentNanodollars: '2000000000' }); + }); + + it('starts a fresh window at UTC midnight', async () => { + let current = new Date('2026-10-17T23:30:00.000Z'); + const file = spendStateFile(); + const budget = new SpendBudget({ + perCallMax: '$1.00', + dailyCeiling: '$1.00', + stateFilePath: file, + now: () => current, + }); + const { callTool } = createHarness(cheapPaidCall(), budget); + + expect((await callTool('paid_call', { url: PAID_URL })).isError).toBeFalsy(); + const sameDay = await callTool('paid_call', { url: PAID_URL }); + expect(JSON.parse(sameDay.content[0].text).code).toBe(MCP_ERROR_CODES.ERR_BUDGET_EXCEEDED); + + current = new Date('2026-10-18T00:00:00.000Z'); + + expect((await callTool('paid_call', { url: PAID_URL })).isError).toBeFalsy(); + expect(budget.snapshot()).toMatchObject({ day: '2026-10-18', spentToday: '$1.00', resetsAt: '2026-10-19T00:00:00.000Z' }); + expect(JSON.parse(readFileSync(file, 'utf8'))).toMatchObject({ day: '2026-10-18' }); + }); + + it('does not charge the budget when the seller rejects the payment', async () => { + const fetchWithPayment = vi.fn().mockResolvedValue(new Response('Payment Required', { status: 402 })); + const budget = new SpendBudget({ perCallMax: '$1.00', dailyCeiling: '$1.00' }); + const { callTool } = createHarness(fetchWithPayment, budget); + + const rejection = await callTool('paid_call', { url: PAID_URL }); + + expect(JSON.parse(rejection.content[0].text).code).toBe(MCP_ERROR_CODES.ERR_PAYMENT_REJECTED); + expect(budget.snapshot().spentToday).toBe('$0.00'); + // Rejected money never settled, so the call must be repeatable within the ceiling. + expect(budget.authorize().allowed).toBe(true); + }); + + it('treats an unreadable state file as fully spent rather than as a clean slate', async () => { + const file = spendStateFile(); + writeFileSync(file, 'not json at all', 'utf8'); + // The budget degrades while loading, so the spy has to be in place first. + const logged = vi.spyOn(console, 'error').mockImplementation(() => {}); + const fetchWithPayment = cheapPaidCall(); + const budget = new SpendBudget({ perCallMax: '$1.00', dailyCeiling: '$10.00', stateFilePath: file }); + const { callTool } = createHarness(fetchWithPayment, budget); + + const refusal = await callTool('paid_call', { url: PAID_URL }); + + expect(JSON.parse(refusal.content[0].text).code).toBe(MCP_ERROR_CODES.ERR_BUDGET_EXCEEDED); + expect(JSON.parse(refusal.content[0].text).details.budget.state).toBe('unavailable'); + expect(fetchWithPayment).not.toHaveBeenCalled(); + expect(logged).toHaveBeenCalledTimes(1); + logged.mockRestore(); + }); + + it('shows both limits in the tool description and the live budget as a tool', async () => { + const budget = new SpendBudget({ perCallMax: '$1.00', dailyCeiling: '$10.00' }); + const { listTools, callTool } = createHarness(cheapPaidCall(), budget); + + const { tools } = await listTools(); + const paidCallTool = tools.find((tool: any) => tool.name === 'paid_call'); + expect(paidCallTool.description).toContain('capped per call at $1.00'); + expect(paidCallTool.description).toContain('$10.00 per UTC day'); + expect(paidCallTool.description).toContain('ERR_BUDGET_EXCEEDED'); + + const budgetTool = tools.find((tool: any) => tool.name === 'payment_budget'); + expect(budgetTool).toBeDefined(); + expect(ToolSchema.safeParse(budgetTool).success).toBe(true); + + const reported = await callTool('payment_budget'); + expect(reported.isError).toBeFalsy(); + expect(JSON.parse(reported.content[0].text)).toMatchObject({ + currency: 'USD', + perCallMax: '$1.00', + dailyCeiling: '$10.00', + spentToday: '$0.00', + reservedToday: '$0.00', + remainingToday: '$10.00', + state: 'ok', + }); + + await callTool('paid_call', { url: PAID_URL }); + expect(JSON.parse((await callTool('payment_budget')).content[0].text).remainingToday).toBe('$9.00'); + }); + + it('refuses to start with limits that could never allow a call', () => { + expect(() => new SpendBudget({ perCallMax: '$10.00', dailyCeiling: '$1.00' })).toThrow(/exceeds dailyCeiling/); + expect(() => new SpendBudget({ perCallMax: 'free', dailyCeiling: '$1.00' })).toThrow(/Invalid USD amount/); + expect(() => new SpendBudget({ perCallMax: '-$1.00', dailyCeiling: '$10.00' })).toThrow(/Invalid USD amount/); + }); + + it('parses money strings into exact integer nanodollars', () => { + expect(parseUsdToNanodollars('$1.00')).toBe(1_000_000_000n); + expect(parseUsdToNanodollars('$0.10')).toBe(100_000_000n); + expect(parseUsdToNanodollars('0')).toBe(0n); + expect(() => parseUsdToNanodollars('$1.0000000000')).toThrow(/more than 9 decimal places/); + }); +}); + +/** + * x402 v2 payment header as the client's transport sends it: the seller's own + * requirements echoed under `accepted`, plus `payload`, the signed envelope. + * `payload` is the only thing here the ledger must never read, so it carries a + * marker no record may contain. + */ +const PAYMENT_OFFER = { + x402Version: 2, + resource: 'https://ledger-offer.test/api/v1/price', + accepted: { + scheme: 'exact', + network: 'stellar:testnet', + asset: 'native', + amount: '$0.10', + }, + payload: { txEnvelopeXdr: 'DECOY-SIGNED-ENVELOPE-MUST-NOT-BE-RECORDED' }, +}; + +/** Settlement header a resource server returns for a payment it accepted. */ +const SETTLEMENT = { + success: true, + network: 'stellar:testnet', + // A fixture, not a ledger entry: 64 hex characters written in full, never shortened. + transaction: '0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c4b5a69788796a5b4c3d2e1f0', +}; + +const ENVELOPE_MARKER = 'DECOY-SIGNED-ENVELOPE-MUST-NOT-BE-RECORDED'; + +function base64Json(value: unknown): string { + return Buffer.from(JSON.stringify(value)).toString('base64'); +} + +function settledResponse(body: unknown, settle: unknown = SETTLEMENT) { + return new Response(JSON.stringify(body), { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'payment-response': base64Json(settle), + }, + }); +} + +describe('payment audit ledger', () => { + const PAID_URL = 'https://ledger-audit.test/api/v1/price'; + + let ledgerDir: string | undefined; + + beforeEach(() => { + ledgerDir = undefined; + mockFetchWithTimeout.mockReset(); + }); + + afterEach(() => { + if (ledgerDir) { + rmSync(ledgerDir, { recursive: true, force: true }); + } + }); + + function tempLedgerFile(name: string): string { + ledgerDir = mkdtempSync(join(tmpdir(), 'lens-mcp-ledger-')); + return join(ledgerDir, name); + } + + /** + * Wires the ledger the way run.ts does: the ledger wraps the transport's fetch, + * and a stand-in for wrapFetchWithPayment adds the payment header that the real + * scheme builds. The price is only observable there — the wrapped fetch hands + * back a plain Response with no amount on it. + */ + function createPaidTransport(ledger: PaymentLedger, respond: () => Response) { + const instrumented = ledger.instrumentFetch( + vi.fn().mockImplementation(async () => respond()) as unknown as typeof fetch + ); + const transport = vi.fn(async (url: string, init?: RequestInit) => + instrumented(url, { ...init, headers: { 'payment-signature': base64Json(PAYMENT_OFFER) } }) + ); + return createHarness(transport as unknown as Mock, undefined, ledger); + } + + it('records a failed payment as an attempt', async () => { + const fetchWithPayment = vi.fn().mockResolvedValue(new Response('Payment Required', { status: 402 })); + const { callTool, ledger } = createHarness(fetchWithPayment); + + const response = await callTool('paid_call', { url: PAID_URL }); + + expect(response.isError).toBe(true); + const [record] = ledger.records(); + expect(ledger.records()).toHaveLength(1); + expect(record).toMatchObject({ + url: PAID_URL, + host: 'ledger-audit.test', + method: 'GET', + outcome: 'failure', + spend: 'none', + errorCode: MCP_ERROR_CODES.ERR_PAYMENT_REJECTED, + transactionHash: null, + }); + expect(Date.parse(record.timestamp)).not.toBeNaN(); + }); + + it('records every paid call that reaches the transport, and refuses the rest before spending', async () => { + const ledger = new PaymentLedger(); + const transport = vi + .fn() + .mockResolvedValueOnce(settledResponse({ price: '1.00' })) + .mockResolvedValueOnce(new Response('Payment Required', { status: 402 })) + .mockResolvedValueOnce(new Response('Internal Server Error', { status: 500 })) + .mockRejectedValueOnce(transportFailure()); + const { callTool } = createHarness(transport, undefined, ledger); + + await callTool('paid_call', { url: PAID_URL, method: 'POST' }); + await callTool('paid_call', { url: PAID_URL }); + await callTool('paid_call', { url: 'https://ledger-audit.test/api/v1/other' }); + await callTool('paid_call', { url: PAID_URL }); + + expect(ledger.records().map((record) => [record.outcome, record.errorCode])).toEqual([ + ['success', null], + ['failure', MCP_ERROR_CODES.ERR_PAYMENT_REJECTED], + ['failure', MCP_ERROR_CODES.ERR_BAD_REQUEST], + ['failure', MCP_ERROR_CODES.ERR_INTERNAL_FAILURE], + ]); + // The method the agent asked for, and one call that threw before any + // response existed — which cannot be proved unspent. + expect(ledger.records()[0].method).toBe('POST'); + expect(ledger.records()[3].spend).toBe('possible'); + + // A url this process cannot even parse is refused by the paid_call + // allow-list gate (#404) before a request is sent, so there is nothing + // to audit: the refusal is the answer, and the ledger stays untouched. + const refusal = await callTool('paid_call', { url: 'not a url at all' }); + expect(JSON.parse(refusal.content[0].text).code).toBe(MCP_ERROR_CODES.ERR_BAD_REQUEST); + expect(ledger.records()).toHaveLength(4); + }); + + it('records the transaction hash the seller echoes on settlement', async () => { + const ledger = new PaymentLedger(); + const { callTool } = createHarness(vi.fn().mockResolvedValue(settledResponse({ price: '1.00' })), undefined, ledger); + + await callTool('paid_call', { url: PAID_URL }); + + expect(ledger.records()[0]).toMatchObject({ + outcome: 'success', + spend: 'settled', + transactionHash: SETTLEMENT.transaction, + network: 'stellar:testnet', + }); + }); + + it('reads the price off the payment header the transport sends', async () => { + const ledger = new PaymentLedger(); + const { callTool } = createPaidTransport(ledger, () => settledResponse({ price: '1.00' })); + + const response = await callTool('paid_call', { url: 'https://ledger-offer.test/api/v1/price' }); + + expect(response.isError).toBeFalsy(); + expect(ledger.records()[0]).toMatchObject({ + amount: '$0.10', + asset: 'native', + network: 'stellar:testnet', + outcome: 'success', + // The envelope settled, so the offer price is what was spent. + spend: 'settled', + }); + }); + + it('records no secret material from the payment header', async () => { + const ledger = new PaymentLedger({ filePath: tempLedgerFile('payments.jsonl') }); + const { callTool } = createPaidTransport(ledger, () => settledResponse({ price: '1.00' })); + + await callTool('paid_call', { url: 'https://ledger-offer.test/api/v1/price' }); + + const record = JSON.stringify(ledger.records()[0]); + const file = readFileSync(ledgerDir + '/payments.jsonl', 'utf8'); + for (const stored of [record, file]) { + expect(stored).not.toContain(ENVELOPE_MARKER); + expect(stored).not.toContain('txEnvelopeXdr'); + expect(stored).not.toContain('payload'); + } + // The offer is recorded; the signature that carried it is not. + expect(record).toContain('$0.10'); + }); + + it('appends one line per attempt to the audit file, oldest first', async () => { + const filePath = tempLedgerFile('payments.jsonl'); + const ledger = new PaymentLedger({ filePath }); + const fetchWithPayment = vi + .fn() + .mockResolvedValueOnce(settledResponse({ price: '1.00' })) + .mockResolvedValueOnce(new Response('Payment Required', { status: 402 })); + const { callTool } = createHarness(fetchWithPayment, undefined, ledger); + + await callTool('paid_call', { url: PAID_URL }); + await callTool('paid_call', { url: PAID_URL }); + await callTool('bazaar_search', { url: 'https://ledger-audit.test' }); + + const lines = readFileSync(filePath, 'utf8').trimEnd().split('\n').map((line) => JSON.parse(line)); + expect(lines).toHaveLength(2); + expect(lines.map((line) => line.outcome)).toEqual(['success', 'failure']); + expect(lines[0]).toMatchObject({ transactionHash: SETTLEMENT.transaction, spend: 'settled' }); + }); + + it('refuses to spend once the audit trail cannot be written', async () => { + const ledger = new PaymentLedger({ filePath: tempLedgerFile('payments.jsonl') }); + const fetchWithPayment = vi.fn().mockResolvedValue(settledResponse({ price: '1.00' })); + const { callTool } = createHarness(fetchWithPayment, undefined, ledger); + + // Stand in for a write that failed mid-run; the ledger marks itself not durable. + ledger.durable = false; + + const refusal = await callTool('paid_call', { url: PAID_URL }); + + expect(refusal.isError).toBe(true); + expect(JSON.parse(refusal.content[0].text).code).toBe(MCP_ERROR_CODES.ERR_INTERNAL_FAILURE); + expect(fetchWithPayment).not.toHaveBeenCalled(); + }); + + it('sums settled spend and flags spend it cannot price', async () => { + const ledger = new PaymentLedger(); + const instrumented = ledger.instrumentFetch( + vi + .fn() + .mockResolvedValueOnce(settledResponse({ price: '1.00' })) + .mockResolvedValueOnce(settledResponse({ price: '1.00' })) as unknown as typeof fetch + ); + const transport = vi.fn(async (url: string, init?: RequestInit) => { + const amount = url.includes('unpriced') ? 'usdc:1000000' : '$0.10'; + const offer = { ...PAYMENT_OFFER, accepted: { ...PAYMENT_OFFER.accepted, amount } }; + return instrumented(url, { ...init, headers: { 'payment-signature': base64Json(offer) } }); + }); + const { callTool } = createHarness(transport as unknown as Mock, undefined, ledger); + + await callTool('paid_call', { url: 'https://ledger-sum.test/api/v1/price' }); + await callTool('paid_call', { url: 'https://ledger-sum.test/api/v1/unpriced' }); + + // $0.10 settled as nanodollars; the asset-prefixed offer is recorded verbatim + // but cannot join a USD total, and the ledger says so instead of quietly + // undercounting what the agent spent. + expect(ledger.totalSpend()).toEqual({ + attempts: 2, + settledNanodollars: '100000000', + possibleNanodollars: '0', + unquantifiedPayments: 1, }); - - expect(response.isError).toBe(true); - const content = JSON.parse(response.content[0].text); - expect(content.code).toBe('ERR_EXPIRED_AUTHORISATION'); + expect(ledger.records()[1].amount).toBe('usdc:1000000'); }); - it('should map missing trustline error to ERR_MISSING_TRUSTLINE', async () => { - const mockFetch = vi.fn().mockRejectedValue(new Error('op_no_trust: The account does not have a trustline for this asset')); - const server = new BazaarMcpServer({ fetchWithPayment: mockFetch, paidCallAllowlist: ['example.com'] }); + 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' })); - let handler: any = null; - (server as any).server.setRequestHandler = (schema: any, fn: any) => { - if (schema === CallToolRequestSchema) { - handler = fn; - } - }; - (server as any).setupHandlers(); + 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 response = await handler({ - params: { - name: 'paid_call', - arguments: { - url: 'http://example.com/paid' - } - } - }); + 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'); - expect(response.isError).toBe(true); - const content = JSON.parse(response.content[0].text); - expect(content.code).toBe('ERR_MISSING_TRUSTLINE'); + 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('should map 402 rejected error to ERR_PAYMENT_REJECTED', async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: false, - status: 402, - text: async () => 'Payment Required' + 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 server = new BazaarMcpServer({ fetchWithPayment: mockFetch, paidCallAllowlist: ['example.com'] }); + const { callTool } = createHarness(transport as unknown as Mock, budget, ledger); - let handler: any = null; - (server as any).server.setRequestHandler = (schema: any, fn: any) => { - if (schema === CallToolRequestSchema) { - handler = fn; - } - }; - (server as any).setupHandlers(); + await callTool('paid_call', { url: 'https://ledger-offer.test/api/v1/price' }); - const response = await handler({ - params: { - name: 'paid_call', - arguments: { - url: 'http://example.com/paid' - } - } + const reported = JSON.parse((await callTool('payment_budget')).content[0].text); + expect(reported.recordedSpend).toEqual({ + attempts: 1, + settledNanodollars: '100000000', + possibleNanodollars: '0', + unquantifiedPayments: 0, }); - - expect(response.isError).toBe(true); - const content = JSON.parse(response.content[0].text); - expect(content.code).toBe('ERR_PAYMENT_REJECTED'); + // 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('should return successfully on a successful call', async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - text: async () => JSON.stringify({ success: true, data: "hello" }) - }); - - const server = new BazaarMcpServer({ fetchWithPayment: mockFetch, 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(); + it('records nothing for discovery, which never pays', async () => { + mockFetchWithTimeout.mockResolvedValue(jsonResponse({ resources: [{ name: 'Mock Price Feed', url: PAID_URL }] })); + const { callTool, ledger } = createHarness(); - const response = await handler({ - params: { - name: 'paid_call', - arguments: { - url: 'http://example.com/paid' - } - } - }); + await callTool('bazaar_search', { url: 'https://ledger-audit.test' }); - expect(response.isError).toBeFalsy(); - const parsedText = JSON.parse(response.content[0].text); - expect(parsedText.success).toBe(true); + expect(ledger.records()).toEqual([]); + expect(ledger.totalSpend().attempts).toBe(0); }); }); @@ -610,247 +1471,3 @@ describe('classifyPaidCallError', () => { }); }); -// ── Cumulative spend budget (#405) ─────────────────────────────────────────── -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { SpendBudget, parseUsdToNanodollars } from '../src/mcp/budget'; -import { MCP_ERROR_CODES } from '../src/mcp/codes'; - - -const PAID_URL = 'http://example.com/paid'; - -type CapturedHandler = (request: any) => Promise; - -/** - * The SDK Server only routes once a transport is connected, so the handlers are - * captured by swapping setRequestHandler for a recorder and re-running - * setupHandlers(), which is what the tests above already do inline. - */ -function createBudgetHarness(fetchWithPayment: Mock, budget: SpendBudget) { - const server = new BazaarMcpServer({ - fetchWithPayment: fetchWithPayment as unknown as typeof fetch, - budget, - // The paid_call allow-list (#404) landed after this suite was written and - // is checked before the budget. Without the host named here every case - // below fails with ERR_HOST_NOT_ALLOWED and never reaches the ceiling. - paidCallAllowlist: ['example.com'], - }); - - 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 { - listTools: () => listTools({}), - callTool: (name: string, args?: Record) => callTool({ params: { name, arguments: args } }), - }; -} - -/** - * 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(new Response(JSON.stringify({ price: '1.00' }), { status: 200 })) - ); -} - -describe('cumulative spend budget', () => { - let tempDir: string | undefined; - - 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 } = createBudgetHarness(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 } = createBudgetHarness(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 = createBudgetHarness(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 = createBudgetHarness(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 } = createBudgetHarness(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 } = createBudgetHarness(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 } = createBudgetHarness(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 } = createBudgetHarness(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/); - }) -})