npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

mcp-policy-guard

v0.2.0

Published

Policy and safety middleware for MCP servers — authorization, rate limits, confirmation gates, audit logging, and PII redaction.

Downloads

429

Readme

mcp-policy-guard

CI npm license node

Policy and safety middleware for MCP servers.

An MCP server hands a model a set of tools and, by default, trusts every call it makes. mcp-policy-guard sits between the two: it decides which calls are allowed, which need a human, how often they may happen, and what the response is permitted to contain — without you touching your tool implementations.

import { guard, allow, deny, requireConfirmation } from "mcp-policy-guard";

guard(server, {
  policies: [allow("search_*"), requireConfirmation("delete_*"), deny("admin_*")],
  rateLimit: { windowMs: 60_000, maxCalls: 30 },
  audit: { sink: "file", filePath: "./audit.jsonl" },
  redact: { patterns: "strict" },
});
  • Zero-config default deny — a tool with no matching allow rule does not run
  • No changes to your tools — one call wraps the whole tools/call path
  • One dependency (pino), ESM + CJS, TypeScript types included

Prompt injection is out of scope. This library governs tool execution, not model prompt integrity. Pair it with input-side gating such as prompt-protection.


Contents


Install

npm install mcp-policy-guard

@modelcontextprotocol/sdk is a peer dependency — install it too if you are building a server with it:

npm install @modelcontextprotocol/sdk

Requires Node.js 22 or newer.

Quickstart

Wrap a server

Register your tools first, then guard the server. The wrapper covers every tool routed through tools/call, including tools registered later.

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { guard, allow, deny, requireConfirmation } from "mcp-policy-guard";
import { z } from "zod";

const server = new McpServer({ name: "crm", version: "1.0.0" });

server.registerTool("list_contacts", { description: "List contacts" }, listContacts);
server.registerTool(
  "delete_contact",
  { description: "Delete a contact", inputSchema: { id: z.string() } },
  deleteContact,
);

guard(server, {
  policies: [allow("list_*"), requireConfirmation("delete_*"), deny("admin_*")],
  // stdout carries the JSON-RPC stream — never send audit there on a stdio server.
  audit: { sink: "file", filePath: "./audit.jsonl" },
});

await server.connect(new StdioServerTransport());

Wrap a handler

For tests, custom transports, or servers that don't expose their handler map:

import { createGuardedHandler, allow } from "mcp-policy-guard";

const guarded = createGuardedHandler(myToolsCallHandler, {
  policies: [allow("search_*")],
});

Both entry points take the same options.

Features

| Capability | Option | Notes | | --- | --- | --- | | Per-tool authorization | policies | Glob patterns, denyrequireConfirmationallow | | Argument-aware rules | when on a rule | Decide from the actual arguments, not just the name | | Identity scoping | identify, identities | Per-caller policies, limits, and token binding | | Human confirmation | requireConfirmation | Single-use tokens, or an out-of-band approver | | Rate limiting | rateLimit | Global, per-tool, per-identity, per-pattern overrides | | PII redaction | redact | 11 builtin patterns, key-based rules, Luhn-checked cards | | Audit trail | audit | JSONL to stdout/stderr/file/callback, or several at once | | Metrics | onDecision + GuardMetrics | Counters by outcome and tool | | Timeouts | timeoutMs | Bound a hung tool | | Argument validation | validate | Reject bad input before the tool sees it | | Tool hiding | filterToolList | Denied tools disappear from tools/list |

Policies

Rules are evaluated in a fixed order that does not depend on how you wrote them:

deny → requireConfirmation → allow → default (deny unless defaultAllow)

A deny anywhere beats every allow, so ordering mistakes fail closed.

import { allow, deny, requireConfirmation } from "mcp-policy-guard";

policies: [
  allow("search_*", "list_*"),        // several patterns per rule
  requireConfirmation("delete_*"),
  deny("admin_*"),
]

Glob syntax

| Pattern | Matches | | --- | --- | | search_* | search_files, search_ — any suffix | | tool_? | tool_a, not tool_ab | | {get,list}_users | get_users, list_users | | * | everything | | !admin_* | vetoes: ["*", "!admin_*"] matches everything except admin tools |

