{"slug": "how-to-add-approval-gates-to-mcp-server-actions-before-they-touch-production", "title": "How to Add Approval Gates to MCP Server Actions Before They Touch Production Data", "summary": "A developer outlines a server-side approach to adding human approval gates to MCP (Model Context Protocol) tool calls, arguing that MCP's built-in annotations like readOnlyHint and destructiveHint are advisory hints rather than enforcement. The proposed design places the gate at the integration layer that owns the side effect, classifies actions into tiers, and denies unclassified actions by default, with an example implementation using Corsair's permission modes and MCP adapters.", "body_md": "[An agent connected to your CRM through MCP gets a simple instruction: clean up the inactive accounts. It finds 214 matches, picks the delete tool, and fills in the arguments with total confidence. Nothing about that sequence is unusual. The only thing missing is a moment where a person sees the exact action and says yes or no.](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F84gzmr5dtfuz7l1wd4ad.png)\n\nThat moment does not exist by default. MCP standardizes how agents discover and call tools, but it deliberately leaves approval to the people building the system. The specification says a human should be able to deny a tool call, then stops there. The safety flags a server can attach to a tool are hints, not locks, and a client that ignores them breaks nothing.\n\nHuman in the loop approval in MCP is a design choice, not a switch you flip. Learning how to add approval gates to MCP server actions comes down to one principle: the gate has to sit where the side effect happens, enforced by code the agent cannot route around. Get that right and the rest becomes engineering.\n\nPausing every tool call for a human is not a safety strategy. It trains reviewers to click Approve without reading. The goal is to spend human attention only where a mistake is expensive.\n\nA practical way to decide is to sort every action an agent can take into tiers, using three questions:\n\nEnvironment matters as much as the action itself. The same delete call that is harmless against a sandbox deserves a gate once it points at live customer data, so production versus staging should be part of the policy.\n\nMost integration layers encode this thinking as a policy per integration. Corsair, for example, labels every endpoint as read, write, or destructive, then maps those labels to allow, require approval, or deny through its [permission modes](https://docs.corsair.dev/concepts/permissions): open, cautious, strict, and readonly. Cautious lets agents read and write freely but sends destructive calls to a human. Strict also gates ordinary writes and blocks destructive actions outright.\n\nWhatever tooling you use, write your tiers down before you write any gating code, because the tiers are the policy.\n\nMCP tool definitions can carry annotations such as `readOnlyHint`, `destructiveHint`, `idempotentHint`, and `openWorldHint`. They look like a ready-made risk system, and it is tempting to wire them straight into approval logic. They are not enforcement, for four reasons.\n\nUse annotations for what they are good at, which is helping clients decide what to show a user. Then enforce on the server.\n\nA real approval gate has five properties:\n\nThe shape of the code is small:\n\n``` js\nasync function runAction(call, caller) {\n  const policy = policyFor(call.action); // allow, require_approval, or deny\n\n  if (policy === \"deny\") return blocked(\"Blocked by policy\");\n  if (policy === \"allow\") return execute(call);\n\n  const approved = await approvals.consume(\n    caller,\n    call.action,\n    hash(call.args)\n  );\n\n  if (!approved) {\n    await approvals.createPending(caller, call.action, call.args);\n    return pendingApproval();\n  }\n\n  return execute(approved.frozenArgs); // runs once, with the reviewed arguments\n}\n```\n\nTwo details matter more than they look.\n\nFirst, put the gate at the layer that owns the integration, not at a single tool. If a delete can be reached through an MCP tool, a background job, or a script, all three must pass the same check, otherwise gating one route simply teaches the agent to take another.\n\nSecond, deny by default for anything unclassified. A new tool with no tier stays gated until someone decides what it is.\n\nThis is the reasoning behind Corsair's [MCP adapters](https://docs.corsair.dev/mcp-adapters/mcp-adapters). They give the agent three tools for listing operations, inspecting schemas, and running scripts, and the permission policy gates the calls that run through them. The check sits at the integration endpoint, so it does not depend on how any one tool describes itself.\n\nOnce the gate exists, the real work is the lifecycle around it. Every human in the loop approval flow has three phases.\n\nWhen a call needs approval, the server does not execute it and does not guess. It records a pending request and tells the agent what happened.\n\nThere are two ways to hold the call:\n\nCorsair supports both, with asynchronous as the default, and its [hosted approval page](https://docs.corsair.dev/hub/permissions) gives reviewers an approve or deny screen without building one yourself. The decision record stays in your own database either way.\n\nGive every pending request an expiry and treat silence as a no. Corsair applies a ten-minute window unless you set another, and its docs recommend denying on timeout.\n\nAfter approval there are two clean ways to continue.\n\nThe agent can retry the same call and the server matches it to the approved record, or the server can execute the stored request itself the moment the decision lands.\n\nIn both cases, the action runs with the arguments the reviewer saw. Never ask the model to rebuild the call from memory, because a slightly different argument is a different action.\n\nAn approval flow without a record is just a delay.\n\nFor every gated call, capture:\n\nKeep approval and success as separate events. A yes does not mean the delete worked, because the downstream API can still reject it or time out.\n\nThe spec expects clients to log tool use for audit, but a client log is not your log. Record events on the server, using [hooks](https://docs.corsair.dev/concepts/hooks) that run before and after each call or equivalent middleware, so no route can skip the record.\n\nThe July 28, 2026 revision of the MCP specification changes how a server can ask a person for input in the middle of a tool call, which makes it the most important update for anyone building approval workflows with MCP servers.\n\nBefore, a server that wanted a confirmation sent its own request back to the client over a connection that had to stay open. The new revision makes the protocol stateless, drops the initialize handshake and sessions, and replaces those server-initiated requests with Multi Round Trip Requests, usually shortened to MRTR.\n\nAny request can land on any server instance, which suits approval flows that may wait on a human.\n\nThe flow works like this:\n\n`input_required`. The result carries one or more input requests, such as an elicitation asking the user a question, and optionally an opaque `requestState`.\nHere is what an approval request looks like on the wire:\n\n```\n{\n  \"resultType\": \"input_required\",\n  \"inputRequests\": {\n    \"approve_delete\": {\n      \"method\": \"elicitation/create\",\n      \"params\": {\n        \"mode\": \"form\",\n        \"message\": \"Delete 214 inactive customer records from production?\",\n        \"requestedSchema\": {\n          \"type\": \"object\",\n          \"properties\": {\n            \"approve\": {\n              \"type\": \"boolean\"\n            }\n          },\n          \"required\": [\"approve\"]\n        }\n      }\n    }\n  },\n  \"requestState\": \"signed, expiring blob\"\n}\n```\n\nThe retry carries the person's answer in `inputResponses`, next to the same `requestState`.\n\nElicitation has three possible outcomes: accept, decline, and cancel. Treat only an accept with `approve` set to `true` as permission to run the action, and handle the other two as a clean refusal.\n\nFour rules keep this flow safe:\n\nKnow the limits of in-band approval.\n\nAn elicitation answer travels back through the client, and the spec only says clients should offer approval controls, so a client that automatically accepts can answer yes on a human's behalf. Form mode also exposes the exchange to the client and the model's context, and it must never be used to collect secrets.\n\nFor changes to production data, a safer pattern is URL mode elicitation, added in the November 2025 revision. It sends the reviewer to a page on your own domain, where your server authenticates them. The client only learns that the user agreed to open the link, and your server learns who actually approved.\n\nIn practice, many teams use both: a quick in-band confirmation for lower tiers, and an out-of-band page for tiers four and five.\n\nReviews that take hours do not fit a single held request at all. For those, return a pending result and resume later, or look at the Tasks extension, which moved out of the core protocol in this revision and lets clients poll for the status of long-running work.\n\nOlder clients may not speak the new revision yet, so keep the asynchronous pattern from the previous section as a fallback.\n\nPut the earlier sections together and the whole policy fits on one page.\n\nApproval fatigue is a security problem in its own right.\n\nWhen reviewers face a flood of low-stakes prompts, they stop reading them. Show the exact action in plain language, include the target and the count, save prompts for the tiers where a mistake is costly, and let routine safe actions pass with logging instead.\n\nApproval gates work best when they live in the integration layer, close to the API call they protect, instead of being rebuilt inside every MCP server.\n\n[Corsair](https://corsair.dev/) is an open source TypeScript integration layer for AI agents that applies permission modes per integration, holds risky calls for human approval with frozen arguments, and keeps the decision record in your own database. It connects to agents through MCP adapters, so the same policy covers every route an agent can take.\n\nStart with one destructive action, gate it, and test approval, denial, and expiry before you widen the policy.\n\nClassify each action by risk, then enforce a policy in server code right before the side effect. Calls that need approval create a pending record, return a clear message to the agent, and run only after a verified person approves, using the arguments they reviewed. Log the request, the decision, and the result.\n\n`destructiveHint` enough to gate dangerous actions?\nNo. The specification treats annotations as hints and tells clients to consider them untrusted unless the server is trusted. Use them to help clients decide what to display, and enforce approval on the server where the action actually executes.\n\nMulti Round Trip Requests, introduced in the July 28, 2026 specification revision, let a server end a tool call with an `input_required` result and ask the client for input. The client collects the answer and retries the same call with it attached. This lets a server request confirmation without holding a connection open or storing session state.\n\nUse elicitation for quick confirmations on lower-risk actions in interactive clients. For production data and high-value actions, use an out-of-band review page, or URL mode elicitation, where your server authenticates the approver. In-band answers pass through the client, so they are weaker evidence of who actually decided.\n\nGate only the tiers where mistakes are costly, usually external-facing, destructive, and irreversible actions. Show the exact action, target, and record count in plain language, expire stale requests, and let routine low-risk actions run with logging. If reviewers approve nearly everything, tighten the policy or improve what the prompt shows.", "url": "https://wpnews.pro/news/how-to-add-approval-gates-to-mcp-server-actions-before-they-touch-production", "canonical_source": "https://dev.to/corsairdev/how-to-add-approval-gates-to-mcp-server-actions-before-they-touch-production-data-kh2", "published_at": "2026-10-01 13:29:21+00:00", "updated_at": "2026-10-01 13:44:28.688470+00:00", "lang": "en", "topics": ["ai-agents", "agent-protocols", "ai-tools", "developer-tools"], "entities": ["MCP", "Corsair"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/how-to-add-approval-gates-to-mcp-server-actions-before-they-touch-production", "markdown": "https://wpnews.pro/news/how-to-add-approval-gates-to-mcp-server-actions-before-they-touch-production.md", "text": "https://wpnews.pro/news/how-to-add-approval-gates-to-mcp-server-actions-before-they-touch-production.txt", "jsonld": "https://wpnews.pro/news/how-to-add-approval-gates-to-mcp-server-actions-before-they-touch-production.jsonld"}}