Developers / Quickstart

Deny one tool call, and read the decision.

The shortest path to a governed action: point an MCP client at the transparent proxy, write one rule, watch the call stop, and find the record it left. Nothing is installed inside the agent.

No gateway required No change to agent code Ends in a verdict you can see

BEFORE YOU START

What this path assumes.

The transparent MCP proxy is chosen here because it needs the least existing infrastructure — no gateway at all. If you already run an Envoy-family gateway, the ext_proc adapter is the shorter route in production.

You need

  • An MCP client and an MCP server you can point at a different URL
  • One tool on that server whose call you are willing to have denied
  • The governor-mcp-proxy binary. It is built from the GovernorAI source tree (go build ./cmd/governor-mcp-proxy), or as a container image from the shared adapter Dockerfile (--build-arg BINARY=governor-mcp-proxy), which targets linux only. There is no prebuilt release artifact for this adapter: the signed release matrix covers the governor CLI alone (linux amd64/arm64, darwin amd64/arm64, windows amd64).

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.

Honesty note

This path governs one tool call on one seam. It does not make an agent governed — discovery has not run, no policy covers the rest of the tool surface, and nothing has been said about the model call itself. It is the smallest thing that is genuinely true, which is a better starting point than a demo that implies more.

Continue