◈Home ▸Blog ◇Contact
Home/Blog/Which MCP Tool Calls Need a Human Approval St…
How toAIMCPAI AgentsSecurity

Which MCP Tool Calls Need a Human Approval Step

10 min readBy Miloš Mitrović

Gate a tool on two properties: whether its effect can be undone by the same agent in the same session, and how far the effect reaches when the arguments are wrong. Anything that fails either test needs an approval step, and that step belongs in the client permission layer, because the MCP specification states that clients MUST consider tool annotations untrusted unless they come from trusted servers (MCP spec, Tools). Your job on the server side is to make approval cheap: separate the read path from the write path, and make the write tool refuse to run without a signed plan.

Key takeaways

  • Reversibility and blast radius decide the gate. Whether the tool technically writes is a weaker signal than whether a wrong write can be walked back.
  • destructiveHint and its siblings are advisory metadata. Defaults assume the worst (destructiveHint true, openWorldHint true), and a hostile server can set them to anything.
  • Splitting x_plan from x_apply moves most calls into auto-allow and concentrates the confirmation into one screen with concrete numbers on it.
  • A plan ticket needs integrity protection, principal binding, a short TTL and single-use enforcement. Those are the same rules the spec places on requestState.
  • Server-side elicitation is user experience, not an authorization boundary. A malicious server simply skips it.
  • Approval fatigue is a security failure. Spend caps and per-session limits catch what dialog number 47 will not.

Which tool calls need a human, and which do not

Score each tool on three questions and let the worst answer decide: can the agent undo the effect with another tool it already has, how many records or dollars does one wrong call touch, and do the arguments originate in content the model read rather than text the user typed. The third question matters most and gets skipped most often. A send_email tool whose recipient list comes from a document the agent summarized is a data exfiltration primitive even though it never deletes anything, which is the failure mode covered in defending tool-using agents against prompt injection.

ToolUndoable in sessionBlast radius of one bad callGate
search_ordersN/A, no effectNoneAuto-allow
refund_planN/A, no effectNoneAuto-allow
refund_applyNo, money movedOne order, bounded amountAsk, with amount shown
send_campaignNo, mail is sentWhole list, reputationAsk, plus a recipient count cap
delete_segmentYes if soft deleteOne objectAuto-allow if soft, ask if hard
run_sqlUnknownUnboundedDo not ship it, split it

The last row is the important one. A tool whose blast radius cannot be stated in its own schema cannot be approved intelligently, because the user is being asked to sanction a string they are not qualified to parse at 2am.

Why tool annotations are metadata, not permissions

Annotations describe intent to a client that already trusts you; they enforce nothing. readOnlyHint defaults to false, destructiveHint defaults to true and is meaningful only when readOnlyHint is false, idempotentHint defaults to false, and openWorldHint defaults to true (MCP schema reference). Those defaults are deliberately pessimistic, which is useful: a server that forgets to annotate gets treated as dangerous rather than safe.

What they cannot do is carry a security decision. The spec is explicit that clients must treat annotations from untrusted servers as untrusted input, and it asks clients to show tool inputs to the user before the call is made, specifically to avoid malicious or accidental data exfiltration. So set annotations accurately, because good clients use them to pick a sensible default policy and to render better prompts. Then assume the enforcement lives elsewhere: in the client rules, in your OAuth scopes, and in the server authorization check that runs on every call regardless of what the model asked for.

Split one dangerous tool into a safe pair

The cheapest way to reduce approval load is to stop shipping tools that mix reading and writing. One manage_subscription tool with an action enum forces the client into a binary choice: prompt on every call including the read-only ones, or allow the write path silently. Two tools, get_subscription and cancel_subscription, let the policy be precise, and they make tool names meaningful in audit logs.

