Skip to main content
Glama
techsaurabh-394

mcp-safe-actions

mcp-safe-actions: 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.

mcp-safe-actions is a small TypeScript library for people who build MCP servers (Model Context Protocol). Wrap a dangerous tool once and it does nothing until a human approves. The model cannot approve its own action.

Why you might want this

  • A prompt-injected or confused model can call delete_file("../../important"). "Please ask first" in a prompt is a request, not a control.

  • Approval in the client UI is the right place for a human decision, and MCP now has a feature for it (elicitation). Not every client supports it yet, so you also need a safe fallback.

  • You want a log of what was previewed, approved, refused and run, without leaking secrets.

This library packages that pattern once, with a test suite that proves the safety rules, instead of rebuilding it in every server.

Related MCP server: ShadowRun

Add it to your MCP server in 4 steps

1. Install

npm install github:techsaurabh-394/mcp-safe-actions

Once it is published to npm: npm install mcp-safe-actions. You also need @modelcontextprotocol/sdk (1.17+) and zod (you probably have both). Node 18+.

2. Wrap your dangerous tool

Replace server.registerTool(...) with registerConfirmedTool(server, ...) and split the work into preview (describe, don't change anything) and execute (runs 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() },

  // What WILL happen. Must not change anything. Be specific.
  preview: ({ path }) => ({
    summary: `Permanently delete "${path}" (${statSync(path).size} bytes)`,
    details: ["This cannot be undone."],
  }),

  // Runs only after the user approved. Re-check anything that could have changed.
  execute: ({ path }) => {
    rmSync(path);
    return { content: [{ type: "text", text: `Deleted ${path}` }] };
  },

  audit: fileAudit("./audit.jsonl"), // optional
});

await server.connect(new StdioServerTransport());

The tool is listed with destructiveHint: true and a description telling the model to wait for the user.

3. Run the demo and the tests

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          # 49 tests

4. Connect it to an MCP client

Claude Desktop (claude_desktop_config.json), using the included example server and a throwaway folder:

{
  "mcpServers": {
    "files": {
      "command": "node",
      "args": ["/absolute/path/to/mcp-safe-actions/dist/examples/files-server.js"],
      "env": { "FILES_DIR": "/absolute/path/to/test-folder", "AUDIT_LOG": "/absolute/path/to/audit.jsonl" }
    }
  }
}

Claude Code: claude mcp add files -- node /absolute/path/to/mcp-safe-actions/dist/examples/files-server.js

Run npm run build first. Then ask the assistant to delete a file in the test folder. You should see either an approval prompt (clients with elicitation) or a preview with a token and a question from the assistant (clients without). The file must still exist until you say yes.

How it works

Flow: model calls, server previews, human approves, action executes, audit logged

Client supports elicitation (best case):

sequenceDiagram
    participant M as AI model
    participant S as Your MCP server
    participant H as Human (client UI)
    M->>S: delete_file("report.pdf")
    S->>S: preview() builds the plan, nothing changes
    S->>H: Approval prompt: delete report.pdf?
    H-->>S: Approve or decline
    alt Approved
        S->>S: execute() and audit log
        S-->>M: Deleted report.pdf
    else Declined or no answer
        S-->>M: User did not approve. Nothing changed.
    end

Client without elicitation (token fallback):

sequenceDiagram
    participant M as AI model
    participant S as Your MCP server
    participant H as Human (in chat)
    M->>S: delete_file("report.pdf")
    S-->>M: Nothing changed yet. Plan + one-time token
    M->>H: Shows plan, asks to go ahead
    H-->>M: Yes
    M->>S: delete_file("report.pdf", confirm_token)
    S->>S: token valid, unused, same arguments, not expired
    S->>S: execute() and audit log
    S-->>M: Deleted report.pdf

Decision table

Situation

Result

Elicitation accepted and the box is ticked

execute() runs

Declined, cancelled, unticked, timed out or errored

Nothing runs

mode: "elicit" and the client has no prompts

Refused, no token issued

Token flow, no token

Preview plus one-time token, nothing runs

Token flow, valid token and identical arguments

execute() runs once

Reused, expired, unknown, or for other arguments or another tool

Nothing runs, reason returned

Modes

mode

Behaviour

Use when

"auto" (default)

Elicitation if supported, otherwise token

Most servers

"elicit"

Elicitation only

Actions that must have a human gate

"token"

Always the token flow

Testing, or by preference

Proof it works: tests that try to break it

49 tests

Unit, end-to-end (a real MCP client and server in memory), property tests (300 random argument objects), and real stdio tests that spawn the example server as a child process

100% line coverage of the library (src/), 96.8% to 100% of branches

npm run coverage

10 deliberate bugs, all caught

npm run mutation breaks the code on purpose (token reusable, approval skipped on error, secrets in the log, ...) and fails if the tests don't notice