Argument conditions

when turns a name-level rule into a call-level one:

import { allow, deny, argStartsWith, argEquals, not } from "mcp-policy-guard";

policies: [
  allow("read_file", { when: argStartsWith("path", "/workspace/") }),
  deny("run_sql", {
    when: not(argStartsWith("query", "SELECT")),
    reason: "only SELECT statements are permitted",
  }),
  allow("run_sql"),
  requireConfirmation("deploy", { when: argEquals("env", "prod") }),
  allow("deploy"),
]

Builtin conditions: argEquals, argMatches, argStartsWith, argGlob, argPresent, argInRange, identityIs, metaEquals, composed with and, or, not. Any (ctx) => boolean works too. Keys accept dotted paths (argEquals("target.env", "prod")).

A condition that throws is treated as not matching — never as permission.

Policy from config

import { policiesFromConfig } from "mcp-policy-guard";

const policies = policiesFromConfig(JSON.parse(await readFile("policy.json", "utf8")));
// { "allow": ["search_*"], "confirm": ["delete_*"], "deny": ["admin_*"] }

Unknown keys throw at load time, so a typo cannot silently widen access.

Confirmation gates

By default a gated tool answers with a single-use token instead of running:

  1. First call returns confirmation_required plus a confirmationToken.
  2. The client retries the identical call with arguments.confirmationToken set.
  3. The token is single-use, bound to tool name + argument hash + issuing identity, and expires (5 minutes by default).

Argument hashing is order-independent, so a client that re-serialises arguments differently on the retry still succeeds.

confirmation: {
  ttlMs: 60_000,          // shorter window
  tokenKey: "approvalId", // rename the argument
  bindIdentity: true,     // default: another caller cannot redeem the token
  store: myRedisStore,    // ConfirmationStore is an interface
}

Out-of-band approval

If a human (or an approval service) sits outside the model loop, skip the token round-trip entirely:

confirmation: {
  approve: async ({ tool, args, identity }) => askOncall(tool, args, identity),
}

Returning false — or throwing — denies the call.

Rate limiting

Token buckets, all checked before any is consumed, so a call rejected by a narrow bucket does not burn global budget.

rateLimit: {
  windowMs: 60_000,
  maxCalls: 60,
  perTool: true,        // default
  perIdentity: true,    // needs identify()
  overrides: [
    { patterns: ["generate_report"], maxCalls: 2 },
    { patterns: ["send_*"], maxCalls: 5, windowMs: 3_600_000 },
  ],
  maxBuckets: 10_000,   // LRU cap; identity cycling cannot grow memory
}

Rejections carry retryAfterMs and the scope that rejected (global, tool, identity, override).

Identity

identify() resolves who is calling from the request or transport context. It feeds policies, rate limits, audit entries, and confirmation-token binding.

identify: (request, extra) => extra?.sessionId,

policies: [
  allow("admin_*", { identities: ["ops-*"] }),
  allow("search_*"),
]

A throwing identify() is logged and the caller is treated as anonymous — which, under default deny, means identity-scoped rules stop matching.

Redaction

Applied to tool results by default, optionally to arguments and audit records.

redact: {
  patterns: "strict",            // all builtins; or a list; default: email/phone/creditCard
  custom: [/EMP-\d{6}/g],
  keys: [...DEFAULT_SENSITIVE_KEYS, "internal_*"],
  replacement: "[REDACTED]",
  preserveLast: 4,               // keep a card's last four
  results: true,                 // default
  arguments: false,              // also scrub what reaches the tool
  auditArgs: true,               // scrub args recorded by the audit sink
}

Builtin patterns: email, phone, creditCard, ssn, ipv4, jwt, awsAccessKeyId, apiKey, bearerToken, privateKey, iban.

Credit-card matches are Luhn-checked first, so order numbers and long ids stay readable. Circular structures, Dates, and class instances are handled without mangling or hanging.

Audit and metrics

Every decision produces one JSONL entry:

