Development

Approval Gates for High-Risk MCP Tools

Antonio Evans9 min read

A cutaway view of a server rack labelled MCP, with three tool slots drawn as drawers.
Key points

Key points

  1. 1Classify every MCP tool as read-only, write-scoped, destructive or external before you decide whether it needs a human approval gate.
  2. 2Gate any tool that writes outside a sandbox, spends money, sends data to a third party or deletes state, and let the rest run unattended.
  3. 3The gate persists the call with its arguments, holds the agent pending, and writes an audit row with approver, timestamp, tool name, hashed arguments and decision.
  4. 4Re-check credentials and argument hashes when a paused call resumes, because the grant that was valid at pause time may not be valid later.

What "high-risk" means for a tool call

A tool in an MCP server is a named action with a JSON Schema for its inputs, and the model is expected to invoke it, with a human in the loop to grant approval the MCP tools documentation. That last clause is often skipped in practice. On a shared server, a client can call tools/list, see everything the server exposes and call any of it through tools/call the MCP tools documentation, so the server itself is where risk labels have to live.

The distinction that matters is not read versus write. It is whether an action can be undone. Revoking a token after a destructive call is forensic, not protective, because the action already ran the Scalekit human-in-the-loop guide. That single test sorts most tools quickly, and it explains why a bulk update and a single-field update deserve different treatment even when they share an OAuth scope.

MCP already ships a vocabulary for this. The ToolAnnotations interface carries four boolean hints: readOnlyHint, destructiveHint, idempotentHint and openWorldHint the MCP annotations post. One critical caveat: every property is a hint, not a contract, and clients must treat annotations as untrusted unless they come from a trusted server the MCP annotations post. The defaults assume the worst: a tool with no annotations is treated as non-read-only, potentially destructive, non-idempotent and open-world the MCP annotations post. Use the hints as an input to your own tiering, never as the gate itself.

The other reason to tier per tool rather than per server: risk lives in combinations. A tool that reads untrusted content and a tool that sends data outward are each manageable alone and dangerous together, because tainted content can steer an outbound call the MCP annotations post. A server-wide "require approval" switch either blocks everything or nothing.

The four-tier scale and the decision rule

Four tiers cover the useful range. Tier the tool by what it touches and whether the effect is reversible.

TierDefinitionTypical toolsHuman gate
Read-onlyReads state, no side effects, no external egressSearch, list, get-by-id, schema introspectionNo, log only
Write-scopedWrites inside a sandbox, staging area or reversible storeCreate draft, update a scratch record, add a commentNo by default, gate on scope widening
DestructiveDeletes, overwrites, bulk updates, merges, permission changesDelete record, force push, bulk update, role changeYes, every call
ExternalSpends money, sends data to a third party, or reaches an open-world systemPayment, email send, webhook to a partner, ticket in a vendor systemYes, every call, with argument review

The decision rule, stated so a reviewer can apply it without debate: put a gate in front of any tool where the answer to "can this be undone in under five minutes with one command?" is no, or where the answer to "does this cross a trust boundary?" is yes. Everything else runs unattended, logged but not paused.

A worked example makes the trade-off concrete. A shared server exposes 40 tools. Suppose 26 are read-only, 9 are write-scoped sandbox operations, 3 are destructive (delete, bulk update, revoke access) and 2 are external (invoice send, partner webhook). Gating the 5 high-risk tools leaves 35 running freely. If each flagged call arrives at roughly 12 per working day, a reviewer handles about 60 approvals per day across the team, which is a sizeable but survivable load, and it is far cheaper than reviewing all 40. Gate everything and the review queue becomes noise; reviewers start rubber-stamping, and the control stops being a control.

The same logic applies to scope escalation. A mid-workflow request for a broader scope is itself an approval event, separate from any single tool call the Scalekit human-in-the-loop guide. Treat it as a fifth trigger, or fold it into the external tier as you prefer, but do not let it happen silently.

How the gate queues a call

The gate sits between the model's request to execute and the tool's actual execution. When a flagged tool is called, the server does not run it. It records the intent, returns a pending status to the agent, and schedules the decision.

