Skip to content

fix(mcp): bound cumulative daily paid-call spend, not just per call - #492

Merged
Miracle656 merged 2 commits into
Divinity-Labs:mainfrom
AGWAM001:fix/mcp-daily-spend-ceiling
Oct 21, 2026
Merged

Miracle656 merged 2 commits into
Divinity-Labs:mainfrom
AGWAM001:fix/mcp-daily-spend-ceiling

Conversation

@AGWAM001

Copy link
Copy Markdown
Contributor

Summary

A per-call limit answers "how much can one mistake cost". The question an operator actually has is "how much can today cost", and nothing answered it: an agent in a retry loop — or one told to try each of five endpoints — could pay the per-call maximum arbitrarily many times, every payment individually inside the limit and the sum unbounded.

SpendBudget (src/mcp/budget.ts) adds a cumulative ceiling on what paid_call may spend within one UTC day.

  • Configurable: MCP_DAILY_SPEND_CEILING (default $10.00) beside the per-call MCP_MAX_PAYMENT_PRICE. The constructor refuses nonsense: a non-positive per-call maximum, or one larger than the ceiling, since then no paid call could ever be allowed.
  • Distinct refusal: ERR_BUDGET_EXCEEDED, returned before the payment fetch, with the live snapshot in details.budget (spentToday, dailyCeiling, remainingToday, resetsAt). An agent can report being out of budget instead of appearing broken.
  • Discoverable to the agent: both limits are appended to the paid_call tool description, and a new payment_budget tool reports spent, reserved and remaining without making a paid call.
  • Survives a restart: the day's 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. A missing file starts at zero; the window still resets at 00:00 UTC.

Decisions

Day window, not sliding 24h. 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 instead of reconstructing a history.

Worst-case reservation. Before a call runs, the budget reserves its per-call maximum, so N concurrent calls cannot collectively breach the ceiling, and a call whose worst case no longer fits the remainder is refused rather than allowed to spend the last few cents.

Conservative accounting, and why. The wrapped x402 transport hands back a plain Response; the settled price is not observable at this layer, so the budget cannot debit the real amount. A call that could have settled is booked at the per-call maximum; a seller rejection that proves nothing settled is booked at nothing. Recorded spend is therefore an upper bound — the ceiling is reached early rather than late, which is the safe direction for a surface that signs with a real key.

Fail closed on state. An unreadable or unwritable state file means the running total is unknown, and an unknown total against a money ceiling is assumed fully spent: paid calls are refused and the refusal says so until the file is fixed.

Also fixed

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 spendControls.maxAmountPerPayment, so the per-call limit the budget reserves against is the one actually enforced.

Tests

tests/mcp.test.ts adds 9 tests: the acceptance case (three $1.00 calls fill the ceiling and the fourth is refused with ERR_BUDGET_EXCEEDED, with the payment fetch call count proving no money moved), remainder-that-cannot-fit refusal, restart persistence through a shared state file, UTC-midnight rollover with an injected clock, 402 rejection not charging the budget, corrupt state failing closed, both limits visible in the description and via payment_budget, constructor validation, exact nanodollar parsing.

  • npx vitest run tests/mcp.test.ts → 13/13 pass
  • npm test (whole repository) → 674 passed, 1 skipped (tests/integration/e2e.test.ts, needs live services), 0 failed
  • npx tsc --noEmit → clean
  • npm run build → clean

Notes

Arithmetic is integer nanodollars throughout, so there is no float drift. No secret key is logged, echoed, or used in a fixture; startup logs limits and file paths only. Config is documented in .env.example and a patch changeset is included.

Related issue

Closes #405

Type of change

  • Bug fix
  • New feature
  • Refactor
  • Docs
  • Tests
  • CI / tooling

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

@drips-wave

drips-wave Bot commented Oct 17, 2026

Copy link
Copy Markdown

@AGWAM001 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

…ivinity-Labs#405)

A per-call cap answers "how much can one mistake cost"; an operator needs "how
much can today cost", and an agent in a retry loop could previously pay the
per-call maximum without limit.

SpendBudget adds a per-UTC-day ceiling with worst-case reservation before each
call, refuses with ERR_BUDGET_EXCEEDED (carrying the live snapshot in
details.budget) before the payment fetch, exposes both limits in the paid_call
description plus a payment_budget tool, and persists the day's total so a
restart does not refill the budget. Unreadable or unwritable state fails closed.

