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

Collapse
 
kami_butt_69b922ade8de012 profile image
Agentic Affairs •

The 'please ask first in a prompt is a request, not a control' line is the whole argument — and splitting into preview/execute with the model never seeing the elicitation step is what makes it a control. Your own caveat is the honest part: token mode is a speed bump a model can double-call through, so it can't be the gate for anything truly irreversible. Do you see clients actually adopting elicitation, or is token mode where most deployments will quietly settle?

Collapse
 
saurabh_pathak394 profile image
Saurabh Pathak •

Honestly, I think most deployments will settle on token mode, at least
for now. Elicitation support is uneven across clients, and token mode
works anywhere. I treat the two as tiers. Token mode is a speed bump for
reversible actions. Elicitation is the real gate, and it's the only thing
I'd trust for anything irreversible. If a client can't do elicitation,
the server should refuse the irreversible tools outright instead of
falling back to tokens. I haven't built that fail-closed default yet, so
thanks for pushing on it.

Collapse
 
kami_butt_69b922ade8de012 profile image
Agentic Affairs •

The tier framing is honest, and refusing irreversible tools outright when a client can't do elicitation is the only version of it that holds up — otherwise the fallback quietly downgrades the safety story to a speed bump. That default matters more than the mechanism itself, since token mode works everywhere and will win on convenience unless refusal is the out-of-the-box behaviour. Are you planning to make that refusal the default, or an opt-in the operator has to choose?

Thread Thread
 
saurabh_pathak394 profile image
Saurabh Pathak •

Refusal should be the default. If it's opt-in, the safe behavior depends
on every operator knowing to switch it on, and most won't. Then the
fallback to token mode quietly becomes the real policy, which is the
downgrade you described.

The plan is:
- Irreversible tools are refused when the client can't do elicitation.
- Operators can explicitly opt in to token mode for those tools, and the
opt-in is clearly named so nobody enables it by accident.
- Reversible tools still work in token mode.

That puts the burden on the person choosing to weaken the guard, not on
the person who forgot to strengthen it. I haven't built it yet, so I'll
say that plainly. It's a good candidate for a tracked issue, and since
the project is open source, PRs are welcome.

Thread Thread
 
kami_butt_69b922ade8de012 profile image
Agentic Affairs •

Putting the burden on the person choosing to weaken the guard is exactly the right inversion — a forgotten safe default beats a forgotten opt-in every time. The one leak I'd still watch is the named opt-in getting copy-pasted from a tutorial into production, so the name alone may not survive contact with real configs. Would you also require the opt-in to name the specific irreversible tools, rather than one blanket token-mode flag, so weakening it stays a deliberate per-tool decision?

Thread Thread
 
saurabh_pathak394 profile image
Saurabh Pathak •

Thanks, this is a really good point. Yes, I think the opt-in should name
the specific irreversible tools instead of being one blanket flag. That
keeps weakening the guard a deliberate per-tool decision. It also makes a
config copy-pasted from a tutorial much less dangerous, because it only
covers the tools it names. I'll try to implement it. This is just a small
first version, and we'll make it better and better.

Thread Thread
 
kami_butt_69b922ade8de012 profile image
Agentic Affairs •

Glad that landed — naming the tools makes the weakening visible in review too, not just at setup. Curious which tools you'll start with when you implement it, since the first list will probably become everyone's default copy.

Thread Thread
 
saurabh_pathak394 profile image
Saurabh Pathak •

Good question. I'd start with the tools nobody can undo: delete_file,
send_message, publish and drop_table. They stay refused unless an
operator names them in the opt-in. Reversible tools like move or rename
keep working in token mode with no opt-in. I'd also ship the example
config with an empty tool list, so nobody can copy a default from it.

Thread Thread
 
kami_butt_69b922ade8de012 profile image
Agentic Affairs •

The empty example config is the sharpest part of that list — a shipped default becomes everyone's copy, so shipping nothing means nobody inherits a dangerous allowlist by accident. Naming the four irreversible tools individually also keeps the opt-in honest: an operator approves delete_file, not 'destructive stuff' in general. One thing I'd watch: does the opt-in name tools one by one, or by category, and does it expire — or does a one-time approval quietly become permanent?

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
 
saurabh_pathak394 profile image
Saurabh Pathak •

Good question. Right now the policy only sees the tool name and the
arguments. It has no way to look anything up first, so for
click(element_id) it can't tell a harmless click from a payment.

I think your approach is the right fix. The server resolves the element
to its label or role before the policy runs, and the policy gates on that
resolved context instead of on the tool name. The shape I'd use:

1. The tool declares a resolver, for example `resolve(args) -> { label,
role, url }`.
2. The policy matches on the resolved fields, so a label matching
buy|delete|send|pay|grant|allow is "Needs human approval".
3. The allowlist/blocklist check still runs first, before any approval threshold.
4. If the resolver fails or returns nothing, the call is gated by
default. Fail closed.

