PP

permission-protocol/mcp-guard

Developer tools
12 stars 7 forks Quality 79 Trend 39MIT

MCP middleware that blocks dangerous AI agent actions using a simple YAML config

Overview

MCP (Model Context Protocol) is how AI agents call tools — like APIs, databases, or deployments. MCP has no built-in access control. MCP Guard adds that layer. Define what's allowed, blocked, or requires approval — in a single YAML file. MCP Guard sits between your MCP client and server, enforces the rules, and logs every decision as an audit receipt. Without MCP Guard, your agent can call any tool. With it, every action is checked. → unsafe tool calls before they execute → sensitive actions for human approval → every decision as an immutable receipt → — audit what would be blocked before enforcing That's it. Your agent can no longer delete user data. Production deploys require approval. Everything is logged to pp-receipts.jsonl, and each decision gets a shareable receipt URL. See MCP Guard block dangerous tool calls in real-time, no setup required: MCP Guard intercepts JSON-RPC messages on stdin/stdout. When it sees a tools/call request: 1.

README

MCP Guard

Stop your AI agents from doing dangerous things.

MCP (Model Context Protocol) is how AI agents call tools — like APIs, databases, or deployments. MCP has no built-in access control. MCP Guard adds that layer.

Define what’s allowed, blocked, or requires approval — in a single YAML file. MCP Guard sits between your MCP client and server, enforces the rules, and records every decision as a signed receipt.

Every tool call, resource read and prompt fetch goes through your policy; only protocol plumbing (initialize, ping, list calls, notifications) passes untouched. MCP Guard does not just log — it blocks execution before it happens.

→ Blocks unsafe tool calls before they execute → Holds sensitive actions for human approval → Signs every decision as an Ed25519 receipt in the Permission Protocol receipt standard, naming who or what decided → Observe mode — audit what would be blocked before enforcing

Get Started (2 minutes)

# Install
npm install @permission-protocol/mcp-guard

# Create your signing key (once; the guard never creates one itself)
npx mcp-guard keys init

# Create a policy file
cat > pp.config.yaml << 'EOF'
default_action: allow
rules:
  - id: block-delete
    tool: delete_user_data
    action: block
  - id: hold-deploy
    tool: deploy_production
    action: require_approval
EOF

# Run your MCP server through the guard
mcp-guard --config pp.config.yaml -- node my-mcp-server.js

That’s it. Your agent can no longer delete user data. Production deploys require approval. Every decision is signed and logged to pp-receipts.jsonl. Without a signing key the guard refuses to start (SIGNING_KEY_MISSING) rather than run unsigned.

default_action: allow lets any tool without a rule run, and the guard warns about it at startup. Use block or require_approval to fail closed.

60-Second Demo (no MCP server needed)

See MCP Guard block dangerous tool calls in real-time, no setup required:

npx @permission-protocol/mcp-guard demo

The demo signs with a throwaway demo key (mcpg-demo-…) in a temporary directory. Its receipts carry scope: "demo", and gates refuse them.

Architecture

┌────────────┐     stdio      ┌─────────────┐     stdio      ┌────────────┐
│  MCP Client│ ──────────────▶│  MCP Guard   │──────────────▶ │ MCP Server │
│  (Claude,  │                │  (proxy)     │                │ (your app) │
│   Cursor)  │ ◀──────────────│              │◀────────────── │            │
└────────────┘   responses    └─────────────┘   responses    └────────────┘
                                    │
                                    ▼
                              pp-receipts.jsonl
                              (audit trail)

