gated-mcp
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@gated-mcpdelete my groceries note and show me what would happen"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
gated-mcp
A single-file MCP stdio server kit for Node 20+. Tools that change things stay off until you turn them on, and until then they answer with a dry run.
$ REQ='{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"delete_note","arguments":{"id":"groceries"}}}'
$ echo "$REQ" | node examples/server.mjs 2>/dev/null | jq -r '.result.content[0].text'
REFUSED (gated off). Nothing was executed.
Tool: delete_note
Set NOTES_ALLOW_GATED=1 in this server's environment to enable it.
{
"ok": false,
"status": "refused",
"reason": "gated",
"executed": false,
"tool": "delete_note",
"message": "\"delete_note\" is gated off. Its handler did NOT run and nothing was changed.",
"enable_with": {
"env_var": "NOTES_ALLOW_GATED",
"set_to": "1",
"scope": "this server's process environment"
},
"default_state": "disabled: NOTES_ALLOW_GATED is unset or not set to an enabling value",
"dry_run": {
"preview_available": true,
"would": "delete the file /private/tmp/gated-mcp/examples/notes/groceries.md",
"bytes": 48,
"title": "Groceries",
"reversible": false
},
"tool_description": "Permanently delete one note file.",
"arguments_received": {
"id": "groceries"
}
}
$ ls examples/notes
groceries.md
ideas.md
trip.md
$ echo "$REQ" | NOTES_ALLOW_GATED=1 node examples/server.mjs 2>/dev/null | jq -r '.result.content[0].text'
{
"ok": true,
"status": "ok",
"data": {
"deleted": "/private/tmp/gated-mcp/examples/notes/groceries.md",
"bytes": 48
}
}
$ ls examples/notes
ideas.md
trip.mdWhy
Agents call tools on their own. That is the point of giving them tools. But a tool that deletes, sends, pays or deploys should not run just because a model decided it should.
With gated-mcp you mark those tools gated: true. They are off by default. A call to a gated tool does not run its handler. It returns a refusal and the tool's dry run: a plain description of exactly what the call would have done. The agent can show that to the person, and the person decides. If they want the tool live, they start the server with one environment variable set. Nothing about that decision lives in the model's context, so the model cannot talk its way past it.
The rest of the kit exists for the same reason: an agent should get true answers. A stray console.log should not break the connection. A secret should not end up in a log. "Found nothing" should never be how a failed lookup is reported. A hung handler should not freeze every other tool.
Related MCP server: Dmint MCP Proxy
Usage
Copy gated-mcp.mjs into your project and import it with a relative path. There is nothing to install. It has no dependencies.
curl -O https://raw.githubusercontent.com/mthburnsbarber-web/gated-mcp/main/gated-mcp.mjsOr install it from GitHub: npm install github:mthburnsbarber-web/gated-mcp, then import ... from "gated-mcp".
import { unlink } from "node:fs/promises";
import { createServer, listResult, okResult } from "./gated-mcp.mjs";
createServer({
name: "notes", // the gate variable becomes NOTES_ALLOW_GATED
version: "1.0.0",
tools: [
{
name: "search_notes",
description: "Find notes that contain a phrase.",
inputSchema: { type: "object", properties: { query: { type: "string" } }, required: ["query"] },
async handler({ query }, { signal }) {
const hits = await search(query, { signal });
return listResult(hits, { what: "notes", source: NOTES_DIR });
},
},
{
name: "delete_note",
description: "Permanently delete one note file.",
inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"] },
gated: true,
async dryRun({ id }) {
return { would: `delete the file ${pathFor(id)}`, reversible: false };
},
async handler({ id }) {
await unlink(pathFor(id));
return okResult({ deleted: pathFor(id) });
},
},
],
});createServer starts reading stdin right away. A full working version of this server is in examples/server.mjs.
Tools
Field | Meaning |
| As in the MCP spec. |
| Does the work. |
|
|
| Describes what the handler would do, without doing it. Called only when a gated tool is refused. |
| Use this variable name instead of |
| This tool's timeout. Defaults to the server's |
| Passed through to |
The gate prefix comes from the server name: notes and notes-mcp both give NOTES_ALLOW_GATED. The values 1, true, yes, on, enable, enabled and allow turn it on (case does not matter). Anything else, including 0 or an empty string, leaves it off. A gated tool without a dryRun is still refused; the refusal then says no exact preview is available and shows the arguments it was called with.
Results that cannot lie about being empty
return listResult(rows, { what: "invoices", source: "billing db" }); // [] becomes a verified empty result
return collect(() => api.list(), { what: "invoices", source: "billing api", signal });
throw new ToolError("billing api returned 503", { code: "UPSTREAM_DOWN" });listResult(items)needs a real array. An empty array givesstatus: "empty"with a note that the lookup ran and found nothing.nullorundefinedthrowsLIST_NOT_ARRAY.collect(producer)runs a lookup. If it throws, the result is an error (COLLECT_FAILED) with the real reason. If it returnsnullorundefined, the result is an error (COLLECT_NO_VALUE). Only an actual empty array becomes an empty result.emptyResult({ what, source, reason })builds the empty shape by hand. Use it only after the lookup ran.okResult(data)wraps a value.Throw
ToolError(or anyError) to fail. The client getsisError: trueand the real message, redacted.
Secrets and logs
import { loadSecret } from "./gated-mcp.mjs";
const apiKey = loadSecret("/run/secrets/billing-key"); // read at runtime, never printedloadSecret reads a credential file and registers the value, so that exact string is replaced with [REDACTED] in every log line and error. registerSecret(value) does the same for a value you got some other way. Handlers log through ctx.log (error, warn, info, debug), which writes to stderr. MCP_LOG_LEVEL sets the level; the default is info.
Timeouts and cancellation
Every handler gets an AbortSignal in ctx.signal. It aborts when the tool's timeout passes or when the client sends notifications/cancelled for that request. Pass it to fetch, child processes and timers. A timed-out call gets an error result. A cancelled call gets no response at all, as the spec requires.
Protocol versions
The kit speaks 2026-07-28, the current revision, and the handshake revisions 2025-11-25, 2025-06-18, 2025-03-26 and 2024-11-05, on the same stream. A client using 2026-07-28 sends the version in each request's _meta and can call server/discover; results then carry resultType, the server's identity, and caching hints on list results (cacheTtlMs and cacheScope options, default 0 and "private"). An older client sends initialize and gets the revision it asked for, or 2025-11-25 if it asked for one the kit does not know.
Server options
Option | Default | Meaning |
| required | Server name. Also the gate prefix. |
|
| Reported to clients. |
|
| Tool definitions. |
| none | Text for the model about how to use the server. |
|
| Default per-tool timeout. |
|
| Caching hints for |
|
| After stdin closes, how long requests already received may finish. |
|
| Send stray stdout writes to stderr. |
|
| Where gate variables are read. |
|
| Start reading stdin immediately. |
Run the example
examples/server.mjs is a notes server: list_notes, read_note and search_notes read Markdown files, and delete_note is gated. Notes live in NOTES_DIR, or examples/notes if it is unset.
Add it to Claude Code:
claude mcp add notes -- node /absolute/path/to/gated-mcp/examples/server.mjs
claude mcp add notes -e NOTES_DIR=/absolute/path/to/your/notes -- node /absolute/path/to/gated-mcp/examples/server.mjsAdd -e NOTES_ALLOW_GATED=1 only when you want delete_note to really delete.
Any other MCP client that launches stdio servers takes the same command in its JSON config:
{
"mcpServers": {
"notes": {
"command": "node",
"args": ["/absolute/path/to/gated-mcp/examples/server.mjs"],
"env": { "NOTES_DIR": "/absolute/path/to/your/notes" }
}
}
}Tests
npm testThe tests start real server processes and talk to them over stdio. There are no mocks.
What you get
Gated tools.
gated: truekeeps a tool off until<PREFIX>_ALLOW_GATED=1is set in the server's environment. A refused call never runs the handler. It returns the refusal, the variable that would enable it, and the tool'sdryRun()preview.tools/listmarks each gated tool and names its variable.A guarded stdout. stdout carries protocol messages only. A stray
console.logorprocess.stdout.writein your code or a dependency goes to stderr as a warning instead of corrupting the JSON-RPC stream.Redacted logs and errors. Every log line, every error message sent to the client, and every echoed argument passes through
redact(). It removes registered secret values, common key and token shapes (api_key=...,Bearer ..., JWTs, private keys, GitHub, Slack, AWS and Stripe-style keys), and long high-entropy strings.Honest results.
listResult,emptyResultandcollectmake a verified empty result look different from a failure. Anull,undefinedor thrown error never becomes an empty list.Per-tool timeouts. A hung handler gets an error result after its timeout. The server keeps answering other requests.
Argument checks. Missing required arguments, wrong types and values outside an
enumcome back as a clear error result, and the handler does not run.Cancellation.
notifications/cancelledaborts the handler's signal, and no response is sent for that request.Current and older protocol revisions.
2026-07-28requests andinitialize-based clients are both served.Annotations left alone. Your
annotationsreach the client exactly as written. The kit never adds any. Being gated does not make a tool destructive, so the kit does not claim it is.A clean shutdown. When stdin closes, requests already received still get their answers, up to
shutdownGraceMs, and then the process exits.One file. No dependencies, no build step. Node 20 or newer.
What it doesn't do
stdio only. There is no HTTP transport and no authorization.
Tools only.
resources/listandprompts/listreturn empty lists. There is noresources/read,prompts/get, completion, progress or logging notifications,subscriptions/listen, sampling, elicitation, or multi round-trip requests. The tool list is fixed when the server starts.Top-level validation only. The kit checks
required,typeandenumon top-level arguments. It does not check nested objects,pattern,minimum,format,oneOfand the rest of JSON Schema. Check those in your handler.One switch per process. Gating is decided by the server's environment, not per call. There is no approval prompt inside the protocol. Once the variable is set, every gated tool that uses it runs. Use
gateEnvto give a tool its own switch.Pattern-based redaction. It can miss a secret with an unusual shape, and it can hide a harmless string that looks like a token. Register real secrets with
loadSecretorregisterSecret. Successful tool results are returned as your handler built them and are not redacted.No forced stop. A handler that ignores its signal keeps running after a timeout or a cancel. Its result is thrown away. JavaScript cannot stop it from outside.
No batching. JSON-RPC batches were removed from MCP in
2025-06-18. An older client that sends one still gets a batch reply, but the kit does not advertise batching.Not the official SDK. For full protocol coverage, use
@modelcontextprotocol/sdk.
License
MIT. See LICENSE.
This server cannot be deployed
Maintenance
Related MCP Connectors
MCP server for your apps' tools and custom tools, plus hosted AI agents and approval-gated workflows
Remote MCP server exposing your Node Command fleet (files, shell, processes, browser) to AI hosts.
Nifty's MCP server — exposes tasks, projects, messages, and files as tools for AI agents.
Guard wraps stdio MCP servers: risky calls wait for a person; no answer, no run. Remote: cooperative
Related MCP Servers
- AlicenseNot gradedqualityBmaintenanceA 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
- AlicenseNot gradedqualityBmaintenanceEnables developers to wrap existing MCP servers with a deterministic policy enforcement gate that intercepts tool discovery and tools/call requests over stdio, allowing only authorized calls to reach the downstream server. It supports dynamic tool exposure or hiding, allow/deny/approval-required decisions, configurable error disclosure modes, and Ed25519-signed retry credentials for approved workflows.Apache 2.0
- AlicenseNot gradedqualityCmaintenanceProvides a runnable local stdio Python server scaffold that coding agents extend into custom tool integrations by describing an idea and letting the agent investigate, build, test, and install it. Ships with no prebuilt tools, so it enables guided, agent-assisted creation of MCP tools connected to external APIs, databases, or websites while writes stay disabled until explicitly agreed.1MIT
- AlicenseAqualityAmaintenanceDependency-free, locally sandboxed MCP stdio server. Tools run as WASM inside a capability-based WASI sandbox (wasmtime); every tools/call is attested with cost/policy and signed manifests fail-closed.3113 PyPI48Business Source 1.1