Skip to main content
Glama
neshboy

mcpseatbelt

by neshboy
README.md
# MCP Seatbelt

MCP Seatbelt is a policy-enforcing gateway for the [Model Context
Protocol](https://modelcontextprotocol.io) (MCP). It sits between an agent
client (Claude Desktop, Cursor, your own agent, etc.) and a real downstream
MCP server, and it checks every `tools/call` request against a declarative
allow/deny/ask policy **before** that call ever reaches the real server.

```
agent client  <--stdio-->  mcpseatbelt wrap  <--stdio-->  real MCP server
                                  |
                         policy.yaml (allow/deny/ask)
                                  |
                         audit.jsonl (hash-chained log)
```

It works by being a real, protocol-speaking MCP proxy on both sides: it
exposes a genuine MCP *server* over stdio (what the agent client connects
to), and it opens a genuine MCP *client* connection over stdio to the real,
wrapped server as a child process, using the official
[`@modelcontextprotocol/sdk`](https://github.com/modelcontextprotocol/typescript-sdk).
Every `tools/call` is intercepted at that boundary, evaluated against the
policy, logged, and only then (if allowed) forwarded to the real server.

## Why this is not just another command wrapper

This portfolio also contains `agentseatbelt` (a generic shell-command
sandbox/wrapper with no protocol awareness - it doesn't know what a "tool"
or an MCP server is, it just wraps a shell command) and `mcpsqueeze` (which
speaks MCP but is about deduplicating/truncating tool *results* after the
fact, purely for token savings, with no authorization concept). MCP
Seatbelt is different from both: it is specifically about **authorization
at the MCP tool-call boundary**. It parses the real MCP `tools/call`
request, matches on the real tool name (and optionally the shape of the
real arguments), and can deny or require confirmation for a call *before*
it ever reaches the downstream server - something a shell wrapper
structurally cannot do (it only ever sees bytes on stdio, not "the agent is
about to call `delete_file`"), and something a result-squeezer has no
reason to do (it only ever sees results, after the real call already
happened).

## Features

- **Real MCP proxy.** Genuine `Server`/`Client` objects from the official
  SDK on both sides, talking real JSON-RPC over stdio - not a mock.
- **Declarative policy.** A YAML file with ordered rules matching tool
  names (exact or glob, e.g. `delete_*`) and, optionally, the shape of the
  call's arguments (equality, regex, or a fixed set of values).
- **Three actions.** `allow` forwards the call. `deny` rejects it outright.
  `ask` prompts for a real interactive y/n confirmation on the controlling
  terminal, and **fails closed** (denies) whenever no controlling terminal
  is available - which is the normal case, since agent clients launch
  `mcpseatbelt` with piped stdio.
- **Tamper-evident audit log.** Every decision (allow/deny/ask-allow/
  ask-deny) is appended to a JSONL file where each entry embeds the
  SHA-256 hash of the previous entry. Editing, deleting, or reordering any
  past entry breaks the chain, and `mcpseatbelt verify-log` detects it.

## Install

```bash
git clone <this repo>
cd mcpseatbelt
npm install
npm run build
```

This produces `dist/cli.js`, runnable as `node dist/cli.js <command>`, or
link it onto your `PATH` for local development:

```bash
npm link   # now `mcpseatbelt` is available as a global command
```

## Quick start

1. Scaffold a starter policy in your project:

   ```bash
   mcpseatbelt init
   # writes .mcp-seatbelt/policy.yaml
   ```

2. Point your agent client's MCP server config at `mcpseatbelt wrap`
   instead of the real server, passing the real server's command after
   `--`. For example, if your client previously launched a server with:

   ```json
   { "command": "node", "args": ["real-server.js"] }
   ```

   change it to:

   ```json
   { "command": "mcpseatbelt", "args": ["wrap", "--", "node", "real-server.js"] }
   ```

   `mcpseatbelt wrap` itself behaves as a real MCP server over stdio, so
   from the agent client's point of view nothing else changes - it still
   gets the same tools, the same results, just policy-checked.

3. Every `tools/call` is now checked against `.mcp-seatbelt/policy.yaml`
   before it reaches `real-server.js`, and recorded in
   `.mcp-seatbelt/audit.jsonl`.

4. Periodically verify the audit log hasn't been tampered with:

   ```bash
   mcpseatbelt verify-log
   ```

### Example policy

See [`examples/policy.yaml`](examples/policy.yaml) for a fuller example.
A minimal one:

```yaml
version: 1
default: deny        # fail closed: anything not explicitly matched is denied

rules:
  - name: allow-reads
    match: "read_*"
    action: allow

  - name: block-deletes
    match: "delete_*"
    action: deny

  - name: confirm-writes
    match: "write_file"
    action: ask
    when:
      path:
        matches: "^/home/"   # only prompt for writes under /home
```

Rules are checked in order; the first rule whose `match` (exact tool name
or a glob supporting `*`/`?`) matches the called tool, and whose `when`
conditions (if any) all hold against the call's arguments, wins. If no rule
matches, `default` applies.

### CLI reference

```
mcpseatbelt wrap [options] -- <command> [args...]
    -p, --policy <path>      policy YAML file (default .mcp-seatbelt/policy.yaml)
    -a, --audit-log <path>   audit log JSONL file (default .mcp-seatbelt/audit.jsonl)
    --non-interactive        never prompt for "ask" rules; always fail closed
    --server-label <label>   label recorded in the audit log for the wrapped server

mcpseatbelt init [options]
    -p, --policy <path>      where to write the starter policy
    -f, --force              overwrite an existing policy file

mcpseatbelt verify-log [options]
    -a, --audit-log <path>   audit log to verify
```

## How `ask` actually works

`mcpseatbelt wrap`'s own stdin/stdout are the MCP JSON-RPC channel to the
agent client - the SDK's `StdioServerTransport` owns those streams, so a
confirmation prompt cannot read/write them without corrupting the protocol.
Instead, `ask` opens the OS terminal device directly (`/dev/tty` on
POSIX, the console on Windows) for the actual y/n exchange, and only
attempts this at all when `mcpseatbelt`'s own stdio look like a real
terminal rather than a pipe. In essentially every real deployment, an
agent client launches `mcpseatbelt` with piped stdio, so this check is
false and `ask` rules fail closed automatically, with no flag required.
See "Limitations" below for the honest version of this story.

## Testing

```bash
npm test
```

The test suite (`vitest`) builds the project, then spawns `mcpseatbelt
wrap` as a real child process wrapping a small real test MCP server
(`test/fixtures/test-server.mjs`, with real `read_file`/`write_file`/
`delete_file` tools that touch the real filesystem), and drives it with a
real `@modelcontextprotocol/sdk` `Client`. It asserts: an allowed call
reaches the real server and returns its real result; a denied call never
reaches the real server (checked via the absence of its filesystem side
effect); an `ask` rule fails closed with no controlling terminal; the
audit log is correctly hash-chained; and `verify-log` detects a
hand-tampered entry.

## Limitations

This is a focused MVP, not a hardened security boundary. Specifically:

- **`ask` is rarely reachable in the primary deployment model.** Since
  agent clients almost always spawn `mcpseatbelt wrap` with fully piped
  stdio (no controlling terminal reachable from this process), `ask`
  rules will in practice almost always resolve to fail-closed deny in
  production use. It is genuinely implemented and tested, but is really
  only interactive when a human runs `mcpseatbelt wrap` directly from a
  real terminal for local testing/debugging. Treat `ask` as "no automatic
  approval" rather than "prompts a human in the loop" for now.
- **Argument matching is intentionally simple.** `when` conditions support
  `equals`/`matches` (regex)/`oneOf` against top-level argument keys only;
  there's no JSONPath, nested-object matching, or numeric range support.
- **No policy hot-reload.** Editing `policy.yaml` requires restarting
  `mcpseatbelt wrap` (i.e. restarting the agent client's connection to it).
- **Only stdio transport.** Both the facade (agent-client-facing) and the
  upstream (real-server-facing) sides use MCP's stdio transport; HTTP/SSE
  MCP servers are not supported.
- **Only `tools/call` is policy-checked.** `resources/*` and `prompts/*`
  requests, if the wrapped server supports them, are passed straight
  through unchecked - only tool calls are in scope for this MVP.
- **The audit log detects tampering, it doesn't prevent it.** Anyone with
  filesystem access to `audit.jsonl` can still truncate the whole file and
  start a fresh, internally-consistent chain from a new genesis; the hash
  chain guarantees *internal* consistency of what's present, not that
  nothing was ever removed wholesale. Ship it to a separate,
  access-controlled sink (e.g. syslog, a WORM bucket) if you need stronger
  guarantees.
- **No signature/identity on audit entries.** The hash chain proves
  entries weren't altered after the fact relative to each other, but
  doesn't cryptographically attest *who* ran `mcpseatbelt` (no signing key).

## License

MIT, see [LICENSE](LICENSE).