mcp-safe-actions
README.md
<p align="center">
<img src="assets/banner.svg" alt="mcp-safe-actions: human approval for destructive MCP tools" width="100%"/>
</p>
<p align="center">
<a href="https://github.com/techsaurabh-394/mcp-safe-actions/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/techsaurabh-394/mcp-safe-actions/ci.yml?style=for-the-badge&label=CI" alt="CI"/></a>
<img src="https://img.shields.io/badge/tests-49%20passing-brightgreen?style=for-the-badge" alt="49 tests passing"/>
<img src="https://img.shields.io/badge/coverage-100%25%20lines-brightgreen?style=for-the-badge" alt="100% line coverage"/>
<img src="https://img.shields.io/badge/TypeScript-strict-3178C6?style=for-the-badge&logo=typescript&logoColor=white" alt="TypeScript strict"/>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-green?style=for-the-badge" alt="MIT"/></a>
</p>
<p align="center">
<a href="https://github.com/techsaurabh-394/mcp-safe-actions"><img src="assets/star.svg" width="28" alt=""/></a>
<b><a href="https://github.com/techsaurabh-394/mcp-safe-actions">Star this repo</a></b> if it saves you from a bad tool call ·
<a href="https://buymeacoffee.com/saurabhdev"><img src="https://img.shields.io/badge/Buy_Me_A_Coffee-FFDD00?style=flat&logo=buymeacoffee&logoColor=black" alt="Buy me a coffee"/></a>
</p>
# 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.
<p align="center">
<img src="assets/demo.svg" alt="Animated terminal: the human declines and nothing is deleted; then the token fallback issues a single-use token that cannot be reused" width="780"/>
</p>
## 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.
## Add it to your MCP server in 4 steps
### 1. Install
```bash
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):
```ts
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
```bash
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:
```json
{
"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

**Client supports elicitation (best case):**
```mermaid
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):**
```mermaid
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](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](docs/SECURITY-MODEL.md).
## API
```ts
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](docs/USAGE.md), including how to test your own tools with an in-memory client.
## GitHub Actions in this repo
```mermaid
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:**
```yaml
# .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
- [x] Elicitation flow, token fallback, timeouts, audit log, annotations
- [x] 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](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
[](https://star-history.com/#techsaurabh-394/mcp-safe-actions&Date)
## Support this project
If this helped you ship a safer agent, please <a href="https://github.com/techsaurabh-394/mcp-safe-actions"><img src="assets/star.svg" width="16" alt=""/> star the repo</a>, share it, or buy me a coffee:
<a href="https://buymeacoffee.com/saurabhdev"><img src="https://img.shields.io/badge/Buy_Me_A_Coffee-FFDD00?style=for-the-badge&logo=buymeacoffee&logoColor=black" alt="Buy me a coffee"/></a>
## License
MIT. See [LICENSE](LICENSE). Copyright (c) 2026 Saurabh Pathak.
---
<p align="center">
<b>Saurabh Pathak</b> · AI Engineer & Full-Stack Developer<br/>
<a href="https://github.com/techsaurabh-394">GitHub</a> ·
<a href="https://www.linkedin.com/in/tech-saurabh/">LinkedIn</a> ·
<a href="https://mernstackdev.com">Blog</a> ·
<a href="mailto:saurabhpathak394@gmail.com">saurabhpathak394@gmail.com</a>
</p>
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues