KetanShukla.dev
MCP7 min read

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 for13
allowed8 · 13.0¢ actually spent
refused5 · 24.2¢ estimated, $0.00 actually spent
regression suite100% 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 ceilingruns immediately, logged to the ledger
above the ceilingrefused synchronously, with the price attached
the humansees 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 maxTokens it 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:

lib/spend-gate.ts
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:

  1. 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.
  2. 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.
  3. 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.

mcpmcp-samplingllm-costfinopshuman-in-the-looptrust-boundary

Read next