DEV Community

Saurabh Pathak
Saurabh Pathak

Posted on Edited on

Stop your AI agent deleting things: human approval for destructive MCP tools

Your AI agent should not be able to delete a file, send an email or drop a table just because it decided to. "Please ask first" in a system prompt is a request. It is not a control.

If you build MCP (Model Context Protocol) servers, you can enforce the rule in code. I wrote a small TypeScript library, mcp-safe-actions, that wraps a dangerous tool so it does nothing until a human approves.

Repo: https://github.com/techsaurabh-394/mcp-safe-actions

The problem

A prompt-injected or confused model can call delete_file("../../important"). Two things are true at once:

  • The right place for a human decision is the client UI. MCP has a feature for this, elicitation.
  • Not every client supports elicitation yet, so you need a safe fallback.

The pattern: preview, approve, execute

You split a dangerous tool into two functions. preview describes what will happen and changes nothing. execute runs only after approval.

import { rmSync, statSync } from "node:fs";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { registerConfirmedTool, fileAudit } from "mcp-safe-actions";

const server = new McpServer({ name: "files", version: "1.0.0" });

registerConfirmedTool(server, {
  name: "delete_file",
  description: "Permanently delete one file.",
  inputSchema: { path: z.string() },
  preview: ({ path }) => ({
    summary: `Permanently delete "${path}" (${statSync(path).size} bytes)`,
    details: ["This cannot be undone."],
  }),
  execute: ({ path }) => {
    rmSync(path);
    return { content: [{ type: "text", text: `Deleted ${path}` }] };
  },
  audit: fileAudit("./audit.jsonl"),
});

await server.connect(new StdioServerTransport());
Enter fullscreen mode Exit fullscreen mode

The tool is listed with destructiveHint: true.

Two approval paths

Client supports elicitation: the server asks the human through the client UI. The model never sees the approval step, so it cannot approve itself. Declined, cancelled, timed out or crashed means nothing runs.

Client does not support elicitation: the server returns the plan plus a one-time token. The assistant has to show the plan to the user and call again with the token. The token is single-use, expires, and only works for the same tool with the same arguments.

The mode option picks the behaviour: auto (default), elicit or token.

How I tested it

Safety code needs tests that try to break it. From the repo:

  • 64 tests: unit, end-to-end with a real MCP client and server in memory, property tests (300 random argument objects), and stdio tests that spawn the example server as a child process.
  • 100% line coverage of src/.
  • A mutation check (npm run mutation) that breaks the code on purpose in 20 ways, such as a reusable token, approval skipped on error, or secrets in the log. It fails if the tests do not notice. All 20 are caught.

The mutation check also exposed one weak test of mine, which I fixed. It is written up in docs/TESTING.md.

What it does NOT do

  • Token mode is a speed bump, not proof of a human. A model could call twice without asking. For actions that must have a human gate, use mode: "elicit".
  • Your preview must be truthful. execute() should re-check state that changed since the preview.
  • It does not sandbox your server. It gates the tools you wrap. Keep least-privilege credentials and validate inputs.
  • Elicitation depends on the client. Support differs by client and version.
  • Version 1.2.0 is on npm. Install with npm install mcp-safe-actions.

Try it

git clone https://github.com/techsaurabh-394/mcp-safe-actions.git
cd mcp-safe-actions
npm install
npm run demo   # prints both approval flows
npm test
Enter fullscreen mode Exit fullscreen mode

Update: two features I listed as "next" are now released (v1.2.0)

  • autoApprove policy: a narrow function you write can let low-risk calls run without a prompt. Only an exact true skips the human. A throw, or a truthy value like "yes", still asks. mode: "elicit" ignores it.
  • Approval channel for headless servers: mode: "channel" with webhookApprover(url) sends the plan to your own service and approves only on HTTP 2xx with {"approved": true}. Errors and timeouts mean not approved. A plain Slack incoming webhook cannot return an answer, so you need a small service in between.

Both are covered by tests and the mutation check, and I wrote down what they do not guarantee in docs/USAGE.md.

What is next

A Python version for FastMCP servers. If you build MCP servers, I would like to know which of your tools you would not let a model call unattended, and what approval flow you would want for them. Issues and PRs are welcome.

Top comments (2)

Collapse
 
autenai profile image
Auten •

Question on autoApprove: does the policy only see the tool arguments, or can the server hand it extra context it looked up first? For a screen-control MCP like ours, click(element_id) is harmless or a payment depending on what that element is, so the tool name alone tells you nothing.

What we would not run unattended is anything where the target says buy, delete, send or grant permission. Gating on the resolved label of the thing being clicked has worked better for us than gating per tool.

(From the Auten team, we build a computer-use MCP.)

Collapse
 
alexcodebytes profile image
Oleksandr •

The mutation check that breaks the code in 20 ways and verifies the tests catch each one is the most impressive part of this. Most libraries test the happy path and call it done. A mutation check is how you prove your safety code actually works.