Also fixes MCP_MAX_PAYMENT_PRICE being passed as maxPrice, which is not an x402
client config option and bounded nothing; it is now spendControls.maxAmountPerPayment.
@Miracle656

Copy link
Copy Markdown
Collaborator

Needs a rebase — #488 (the SSRF guard for bazaar_search) merged an hour ago and touches src/mcp/server.ts and src/mcp/codes.ts, so this conflicts now.

Two things worth knowing before you rebase, because three PRs are converging on the same file:

Push the rebase and I will review properly. The spend ceiling is the half of #404 that actually bounds the loss — an allow-list stops the money going somewhere strange, a ceiling stops it going somewhere allowed ten thousand times.

@Miracle656

Copy link
Copy Markdown
Collaborator

Coordination note — ten PRs are editing one 179-line file

If your MCP PR has just gone red with a conflict, this is why, and it is my fault rather than yours.

I published 22 MCP issues (#404–#425) as if they were independent. They are not: almost all of them land in src/mcp/server.ts, which is 179 lines long. Every merge therefore conflicts everything else. The batch should have been sequenced, or scoped so the work genuinely separated — that was a mistake in how I wrote the issues, not in how you answered them.

Already merged:

Order I intend to take the rest in, so you can rebase once rather than repeatedly:

  1. feat: add configurable host allow-list to paid_call, refuse before any request #487 — paid_call host allow-list (paid_call will pay whatever URL the agent names #404). The gate that decides which hosts may be paid. This one goes next because everything else about spending is narrower than it.
  2. feat(mcp): bound cumulative spend, audit payments, serve over HTTP #491 / fix(mcp): bound cumulative daily paid-call spend, not just per call #492 — cumulative spend ceiling and payment audit (The spend cap is per call, so it is not a cap #405, Nothing records what the agent spent #406). These bound and record what happens to hosts the allow-list permits.
  3. fix: classify MCP paid-call errors by structured signals #480 — structural error classification (Errors are classified by searching the message text #410).
  4. fix(mcp): bound tool result size, stream-cap upstream reads, and report truncation explicitly #489 — bounded tool results (Tool results are unbounded #416).
  5. feat: add lens_get_price tool returning aggregated pair price with issuer-strict assets #493 / feat: add lens_list_pairs, lens_get_pools and lens_screen_pairs tools #482 / feat: declare the resources capability and expose pairs, venues, OpenAPI spec, and status #494 — the new Lens tools and the resources capability (An agent cannot ask Lens for a price #408, No tool exposes pairs, pools or the screener #409, The server advertises only tools, not resources #418).
  6. test(mcp): add end-to-end 402 payment loop test against a stub facilitator #490 — the end-to-end 402 loop test (No test drives a real 402 payment loop #423).

Please do not all rebase at once. If you are below the one currently in flight, wait until the one above you lands — otherwise you rebase, I merge someone else, and you rebase again. I will comment on your PR when it is your turn.

One thing to settle between you: src/mcp/codes.ts is being extended by at least four of these, and #488 has already added a separate set of refusal reasons in src/net/ssrf.ts. We should end up with one vocabulary for "I refused to do that", not four. Whoever lands next, please look at what is already there and extend it rather than adding a parallel set — and say in your PR description what you added, so the next person can see it without reading the diff.

Nothing here reflects on the quality of the work. Several of these are among the best PRs this repo has had.

@Miracle656

Copy link
Copy Markdown
Collaborator

#487 has landed — the paid_call host allow-list. You are next in the MCP queue; please rebase on current main.

I resolved #487's conflicts myself because nine PRs were stuck behind it. Three things on main now that will shape your rebase:

1. Two refusal codes, deliberately separate.

ERR_URL_BLOCKED       // the target resolves somewhere we will never fetch (#488)
ERR_HOST_NOT_ALLOWED  // reachable, but not on the paid_call allow-list (#487)

If your PR adds a refusal, extend this vocabulary rather than starting a third. "I cannot reach there" and "I will not pay there" are different answers; whatever you add should be a third distinct answer, not a synonym for one of these.

2. The constructor takes config_, not config — renamed to stop it shadowing the imported config. Both gates initialise there: paidCallRules and bazaarFetchOptions.

3. src/mcp/ is exempt from the console.* guard, and must stay exempt. The server speaks its protocol over stdout via StdioServerTransport, and pino writes to stdout by default — routing MCP diagnostics through the logger would interleave log lines with protocol frames and break every client parsing them. Use console.error there. Everywhere else in the repo, use the logger; the guard will tell you.

Push the rebase and I will review it. If it conflicts in a way that is not obvious, say so and I will resolve it rather than leave you stuck.

@Miracle656 Miracle656 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The design is right and I want this. It needs a rebase — the auto-merge against current main produces broken code in four places, and I am not going to reconstruct your test file by guesswork.

What the merge breaks

main gained #487 (paid_call allow-list) and #488 (SSRF guard) after you branched, and both touch the same constructor. Git stitched them together without conflict markers, which is the worst case:

  • McpServerConfig — the /** opening the allow-list doc comment is swallowed by budget?: SpendBudget;
  • BazaarMcpServer — two overlapping constructors, one taking config, one taking config_
  • the paid_call description — your budget ternary and the allow-list text concatenated with no +
  • tests/mcp.test.ts — two closing braces short; it no longer parses

I repaired the first three locally to read it, then stopped at the fourth. Reconstructing where your describe blocks were meant to close is me inventing your tests.

Note the constructor parameter is now config_, renamed on main to stop it shadowing the imported config.

The design, which I read carefully and which is excellent

Integer nanodollars via BigInt. Money in floats is how a ceiling quietly becomes $1.0000000000000002. And parseUsdToNanodollars throws at boot on a malformed ceiling rather than running with an unintended budget — the right direction to fail for a spend limit.

Reservations, which I did not ask for and should have.

if (this.spentNanodollars + this.reservedNanodollars + this.perCallMaxNanodollars > this.dailyCeilingNanodollars)

Holding the worst case before the call and settling after is what makes the ceiling true under concurrency. Checking only spent lets two in-flight calls both pass and jointly exceed it — the classic way a limit that looks correct is not.

The day-boundary case in commit:

The window closed while the call was in flight, so the payment belongs to the window that has just ended and must not be booked against the new day.

That is a genuinely subtle call and you got it the right way round. Booking it against the new day spends tomorrow's budget on yesterday's call.

The refusal is actionable — spent, ceiling, remaining, the per-call maximum it must cover, and when the window resets. An agent can explain itself or wait, instead of retrying.

And it fails closed when the state file cannot be trusted, telling the operator to fix it and restart rather than silently resuming with a fresh budget.

One thing to consider while rebasing

Holds live in memory. If the process dies between reserve and commit on a payment that did settle, the persisted spentToday loses it, and enough crashes could walk past the ceiling. Not necessarily worth fixing — persisting reservations has its own failure modes — but worth a sentence saying the choice was made deliberately.

Rebase and I will merge it. This is the half of #404 that actually bounds the loss: an allow-list stops money going somewhere strange, a ceiling stops it going somewhere allowed ten thousand times.

@Miracle656

Copy link
Copy Markdown
Collaborator

Merged. This is money code written like money code, and the design decisions are the ones I'd have argued for.

It survives a restart. The running total is persisted and reloaded, so restarting the server does not refill the budget. An in-memory counter would have made the ceiling advisory on any host that recycles processes — which is most of them.

It fails closed. An unreadable or corrupt state file is treated as fully spent, not as a clean slate, and the error tells the operator to fix the file and restart. That is the exact opposite of the right default for a rate limiter, and exactly right here: a cap that forgets itself when its storage breaks is not a cap.

It writes atomically — temp file then rename — so a crash mid-write cannot leave a truncated total that reads as a smaller spend.

Money is exact integer nanodollars, parsed from strings, with more than nine decimal places rejected rather than silently rounded. And a rejected payment is not charged, and a call whose worst case no longer fits is refused rather than allowed to overspend the remainder. Those two tests are the ones I'd have written first and they were already there.

What merging cost, and why

The branch was 47 commits behind, and both sides had restructured the same class. Git auto-merged several regions into something that looked clean and did not compile — the worst kind of conflict, because nothing flags it:

  • Two constructors spliced together: your constructor(config) ended up inside main's field declarations.
  • Your inserted budget?: SpendBudget field swallowed the opening /** of the following doc comment.
  • The payment_budget tool was spread into an array literal that main no longer has (it builds tools then pushes conditionally).

One of these was already broken on your branch, not from the merge:

description:
  'Make a paid API call ... ' +
  (this.budget ? this.budget.describeLimits() : 'No cumulative spend ceiling ...'),
  'This tool spends real USDC ...' +

The ), terminates description, leaving the rest as a bare expression in an object literal — a syntax error. Concatenated properly now. Worth running npm run typecheck before pushing; it finds that one instantly.

The test file would have dropped 19 tests that landed on main after you branched (the Zod argument suite, the deadline suite, the error-classification suite). Your file replaced main's wholesale, so git reported no conflict and said nothing. I kept main's and appended your budget suite — 46 tests now.

Your budget harness also needed paidCallAllowlist: ['example.com']: the paid_call allow-list (#404) landed after you branched and is checked before the budget, so every case was failing with ERR_HOST_NOT_ALLOWED and never reaching the ceiling. Also restored the ToolSchema import.

None of that is a criticism of the work — it is the cost of a 47-commit gap on a file three other PRs touched this week. Rebasing sooner would have surfaced all of it as honest conflicts.

Verified on the merge result: tsc --noEmit silent, 85 suites pass, nothing red.

Closes #405. I'll review #523 next, since it holds #406 — which I reopened this morning after wrongly closing it with a mistyped issue number in an unrelated merge.

Miracle656 added a commit that referenced this pull request Oct 21, 2026
Twice in one day a branch removed test cases that existed on `main`, and git
reported no conflict either time.

PR #480 dropped 19 — the Zod argument suite from #555 and the upstream deadline
suite from #525. PR #492 would have dropped the same 19. Both branches were
opened before those landed, so merging `main` in replaced `tests/mcp.test.ts`
wholesale rather than merging it: the result is the older file plus the
author's additions, and everything added in between is gone. Both PRs reported
"mergeable". Both typechecked. The only way I noticed was counting `it(` on
each side, by hand, twice.

`scripts/check-dropped-tests.mjs` compares the base against the tree a merge
would actually produce — not against the branch tip, which over-reports wildly
(a branch 47 commits behind is "missing" every case added since it forked,
merged or not). It lists the cases by file and name and exits 1.

It does not run the tests and does not judge whether a removal is right.
Removing a test is sometimes exactly correct — the feature went away, the case
was a duplicate, the assertion was wrong. The point is that the removal is
deliberate rather than accidental: say so in the PR and apply
`tests-removed-intentionally`.

Validated both ways before committing, since a guard nobody has seen fire is an
assumption: a branch with a case deleted exits 1 and names it; an unchanged
branch exits 0.

Known limitation, written down rather than left to be discovered: when the
merge conflicts, `git merge-tree` writes a tree containing both sides, so no
case looks missing and the check passes. That is the case a human already has
to resolve by hand — which is exactly where #492's loss would have happened.
The check is strongest for the silent case, which is the one that bit.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB
Jonathan-ai9 added a commit to Jonathan-ai9/Lens that referenced this pull request Oct 21, 2026
A tool result is written straight into the agent's context window, so an
unbounded one is self-inflicted denial of service whose failure gets blamed on
the model. All four tools serialise compactly and are capped at
MCP_MAX_RESULT_BYTES; a truncated result carries truncated, the total, the page
returned, and a notice saying how to ask for more.

bazaar_search gains limit/offset arguments, forwards them to the node and pages
whatever comes back. lens_get_price keeps structuredContent only while the
payload fits, since shipping it regardless would move the blow-up to another
field. Upstream bodies are read through a capped stream reader that cancels at
the cap, so an oversized body is never buffered; a discovery body too large to
parse returns ERR_RESPONSE_TOO_LARGE, which is deliberately not one of the two
pre-request refusals: we did fetch it, we will not dump it.

Rebased onto main after Divinity-Labs#480 (structured paid-call errors), Divinity-Labs#487 (paid_call host
allow-list) and Divinity-Labs#492 (spend ceiling) landed: the bounds now sit on top of those
gates rather than beside them, and limit/offset go through the Zod tool schemas.
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.

The spend cap is per call, so it is not a cap

2 participants