The same logic applies to arguments. A write tool that accepts a free-form filter ("cancel everything matching status=trial") has a blast radius the user cannot see at approval time. Take explicit identifiers instead, and if the agent needs to act on a set, make it enumerate the set with a read tool first. That turns one unreviewable approval into one reviewable list. It also makes retries safe, which matters for the reasons described in making MCP tools idempotent for retrying agents.

Build the plan and apply handshake

For anything that moves money, sends mail or deletes data, return a signed plan from a read-only tool and require that plan as the only input to the write tool. The write tool then has nothing to guess: it verifies a ticket, re-derives the effect from the server side data inside it, and executes. The user is approving a computed number, not a natural language intention.

// plan: read only, safe to auto-allow
server.registerTool("refund_plan", {
  description: "Compute exactly what refund_apply would do. Makes no changes.",
  inputSchema: { order_id: z.string(), amount_cents: z.number().int().positive() },
  annotations: { readOnlyHint: true }
}, async ({ order_id, amount_cents }, { authInfo }) => {
  const order = await orders.get(order_id, authInfo.sub);
  if (amount_cents > order.refundable_cents) {
    return { isError: true, content: [{ type: "text",
      text: `Max refundable on ${order_id} is ${order.refundable_cents}.` }] };
  }
  const plan = { order_id, amount_cents, currency: order.currency,
                 after: order.refundable_cents - amount_cents };
  return {
    structuredContent: { plan, ticket: mintTicket(plan, { sub: authInfo.sub, ttlSeconds: 300 }) },
    content: [{ type: "text",
      text: `Refund ${amount_cents} ${order.currency} on ${order_id}, leaving ${plan.after}.` }]
  };
});

// apply: the only tool that moves money, and it cannot run on a guess
server.registerTool("refund_apply", {
  description: "Execute a refund produced by refund_plan. Requires that plan's ticket.",
  inputSchema: { ticket: z.string(), idempotency_key: z.string().uuid() },
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true }
}, async ({ ticket, idempotency_key }, { authInfo }) => {
  const plan = verifyTicket(ticket, { sub: authInfo.sub });  // signature, expiry, principal
  if (!plan) {
    return { isError: true, content: [{ type: "text",
      text: "Ticket invalid or expired. Call refund_plan again and show the user the result." }] };
  }
  if (!(await tickets.consume(ticket))) {
    return { isError: true, content: [{ type: "text", text: "Ticket already used." }] };
  }
  return { structuredContent: await payments.refund(plan, idempotency_key),
           content: [{ type: "text", text: `Refunded ${plan.amount_cents}.` }] };
});

The ticket rules are not arbitrary. The spec applies exactly this list to requestState in the multi round-trip pattern: treat it as attacker controlled input, protect integrity with HMAC or AEAD, bind it to the authenticated principal, give it a short expiry, tie it to the originating request, and enforce single use server side when the operation must happen at most once (MCP spec, Multi Round-Trip Requests). A plan ticket has the same threat model, since it also travels through the model context where anything can rewrite it. Note the error returns: both failures come back as tool execution errors with instructions, so the agent can recover instead of stalling, which is the pattern in returning MCP tool errors agents can recover from.

When to confirm inside the server with elicitation

Use server-side confirmation when the risk depends on data only the server can see, and never as your only gate. In the current spec a server answers tools/call with an InputRequiredResult carrying an elicitation/create request, and the client retries the call with the user response attached. The client returns one of three actions, accept, decline or cancel, and you have to handle all three: decline means the user said no, cancel means they walked away, and treating them the same produces a server that nags forever (MCP spec, Elicitation).

This is genuinely useful for the case where the argument looks harmless and the consequence is not, for example a campaign send whose segment resolved to 400,000 profiles instead of the 4,000 the user expected. The server knows that number; the client does not. What elicitation is not is an authorization boundary, since the code that decides to elicit is the same code that performs the action, so a hostile or compromised server skips it. Two more constraints: form mode MUST NOT be used to collect passwords, API keys or payment credentials (that is what URL mode exists for), and the client owns whether a confirmation dialog is even shown. Implementation details are in adding elicitation to an MCP server.

