8 allowed at 13.0¢. 5 refused at $0.00.
MCP sampling lets a server spend your money using your API key. A gate that reasons about estimated cost before the call means a refusal is free — and that changes what you gate on.
Every MCP server I had built up to this point was a vending machine. You put a request in, a result comes out, and it costs you exactly what you put in.
Sampling turns that inside out. It is the server asking your host to run a model call. The server has no API key. It has no model. It has yours.
Once you have implemented it, the interesting question stops being "how does the round trip work" and becomes "what stops a server I do not control from running up a bill on my account."
The numbers
🛑 The Kitchen → summarise_week [claude-sonnet-5] 6.0¢ estimated — REFUSED, $0.00 spent
tokens on the tab : 0
refused (not spent) : 6.0¢ ← what the ceiling saved you| model calls servers asked for | 13 |
| allowed | 8 · 13.0¢ actually spent |
| refused | 5 · 24.2¢ estimated, $0.00 actually spent |
| regression suite | 100% passing, 13 cases |
| cost of re-running that suite | $0.00, replayed from stored traces |
13.0¢
spent, 8 calls
$0.00
cost of 5 refusals
24.2¢
refused, unspent
A refusal costs nothing, and that is the whole design
The gate reasons about an estimate, before the call. Nothing has been sent to the model when the decision is made, so refusing spends exactly zero tokens.
That sounds like a detail. It is actually the argument for gating on price at all.
A gate that stops a call after the model has run is an audit log. A gate that stops it before is a budget. The first tells you what happened; the second decides what happens. And because the pre-call version is free to say no, you can afford to set the ceiling tight and let the human raise it, rather than setting it loose and hoping.
What changes when you gate on money instead of danger
I already had an approval gate from an earlier project in this series, built for destructive tool calls. The spend gate is deliberately the same shape — and the two places it differs are the entire lesson.
Difference one: gate on amount, not on occurrence
The destruction gate keys on what the call is. smash_jar always asks; jar_history never does. That works because destruction is a property of the verb.
Cost is not a property of the verb. The same summarise_week call is a twentieth of a cent against a small pantry and forty cents against a large one. A gate that stops it every time is click-fatigue wearing a new hat: you approve nine trivial summaries a day and then approve the expensive one by reflex, because approving is what the button is for.
So the rule is a number. Cheap calls flow. Expensive ones stop.
Difference two: this gate cannot pause
This took the longest to get right and it is worth understanding before reading any code.
The destruction gate pauses. The loop stops, the HTTP request ends, the whole agent is written to Postgres, you close your laptop, and five minutes later a new request thaws it out. That works because the thing being frozen is mine. My agent does not mind waiting.
A sampling request is not mine. Somebody else's server is sitting inside a tools/call waiting for an answer, and it will time out in under a minute. Freezing it does not pause it — it deadlocks it.
So the gate is shaped differently, on purpose:
| below the ceiling | runs immediately, logged to the ledger |
| above the ceiling | refused synchronously, with the price attached |
| the human | sees a card afterwards, and can grant a one-shot allowance the agent's next attempt spends |
The refusal is synchronous. The approval is asynchronous and lands on the retry. That is a real limitation of the boundary rather than a shortcut, and pretending otherwise would have produced a demo that hangs.
Three things in a sampling request that you must not believe
The rule from the approval gate carries over unchanged: a hint from the other side of a network boundary is not a permission model. A sampling request asks for three things, and the host grants none of them on request.
- The model it names. A server that asks for the most expensive model does not get the most expensive model. The host resolves the model itself.
- The
maxTokensit declares. Clamped host-side. A server asking for 100,000 output tokens gets the host's ceiling. requestState. This one is new, and it is the sharpest version of the lesson in the whole series.
requestState, or: data does not stay yours by having been yours
The multi-round-trip sampling protocol gives the server a way to keep state across the two halves of an exchange. It returns an opaque requestState string, and the client echoes it back verbatim on the retry.
The MCP SDK's own documentation is unusually blunt about what that means:
It travels through the client and MUST be treated by the server as attacker-controlled input… The SDK applies no integrity protection by default.
Read that from the server's side and it is the mirror image of the approval-gate thesis. That one said: a hint the server sent you is not a permission model. This says: state you sent the client and got back is not your state either. It went somewhere you do not control and came home. The fact that you wrote it originally means nothing.
My kitchen server signs its requestState with an HMAC and refuses any state that fails verification. The host-side half of the same wariness is almost aggressively boring:
export function passThroughRequestState(state: unknown): string | undefined {
return typeof state === "string" ? state : undefined;
}Hand the blob back exactly as received. Never read it, never parse it, never let it influence a decision on this side. It is the server's problem. My job is to not become a courier that also opens the envelope.
One wallet, many spenders, every draw labelled
A server's model call could plausibly be billed three ways:
- Its own per-server budget. Tidy, and wrong — the money is not the server's, so a per-server pot implies an ownership that does not exist. It also lets a server be "in budget" while the run as a whole is already broke.
- Untracked, outside the run. How it works if you do not think about it. Your whole-run token ceiling then guards everything except the one spender you do not control.
- The same run budget the agents draw from, tagged with who asked. ← this one.
Option 3 is what makes the seatbelt mean what it says. An earlier project built one wallet shared by reference across an agent tree so that "the whole run stops at 600,000 tokens" was true rather than approximately true. A server's draw goes on that same tab, or the sentence quietly becomes "600k plus whatever the servers felt like."
The label is the new work, and it is one column: ledger_entries.server_key. Every row says which server asked, what it estimated, what it actually cost, and whether it was allowed. That is what turns "we have a budget" into something you can answer questions about after the fact.
The regression suite costs nothing to run
Thirteen cases across three projects, replayed from stored traces rather than re-executed against the API. $0.00 per run, which means it runs on every push without anybody having to think about whether they can afford it.
This is downstream of the same decision as the gate. Once every model call is recorded with its inputs, its outputs, and its price, the recording is a test fixture. Evals stop being a thing you schedule and become a thing you run.
The two files are lib/spend-gate.ts, which decides, and lib/sampling.ts, which does. Read them next to each other.