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 (2)
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.
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.