Model Context Protocol (MCP) Governance Proxy Open-Core
The @intutic/mcp-governance-proxy package (intutic-mcp-proxy) is a transparent stdio proxy for Model Context Protocol (MCP) servers. It intercepts, evaluates, and logs JSON-RPC 2.0 tool execution frames in-process before forwarding them to downstream MCP servers.
This page is the package/CLI/config reference. For the concepts behind each control — what DLP redaction, tools/list curation, TOFU pinning, and injection scanning each actually do and don't cover — see MCP Server Governance.
Overview
Modern AI coding agents (Claude Code, Cursor, Windsurf, Claude Desktop) interact with workspace tools, databases, and cloud infrastructure through MCP Servers (e.g., GitHub MCP, Postgres MCP, GKE MCP, Filesystem MCP).
@intutic/mcp-governance-proxy acts as a transparent wrapper between the AI agent and any real MCP server:
AI Coding Agent (Claude Code / Cursor)
│
│ (stdio JSON-RPC 2.0 tool frames)
▼
[ @intutic/mcp-governance-proxy ] ◄── registry → allowlists → SSO groups →
│ DLP → SOP rules → injection scan →
│ anomaly detectors → WASM rules →
│ call budgets
┌────────┼─────────────────┐
▼ ▼ ▼
[ allow ] [ block ] [ hold ]
Forward JSON-RPC error JSON-RPC error naming the hold;
to the (-32603), the identical retry runs once
server nothing runs a person approves itA third outcome, redact, applies only to the RESPONSE direction (a tool result or resources/read body) — the call already ran by the time this proxy sees the result, so there is nothing left to block; the proxy instead strips sensitive content out of what the agent will read. See Decisions and directions below.
Execution Modes (Standalone vs Governed Proxy)
The @intutic/mcp-governance-proxy package supports three execution modes:
| Mode | Command Syntax | Purpose & Exposed Capabilities |
|---|---|---|
| Standalone Governance Server | npx -y -p @intutic/mcp-governance-proxy intutic-mcp-proxy | Exposes governance tools directly to the agent: intutic_governance_status, intutic_list_sops, intutic_list_incidents, and the three below. |
| Governed Proxy Wrapper (stdio) | npx -y -p @intutic/mcp-governance-proxy intutic-mcp-proxy --workspace-id <wk_id> -- <real-mcp-command> | Intercepts, evaluates, and logs tool calls for a downstream MCP server spawned as a stdio child process, before forwarding. |
| Governed Proxy Wrapper (remote bridge) | npx -y -p @intutic/mcp-governance-proxy intutic-mcp-proxy --workspace-id <wk_id> --remote-url <url> [--remote-transport sse|http] | Same governance pipeline as the stdio wrapper, applied to a remote MCP server reached over HTTP or Server-Sent Events instead of a spawned child process — see Remote (HTTP/SSE) MCP servers for the full mechanism. |
The standalone server's tools that answer a refusal:
| Tool | Answers |
|---|---|
intutic_hold_status (holdId) | Whether a hold has been approved or rejected, and whether retrying the identical call now passes: wait, passes (with the time the approval's bypass expires), held_again (approved, but the workspace's reviewHoldBypassEnabled is off or the bypass has expired) or do_not_retry |
intutic_mcp_registry_status (server, optional) | For each server in the registry: its status, whether calls to it are allowed or refused and with which code, and its switched-off tools |
intutic_mcp_budget_remaining | Each MCP call budget that counts the caller's calls: limit, calls used and left this period, and when it resets. Counts are read from the Valkey the proxies count in (INTUTIC_VALKEY_URL); without one, used is null |
Configuration Example (Claude Desktop & Claude Code)
You can configure both modes together in claude_desktop_config.json or ~/.claude.json:
{
"mcpServers": {
"intutic": {
"command": "npx",
"args": [
"-y",
"-p",
"@intutic/mcp-governance-proxy",
"intutic-mcp-proxy"
]
},
"intutic_governed_filesystem": {
"command": "npx",
"args": [
"-y",
"-p",
"@intutic/mcp-governance-proxy",
"intutic-mcp-proxy",
"--workspace-id", "wk_production",
"--",
"npx", "-y", "@modelcontextprotocol/server-filesystem", "/projects"
]
}
}
}Decisions and directions
The interceptor (ToolCallInterceptor.decide, src/interceptor.ts) evaluates every tools/call REQUEST and returns one of the Decision type's variants:
| Decision | Direction | Meaning |
|---|---|---|
allow | request | Forward the JSON-RPC frame to the real server. |
block | request | Refuse the call pre-flight; return a JSON-RPC -32603 error to the agent, with a refusal code in error.data.code. Nothing runs. |
hold | request | A require_approval rule matched: refuse the call for now, record a hold for review, and return a -32603 error whose message names the hold id (also in error.data.holdId, with error.data.code HELD). See Approval holds. |
redact | response | Declared by the Decision type but produced structurally, not as a decide() return value — see below. |
decide() runs this pipeline, in order, over every tools/call request:
- MCP server registry — refuses a blocked server, a server held for re-approval after a high-risk tool change, every server not approved when the workspace's default policy is
deny, and a tool switched off within its server. See The registry. - Server allowlist (
mcpAllowedServers) — refuses the whole server if it's not on an explicit, non-empty allowlist. - Tool allowlist (
mcpAllowedTools) — refuses the individual tool the same way. - SSO group policy — the workspace's
sso_group_policy, applied to the member the proxy's API key belongs to. See Who made the call. - DLP scan — blocks a request whose arguments contain a credential-shaped value or a destructive command pattern (
rm -rf /,DROP TABLE,DROP DATABASE,DROP SCHEMA,TRUNCATE TABLE). Command patterns are matched against each argument string as the tool receives it, object keys included, and the SQL keywords may be separated by any whitespace, a line continuation, a block or--comment, or an escaped\n/\t:DROP/**/TABLEandDROPandTABLEon separate lines are blocked. The scan reads text, not SQL, so a quoted mention such asSELECT 'drop table'is blocked too — a quoted string is also how a shell command carries the real statement. - SOP policy rules — workspace-defined
block/warn/require_approvalrules matched against tool name and serialized arguments, served by the control plane for the proxy's workspace (GET /api/v1/sop/rules, or through the MCP daemon, which can start from the sync daemon's verified policy snapshot). The proxy never reads.intutic/sops/, so with no control plane it has no SOP rules.require_approvalholds the call for a person's approval (holdabove). The workspace'shook_rulepolicy guardrails arrive with these rules, an enforcing one asblockand a shadow one aswarn, withruleIdguardrail.<id>. The control plane serves only the guardrails that apply to the role of the member the proxy's API key belongs to. - Prompt-injection scan (request direction) — see Prompt-injection scanning below.
- Anomaly detectors and WASM rules, native or Rego — see the session-scope note and Custom rules below. A Rego rule's hold takes the same path as a
require_approvalrule. - Call budgets (
mcpBudgets) — counts the call in Valkey against every budget that covers it, and refuses it when one is used up. Last, so a call another step refuses spends nothing. See Call budgets.
Decide rules and the 3.0 SOP keys (a qualified requires_before, scope_paths_mode: enforce, plan_approval:, closed_lanes:, node_kind:) are not evaluated here: the hook gates, the gate SDKs or the LLM proxy enforce each of them (SOP Front Matter), and none of their refusal codes is one of this proxy's refusal codes. Where this proxy is a harness's only enforcement, as on Claude Desktop, and on Roo Code unless its model traffic goes through the LLM proxy (Harness Matrix), those rules do not apply to MCP tool calls.
The DLP scan also runs the enabled PII detectors: card numbers, IBANs and SSNs by default, or the actions the workspace's piiDetectors setting gives them. Arguments are never rewritten, so a match blocks the call even when its detector is set to redact. The LLM proxy and the hook gates treat the same value differently; see What each surface does with a match.
Refusal codes
Every refusal is a JSON-RPC -32603 error whose message is written for the agent, and whose error.data names it for a client that reads it programmatically: code, one of the codes below; ruleId, the rule, setting or detector that decided; and, for some codes, more fields. A hold adds status: "pending_approval" and holdId, which is empty when the hold could not be recorded, so there is nothing to approve yet. BUDGET_EXCEEDED adds the budget's budgetId, scope, period, limit, used and resetAt, and BUDGET_UNAVAILABLE the budgetIds it could not check.
{"code": -32603, "message": "[Intutic Governance] Tool call blocked: Tool \"query\" is disabled on MCP server \"db\" …",
"data": {"code": "TOOL_DISABLED", "ruleId": "mcp_registry.db.query"}}| Code | ruleId | Meaning |
|---|---|---|
REGISTRY_UNAVAILABLE | mcpProxyFailBehavior | The MCP server registry has not loaded since the proxy started, and the proxy fails closed |
SERVER_BLOCKED | mcp_registry.<server> | The server is blocked in the MCP server registry |
SERVER_HELD | mcp_registry.<server> | The server changed its tools in a way scored high risk and waits for an owner or admin to approve it again |
SERVER_NOT_APPROVED | mcpDefaultPolicy | The workspace refuses servers it has not approved (mcpDefaultPolicy: deny), and this one is not approved |
TOOL_DISABLED | mcp_registry.<server>.<tool> | The tool is switched off on this server in the registry |
SERVER_NOT_ALLOWED | mcpAllowedServers | The server is not on the workspace's mcpAllowedServers list |
TOOL_NOT_ALLOWED | mcpAllowedTools | The tool is not on the workspace's mcpAllowedTools list |
SSO_GROUP | sso_group.high_risk.<tool> or sso_group.require_obo.<tool> | The workspace's SSO group policy does not clear this tool for the member, or the member's groups are unknown |
DLP | dlp.<pattern> | The arguments contain a credential, a destructive command or a PII value the DLP scan blocks |
SOP_RULE | The SOP rule id | A block SOP rule matched |
HELD | The SOP rule id | A require_approval SOP rule matched: the call is held for a person's approval, and holdId names the hold |
INJECTION | injection.tool_input | The arguments matched a prompt-injection pattern, and the workspace blocks injection |
ANOMALY | The detector id | An anomaly detector stopped the call |
WASM_RULE | wasm:<rule id> | A custom WASM or Rego rule blocked the call |
REASK | The detector id, or wasm:<rule id> | An anomaly detector or WASM rule refused this attempt; revise the approach |
REASK_EXHAUSTED | The detector id, or wasm:<rule id> | The same detector or rule refused three attempts, so it now blocks outright |
BUDGET_EXCEEDED | The budget id | An MCP call budget covering this call is used up until resetAt |
BUDGET_UNAVAILABLE | mcpProxyFailBehavior | A call budget covers this call but could not be checked, and the proxy fails closed |
GOVERNANCE_UNAVAILABLE | mcpProxyFailBehavior, piiDetectors, wasm:<rule id> or wasm | A governance check could not complete; the reason names it. With mcpProxyFailBehavior or piiDetectors, the control plane's policy, the workspace's custom rules, the PII setting or the DLP scan was unavailable and the proxy fails closed. With wasm:<rule id> or wasm, a custom WASM or Rego rule reached no verdict (cause deadline, budget, error, result, or quarantined after three runaways in a row): refused whatever the fail setting, and refused again if retried with the same input |
TOFU_UNAVAILABLE | mcpProxyFailBehavior | The server's pinned tool definitions could not be read or written, and the proxy fails closed |
TOOL_DEFINITIONS_CHANGED | tofu.<server> | The server's tool definitions changed since they were first pinned, and the proxy fails closed |
RESULT_WITHHELD_DLP | dlp.<pattern> | The tool ran, but its result held sensitive data that could not be redacted safely, so it was not delivered |
RESULT_WITHHELD_INJECTION | injection.tool_result | The tool ran, but its result matched a prompt-injection pattern, so it was not delivered |
The two RESULT_WITHHELD_* codes answer a call that already ran; every other code means the call did not run.
In a workspace whose intervention mode is Opaque, a refusal on workspace policy keeps its JSON-RPC code and error.data.code, but its message is "Blocked by workspace policy. Ask a workspace admin if you need this action." and its ruleId is empty. That covers every code above except the operational ones: REGISTRY_UNAVAILABLE, BUDGET_EXCEEDED, BUDGET_UNAVAILABLE, TOFU_UNAVAILABLE, TOOL_DEFINITIONS_CHANGED and a GOVERNANCE_UNAVAILABLE that names no custom rule. A hold keeps its status and holdId, and a withheld result still says the call ran. The events the proxy reports keep the full reason.
Anomaly-detection session scope
The sequence detectors (consecutive_repeat, ping_pong_cycle, landmark_cycle, tool_diversity_collapse) and the reask ladder read a rolling window of the session's tool calls. Each MCP server is wrapped with its own proxy process, so a harness session with three servers runs three proxies — and each used to keep its own window, so a loop that alternated between two servers was invisible and a reask budget reset per server.
The window is now shared through a local Valkey named by INTUTIC_VALKEY_URL in ~/.intutic/env/runtime.env: every proxy of one harness session — identified by the harness process that spawned them (<workspace>:mcp:<parent pid>:<start token>; INTUTIC_MCP_SESSION_SCOPE overrides it) — reads and appends the same 60-entry sequence, the same 60-second call window and the same reask counters (one hour, set when a counter is created). One Valkey round trip per tools/call, and none when the window is not shared.
It is never a hard dependency: with no URL configured, with Valkey down, or when a read takes longer than 200 ms, the proxy uses its own in-process window — the behaviour before the window was shared. Standalone intutic start writes no runtime env, so sharing there needs VALKEY_URL in the harness's environment. Tool names are shared bare (the tool_sequence WASM rules see), so two servers that both expose a tool called search count as one; session_id in the WASM context stays per process.
An empty allowlist means unrestricted at every allowlist step above — never "permit nothing." A check that cannot complete fails open by default: DLP/SOP/TOFU checks that error out, and a registry the proxy has never been able to load, let the call through rather than blocking every request while policy is unreachable. The workspace's mcpProxyFailBehavior chooses, and INTUTIC_MCP_FAIL_OPEN=false sets it locally until the proxy has loaded the workspace's choice — see When the registry has not loaded.
A custom rule that reaches no verdict is the exception: it always refuses the call, with GOVERNANCE_UNAVAILABLE and ruleId wasm:<rule id>, whatever the fail setting. The setting is for an unreachable control plane, which an agent cannot cause; a rule's deadline or budget an agent can exhaust by padding its arguments — see When a rule reaches no verdict.
Custom rules
The proxy runs the custom rules in the local rules directory (~/.intutic/wasm/, or INTUTIC_WASM_DIR), each named local:<file name> and run in priority order, lower first. It rescans the directory when a file changes, and on every policy refresh.
The RESPONSE direction is a separate code path (processServerLine, src/proxy.ts), because by the time a result comes back the call has already executed — refusing to deliver the result protects nothing about whether the call happened, only what the agent gets to read afterward. That path applies, in order:
- DLP redaction — strips credential-shaped values and enabled PII detector matches (as
[REDACTED_PII]) out of the result text. If a match spans JSON syntax and the redacted text no longer parses, the whole result is withheld and replaced with an error explaining why (the call ran; only the delivery was refused). - Prompt-injection scan (response direction) — runs on the already-redacted text, so a secret can never reach this path unredacted. See below.
tools/listcuration — allowlist filtering, removal of tools the registry switched off, and operator description overrides, then a report-only injection scan over the resulting (post-curation) descriptions.- Server-level TOFU pinning — compares a
tools/listresponse's fingerprint against what was first pinned for this{workspace, server}pair; see MCP Server Governance for the full mechanism.
Prompt-injection scanning
Ported from the Rust LLM-traffic proxy's injection.rs — five regex patterns that catch well-known injection phrasings (instruction override, system-prompt extraction, role reassignment, guardrail-bypass language, forged instruction boundaries like [INST]/<|im_start|>). This is pattern matching, not a classifier: it catches the obvious cases and nothing more subtle, and it is deliberately narrow to keep false positives low — people legitimately tell an agent to "ignore my last message."
The scanner (src/injection.ts) runs at three points:
- Response direction: a
tools/callresult orresources/readbody, after DLP redaction completes. This is the multi-agent-graph attack shape — content the agent fetched (a web page, a file, another tool's output) can carry text that looks exactly like instructions from the orchestrator. tools/listdescriptions, post-curation. Report-only in v1 — a matched description never hides or blocks a listing; curation (mcpAllowedTools, operator overrides) and TOFU pinning already govern what the agent sees in a tool listing.- Request direction: the
argumentsof an incomingtools/call, as a step insideinterceptor.decide.
Configuration — mcpInjectionAction: 'warn' | 'block', default warn.
Workspace patterns — mcpInjectionPatterns: string[] in workspace settings adds your own regex sources on top of the five built in. The control plane serves them with the rest of the MCP curation (GET /api/v1/policy/resolve and GET /api/v1/sop/rules); the proxy compiles them itself, drops one that does not compile and counts the drop, and reports a match as workspace:<n> so a finding says which layer caught it. The built-in floor applies whether or not the control plane is reachable. Patterns are matched case-insensitively and capped at 512 characters each. This mirrors the Rust proxy's own posture: its PromptInjectionDetector never disposes an injection finding as an unconditional kill on its own — only reask (once findings reach a 2-technique threshold, or the source is untrusted content) or steer below that. A default of unconditional blocking in this proxy would be stricter than the capability it was ported from.
In block mode:
- A request-side match returns a
blockdecision citing pattern names only (never the matched text — matched text is attacker-controlled and this proxy never quotes it into logs or events). - A response-side match replaces the delivered result with a withheld-error frame. The wording is deliberate: it says the tool call already executed and its output was not delivered — it does not say the call was blocked, because it wasn't.
Every match — in either mode, on any of the three surfaces — emits an injection_detected event carrying the pattern names and the source (tool_result / tool_description / tool_input). Severity on that event escalates to high when findings reach the 2-technique threshold, or when the source is untrusted content (tool_result / tool_description) — mirroring the Rust detector's own escalation rule. A block-mode block additionally emits the existing tool_blocked event, so any consumer already keyed on tool_blocked sees this new block reason too. The control plane files every injection_detected, anomaly_detected and tool_redacted event as a detector finding under an mcp: detector id — see MCP Server Governance.
mcpInjectionAction rides the same policy-snapshot channel as every other curation field in this package (PolicyClient.absorbCuration) — set it via workspace settings on the control plane, or via the INTUTIC_MCP_INJECTION_ACTION environment variable for standalone/open-core use without a control plane.
Stdio Isolation Protocol
In the Model Context Protocol specification:
stdoutis strictly reserved for JSON-RPC 2.0 messages.stderris used for logging and diagnostic outputs.
The Intutic MCP proxy guarantees strict stdio isolation — all governance logging, audit events, and trace metrics are routed to stderr (via this package's own stderrLog.ts, never @intutic/logger, which defaults to stdout) and the local Valkey cache, preventing JSON-RPC parsing errors in host agent environments.
Troubleshooting & Common Errors
1. Error: "Server disconnected" in Claude Desktop or Cursor
- Symptom: Claude Desktop or Cursor displays a red
Server disconnectedstatus badge when opening the application. - Root Cause: The entry's command could not start the proxy — usually a path to
dist/index.jsthat does not exist on this machine, ornpx @intutic/mcp-governance-proxywithout-p(the package has two binaries,intutic-mcp-proxyandintutic-mcp-daemon, sonpxcannot pick one and exits). An entry with no command after--and no--remote-urlis not an error: it runs the standalone governance server. - Remedy:
- Manual Fix: Ensure your
claude_desktop_config.jsonpasses a valid target MCP server command after--:json{ "mcpServers": { "filesystem": { "command": "node", "args": [ "/path/to/packages/mcp-proxy/dist/index.js", "--workspace-id", "wk_production", "--", "npx", "-y", "@modelcontextprotocol/server-filesystem", "/Users/yourname" ] } } }
- Manual Fix: Ensure your