The pause can be short (minutes, while someone reads a diff) or long (days, waiting on a budget owner). Different mechanisms suit different waits. Cloudflare's Agents docs describe three patterns, chosen by who introduces the pause and where it lands: MCP elicitation handled by the client for server-initiated requests, a durable workflow approval that can wait months without keeping an agent running, and a Code Mode approval that holds a connector call until a configured expiry the Cloudflare human-in-the-loop patterns. For most shared-server gates, the durable workflow is the right default because a paused expense escalation should not depend on a process staying warm.

The queue-then-execute model also decouples approval latency from agent latency: the agent does not block a worker while a person thinks about it the Scalekit human-in-the-loop guide. A sketch of the state a queued call needs:

{
  "request_id": "req_01J8XK9",
  "tool": "billing.send_invoice",
  "tier": "external",
  "args_hash": "sha256:9f2c...a71",
  "args_preview": {"customer_id": "c_8841", "amount_minor": 210000},
  "agent_id": "agent_ops_3",
  "user_id": "u_5590",
  "status": "pending",
  "expires_at": "2026-05-04T18:00:00Z",
  "decided_by": null,
  "decided_at": null,
  "decision": null
}

Store the full arguments alongside the hash if you need replay, or store only the hash and a redacted preview if arguments carry secrets. The hash is what makes the audit row trustworthy: it proves the approved arguments are the arguments that ran, and it lets you detect a swap between approval and execution.

Approval and rejection are both outcomes the agent must handle. Cloudflare's agent API exposes them as explicit methods, approve() and reject(), which set the store or workflow state and let the waiting task continue or terminate the Cloudflare human-in-the-loop patterns. Mirror that in your server: a rejected call should return a structured result the model can reason about (what was rejected, and why), not an opaque error that pushes the model into a retry loop.

The resumption check most implementations miss

The failure mode that bites production systems is in the window between pause and resume. When an agent pauses for approval, the credential valid at pause time is not guaranteed to be valid at resume time the Scalekit human-in-the-loop guide. Tokens expire, users revoke consent, connections are removed from a vault, and the world the agent was operating in has moved on.

So resumption is a fresh authorization decision, not a replay. Before executing an approved call, the gate should re-verify four things: the credential is still live for that user and connection, the user still has the scope the tool needs, the arguments hash matches what was approved, and the call has not expired. If any check fails, the call does not execute; it returns to the approver with a reason.

The vault pattern helps here: hold credential state in a vault across the pause window, and have the agent reference a user identity and connection name rather than a raw token the Scalekit human-in-the-loop guide. That way the credential can rotate mid-wait without invalidating the queued call, and the audit row still points at a human identity rather than a shared service account. MCP's own authorization guidance recommends authorization when a server accesses user-specific data, when you need to audit who performed which actions, and when your server grants access to APIs requiring user consent the MCP authorization tutorial; an approval gate is the piece that makes "who performed" answerable for writes.

One more resumption detail worth writing into the spec: single-use approvals. Once a queued call executes or expires, its approval token should be spent. Otherwise a leaked approval record becomes a permanent key to a destructive tool.

The audit row that records who approved

The audit row is the deliverable that survives the incident review. It should answer five questions without needing the application's help: who approved, when, which tool, with what arguments, and what they decided.

FieldExampleWhy it is there
approver identityu_5590, verified via SSO subjectProves a human, not the agent, made the call
timestamp2026-05-03T14:12:07ZEstablishes the decision pre-dated execution
tool and tierbilling.send_invoice, externalTies the decision to the risk classification in force
args hashsha256:9f2c...a71Proves approved arguments equal executed arguments
decisionapproved, rejected, expiredDistinguishes "ran with consent" from "never ran"
correlation IDsagent ID, request ID, trace IDLets you join the row to logs and traces

Build the row before execution and update it after, so an execution that fails halfway is still attributable. Separate the hash from the preview: argument previews are for humans reading a queue, hashes are for verification, and mixing the two invites a situation where redacted data silently changes what was approved.

The audit obligation is not decorative. A framework for agent governance needs evidence that logical access controls functioned at the time of each action, and for writes that means evidence a person authorized that specific operation at a timestamp; a scope grant alone does not satisfy it the Scalekit human-in-the-loop guide. If your tool layer speaks OpenAPI underneath, the design-first discipline pays off here too: descriptions written before the code tend to carry accurate parameter names and schemas, while descriptions back-filled from code drift the OpenAPI best practices guide. Accurate schemas make accurate previews, and accurate previews make approvals real rather than ceremonial.

