Skip to content

feat(mcp): record and meter what every paid call spent - #523

Open
AGWAM001 wants to merge 1 commit into
Divinity-Labs:mainfrom
AGWAM001:feat/mcp-payment-audit-406
Open

AGWAM001 wants to merge 1 commit into
Divinity-Labs:mainfrom
AGWAM001:feat/mcp-payment-audit-406

Conversation

@AGWAM001

Copy link
Copy Markdown

Summary

paid_call signs with a live mainnet key, and until now nothing said what it spent: the wrapped x402 fetch hands back a plain Response, and the price the agent agreed to is only visible on the request the transport makes. This adds the receipt.

  • src/mcp/ledger.ts — PaymentLedger. One JSON record per attempt is appended to MCP_PAYMENT_LEDGER_FILE (default ~/.lens-mcp/payments.jsonl): timestamp, full URL, host, method, price/asset/network exactly as the seller offered them, settlement hash, outcome, spend class, and the error code the agent was shown. Failed and refused calls are recorded too — a payment can settle on a call that errored, so a log of successes is not an audit trail.
  • Spend is classified, not assumed. settled when the seller's settlement header confirms it, possible when a payment was offered and nothing proves it failed, none when something proves nothing moved. transactionHash is null whenever the resource does not echo its settlement, so the trail reads as an upper bound instead of inventing certainty.
  • No secret material, anywhere. The ledger reads accepted.{amount, asset, network} off the payment header and deliberately never payload, the signed transaction envelope; the signing key never enters the class. A test carries a decoy envelope and asserts its marker appears in neither a record nor the file.
  • Fail closed. If the audit file cannot be created, or a record cannot reach it, paid_call refuses further calls with ERR_INTERNAL_FAILURE before sending a request. A tool that moves money does not get to spend without leaving a record.
  • Metrics with a bounded label set. mcp_payment_attempts_total{host,network,outcome}, mcp_payment_amount_usd_total{host,network} and mcp_payment_unquantified_total{host,network} on the shared registry. Series are permanent, so host may only take a value from the paid-call allow-list feat: add configurable host allow-list to paid_call, refuse before any request #487 added — the same list that decides what can be paid at all, so there is no second knob to drift — and network only stellar:testnet / stellar:pubnet, the two networks this signer can use. Anything else is labelled unknown; the audit file always keeps the real host.
  • A total a ceiling can read. totalSpend() returns attempts, settled spend and unconfirmable spend as integer nanodollars in decimal strings (no float drift), plus a count of payments it could not price in USD — so the sums announce when they understate.

What this deliberately does not do

  • No new listener. The MCP server runs on stdio, so these counters are registered but not scrapeable from that process; exporting them needs the HTTP transport (There is no HTTP transport, only stdio #419). npm run mcp:server prints where the audit file is at startup, so an operator always knows where the record lives.
  • totalSpend() is what this process recorded since boot. Reading the file back across restarts and spending against a cumulative cap is The spend cap is per call, so it is not a cap #405's work; this is the measurement it needs, not the cap.
  • bazaar_search is not recorded. Discovery never pays, so it has no place in a spend trail.

Related issue

Closes #406

Maintainer note: the open #522, same author, also lists Closes #406, and its branch was cut before #487/#488 landed. This PR is only the #406 slice, re-based on current main (94bd8ea). If you take it, #522 should be narrowed to #405/#419/#421/#425 rather than merging the same audit ledger twice. Closed-unmerged #491 and the open #447 covered this area too; nothing here re-implements their budget or HTTP work.

Type of change

  • New feature
  • Docs
  • Tests

Checklist

  • I have read CONTRIBUTING.md
  • npx tsc --noEmit passes
  • npm run build passes
  • I added / updated tests where relevant
  • I updated docs where relevant

Tests

npx vitest run tests/mcp.test.ts tests/mcp-paid-call-allowlist.test.ts tests/x402-paid-call-loop.test.ts tests/httpMetrics.test.ts → 49 passed (21 of them in tests/mcp.test.ts).

Each acceptance criterion, with the test that names it:

Criterion Test
every payment attempt recorded, successful or not records every paid call, whatever the transport did with it — one server, four transports (success, refused 402, 500 after the payment loop, a thrown fetch failed), four records, spend ['settled', 'none', 'possible', 'possible']
a failed payment is recorded as an attempt records a failed payment as an attempt
transaction hash recorded where one exists records the transaction hash the seller echoes on settlement
spend exported as a metric with bounded labels exports spend as metrics with a bounded label set — the allow-listed series, the unknown fallback, and that a host outside the allow-list never becomes a series of its own
no secret material recorded records no secret material from the payment header, appends one line per attempt to the audit file, oldest first
fail closed refuses every paid call when the audit file cannot be created, stops spending once a record fails to reach the audit file — both trigger a real mkdir/append failure rather than a flag set by the test
the running total a ceiling reads sums settled spend and flags spend it cannot price

The block is entirely offline: DNS and the transport are injected, and every audit file is created under mkdtempSync and removed afterwards.

Two failures you will see that are not from this diff: the prices load test already fails on main, and examples/mcp-server/run.ts reports an excess-property error on maxPrice against the installed @x402/fetch types — tsconfig.json only includes src/**, so neither CI's tsc nor npm run build reaches it, but npm run mcp:server typechecks it.

Files

8 files, +1074/−22: src/mcp/ledger.ts (new), src/mcp/server.ts, examples/mcp-server/run.ts, tests/mcp.test.ts, examples/mcp-server/README.md, README.md, .env.example, .changeset/mcp-payment-audit.md (minor).

paid_call moved money from a live key and left no receipt: the wrapped x402
fetch returns a plain Response, and the price only exists on the request the
transport makes. PaymentLedger appends one record per attempt to
MCP_PAYMENT_LEDGER_FILE - failed and rejected calls included, since a payment
can settle on a call that errored - and never reads the signing key or the
signed envelope out of the payment header.

Each record says whether its spend is confirmed (settled), unprovable
(possible) or excluded (none), so the trail reads as an upper bound instead of
inventing certainty a seller never confirmed. An audit file that cannot be
created, or that a record cannot reach, makes paid_call refuse further calls
rather than spend unrecorded.

The same records count into mcp_payment_attempts_total,
mcp_payment_amount_usd_total and mcp_payment_unquantified_total. Their host
label may only take a value from the paid_call allow-list - the same list that
decides what can be paid at all - and their network label only the two networks
this signer can use, so no caller mints permanent time series with the URLs it
tries. totalSpend() gives a cumulative ceiling the running sum to read.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Nothing records what the agent spent

1 participant