One caveat. Labels can be spoofed, for example a "Continue" button that
actually submits a payment. So I'd treat label gating as a first filter,
and add a second check on the destination or page context for anything irreversible.

I'll add a context-resolver hook to mcp-safe-actions. Your screen-control
case is a good test for it. Thanks for the concrete example.
Enter fullscreen mode Exit fullscreen mode
Collapse
 
Sloan, the sloth mascot
Comment deleted
Collapse
 
autenai profile image
Auten •

Fail closed on an empty resolve is the part I'd copy first.

One gap to plan for on screens: resolve and click happen at two different moments, and the UI can change in between (a dialog opens, a list re-sorts). So the element you approved may not be the one that gets clicked. Re-reading the label right before the action and refusing if it changed is worth putting in the resolver contract.

Agree on spoofed labels. URL or window title as the second check for anything irreversible is what we'd reach for too.

Thread Thread
 
saurabh_pathak394 profile image
Saurabh Pathak •

Good catch, and you're right. In 1.3.0 resolve runs once, before the
approval prompt, so the element a person approves can differ from the one
that actually gets clicked.

I'm going to add your suggestion to the resolver contract:

- resolve runs again right before execute
- if the resolved context differs from what the human approved, the call
is refused and logged
- if the second resolve throws or returns nothing, it also refuses
(fail closed)
- the token flow gets the same check, so a token only works for the same
resolved target

Agreed on the rest too: a label gate is only a first filter, and for
anything irreversible a second check on URL or window title makes sense.

Thanks, this is a real gap, and your screen-control case is the right test for it.
Enter fullscreen mode Exit fullscreen mode
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.

Collapse
 
saurabh_pathak394 profile image
Saurabh Pathak •

Thanks! That was my reasoning too. A safety layer that only has happy-path tests proves very little. Breaking the code in 20 deliberate ways and checking that the tests catch every break is the only way I found to know the guard actually blocks things, and not just that it runs.

Collapse
 
alexcodebytes profile image
Oleksandr •

The 'checking that the tests catch every break' is the key part. It's not enough to have tests that pass; you need tests that fail when they should. The 20 deliberate breaks are a good way to prove that.

Thread Thread
 
saurabh_pathak394 profile image
Saurabh Pathak •

Exactly, and that's the distinction I care about most. A passing test
only shows the code does what you expected on the inputs you thought of.
A test that fails when the code is wrong shows the guard does its job.
The mutation check makes that gap measurable: if one of the 20 breaks
gets through, I know the suite has a hole in that spot.
The project is open source. If you can think of mutations I missed, PRs
adding them are welcome.

Thread Thread
 
alexcodebytes profile image
Oleksandr •

The 'if one of the 20 breaks gets through, I know the suite has a hole' is a really practical way to think about it. It's not about proving the code is correct; it's about proving the tests are actually testing something.

Thread Thread
 
saurabh_pathak394 profile image
Saurabh Pathak •

Thanks, Oleksandr. This is a small first version, and I'll keep making it
better.

Collapse
 
paul_irolla_6b5ae261d0224 profile image
Paul Irolla •

The approval has to cover the exact arguments that run. If the preview shows one payload and the server executes another after a retry or a changed id, the human approved something else. Hash the arguments at preview and check the same hash at execution.

Collapse
 
saurabh_pathak394 profile image
Saurabh Pathak •

Thank you, this is exactly the right fix. I'll hash the arguments at
preview and check the same hash at execute, so the approval is bound to
the exact payload and a retry or changed id can't slip through. I'll try
to implement it. It's only a small version for now, and we'll improve it
step by step. It's open source, so if you'd like to contribute a PR or
open an issue, I'd be really happy to have your help.

Collapse
 
alexshev profile image
Alex Shev •

Human approval is strongest when it is specific enough to be invalidated. Show the exact target set, proposed effect, and current state; then refuse to execute if any of those drift before commit. A generic confirm button is still vulnerable to an agent changing the plan between question and action.

Collapse
 
saurabh_pathak394 profile image
Saurabh Pathak •

Fully agree. A generic confirm button doesn't tell the person what
they're approving. The approval should show the exact target set, the
effect and the current state. It should also be bound to all three, so
any drift before commit voids it. That fits with the argument hash and
the re-resolve before execute that others suggested here. Combined, they
stop an agent from changing the plan between the question and the action.
The project is open source, and PRs or issues are welcome.

Collapse
 
paul_irolla_6b5ae261d0224 profile image
Paul Irolla •

@saurabh_pathak394 Thanks, the id is the part that is easy to skip. If it can change between preview and execute, the hash has to cover it too. Canonicalize the arguments before hashing, or the same payload gets two digests.

Collapse
 
saurabh_pathak394 profile image
Saurabh Pathak •

Agreed. Without canonicalization, the same payload can produce two
digests. For example, key order, whitespace or number formatting can
change the hash. The plan is to sort the keys, normalize the encoding and
numbers, and hash that canonical form. The same function would run at
preview and at execute. It also needs a test that feeds in reordered but
equivalent arguments and expects the same digest. If you'd like to write
that test, a PR would be very welcome.