Making the review queue usable

A gate that asks the reviewer to read raw JSON will be approved blind within a week. The queue needs three things.

First, a rendered preview per tier. For a destructive tool, show the blast radius: how many records match, what the diff is, whether the target is production. For an external tool, show the recipient, the payload, and the amount. The Cloudflare workflow example reports progress to the human with a message naming the amount awaiting approval the Cloudflare human-in-the-loop patterns, which is the minimum standard.

Second, ownership. Route each tier to the right reviewer: finance for invoices, platform owners for deletes and permission changes, and the data owner for external sends. A single queue for everything means the wrong person approves the wrong risk, or nobody does.

Third, expiry and escalation. Set a TTL per tier (hours for a checkout-style send, days for a bulk cleanup) and define what happens on timeout. Silent auto-approval defeats the gate; silent expiry is honest and forces the agent to ask again. Log the timeout as a decision, not as an error.

For tools whose tool names differ across servers, name the gate's identifiers carefully. A shared client can see only one flat list of names, and duplicates break connections, which is why namespacing by server name is a known fix the LiteLLM namespacing issue. Your audit row should carry both the namespaced name and the underlying server, or queries will collide when two teams both ship a delete_record.

If you would rather have the queue, the vault and the resumption checks assembled for you than build them from scratch, a one-day build with a Quanta specialist is often the faster path: describe the server and the tools you expose at Quanta.

Rolling it out without stalling the agent

Ship in three passes. First, classify all tools and turn the gate on in log-only mode, recording would-be approvals without pausing anything. After a week you have a real distribution: which tools actually fire, with what arguments, and how often each tier triggers. Use those numbers to set the TTLs and the routing, and to check that the 5 heavy tools do not in fact account for 400 calls a day.

Second, enable the gate for destructive and external tools only, keep write-scoped tools in log-only, and watch the reviewer load. If approvals spike above what the team can read carefully, split the queue by owner rather than lowering the tier.

Third, tighten. Make write-scoped tools gate on scope widening (bulk sizes, target environment, cross-tenant flags), add single-use approval tokens, and alert on the metric that matters: approvals completed in under a few seconds with no preview opened. That metric tells you the gate has become theater long before an incident does.

If you need a second opinion on where the tier lines fall for your specific server, or want an expert to review the audit schema against your compliance obligations, a consulting call is the right next step.

FAQ

FAQ

Does every tool in a shared MCP server need approval?

No. Gate only the tools that write outside a sandbox, spend money, send data to a third party or delete state. Read-only tools and reversible sandbox writes should run unattended and just be logged, because gating everything floods the queue and trains reviewers to approve without reading.

Can I just rely on the tool annotations for risk tiering?

Only as an input. The readOnlyHint, destructiveHint, idempotentHint and openWorldHint fields are explicitly hints rather than contracts, and clients are told to treat them as untrusted unless the server is trusted the MCP annotations post. Unannotated tools default to the pessimistic reading, so write your own tier per tool and reconcile it against the hints.

Where does the approval checkpoint belong?

In the server, before execution. The pause can be implemented as a durable workflow gate that survives for days or as a client-side elicitation for short waits the Cloudflare human-in-the-loop patterns. Putting it only in the client means every new host application has to reimplement the control.

What happens if the credential expires while the call is pending?

The call must not run. A credential valid at pause time is not guaranteed to be valid at resume time the Scalekit human-in-the-loop guide, so the gate re-verifies credential, scope, argument hash and expiry on resume and routes failures back to the approver instead of executing.

Should the audit row store the arguments or a hash?

Both, with care. Store a hash to prove the approved arguments are the arguments that ran, and a redacted preview for humans. Keep the two separate so a change to the redaction rules cannot silently change what was approved.

Sources

Sources

  1. Tools
  2. Human-in-the-Loop Tool Calling: Approval Gates for AI ...
  3. Tool Annotations as Risk Vocabulary: What Hints Can and ...
  4. Human-in-the-loop patterns · Cloudflare Agents docs
  5. Understanding Authorization in MCP
  6. Best Practices - OpenAPI Documentation
  7. Namespaced MCP Tool Names #12271 - BerriAI/litellm
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles