There's a good checklist going around dev.to for vetting an MCP server before you wire it into an agent. Four things to look at. Tool surface area: how many tools, and are they atomic or coarse. Auth model: API key, OAuth, token scope. Maintenance: last commit, open issues, is anyone home. Token profile: does it dump a full document when a summary would do.
It's a genuinely good checklist. I've used a version of it. For most tooling categories it's exactly the right lens.
Then you point an agent at something that moves money, and three of those four rows go quiet.
Not because they stop mattering. Because one of them grows until it's the only thing you're really deciding.
A read tool and a write tool are not the same animal
Here's the thing that took us a while to say out loud.
If an agent calls a code-search tool twice, you get the same answer twice and waste a few tokens. If it reads a git diff twice, nobody notices. Reads are safe to repeat. That's the whole reason retries are the default everywhere in the agent stack. A tool call times out, the client tries again, you move on.
A payment tool call is not a read. Retry it once and you've billed someone twice.
We build open banking payments. The failure that actually keeps me up isn't a hallucinated argument or a server returning a forged result. It's the boring one. The connection blips mid-checkout, the client does what clients do and retries, and now there are two payment intents where the user meant one.
So the eval question for a money-moving tool isn't "what's the auth model." It's "what happens on the second call I didn't mean to make."
Idempotency lives on the intent, not the turn
The fix is old and unglamorous. Idempotency keys. Everyone in payments already knows them. The part people get wrong with agents is where the key lives.
The instinct is to make the agent turn idempotent. Same prompt, same result. That's the wrong seam. The agent turn is fuzzy by design, and you don't want it to be the thing carrying the guarantee.
Put the key on the payment intent. The client generates it once, before the tool is ever called, and it travels with the money, not with the conversation.
// The key is minted where the intent is born, not inside the agent loop.
const intent = {
idempotencyKey: crypto.randomUUID(), // one per real-world payment
amount: 4200,
currency: "GBP",
payee: "merchant_8842",
};
// Retries of the SAME intent collapse to one charge.
// A genuinely new payment gets a new key, on purpose.
await paymentsTool.charge(intent);
Now a dropped connection is harmless. The retry carries the same key, the server recognises it, and the second call returns the first result instead of moving money again. The agent can be as jittery as it likes. The guarantee sits below it, where the stakes are.
Read paths open, write paths gated
The other move is to stop treating "tools" as one category.
On the read side we let the agent run. Fetch balances, list transactions, pull an account's status, look up a payout. If it over-calls, it wastes tokens and we tune it later. Low blast radius, no gate.
On the write side, anything that changes state or moves money goes behind a human confirmation. Not the agent confirming to itself. A person, or a service acting under an explicit, narrow mandate, in the loop before the call executes.
If you're on Claude Code or a similar setup, the cheap version of this is a pre-call hook that classifies the tool and decides whether it needs a gate.
// Classify by side effect, not by name.
const NON_RETRYABLE = new Set(["charge", "refund", "payout", "mandate.create"]);
function preToolUse(call) {
if (NON_RETRYABLE.has(call.tool)) {
return requireHumanApproval(call); // blocks until a person says yes
}
return allow(call); // reads sail through
}
The point isn't the code. It's the split. Reads and writes want different defaults, and a checklist that scores a server as one thing misses that the same server can hold both.
The credential is a mandate, not a key
The auth row on the checklist usually asks whether it's an API key or OAuth. Fine question. Wrong altitude for payments.
What you actually want to hand an agent is a mandate. Scoped to an amount and a payee. Time-boxed, so it expires whether or not anyone remembers to revoke it. Revocable mid-flight. And auditable after the fact, so when someone asks "why did this money move" there's a straight answer that doesn't depend on trusting the model.
A key says "this caller is allowed." A mandate says "this caller is allowed to move this much, to this party, until this time, and here's the record." The second one is the only thing I'd let near a live payment rail.
We already keep that audit spine for money movement, because we're regulated and there's no version of this job where you don't. The work with agents wasn't inventing it. It was extending the same discipline to tool calls, so an agent's action leaves the same trail a human's would.
So, the checklist
Keep all four rows. For a knowledge base or a code-search server, run the standard lens and move on.
But the moment a tool can move money, promote one question above the rest and answer it first: is this call retryable, and if it isn't, what stops the second one? Everything else on the checklist is downstream of that.
A read tool can be replayed all day. A payment tool replayed once bills a real person real money.
Are you seeing any of the community MCP servers treat retryable and non-retryable tools as different classes yet, or is that still left entirely to whoever's calling them?
Top comments (11)
Payments make auth part of every eval, not a separate row. The tool call, user intent, merchant context, amount, idempotency key, and approval boundary all need to line up before the action should be considered safe.
Right, auth stops being a row and becomes the join condition. Tool call, intent, merchant, amount, idempotency key, approval boundary all have to line up in the same breath, and if any one of them is fuzzy the action isn't safe yet. The line I keep drawing is which of those the server can actually assert versus which the caller has to carry itself. Most servers today can only speak to a couple of them, so the rest lands on you whether you planned for it or not.
That server-versus-caller split is the uncomfortable part. If the server can assert only merchant and amount, but approval boundary and idempotency context live in the caller, the eval has to test the whole chain or it is testing a fantasy version of auth.
That server/caller split is the part I keep coming back to. The server can assert resource ownership, idempotency state, and maybe merchant scope; the caller has to carry user intent, approval freshness, and why this amount is legitimate now. If those are not joined into one signed or at least auditable decision, auth looks green while the business action is still unsafe.
This is the right altitude for payments. The idempotency key has to belong to the user's intent, not the model's current turn, because retries are exactly where the agent stops being the interesting part. I also like the read/write split. A server can be safe for balance checks and still too blunt for money movement unless the mandate is narrow enough to audit later.
Exactly — "the model's current turn" is the phrase I was reaching for and didn't quite land in the post. That's the seam. And your second point is the one people skip past: a server can pass a balance-check eval clean and still be the wrong thing for money movement, because the eval never asked the mandate to be narrow enough to reconstruct later. Same server, two different risk classes.
Really useful breakdown of mandate vs key. The time-boxed and revocable properties are what actually matter operationally when something goes sideways and you need to kill a credential without rotating everything.
On your closing question: I am seeing some movement here. Teams building agent-native payment infrastructure (CAI Labs being one example) are designing settlement that lives inline where the agent executes, so the server can express retry safety guarantees rather than punting it to whichever client happens to call the tool.
Pushing retry-safety down so the server expresses the guarantee instead of every caller reinventing it is the right direction. The bar I'd hold any of these approaches to is verifiable, not just declared. If the server says it's idempotent, I still have to trust it; if I can prove a replay collapsed to a single charge, I don't have to. That's the difference between a safety property and a marketing line, and it's the thing I'd want to see before letting settlement live inline with execution. How does that assurance actually surface to whoever's on the other end of the tool call?
On your closing question: the spec does have annotations for exactly this — readOnlyHint, destructiveHint, idempotentHint — but "hint" is doing real work there. They're advisory, declared by the server about itself, and nothing verifies them. So in practice it's still on the caller, and your NON_RETRYABLE set is the honest version of it: a list the client owns rather than a claim the server makes.
The part I'd add is what to do when you don't control where the key is minted.
Your example works because the client creates the intent before the tool is ever called. Often you're on the receiving end of somebody else's retry instead — a webhook, an inbound email, any queue with at-least-once delivery — and no key ever arrives.
There you have to derive one from the payload: hash the fields that make it the same real-world event, and treat a match inside a short window as a redelivery. It works because a redelivery is byte-identical by definition, while a genuine second event almost never is. Cruder than a real key, and you have to choose the fields and the window deliberately rather than by feel — but it turns "have I already done this" into something answerable without the sender's cooperation.
Same shape as your payment case, either way: the guarantee has to sit below the layer that's allowed to be jittery.
This is the comment I was hoping someone would leave. You're right that the hints exist, and "hint" is the whole problem. readOnlyHint, destructiveHint, idempotentHint are the server describing itself, unverified, so I can't build a boundary on them any more than I'd trust a query that swears it's a SELECT. The NON_RETRYABLE set is ugly precisely because the client owns it.
The derived-key point is the part I left out and shouldn't have. At-least-once delivery is exactly where no key arrives and you're reconstructing intent from the payload. Hashing the fields that make it the same real-world event, matched inside a short window, is the same fallback we use on inbound webhooks, and choosing the fields and the window is the whole game. Too many fields and a legitimate retry looks new; too few and two real events collide. I've never found a window that generalises, so we set it per event type by hand and lean on the natural cadence of that event. Have you landed on a saner default, or is it always hand-tuned for you too?
Hand-tuned here too, and I've stopped hunting for the general one — but I think the reason why is the useful bit.
The window is only load-bearing when the key is fuzzy. Where I can match on something byte-identical, the window stops being the thing making the decision and becomes a safety net, so it can be generous and it's only ever wrong in the safe direction. Two genuinely different events that serialise to the same bytes, close together, basically don't happen.
The moment I have to normalise or fuzzy-match to build the key, the window is suddenly doing the actual work, and that's where I've never found a default either. So I've come round to treating "do I need a window at all" as the first question rather than "how long should it be".
On picking the fields, the rule that's held up: include what makes it the same event from the sender's point of view, then explicitly drop anything they regenerate per attempt — timestamps, message ids, attempt counters, retry headers. When a legitimate retry looks new to me it's almost never that I used too many fields, it's that one volatile field crept in that had no business being in the key.