Skip to main content
Glama
README.md
# 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.

```console
$ 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.

## Usage

Copy `gated-mcp.mjs` into your project and import it with a relative path. There is nothing to install. It has no dependencies.

```sh
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"`.

```js
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`](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

```js
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

```js
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`](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:

```sh
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:

```json
{
  "mcpServers": {
    "notes": {
      "command": "node",
      "args": ["/absolute/path/to/gated-mcp/examples/server.mjs"],
      "env": { "NOTES_DIR": "/absolute/path/to/your/notes" }
    }
  }
}
```

### Tests

```sh
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`](https://github.com/modelcontextprotocol/typescript-sdk).

## License

MIT. See [LICENSE](LICENSE).