Dev.to Security 🔐 Cybersecurity 👁 0 📖 4 min read

Your MCP Eval Checklist Has an Auth Row. In Payments It's the Whole Table.

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, OAut

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?

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.