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
23 changes: 23 additions & 0 deletions .changeset/mcp-payment-audit.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,27 @@
---
'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.
'lens': patch
---

Expand Down
20 changes: 20 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,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.
# Path to the OpenAPI document served by the MCP `lens://openapi.json` resource.
# Leave unset to serve the openapi.json at the package root; set it only when the
# document lives elsewhere, e.g. mounted separately in a container.
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,12 @@ const unsubscribe = client.subscribe(

`payment_budget` appears whenever a spend budget is configured, which the example always sets, and `lens_get_price` only where the server can reach Lens's own price data. 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.

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)
28 changes: 28 additions & 0 deletions examples/mcp-server/run.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import 'dotenv/config';
import { BazaarMcpServer } from '../../src/mcp/server';
import { PaymentLedger, defaultPaymentLedgerFile, metricHostsFromPaidCallAllowlist } from '../../src/mcp/ledger';
import { createLensPriceQueries } from '../../src/mcp/priceQueries';
import { activeNetwork } from '../../src/config';
import {
DEFAULT_MCP_METRICS_PATH,
DEFAULT_MCP_METRIC_NETWORKS,
Expand Down Expand Up @@ -61,6 +64,24 @@ async function main() {

const testnetClient = new ExactStellarScheme(testnetSigner);
const pubnetClient = new ExactStellarScheme(pubnetSigner);

// 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),
});

// 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
Expand Down Expand Up @@ -109,10 +130,17 @@ async function main() {
const server = new BazaarMcpServer({
fetchWithPayment,
budget,
paidCallAllowlist,
// Lens's own aggregated prices, read from the same Postgres the API serves.
priceQueries: createLensPriceQueries(),
paymentLedger
});

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(`[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}`);
Expand Down
70 changes: 70 additions & 0 deletions src/mcp/ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { Counter } from 'prom-client';
import { register } from '../metrics.js';
import type { McpErrorCode } from './codes.js';
import { parsePaidCallAllowlist } from './paidCallAllowlist.js';
import { parseUsdToNanodollars } from './budget.js';

export type PaymentOutcome = 'success' | 'failure';
Expand Down Expand Up @@ -64,7 +65,17 @@
* 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. Pass the
* origins from {@link metricHostsFromPaidCallAllowlist} to use the operator's
* paid_call allow-list as the label domain; anything else records as `unknown`.
* 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`. Defaults to {@link DEFAULT_PAYMENT_METRIC_NETWORKS}.
*/
* an agent asks for lets one caller mint unbounded time series. The default

Check failure on line 78 in src/mcp/ledger.ts

View workflow job for this annotation

GitHub Actions / Typecheck & build

';' expected.

Check failure on line 78 in src/mcp/ledger.ts

View workflow job for this annotation

GitHub Actions / Typecheck & build

Unexpected keyword or identifier.

Check failure on line 78 in src/mcp/ledger.ts

View workflow job for this annotation

GitHub Actions / Typecheck & build

Unexpected keyword or identifier.

Check failure on line 78 in src/mcp/ledger.ts

View workflow job for this annotation

GitHub Actions / Typecheck & build

')' expected.

Check failure on line 78 in src/mcp/ledger.ts

View workflow job for this annotation

GitHub Actions / Typecheck & build

';' expected.

Check failure on line 78 in src/mcp/ledger.ts

View workflow job for this annotation

GitHub Actions / Typecheck & build

';' expected.

Check failure on line 78 in src/mcp/ledger.ts

View workflow job for this annotation

GitHub Actions / Typecheck & build

'(' expected.

Check failure on line 78 in src/mcp/ledger.ts

View workflow job for this annotation

GitHub Actions / Typecheck & build

Unexpected keyword or identifier.

Check failure on line 78 in src/mcp/ledger.ts

View workflow job for this annotation

GitHub Actions / Typecheck & build

';' expected.

Check failure on line 78 in src/mcp/ledger.ts

View workflow job for this annotation

GitHub Actions / Typecheck & build

Property or signature expected.
* 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.
Expand All @@ -75,6 +86,13 @@
now?: () => Date;
}

/**
* The network ids this server's signer can pay on, so they are the whole
* `network` label domain. A label taken from whatever a seller echoes in a
* header would mint a permanent time series per distinct string.
*/
export const DEFAULT_PAYMENT_METRIC_NETWORKS: readonly string[] = ['stellar:testnet', 'stellar:pubnet'];

const UNKNOWN_LABEL = 'unknown';
const NANODOLLARS_PER_USD = 1_000_000_000n;

Expand Down Expand Up @@ -111,6 +129,49 @@
return join(homedir(), '.lens-mcp', 'payments.jsonl');
}

/**
* The `host` label domain, taken from the same operator configuration that
* decides which hosts `paid_call` may pay for. A call to anything else is
* refused before a request is sent, so this set already bounds every host that
* can appear in a record — no second allow-list to keep in sync.
*
* Entries that do not parse are dropped here as they are by the gate itself,
* which matches nothing rather than matching everything.
*/
export function metricHostsFromPaidCallAllowlist(entries: readonly string[]): string[] {
return [...new Set(parsePaidCallAllowlist(entries).rules.map((rule) => rule.origin))];
}

/**
* Parses a USD money string such as '$1.00', '1.00' or '$0.05' into integer
* nanodollars. Throws on anything that is not a non-negative USD amount, which
* is how a payment priced in asset units is told apart from one priced in dollars.
*/
function usdOfferToNanodollars(money: string): bigint {
const cleaned = String(money).trim().replace(/^\$/, '');
const match = /^(\d+)(?:\.(\d+))?$/.exec(cleaned);
if (!match) {
throw new Error(`Not a USD amount: ${JSON.stringify(money)}`);
}
const fraction = match[2] ?? '';
if (fraction.length > 9) {
throw new Error(`USD amount ${JSON.stringify(money)} has more than 9 decimal places`);
}
return BigInt(match[1]) * NANODOLLARS_PER_USD + BigInt(fraction.padEnd(9, '0') || '0');
}

function isUsdAmount(amount: string | null): boolean {
if (amount === null) {
return false;
}
try {
usdOfferToNanodollars(amount);
return true;
} catch {
return false;
}
}

function boundedText(value: unknown, maxChars: number): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
Expand Down Expand Up @@ -233,6 +294,7 @@
constructor(config: PaymentLedgerConfig = {}) {
this.filePath = config.filePath;
this.metricHosts = config.metricHosts ?? [];
this.metricNetworks = config.metricNetworks ?? DEFAULT_PAYMENT_METRIC_NETWORKS;
this.metricNetworks = config.metricNetworks ?? [];
this.now = config.now ?? (() => new Date());
this.durable = true;
Expand Down Expand Up @@ -333,6 +395,10 @@
}

/**
* The running total of recorded spend: what the ledger saw settle, what it
* cannot rule out, and how much of the trail it could not price in USD. This
* is the measurement a cumulative ceiling should read, as distinct from the
* worst case it reserves before a price is known.
* 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.
Expand All @@ -353,6 +419,7 @@
let nanodollars: bigint | null = null;
if (record.amount !== null) {
try {
nanodollars = usdOfferToNanodollars(record.amount);
nanodollars = parseUsdToNanodollars(record.amount);
} catch {
// Not a USD money string: an atomic-unit offer, or something a seller
Expand Down Expand Up @@ -396,12 +463,15 @@

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;
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 = usdOfferToNanodollars(record.amount);
nanodollars = parseUsdToNanodollars(record.amount);
} catch {
nanodollars = null;
Expand Down
Loading
Loading