Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/mcp-payment-audit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'lens': minor
---

Record what every `paid_call` cost (#406). The server spent from a live key with
no receipt: `wrapFetchWithPayment` hands back a plain `Response`, the price is
only visible on the request the transport makes, and nothing said afterwards how
much left the account. `PaymentLedger` appends one JSON record per attempt —
timestamp, full URL, host, method, price, asset, network, settlement hash,
outcome, spend, error code — to `MCP_PAYMENT_LEDGER_FILE`
(default `~/.lens-mcp/payments.jsonl`), failed and rejected calls included,
since a payment can settle on a call that errored. Each record labels its spend
`settled`, `possible` or `none` so the trail reads as an upper bound instead of
inventing certainty a seller never confirmed. Neither the secret key nor the
signed envelope is ever read out of the payment header. An audit trail that
cannot be written fails closed: `paid_call` refuses further calls rather than
spending unrecorded. The same records are exported as
`mcp_payment_attempts_total`, `mcp_payment_amount_usd_total` and
`mcp_payment_unquantified_total`, whose `host` label may only take a value from
the paid-call allow-list and whose `network` label only the two networks this
signer can use, so no caller mints time series with the URLs it tries — and
`totalSpend()` gives a cumulative ceiling the running sum to read. The stdio
server has no HTTP listener, so those series are registered but not yet
scrapeable from its process.
20 changes: 20 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,26 @@ MCP_MAX_PAYMENT_PRICE=$1.00
# refused with ERR_HOST_NOT_ALLOWED before a request is sent. This is a
# prompt-injection guard — text an agent reads must not choose where money goes.
MCP_PAID_CALL_ALLOWED_HOSTS=
# Where the audit trail of every paid call is appended, one JSON record per line:
# timestamp, full URL, host, method, price, asset, network, transaction hash,
# outcome, spend and the error code. Never the secret key, and never the signed
# payment envelope. A call that FAILED is recorded too — a payment can settle on
# a call that errored — and each record says whether the spend is confirmed
# (`settled`), merely possible (`possible`), or excluded (`none`).
# If this file cannot be created or written, paid_call refuses to run: a tool that
# moves money does not get to spend without leaving a record (fail closed). Keep it
# off ephemeral filesystems, and back it up like any other financial record
# (default: ~/.lens-mcp/payments.jsonl).
MCP_PAYMENT_LEDGER_FILE=
# The same spend is exported as Prometheus counters
# (mcp_payment_attempts_total, mcp_payment_amount_usd_total,
# mcp_payment_unquantified_total). Their host label can only ever take a value
# from MCP_PAID_CALL_ALLOWED_HOSTS above, and their network label only
# stellar:testnet / stellar:pubnet, so an agent looping over URLs cannot mint
# unbounded time series; anything else is labelled `unknown`. There is no separate
# knob for this. The audit file always keeps the real host regardless.
# Note that npm run mcp:server serves stdio only, so these series are registered
# but not scrapeable from that process until an HTTP transport exists.


# ─── Snapshot retention ────────────────────────────────────────────────────
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,12 @@ data through two tools: `bazaar_search` and `paid_call`. The server signs
x402 payment challenges with Stellar secret keys on both `stellar:testnet`
and `stellar:pubnet`.

Every paid call — failed ones included — is appended to a durable audit trail and
counted in spend metrics whose labels are bounded to the operator's allow-list, so
an operator can always answer what the agent spent and where. The trail never
holds the signing key or a signed payment, and a spend that cannot be recorded is
refused rather than made silently.

**⚠️ Warning:** the mainnet key signs with real XLM. Read the configuration,
spending warning, and worked example before running.

Expand Down
61 changes: 61 additions & 0 deletions examples/mcp-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ paid call from the mainnet account.
| `MCP_AGENT_SECRET_KEY_TESTNET` | Yes | Stellar secret key used to sign payments on `stellar:testnet` |
| `MCP_AGENT_SECRET_KEY_MAINNET` | Yes | Stellar secret key used to sign payments on `stellar:pubnet` (mainnet) — **real money** |
| `MCP_MAX_PAYMENT_PRICE` | No | Maximum price the server will pay per request (default: `$1.00`) |
| `MCP_PAYMENT_LEDGER_FILE` | No | Where the audit trail of every paid call is appended (default: `~/.lens-mcp/payments.jsonl`) |

Both keys are required: `run.ts` exits with an error if either is unset.

Expand Down Expand Up @@ -69,6 +70,7 @@ The server starts on stdio and logs:
```
[mcp-server] Starting Bazaar MCP server on stdio...
[mcp-server] Agent configured for stellar:testnet and stellar:pubnet
[mcp-server] Payment audit trail: /home/you/.lens-mcp/payments.jsonl
```

In another terminal, run the agent example, which connects an MCP client,
Expand Down Expand Up @@ -184,9 +186,68 @@ An agent is the least able caller to notice a wrong answer: the server
prefers returning a structured error over guessing a best-effort result, so
always check `isError` before acting on a tool response.

## Payment audit trail

`paid_call` moves money from a live key, so every attempt through it is
recorded — including the ones that failed, because a payment can settle on a
call that errored. One JSON object per line is appended to
`MCP_PAYMENT_LEDGER_FILE` (default `~/.lens-mcp/payments.jsonl`):

| Field | What it holds |
|---|---|
| `timestamp` | ISO-8601 time the call finished |
| `url` | The full URL as called — never shortened or masked |
| `host`, `method` | The URL's host, and the HTTP method used |
| `amount`, `asset`, `network` | The price exactly as the seller offered it, with its asset and network, or `null` |
| `transactionHash` | The settlement hash the seller echoed back, or `null` |
| `outcome` | `success` or `failure` for the HTTP call |
| `spend` | `settled` (confirmed by the seller), `possible` (a payment was offered and nothing proves it failed) or `none` |
| `errorCode` | The MCP error code the agent was shown, or `null` |

Two things are never recorded: the signing secret key, and the signed payment
envelope. The ledger reads a price, an asset and a network off the payment
header and nothing else from it, so the trail can be backed up or shared without
turning it into a key store.

`transactionHash` is `null` more often than you would hope — it appears only when
the resource server echoes its settlement back in a `payment-response` header. A
record with `spend: "possible"` and no hash means "we cannot rule out that this
paid": read the trail as an upper bound, not as a statement of what certainly
happened. `PaymentLedger.totalSpend()` returns the same accounting as integer
nanodollars in decimal strings, plus a count of payments it could not price in
USD, so you know when the sums understate spend.

**The trail is mandatory.** If the file cannot be created, or a record cannot be
appended to it, `paid_call` refuses further calls with `ERR_INTERNAL_FAILURE`
rather than spending unrecorded money. Put it somewhere durable and writable.

### Spend metrics

The same records feed three counters on the shared registry ([`src/metrics.ts`](../../src/metrics.ts)):

| Series | Labels | Counts |
|---|---|---|
| `mcp_payment_attempts_total` | `host`, `network`, `outcome` | every paid call attempted |
| `mcp_payment_amount_usd_total` | `host`, `network` | USD value of payments confirmed settled |
| `mcp_payment_unquantified_total` | `host`, `network` | payments that could not be priced in USD |

Both label sets are bounded on purpose: Prometheus series are permanent, so a
`host` label taken from whatever URL an agent names would let one caller mint
unbounded time series. `host` may only take a value from
`MCP_PAID_CALL_ALLOWED_HOSTS` — the same list that decides what can be paid at
all, so it already bounds every host that can appear in a record — and `network`
only `stellar:testnet` or `stellar:pubnet`, the two networks this server's signer
can use. Anything outside those domains is labelled `unknown`; the audit file
keeps the real host regardless.

`npm run mcp:server` is a stdio process with no HTTP listener, so these series
are registered but not scrapeable from it. Read `register` from an embedder that
does serve metrics, or wait for an HTTP transport for the MCP server.

## Related

- MCP server implementation: [`src/mcp/server.ts`](../../src/mcp/server.ts)
- Payment audit ledger: [`src/mcp/ledger.ts`](../../src/mcp/ledger.ts)
- Error codes: [`src/mcp/codes.ts`](../../src/mcp/codes.ts)
- Tests: [`tests/mcp.test.ts`](../../tests/mcp.test.ts)
- Repo README MCP section: [`README.md`](../../README.md#mcp-model-context-protocol)
33 changes: 24 additions & 9 deletions examples/mcp-server/run.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'dotenv/config';
import { BazaarMcpServer } from '../../src/mcp/server';
import { PaymentLedger, defaultPaymentLedgerFile, metricHostsFromPaidCallAllowlist } from '../../src/mcp/ledger';
import { wrapFetchWithPaymentFromConfig } from '@x402/fetch';
import { ExactStellarScheme } from '@x402/stellar/exact/client';
import { createEd25519Signer } from '@x402/stellar';
Expand All @@ -19,7 +20,25 @@ async function main() {
const testnetClient = new ExactStellarScheme(testnetSigner);
const pubnetClient = new ExactStellarScheme(pubnetSigner);

const fetchWithPayment = wrapFetchWithPaymentFromConfig(globalThis.fetch, {
// paid_call spends from the keys above, so the hosts it may pay for are the
// operator's decision. Unset means no host is payable at all (#404).
const paidCallAllowlist = (process.env.MCP_PAID_CALL_ALLOWED_HOSTS || '')
.split(',')
.map(entry => entry.trim())
.filter(Boolean);

// The audit trail is built before the transport so it can observe the payment
// the transport makes: a wrapped x402 fetch hands back a plain Response and the
// price only appears on the request, so the offer has to be read at the boundary.
// Its `host` metric label takes the same allow-list that gates spending, so an
// agent cannot mint time series with the URLs it tries.
const ledgerFile = process.env.MCP_PAYMENT_LEDGER_FILE || defaultPaymentLedgerFile();
const paymentLedger = new PaymentLedger({
filePath: ledgerFile,
metricHosts: metricHostsFromPaidCallAllowlist(paidCallAllowlist),
});

const fetchWithPayment = wrapFetchWithPaymentFromConfig(paymentLedger.instrumentFetch(globalThis.fetch), {
maxPrice: process.env.MCP_MAX_PAYMENT_PRICE || '$1.00',
schemes: [
{
Expand All @@ -33,20 +52,16 @@ async function main() {
]
});

// paid_call spends from the keys above, so the hosts it may pay for are the
// operator's decision. Unset means no host is payable at all (#404).
const paidCallAllowlist = (process.env.MCP_PAID_CALL_ALLOWED_HOSTS || '')
.split(',')
.map(entry => entry.trim())
.filter(Boolean);

const server = new BazaarMcpServer({
fetchWithPayment,
paidCallAllowlist
paidCallAllowlist,
paymentLedger
});

console.error('[mcp-server] Starting Bazaar MCP server on stdio...');
console.error('[mcp-server] Agent configured for stellar:testnet and stellar:pubnet');
// The audit file records where money went, never the key that signed it.
console.error(`[mcp-server] Payment audit trail: ${ledgerFile}`);
console.error(
paidCallAllowlist.length > 0
? `[mcp-server] paid_call may pay these allow-listed origins: ${paidCallAllowlist.join(', ')}`
Expand Down
Loading