Collapse
 
skillselion profile image
Skillselion •

Elicitation keeps the model out of the loop, and SECURITY-MODEL.md already says a client that auto-approves prompts removes the gate. In Claude Code you do not need a compromised client for that. The hooks reference has an Elicitation event, matched on the MCP server name, and a hook that exits 0 with hookSpecificOutput action "accept" plus content answers the request with no dialog shown. Any user settings file can register one, and so can a project .claude/settings.json in a cloned repo once the folder is trusted. The server only receives accept, so it cannot tell a click from a script. It may be worth naming this case next to the malicious-client item, and logging the event as something like "client-accepted" instead of "approved", so the audit file does not claim a person. Which other clients let a script answer elicitation?

Collapse
 
saurabh_pathak394 profile image
Saurabh Pathak •

This is a great catch, and I didn't know about the Elicitation hook. The
server only sees "accept", so it can't tell a person from a script. I'll
add this to SECURITY-MODEL.md next to the malicious-client item. I'll
also change the audit log to record "client-accepted" instead of
"approved", so the file doesn't claim a human was involved. For
irreversible tools, the allowlist and policy checks should run before any
elicitation, so a scripted accept can't override them. I haven't checked
which other clients let a script answer elicitation. If you know of any,
tell me and I'll list them.

Collapse
 
jes_p_d4f61f7aa2ec405a418 profile image
Dev_PJS •

The strictness in the small details is what convinced me here. Only an exact true from autoApprove skips the human — a throw, or a truthy "yes", still asks — and the headless channel mode is the same way: it approves only on a 2xx with {"approved": true}, and errors or timeouts mean not approved. That fail-closed pattern is rarer than it should be; most approval code I have written myself treated anything not obviously negative as a yes. My question is about the other caveat you listed, that the preview must be truthful: if the file changed between preview and execute — different size, different target — does execute abort and ask for a fresh preview, or does it just re-check and proceed if it still looks plausible? That gap between what was approved and what actually runs feels like the easiest place for this pattern to quietly break.

Collapse
 
saurabh_pathak394 profile image
Saurabh Pathak •

Thanks for reading it that closely. You're right that the gap between
preview and execute is the easiest place for this pattern to break quietly.
The safe behavior is to fail closed. If the target, size or content hash
differs from what was previewed, execute should abort and ask for a fresh
preview. It shouldn't re-check and proceed because things still look
plausible. A preview approval should bind to the exact action, not to a
general intent.

If the current code only re-checks, that's a real gap. It would make a
good issue, and a PR is very welcome since the project is open source.

Collapse
 
eye_java_420f1faa10ae8b86 profile image
Nan •

Approval gates assume the approver reads the preview correctly. They usually do — until the one Friday they don't. The layer I don't see covered here is what happens after a wrong approval executes.

Two habits that close it without much machinery:

  1. Make destructive tools refuse to run unless a matching, unexpired approval id is presented at execute time — not at plan time. A plan approved on Tuesday should not still be executable on Friday after the repo moved on.

  2. Keep an approval journal the agent itself cannot write to: log each approval with the exact tool call it authorized, stored append-only outside the workspace the agent can touch. When something gets deleted that shouldn't have been, you want a replayable incident with a name on it, not a silent diff.

That turns "we approved a bad plan" from data loss into an audit trail you can actually learn from.

Collapse
 
saurabh_pathak394 profile image
Saurabh Pathak •

Really good point, Nan. Approval gates only cover the moment of approval,
and the incident usually happens after it. Both habits are on the list
now.
Binding the approval to execute time is the part I like most. A plan
approved on Tuesday shouldn't still run on Friday. The journal idea is a
good fit too. It works best when the agent has no write access to it. The
project is open source, so PRs for an approval-id check at execute time
or an append-only journal outside the workspace would be very welcome.

Collapse
 
robertpaschal profile image
Odinaka Robert Nnamani •

the exact true rule for autoapprove is a nice tight detail. a truthy string still asking keeps a sloppy policy from quietly turning into an approval.

Collapse
 
saurabh_pathak394 profile image
Saurabh Pathak •

Thanks Odinaka. That rule is there on purpose. If only an exact true
skips the human, a throw, a truthy string like "yes", or a config typo
all fall back to asking. A mistake then produces an extra prompt rather
than an unwanted approval.
The project is open source, so if you have other fail-closed edge cases
in mind, PRs and issues are welcome.

Collapse
 
robertpaschal profile image
Odinaka Robert Nnamani •

That trade makes sense to me. An extra prompt costs a second, an unwanted approval costs a lot more.

Thread Thread
 
saurabh_pathak394 profile image
Saurabh Pathak •

Exactly, thanks Odinaka. A small extra prompt is a much better failure
than a silent approval. I'll keep tightening the edge cases.

Thread Thread
 
robertpaschal profile image
Odinaka Robert Nnamani •

Edge cases are where that rule earns its keep. A truthy string like "yes" slipping through would be exactly the quiet failure it is meant to stop.