Skip to content
Merged
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
43 changes: 43 additions & 0 deletions .changeset/mcp-spend-ceiling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
'lens': patch
---

The MCP server's spend cap is now cumulative, not just per call. `maxPrice`
bounded a single payment, so an agent in a retry loop — or one told to "try each
of these endpoints" — could pay that amount arbitrarily many times: every
payment individually inside the limit, the sum unbounded. A per-call limit
answers "how much can one mistake cost"; an operator's question is "how much can
today cost".

`SpendBudget` (`src/mcp/budget.ts`) adds a ceiling on the total paid by
`paid_call` within one UTC day, configured with `MCP_DAILY_SPEND_CEILING`
(default `$10.00`) alongside the per-call `MCP_MAX_PAYMENT_PRICE`. The day window
was chosen over a sliding 24 hours because it answers the operator's question
directly, needs one stored number rather than one entry per payment, and rolls
over from the wall clock — so a restarted server resumes the same day's total and
resets it after 00:00 UTC.

Before a call is made the budget reserves its worst case, the per-call maximum,
and refuses when that no longer fits what is left. It refuses with the new
`ERR_BUDGET_EXCEEDED` code carrying the spent, ceiling, remaining and reset time
in `details.budget`, so an agent can report being out of budget instead of
looking broken; the refusal happens before the payment fetch, so no money moves.
Both limits are in the `paid_call` description and the live totals are readable
without spending, through the new `payment_budget` tool. The running total
persists to `MCP_SPEND_STATE_FILE` (default `~/.lens-mcp/spend-state.json`) and is
read back at startup, because a restart does not refund what the previous process
already spent.

Accounting is deliberately conservative: the wrapped x402 transport exposes only
a plain `Response`, so a paid call that could have settled is booked at the
per-call maximum and a rejection that proves nothing settled is not booked at all.
Recorded spend is an upper bound, which means the ceiling is reached early rather
than late — and an unreadable or unwritable state file is treated as fully spent,
not as a clean slate.

Also fixed while wiring this up: `MCP_MAX_PAYMENT_PRICE` was passed to
`wrapFetchWithPaymentFromConfig` as `maxPrice`, which is not an option of the
x402 client config and was silently ignored — every payment was capped by the
library default of `$1` whatever the operator asked for. It is now passed as
`spendControls.maxAmountPerPayment`, so the per-call limit the budget reserves
against is the one actually enforced.
20 changes: 20 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -225,3 +225,23 @@ MCP_PAID_CALL_ALLOWED_HOSTS=
# against a table that has never been pruned cannot hold a long lock or blow
# the WAL. Default 1000.
SNAPSHOT_PRUNE_BATCH_SIZE=1000


# --- MCP agent spending limits ---
# 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
# (default: ~/.lens-mcp/spend-state.json).
MCP_SPEND_STATE_FILE=
28 changes: 27 additions & 1 deletion 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 { SpendBudget, defaultSpendStateFile } from '../../src/mcp/budget';
import { wrapFetchWithPaymentFromConfig } from '@x402/fetch';
import { ExactStellarScheme } from '@x402/stellar/exact/client';
import { createEd25519Signer } from '@x402/stellar';
Expand All @@ -19,8 +20,18 @@ 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.
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, {
maxPrice: process.env.MCP_MAX_PAYMENT_PRICE || '$1.00',
spendControls: {
maxAmountPerPayment: maxPrice,
},
schemes: [
{
network: 'stellar:testnet',
Expand All @@ -33,6 +44,18 @@ async function main() {
]
});

// The running total outlives the process: this server signs with a real key, and a
// restart does not refund what the previous process already spent. Point
// MCP_SPEND_STATE_FILE somewhere durable if the default location is not.
const budget = new SpendBudget({
perCallMax: maxPrice,
dailyCeiling,
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 || '')
Expand All @@ -47,6 +70,9 @@ async function main() {

console.error('[mcp-server] Starting Bazaar MCP server on stdio...');
console.error('[mcp-server] Agent configured for stellar:testnet and stellar:pubnet');
// Limits and file paths only: the signing key is never logged.
console.error(`[mcp-server] Spend limits: ${budget.describeLimits()}`);

console.error(
paidCallAllowlist.length > 0
? `[mcp-server] paid_call may pay these allow-listed origins: ${paidCallAllowlist.join(', ')}`
Expand Down
Loading