Run the proxy
The transparent MCP proxy listens on :9003 over HTTP. It can sit in front of an MCP server directly, or be chained inside an existing proxy path.
# There are no flags and no config file. The proxy is configured entirely by
# environment. GOVERNOR_MCP_UPSTREAM_URL is the only required value — the
# process exits(2) without it. One proxy fronts one upstream MCP server.
GOVERNOR_MCP_UPSTREAM_URL=https://mcp.internal/servers/payments \
GOVERNOR_MCP_ID=mcp-payments \
GOVERNOR_MCP_NAMESPACE=mcp-quickstart \
GOVERNOR_API_KEY=gsk_... \
GOVERNOR_GATEWAY_URL=http://localhost:8080 \
./governor-mcp-proxy
# Defaults, if you do not set them:
# GOVERNOR_GATEWAY_URL http://localhost:8080
# GOVERNOR_MCP_PROXY_ADDR :9003
# GOVERNOR_MCP_DECISION_TIMEOUT 5s
# GOVERNOR_MCP_UPSTREAM_TIMEOUT 30s
# GOVERNOR_MCP_LOG_LEVEL info
# Optional: GOVERNOR_MCP_NAMESPACE, GOVERNOR_ORG_ID (sent as X-Org-ID).
#
# Per-call identity travels in request headers and overrides the defaults:
# x-governor-agent-id x-governor-org-id x-governor-principal-id
# x-governor-session-id (falls back to Mcp-Session-Id) x-governor-trace-id
Repoint the client
Change the MCP client's server URL to the proxy. Nothing else in the client changes, and the agent is unaware of the interception.
Governed: tools/call. Passed through untouched: initialize, tools/list, ping and notifications — so discovery and handshake behave exactly as before.
Write one deny rule
Start with a rule narrow enough that you can trigger it deliberately: one tool name, denied unconditionally. Policy is a reviewed artifact, so this is a file, not a console setting.
# deny-refund.yaml
id: mcp-quickstart
name: "MCP quickstart"
namespace: mcp-quickstart # must equal GOVERNOR_MCP_NAMESPACE above
governance_mode: enforcement
fail_closed: true
version: 1
rules:
- id: deny_refund_payment
priority: 1
match:
tool: "refund_payment"
action: deny
# Install it: governor policies create -f deny-refund.yaml
# (the CLI POSTs the same document to /api/v1/policies)
# The Rego equivalent. The gateway queries data.governor.decision.decision;
# a module in that package contributes deny / deny_reason / rule_id.
package governor.decision
import future.keywords.if
deny if {
input.action.tool == "refund_payment"
}
deny_reason := "refund_payment is denied by the quickstart policy" if {
input.action.tool == "refund_payment"
}
rule_id := "deny_refund_payment" if {
input.action.tool == "refund_payment"
}
Call the tool
Invoke the tool through the agent as normal. The call is intercepted, evaluated, and stopped before it reaches the MCP server: on this seam a deny is returned as a JSON-RPC error, and a pause as an approval response.
# A denied tools/call. HTTP 200 — the verdict lives in the JSON-RPC error, not
# the status code. -32002 covers policy deny, kill switch, and every
# fail-closed denial. The id is echoed from the caller's request.
HTTP/1.1 200 OK
x-governor-decision: deny
{
"jsonrpc": "2.0",
"id": "abc",
"error": {
"code": -32002,
"message": "denied by policy",
"data": { "reason": "prompt injection detected" }
}
}
# A pause is -32001, and carries the approval so the caller can surface it:
{
"jsonrpc": "2.0",
"id": 7,
"error": {
"code": -32001,
"message": "approval required",
"data": {
"approval_id": "appr-123",
"approval_url": "https://governor/approvals/appr-123"
}
}
}
# A redact verdict returns no error at all. The gateway answers the proxy with
# an allow plus the shaped arguments:
{
"decision": "allow",
"args_constrained": true,
"shaped_args": { "patient": "REDACTED", "note": "visit summary" }
}
# args_constrained — not the presence of shaped_args — is the authoritative
# signal: a constrain that drops every argument forwards an EMPTY map, never
# the originals. The proxy rewrites params.arguments in place, preserving
# jsonrpc / id / method and any sibling params field, then forwards. The
# upstream MCP server receives the shaped values and never the originals.
Read the record
The decision is written as it happens, into the hash-chained ledger. This is the step that separates governance from blocking: the point is not only that the call stopped, but that you can show an assessor why.
# The execute response carries action_id; the decision record carries the same
# value in action_id. That is the join. Tenant context is required — an
# X-Org-ID header or a JWT with an org_id claim.
curl -s "$GOVERNOR/api/v1/events?decision=deny&limit=5" \
-H "Authorization: Bearer $GOVERNOR_API_KEY" \
-H "X-Org-ID: $GOVERNOR_ORG_ID"
# → { "events": [ ... ], "total": 1 }
{
"id": "...",
"type": "action.denied",
"timestamp": "...",
"account_id": "acct_...",
"session_id": "...",
"agent_id": "...",
"namespace": "mcp-quickstart",
"action_id": "...",
"tool": "refund_payment",
"decision": "deny",
"rule_id": "deny_refund_payment",
"policy_id": "mcp-quickstart",
"args_hash": "...",
"trace_id": "...",
"latency_ms": 0,
"metadata": { "reason": "Matched rule: deny_refund_payment" }
}
# Arguments are never stored — args_hash is a SHA256 of them.
# Filters: session_id, agent_id, namespace, policy_id, environment,
# event_type, decision, q, from, to, limit (max 500), offset.
# The same call is emitted as an action.evaluated event, and the tool-call
# view of it is at GET /api/v1/actions.
Prove it fails closed
Stop the policy engine and repeat the call. It must be denied, not allowed. Any transport error, timeout, non-2xx or unparseable response resolves to deny — enforcement does not degrade quietly.
There is nothing to set. Where the Envoy filter needs failure_mode_allow: false declared explicitly, the MCP proxy has no equivalent knob: its decision client is constructed fail-closed, and no environment variable, flag or config field turns that off. A transport error, a timeout, any non-2xx, an unparseable response, a body over 1 MiB, a tools/call missing its tool name, and a tools/call inside a JSON-RPC batch all resolve to -32002 and are never forwarded upstream — including a non-2xx whose body says allow. The only adjacent setting is GOVERNOR_MCP_DECISION_TIMEOUT (default 5s), which bounds how long a hung gateway takes to become a deny.