What the tests prove, among other things: a declined prompt never runs the action; a model-supplied token cannot skip the human prompt; tokens are single-use, expire, and only work for the same tool and arguments; two simultaneous uses of one token run the action exactly once; a prompt that times out or crashes fails closed; secret argument values never reach the audit log; the example server blocks path traversal and symlink escapes.

Details and an honest note about a weak test the mutation check caught: docs/TESTING.md.

Guarantees, and what it does NOT guarantee

It guarantees

How

Nothing runs before approval

execute() is reachable only after an accepted prompt or a valid token

The model can't self-approve (elicitation)

The prompt goes to the human through the client, not through the model

Tokens are single-use, bound to tool and exact arguments, and expire

Checked server-side

Failures fail closed

Errors and timeouts never fall through to execution

Audit without leaking secrets

Logs your summary and a hash of the arguments, never the raw arguments

Be clear-eyed:

  • Token mode is a speed bump, not proof of a human. A model could call twice without asking. For actions that need a real human gate, use mode: "elicit".

  • Your preview must be truthful, and execute() should re-check state that may have changed since.

  • It doesn't 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.

Full threat model: docs/SECURITY-MODEL.md.

API

registerConfirmedTool(server, {
  name, title?, description, inputSchema,     // like server.registerTool
  preview(args) => { summary, details? },     // no side effects
  execute(args) => CallToolResult,            // after approval
  risk?: "destructive" | "write",             // default "destructive" (sets annotations)
  mode?: "auto" | "elicit" | "token",         // default "auto"
  approvalTimeoutMs?: number,                 // default 5 minutes; a timeout means "not approved"
  store?: new ApprovalStore({ ttlMs, maxPending }),
  audit?: (event) => void | fileAudit(path),
  annotations?,                               // override tool annotations
});

confirm_token is a reserved input name. More in docs/USAGE.md, including how to test your own tools with an in-memory client.

GitHub Actions in this repo

flowchart LR
    A[Push or pull request] --> B[CI]
    B --> B1[Build and 49 tests on Node 18, 20, 22]
    B --> B2[Demo runs end to end]
    B --> B3[Coverage report]
    B --> B4[Mutation check: 10 deliberate bugs]
    B --> B5[SVG diagrams valid]
    C[Tag v*] --> D[Release]
    D --> D1[Tests]
    D1 --> D2[GitHub Release with notes]
    D2 --> D3[npm publish with provenance, only if enabled]

Workflow

Runs when

Does

ci.yml

every push to main and every pull request

Build, tests on Node 18/20/22, demo, npm pack check, coverage, mutation check, SVG validation

release.yml

you push a tag like v1.1.0

Tests, GitHub Release, and npm publish if you enabled it

dependabot.yml

monthly

Opens update PRs for npm packages and Actions

Add the same safety net to your own server's repo:

# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 22, cache: npm }
      - run: npm ci
      - run: npm test   # include a test that the dangerous tool does NOT run without approval

test/helpers.ts in this repo shows how to simulate a human who approves, declines, or never answers.

Roadmap

  • Elicitation flow, token fallback, timeouts, audit log, annotations

  • 49 tests, 100% line coverage, mutation check, real stdio tests

  • Publish to npm

  • Approval channel hooks (Slack, webhook) for headless servers

  • Policy: auto-approve low-risk calls, always ask for high-risk ones

  • Python version for FastMCP servers

Contributing

Issues and PRs welcome: CONTRIBUTING.md. Any change to approval logic needs a test showing the action does not run without approval, and npm run mutation must still pass.

Star history

Star History Chart

Support this project

If this helped you ship a safer agent, please star the repo, share it, or buy me a coffee:

License

MIT. See LICENSE. Copyright (c) 2026 Saurabh Pathak.


Related MCP Connectors

Related MCP Servers

  • A
    license
    B
    quality
    C
    maintenance
    Security gateway that wraps any MCP server with per-tool policies, approval gates, and optional Ed25519-signed decision receipts. Shadow mode logs every tool call without blocking; enforce mode applies block, rate-limit, and minimum-tier rules. Receipts are independently verifiable offline with no accounts needed.
    5
    653 npm
    11
    MIT
  • A
    license
    Not graded
    quality
    B
    maintenance
    A local-first stdio proxy for MCP servers that traps destructive tool calls and holds them for manual commit or discard, preventing accidental mutations.
    AGPL 3.0
  • A
    license
    Not graded
    quality
    B
    maintenance
    MCP server that enforces single-use human confirmations for sensitive tool calls, sanitizes prompts against prompt injection, and re-validates file paths before destructive actions.
    30 PyPI
    MIT
  • A
    license
    Not graded
    quality
    C
    maintenance
    Enforces deterministic security policies as an inline firewall for MCP server tool calls, with AST-based validation, cryptographic audit logging, and CLI-based evaluation and verification.
    MIT