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()); 

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:

  • 49 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 10 ways, such as a reusable token, approval skipped on error, or secrets in the log. It fails if the tests do not notice. All 10 are caught.

The mutation check also exposed one weak test of mine. 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.
  • It is not on npm yet. Install from GitHub: npm install github:techsaurabh-394/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 

What is next

Approval channel hooks (Slack, webhook) for headless servers, a policy mode that auto-approves low-risk calls and always asks for high-risk ones, and 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.