Write the approval policy in the client

Approval policy is client configuration, and in Claude Code that means allow, ask and deny rules keyed on mcp__server__tool in settings, with deny winning over allow (Claude Code identity and access management). Rules target a whole server or an exact tool name, so tool naming is a security interface: refund_plan and refund_apply can be governed separately, while a single refund tool with a dry_run flag cannot.

{
  "permissions": {
    "allow": [
      "mcp__billing__refund_plan",
      "mcp__billing__list_refunds"
    ],
    "ask": [
      "mcp__billing__refund_apply",
      "mcp__marketing__send_campaign"
    ],
    "deny": [
      "mcp__billing__close_account"
    ]
  }
}

Put this in the project settings file that ships with the repository so every engineer and every CI agent inherits the same gates, and keep the deny list for tools no automated session should ever touch. Pair it with least privilege on the token: the MCP security guidance calls out scope inflation directly, recommending a minimal baseline scope set with incremental elevation rather than a catalog of wildcards granted up front (MCP security best practices). A prompt the user clicks through cannot exceed a scope the token never carried, which is also why the server must validate audience rather than forward client tokens, covered in why your MCP server must not pass tokens through.

Keep approvals meaningful when the agent fans out

Treat click-through rate on your approval prompts as a metric, because an approval a user grants without reading is worse than no approval at all: it manufactures consent and creates an audit trail that implies review happened. Two numbers tell you whether the design is holding. Count prompts per completed task, and measure median time to decision. When prompts per task climbs past roughly five, or median decision time drops under two seconds, the gate has become a button and you should move enforcement to something that does not depend on attention.

The mechanisms that survive fatigue are quantitative. Cap total spend or total recipients per session on the server, since the spec puts rate limiting and access control on the server as a MUST rather than treating them as client concerns. Require a fresh plan ticket for each write so a single approval cannot be replayed across a loop. Log every call with the authenticated principal, the arguments and the approval decision, because an incident investigation that cannot distinguish an approved call from an auto-allowed one is guesswork. For the fan-out mechanics see rate limiting an MCP server against agent fan-out, and for budget ceilings on spending agents see agents that spend.

Trade-offs and what I would ship

The honest tension is that every approval you add buys safety with attention, and attention runs out faster than most designs assume. Ask-on-everything feels defensible in a design review and degrades within a day of use into reflexive approval, at which point you have the latency cost and none of the protection. Allow-everything is the opposite failure and at least it is legible: nobody believes a review happened.

What I ship is narrow: auto-allow every read tool, ask on exactly the tools that move money, send external messages or perform a hard delete, and deny the handful that should never run unattended. Under that, the plan and apply split carries most of the weight, because it converts a vague approval into a specific number and makes the write path unreachable by a model that hallucinated an identifier. Server-side elicitation goes only on calls where the server knows a magnitude the client cannot compute. Spend and volume caps run underneath all of it, since they are the only control that still works at 3am on the two hundredth tool call of a long-horizon run.

One thing I would not do is rely on annotations to drive policy in a multi-server setup. The moment a client aggregates tools from servers of differing provenance, a single readOnlyHint: true on a tool that writes turns your gate off, and the spec already tells you not to trust that field. Set them honestly for the clients that render them, and write your actual policy against tool names you control.

Sources

M
Miloš Mitrović
Email Marketing for Ecommerce

Have a question or a project?

Whether it is about this post or a system you want built, I'm happy to talk.

Get in touch

404

Post not found. It may have been moved or the link is incorrect.

← Back to the blog
Summarize with AI
ChatGPT, Perplexity, and Grok open with the prompt ready to run. Claude, Gemini, and Copilot open a chat with the prompt copied; press Ctrl+V (Cmd+V on Mac) to paste. The full text is included, so it works even without web access.