Skip to main content
Glama

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.md

Why

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.mjs

Or 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

name, description, inputSchema

As in the MCP spec. inputSchema defaults to an empty object schema.

handler(args, ctx)

Does the work. ctx is { signal, log, env }. Return any JSON value, a string, or an MCP { content: [...] } result.

gated

true keeps the tool off until its variable is set.

dryRun(args, ctx)

Describes what the handler would do, without doing it. Called only when a gated tool is refused.

gateEnv

Use this variable name instead of <PREFIX>_ALLOW_GATED.

timeoutMs

This tool's timeout. Defaults to the server's timeoutMs (60 seconds).

title, outputSchema, annotations

Passed through to tools/list as you wrote them.

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 gives status: "empty" with a note that the lookup ran and found nothing. null or undefined throws LIST_NOT_ARRAY.

  • collect(producer) runs a lookup. If it throws, the result is an error (COLLECT_FAILED) with the real reason. If it returns null or undefined, 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 any Error) to fail. The client gets isError: true and the real message, redacted.

Secrets and logs

import { loadSecret } from "./gated-mcp.mjs";
const apiKey = loadSecret("/run/secrets/billing-key"); // read at runtime, never printed

loadSecret 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

name

required

Server name. Also the gate prefix.

version

"0.0.0"

Reported to clients.

tools

[]

Tool definitions.

instructions

none

Text for the model about how to use the server.

timeoutMs

60000

Default per-tool timeout.

cacheTtlMs, cacheScope

0, "private"

Caching hints for 2026-07-28 list results.

shutdownGraceMs

5000

After stdin closes, how long requests already received may finish.

guardStdout

true

Send stray stdout writes to stderr.

env

process.env

Where gate variables are read.

autoStart

true

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.mjs

Add -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 test

The tests start real server processes and talk to them over stdio. There are no mocks.

What you get

  • Gated tools. gated: true keeps a tool off until <PREFIX>_ALLOW_GATED=1 is 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's dryRun() preview. tools/list marks each gated tool and names its variable.

  • A guarded stdout. stdout carries protocol messages only. A stray console.log or process.stdout.write in 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, emptyResult and collect make a verified empty result look different from a failure. A null, undefined or 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 enum come back as a clear error result, and the handler does not run.

  • Cancellation. notifications/cancelled aborts the handler's signal, and no response is sent for that request.

  • Current and older protocol revisions. 2026-07-28 requests and initialize-based clients are both served.

  • Annotations left alone. Your annotations reach 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/list and prompts/list return empty lists. There is no resources/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, type and enum on top-level arguments. It does not check nested objects, pattern, minimum, format, oneOf and 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 gateEnv to 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 loadSecret or registerSecret. 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.

Related MCP Connectors

Related MCP Servers

  • 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
    Enables 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
  • A
    license
    Not graded
    quality
    C
    maintenance
    Provides 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.
    1
    MIT
  • A
    license
    A
    quality
    A
    maintenance
    Dependency-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.
    3
    113 PyPI
    48
    Business Source 1.1