MCP Guard parses every JSON-RPC message on stdin and forwards only what it parsed and evaluated, re-serialized:

  1. A line that is not a single JSON-RPC 2.0 object (invalid JSON, a batch array, a malformed frame) gets an error and is never forwarded.
  2. Plumbing passes through: initialize, ping, notifications/*, tools/list, resources/list, resources/templates/list, resources/unsubscribe, prompts/list, logging/setLevel, completion/complete.
  3. Everything else — tools/call, resources/read, prompts/get, resources/subscribe and any method MCP Guard does not know — is decided by the rules, then default_action.
  4. Allowed → forwarded (agent metadata under _pp is stripped first). Blocked → error -32001. Held → waits for a human (or returns -32002 without an approval UI).
  5. Every final decision (allowed, blocked, approved, denied, released by countdown, expired) emits a signed receipt (one stderr line + pp-receipts.jsonl). A held call is recorded unsigned until it is decided.

Config Reference

# pp.config.yaml
default_action: block        # allow | block | require_approval — when no rule matches
mode: enforce                # "enforce" (default) or "observe" (log only, never block)
approval_ttl_seconds: 900    # held requests expire after this (default 900)
receipt_ttl_seconds: 3600    # receipts for unscoped actions expire after this (default 3600)
scoped_receipt_ttl_seconds: 900  # receipts for merge / migration / deploy expire after this (default 900)
server_label: github-mcp     # names the MCP server in receipts (default "mcp-server"; never the command line)
signing: required            # required (default) | off: unsigned records that no gate accepts

rules:
  - id: unique-rule-id       # unique identifier
    tool: tool_name          # exact tool name (tools/call rules)
    action: allow            # allow | block | require_approval

  - id: hold-reads
    method: resources/read   # govern another method (no "tool" field)
    action: require_approval

  - id: hold-email
    tool: send_email
    action: require_approval
    reversibility: reversible  # reversible | irreversible (default: irreversible)
    countdown_seconds: 300     # reversible only: auto-release unless someone holds it

  - id: spend
    tool: spend
    action: require_approval
    cap_usd: 50                # under the cap: runs now, shows in Verify; at/over: hard stop
    amount_arg: amount_usd     # required with cap_usd: the argument the tool actually charges
    receipt_ttl_seconds: 300   # optional: this rule's receipts expire sooner (any expiry is at most 86400)

The loader is strict: an unknown key, a duplicate rule, a countdown on an irreversible rule, or cap_usd without amount_arg fails at startup instead of being ignored. A missing or non-numeric amount is a hard stop. Agent metadata under _pp (confidence, requires_input) can route a card to a different lane but can never turn a held or blocked call into an allowed one, and requires_input never changes how an irreversible hold is presented.

Modes

Mode Behavior
enforce Block/hold tool calls per rules (default)
observe Log decisions + emit receipts, but always forward (dry-run)

Use observe mode to audit what would be blocked before turning enforcement on. Switch via config or CLI: --mode observe.

Actions

Action Behavior JSON-RPC Error Code
allow Forward request to server —
block Reject immediately -32001
require_approval Hold for approval (or reject without an approval UI) -32002

Other errors the agent can receive: -32003 denied by a human, -32004 approval request expired, -32005 release refused (receipt failed verification), -32006 the decision could not be signed (missing, invalid or revoked key: nothing was forwarded), -32700 / -32600 / -32602 malformed input.

Receipts

Every final decision is signed in the Permission Protocol receipt standard (jcs_v2, the format the Permission Protocol app issues and @permission-protocol/verify checks). Each line of pp-receipts.jsonl is:

{
  "artifact": {
    "receipt_id": "rcpt_mg_mvtj9td5_63c091bfe0e7",
    "status": "APPROVED",
    "receipt_version": 2,
    "canonicalization": "jcs_v2",
    "key_id": "mcpg-3c1d2e4f5a6b7c8d",
    "alg": "ed25519",
    "signed_payload_hash": "",
    "signature_b64": "",
    "payload_bytes_b64": "",
    "issued_at": "2026-10-29T15:30:00.000Z",
    "expires_at": "2026-10-29T15:45:00.000Z"
  },
  "record": {
    "id": "rcpt_mg_mvtj9td5_63c091bfe0e7",
    "agentId": "my-agent",
    "requestJson": "{\"action\":{\"operation\":\"tools/call\",\"resource\":\"infra-mcp\",\"tool\":\"deploy\"},…,\"scope\":{\"capability\":\"deploy:production\",\"commitSha\":\"a1b2c3d4\"}}",
    "inputHash": "sha256:",
    "status": "APPROVED",
    "policyVersion": "mcp-guard-config:sha256:",
    "reasonCodes": "[\"RULE_MATCHED:deploy\",\"OPERATOR_APPROVED\"]",
    "deciderId": "local:rod",
    "deciderAuthMethod": "local_console",
    "attributionConfidence": "heuristic",
    "resolutionType": "allow_once",
    "scope": "production",
    "…": "the rest of the 24 signed fields"
  }
}
  • The artifact is what a gate verifies. Its signed bytes are the record’s fields, canonicalized. Verifiers read the decision, expiry, decider and scope only from the signed bytes, never from the convenience fields beside them, and a receipt never says it is “verified”.
  • Who decided is signed. A rule, a spend cap or a countdown signs system/mcp-guard-policy (policy). An approval or denial in the console signs local: (--operator, default the OS user) with local_console and heuristic confidence: the console token proves access to the guard’s terminal, the name itself is configured. With no operator, a console approval is refused (UNATTRIBUTED_PRODUCTION_DECIDER) and the call stays held.
  • No raw arguments. The signed request names the tool, method, server label, rule and scope; the arguments are bound only by inputHash (pp.hashable_payload.v1 over {tool, operation, input, metadata: null}), which anyone holding the call can recompute (verify --call). Raw arguments appear only in the authenticated approval UI.
  • Reason codes come from a fixed set: RULE_MATCHED:, DEFAULT_ACTION, COUNTDOWN_AUTO_RELEASE, OPERATOR_APPROVED, OPERATOR_DENIED, APPROVAL_EXPIRED.

Receipts are written to stderr (one summary line), pp-receipts.jsonl (or $PP_SHARED_RECEIPTS_PATH), and optionally POSTed to $PP_RECEIPT_ENDPOINT (with $PP_RECEIPT_TOKEN as bearer auth).

Signing keys

mcp-guard keys init            # first key: mcpg-
mcp-guard keys show            # key id, fingerprint, keys.json path, and the --trust value for gates
mcp-guard keys rotate          # new signing key; older keys stay in keys.json so their receipts still verify
mcp-guard keys revoke  # every receipt that key signed stops verifying; rotate to sign again

Keys live in ~/.config/mcp-guard/keys (or $PP_KEYS_DIR), never in the working directory: keys.json (the key set, in the shape served at /.well-known/permission-protocol/keys.json) and one .private.pem (0600). The guard signs with the newest key and never creates one. A missing, exposed, mismatched or revoked key stops the guard at startup in enforce mode, and if the key changes while it runs, allowed calls are refused with -32006 and held calls stay held. In observe mode calls are still forwarded and their records say signingFailed.

Verifying a receipt

# Once: commit the key set where your gate runs, and keep the --trust value it prints
mcp-guard keys show
cp ~/.config/mcp-guard/keys/keys.json .pp/keys.json

# In CI: verify offline against the pinned key
mcp-guard verify --receipt-file receipt.json --keys .pp/keys.json \
  --trust "$MCP_GUARD_TRUST" --scope deploy:production --scope-sha "$GITHUB_SHA"

The verifier requires --scope, trusts only the key set you give it (and, with --trust, only the pinned key), requires a signed APPROVED status and an unexpired signed expiresAt, checks scope exactly, and refuses:

  • scoped receipts no person approved (--allow-policy-approval accepts them);
  • receipts signed by a demo key (--allow-demo);
  • mcp-guard 0.2 receipts (--accept-legacy --pubkey accepts them, labelled legacy);
  • with --call call.json ({"tool", "operation", "input"}), a receipt whose inputHash does not recompute from the call.

Exit 0 = authorized, 1 = refused, 2 = missing input. See templates/github-actions/receipt-gate.yml for a deploy workflow that takes the verifier, key set, pin and receipt only from trusted places.

Which commit a receipt binds. A deploy: receipt binds the commit being deployed. A github:merge receipt (a merge_pr call, or a PR approved through the webhook) binds the PR’s head commit (pull_request.head.sha), not the merge commit GitHub creates: gate it with --scope github:merge --scope-ref refs/pull//merge --scope-sha . A new push to the PR needs a new receipt.

The same artifact verifies with the org verifier, offline:

npx @permission-protocol/verify receipt.json --keys .pp/keys.json --trust "$MCP_GUARD_TRUST"

CLI

mcp-guard [options] -- 
mcp-guard keys init [--demo] | keys rotate [--demo] | keys revoke  | keys show
mcp-guard verify --receipt-file  --keys  --trust ID=sha256: --scope s [--scope-ref r] [--scope-sha h]
mcp-guard export-key [out.pem]   # legacy 0.2 public key only, for --accept-legacy

Options:
  --config          Path to config file (default: ./pp.config.yaml)
  --agent-id          Agent identifier for receipts (default: "unknown")
  --operator        Signed as the decider of console decisions (local:; default: OS username)
  --mode            enforce or observe (overrides config file)
  --approval-port   Enable the approval UI on 127.0.0.1:
  -h, --help              Show help

Example: Claude Desktop

In your Claude Desktop MCP config, replace:

{
  "mcpServers": {
    "my-server": {
      "command": "node",
      "args": ["my-mcp-server.js"]
    }
  }
}

With:

{
  "mcpServers": {
    "my-server": {
      "command": "mcp-guard",
      "args": ["--config", "pp.config.yaml", "--agent-id", "claude-desktop", "--", "node", "my-mcp-server.js"]
    }
  }
}

Approval Flow

When a call matches a require_approval rule, MCP Guard can hold it open and wait for a human in a local web UI instead of rejecting it.

mcp-guard --config pp.config.yaml --approval-port 3100 -- node my-mcp-server.js
# stderr: [mcp-guard] Approval UI: http://127.0.0.1:3100/#t=

Open the exact URL printed at startup. The part after #t= is a per-process access token; it never leaves the browser except as the Authorization: Bearer header on API calls.

How it works:

  1. Agent calls a tool that requires approval; MCP Guard holds the response open.
  2. The call appears in the UI with every argument the server will receive.
  3. Approve → MCP Guard signs a receipt naming you as the decider, verifies it against its own pinned key, then forwards the call. If it cannot sign, the call stays held and the UI shows the error code.
  4. Deny → the agent gets error -32003, and the denial is signed. A decision is final: approving a denied call returns 409.
  5. No decision within approval_ttl_seconds → the agent gets error -32004, and the expiry is signed as a denial.

Security of the approval channel: it listens on 127.0.0.1 only, rejects any other Host header, sends no CORS headers, and requires the token on every API route except the HMAC-signed GitHub webhook. PP_APPROVAL_TOKEN (32+ characters) supplies the token instead of generating one. The wrapped server is started without any PP_* environment variables, so a tool that echoes its environment cannot leak the token.

When --approval-port is not set, require_approval calls return -32002 immediately.

Why not just use X?

Approach Why MCP Guard is different
API gateway MCP operates at the tool-call layer, not HTTP. MCP Guard understands tool names and MCP’s JSON-RPC protocol.
RBAC / IAM Those gate users. MCP Guard gates agent actions — the agent is already authenticated, the question is what it’s allowed to do.
“Just code it” You could. MCP Guard gives you a declarative config, audit receipts, observe mode, and an approval UI — without changing your server code.

What This Is NOT

  • Not a dashboard. It’s a proxy. It sits in the data path and enforces rules.
  • Not a scanner. It doesn’t analyze your code or model outputs. It gates tool calls.
  • Not compliance theater. Every decision has an Ed25519-signed receipt in the open receipt standard that names who or what decided.
  • Not a replacement for good architecture. It’s one layer in a defense-in-depth strategy.

Agents shouldn’t be able to execute arbitrary actions without an explicit decision point.

Part of Permission Protocol

MCP Guard is a building block of the Permission Protocol — explicit authority for autonomous AI systems.

License

MIT

View the full README on GitHub

Install

npx mcp-guard keys init
New to MCP? Start with the mcpskill.org guide →

Configuration

{ "mcpServers": { "my-server": { "command": "node", "args": ["my-mcp-server.js"] } } }