Skip to content

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

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

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

Conversation

@AGWAM001

Copy link
Copy Markdown
Contributor

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.
AGWAM001 and others added 4 commits October 21, 2026 01:33
`Merge branch 'main' into feat/mcp-payment-audit-406` hand-resolved its three
conflicts by interleaving both sides instead of combining them, so
`npx tsc --noEmit` stopped at src/mcp/server.ts:361 and tests/mcp.test.ts could
not even be transformed.

Redone as a union. Every paid_call exit now settles the budget reservation and
writes the audit record; a thrown error is classified once, through the shared
classifyPaidCallError in codes.ts, so the ledger holds the same code the agent
is shown. The test file keeps main's whole suite plus the Divinity-Labs#406 ledger suite,
whose two fixtures predated that classifier and the outbound allow-list. The
example wires allow-list, ledger, budget and price queries into one server
instead of leaving three half-written constructors behind.
@Miracle656

Copy link
Copy Markdown
Collaborator

This has landed already, by another route. src/mcp/ledger.ts, PaymentLedger, the paid_call recording, the bounded metric labels and .changeset/mcp-payment-audit.md are all on main as of 59cc2a0 ("feat(mcp): bound cumulative spend, audit payments, serve over HTTP"), with fifteen references in tests/mcp.test.ts. Every paid_call outcome is recorded:

src/mcp/server.ts:624   this.paymentLedger.record({ ... })
src/mcp/server.ts:635   this.paymentLedger.record({ ... })
src/mcp/server.ts:647   this.paymentLedger.record({ url, method, outcome: 'success', response: res })
src/mcp/server.ts:681   this.paymentLedger.record({ ... })

So issue #406 is satisfied and I have closed it. I am leaving this PR open rather than closing it for you — close it when you have read this.

I tried merging it anyway in case the branch carried refinements worth keeping, and it does not survive the merge. Git reports no conflict, which is misleading: the two histories edited adjacent regions of the same declarations, so it merges into code that does not compile.

src/mcp/ledger.ts — two overlapping JSDoc edits spliced into one block, leaving orphaned comment text after a */ and metricHosts declared twice:

  /**
   * 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
   * of an empty list therefore labels every series `unknown`; pass the server's
   ...
   */
  metricHosts?: readonly string[];

src/mcp/server.ts — paymentLedger?: PaymentLedger; inserted on top of the opening /** of the limits doc, so that comment's body became statements.

After repairing both by hand, tsc still gives: PaymentLedger imported twice, paymentLedger declared three times, duplicate function implementations in ledger.ts, and network, code and mayHaveSettled redeclared in the same scopes. That is not a merge that can be finished safely — this is payment accounting, and a hand-untangled interleaving of two near-identical implementations is precisely where a double-count or a dropped record hides.

Worth recording the general lesson, because it is the second time this shape has cost real time in this repo: a clean git merge on a long-lived branch is not evidence the result compiles. Git merges line regions, not declarations, so two branches that both edited the same interface produce a file with duplicated members and comment blocks with no opening. Always run tsc --noEmit on the merge result before trusting it.

One difference that is a genuine improvement

Your branch binds the Prometheus host label domain to the operator's own paid_call allow-list:

export function metricHostsFromPaidCallAllowlist(entries: readonly string[]): string[] {
  return [...new Set(parsePaidCallAllowlist(entries).rules.map((rule) => rule.origin))];
}

main instead takes the label domain from a separate MCP_METRIC_HOSTS variable via parseMetricLabelAllowList. Yours is better reasoned: a host paid_call will refuse to pay can never appear in a record, so the gate's own list already bounds the label domain, and there is no second list to drift out of sync. The argument in your doc comment — "no second allow-list to keep in sync" — is the right one.

That is worth a small, focused PR against current main: derive the default of metricHosts() in examples/mcp-server/run.ts from the paid_call allow-list, keeping MCP_METRIC_HOSTS as an override for an operator who wants a narrower label domain than their payment allow-list. Maybe forty lines, no interaction with the ledger internals, and reviewable on its own. Open an issue for it and I will assign it to you.

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

2 participants