{"ts":"2026-07-27T18:59:24.100Z","callId":"ngm-1","tool":"delete_contact","decision":"requireConfirmation","latencyMs":1,"outcome":"confirmation_required","identity":"ops-jane","argsHash":"45fb02…"}

Arguments are hashed, not recorded, unless you opt in with includeArgs.

audit: {
  sink: [(entry) => log.info(entry), "file"],  // fan out
  filePath: "./audit.jsonl",
  includeArgs: false,
  outcomes: ["denied", "error"],               // only record what you care about
  await: true,                                 // false to keep writes off the hot path
}

A sink that throws is logged and swallowed: observability can never turn a permitted call into a failed one.

For counters, feed onDecision into GuardMetrics:

import { GuardMetrics } from "mcp-policy-guard";

const metrics = new GuardMetrics();
guard(server, { onDecision: (event) => metrics.record(event) });

metrics.snapshot();
// { calls, allowed, denied, rateLimited, confirmationsRequired, errors, byTool, avgLatencyMs }

callId correlates the audit entry with the decision event for the same call.

Timeouts and validation

timeoutMs: 15_000,

validate: (ctx) =>
  ctx.tool === "search" && typeof ctx.args.q !== "string"
    ? "search requires a string `q`"
    : true,

validate runs after policy and before confirmation, so bad arguments never mint a token. Returning a string rejects the call with invalid_arguments.

A timed-out call returns a timeout error result. JavaScript cannot abort an already-running promise — the underlying work may continue; the timeout bounds what the host waits for.

Tool list filtering

guard(server) also wraps tools/list so tools the policy would refuse never appear to the model. Disable with filterToolList: false.

Rules carrying a when condition stay listed — their verdict depends on arguments that don't exist yet — and are blocked at call time instead. Hiding is ergonomics, not a security boundary: every call is checked regardless.

Architecture

flowchart LR
  Host[MCP Host] -->|tools/call| RL[Rate limit]
  RL -->|over budget| Err[Error result]
  RL --> Pol[Policy]
  Pol -->|deny| Err
  Pol --> Val[Validate]
  Val -->|invalid| Err
  Val --> Conf{Confirmation?}
  Conf -->|token needed| Token[One-time token]
  Conf -->|approved| Tools[Tool handler]
  Tools --> Redact[Redaction]
  Redact --> Host
  RL --> Audit[Audit + metrics]
  Pol --> Audit
  Conf --> Audit
  Tools --> Audit

The ordering is deliberate: rate limiting runs first so a spamming agent is cheap to reject, and audit records every branch — including the ones that never reach a tool.

Threat model

Protects against

  • Over-broad tool exposure — default deny, plus argument-level conditions
  • Accidental or unconfirmed destructive writes — confirmation gates
  • Runaway agent loops — layered rate limits
  • PII leaking through tool results, arguments, or audit logs
  • Missing decision trail — every branch is audited
  • Token replay, cross-tool reuse, and cross-identity theft
  • Unbounded memory from cycled identities or token floods

Does not protect against

  • Prompt injection or jailbreaks in the model context
  • A compromised MCP host process
  • Confused-deputy problems in your own business API's authorization
  • Argument content in side channels — arguments are hashed in audit by default; enable includeArgs only in locked-down environments
  • Timing side channels inside your own tool handlers

Long form: docs/threat-model.md.

Upgrading from 0.1

0.2 is backward compatible: every 0.1 option and export still works. Two behaviours changed — argument hashes are now order-independent (old audit hashes won't match new ones), and tools/list is filtered by default.

Full notes: docs/migration-v0.2.md.

Documentation

Runnable examples live in examples/:

npm run build
node examples/basic-wrap.mjs
node examples/crm-confirmation.mjs
node examples/advanced-policies.mjs
node examples/observability.mjs
# examples/mcp-server.mjs is a real stdio server — point an MCP host at it

Contributing

Bug reports, feature requests, and pull requests are welcome. Start with CONTRIBUTING.md — it covers local setup, the test layout, commit conventions, and what a good issue looks like.

License

MIT © Muhammad Zia

Maintainers: release process in PUBLISH.md.