raxit
Frameworks

MCP servers

Governing MCP servers in TypeScript via opt-in, per-instance autopatch.

Unlike LangChain.js / LangGraph.js / Mastra — auto-installed process-wide by raxit run's NODE_OPTIONS preload — MCP has no framework-wide dispatch point to patch at import time. There is no single base class every MCP server's tool dispatch runs through, so governance here is opt-in and per-instance: you call installMcpAutoPatch(server) yourself, once, on your own @modelcontextprotocol/sdk Server instance. It wraps server.setRequestHandler, so once a tools/call handler is registered it is checked against policy — permit / deny, with MODIFY support in the wrapper — before the handler runs. TypeScript only.

The ordering contract (load-bearing)

installMcpAutoPatch(server) only governs handlers registered after the call — it cannot retroactively wrap a tools/call handler already registered before it runs.

import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
import { installMcpAutoPatch } from "raxit/autopatch";

const server = new Server({ name: "my-server", version: "1.0.0" }, { capabilities: { tools: {} } });

installMcpAutoPatch(server);          // <-- MUST come first

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  // ... your tool dispatch — now governed permit/deny before it runs
});

Get this backwards (register the handler, then call installMcpAutoPatch(server)) and the tools/call handler runs completely ungoverned, silently. installMcpAutoPatch best-effort warns to stderr if it detects an already-registered tools/call handler at patch time — but don't rely on the warning catching every case; get the order right.

Run it yourself

npm install @raxitlabs/raxit

Because governance is wired explicitly in-source (not process-start injection), the governed server just runs under plain node — no raxit run wrapper needed for the server process itself.

How it works

The policy is default-deny with two allows:

.raxit/security.yaml
apiVersion: raxit.security/v1
agent: mcp-support-bot
default: block
rules:
  - effect: allow
    resource: search_docs
  - effect: allow
    resource: list_open_tickets
  # shell.exec is intentionally UNLISTED -> default-deny (deny.default_or_forbid).

A denied call throws inside the wrapped handler; the MCP SDK's Protocol._onrequest turns that rejection into a JSON-RPC error response, which client.callTool() surfaces as a rejected McpError carrying the reason code in its message — the handler body never runs. Real captured output from a run:

raxit MCP opt-in autopatch full demo · daemon=http://127.0.0.1:8426

  | raw MCP support-bot server · installMcpAutoPatch(server) installed before tools/call handler
  |
  | [search_docs] -> search_docs OK — top hit for "refund policy": 'Refunds within 30 days are issued at face value.' (stub)
  | [list_open_tickets] -> list_open_tickets OK — 2 open tickets for "cus_1" (stub)
  | [shell.exec] BLOCKED -> McpError: MCP error -32603: [raxit BLOCKED] 'shell.exec' denied (reason: deny.default_or_forbid). The operation was not executed. Details: raxit explain <id>

PASS  installMcpAutoPatch(server) engaged before tools/call was registered
PASS  search_docs -> permit (ran)
PASS  list_open_tickets -> permit (ran)
  shell.exec reason=deny.default_or_forbid
PASS  deny.default_or_forbid: shell.exec -> deny (BLOCKED, never ran) — governance LIVE, not just installed
  audit.verify -> ok=true signatures_verified=true signed_count=3 count=3
PASS  audit.verify: signed Decision Provenance chain verified green

ALL PASS

The shell.exec deny is asserted live — proof that governance actually engaged on this run, not merely that the patch function was called without error.

MODIFY is supported by the wrapper, not demonstrated here

installMcpAutoPatch's wrapper substitutes a MODIFY verdict's rewritten arguments into the request before the handler runs — the mechanism is the same shared engine every other interception point uses. This example's policy only exercises permit/deny; it doesn't include a modify: rule. See Mastra for a TS worked example that does exercise MODIFY on the autopatch path.

What got denied and why

  • deny.default_or_forbidshell.exec appears nowhere in the policy, so it falls to default: block. The handler body never ran, confirmed by the demo's own call log.

All three decisions land in one hash-chained, Ed25519-signed Decision Provenance log, verified green with client.audit.verify({ signatures: true, pubkeys: [...] }). See Audit and Decisions for the full permit / defer / deny vocabulary and reason-code catalog.

  • LangChain.js / LangGraph.js — process-wide, auto-run-at-import autopatch — the contrast case for this page's opt-in, per-instance model.
  • Mastra — another process-wide autopatch hook that additionally supports MODIFY on the autopatch path.

On this page