Skip to main content
You already run an MCP server and an agent that calls it. Put ctrlrun gateway between them and every tools/call is decided by a policy, bound to an approval where the policy says so, reserved by effect key, relayed and recorded. The agent’s configuration changes one URL. The server does not change at all.
Prerequisites: pip install "ctrlrun[gateway]"; the server reachable over HTTP; a directory for ctrlrun.yaml and the store. The gateway speaks MCP revision 2026-07-28 and accepts 2025-11-25, 2025-06-18 and 2025-03-26.
1

Name the tools

Every tool you want decided gets an entry mcp.<alias>.<tool>. A write gets an effect: template over the tool’s own parameter names; a tool not listed is denied.
runnable
2

Start it, and repoint the agent

Point the agent’s MCP client at http://127.0.0.1:8900/mcp. That is the change.
3

What the agent sees

A refusal is a JSON-RPC error with a code the client can read, never a tool result with isError, because a tool result reaches the model as text and invites the retry the refusal exists to prevent.
A human runs ctrlrun approve apr_…, or answers in Slack through the webhook, and the agent’s next identical call runs. A different call, a different namespace, a different cluster, does not: the approval is bound to the hash of what the human saw.A relayed result carries _meta["com.ctrlrun/receipt"] with the receipt id.
4

Choose the principal, and read the note

The gateway never reads a principal off the request body, and --principal-from-client-info was removed by name: a client’s own claim about who it is was a way for the agent to pick its authority. A tools/call with no principal is refused with -41007.

What you get, in your terms

  • No code changes.
  • Any language.
  • Approvals bound to the exact tool call.
  • Every call that reaches a decision leaves a receipt, denied ones included. A call still waiting on a human has not been decided yet and has none until it is.
  • Measure first: mode: observe at the top of the same file.

If it didn’t work

  • -32020, header mismatch: the MCP header and the body disagree; the gateway routes on the body and refuses rather than guessing.
  • unsupported protocol version: the client declared a revision outside the accepted set.
  • The gateway exits 2 at start: a non-loopback --listen without --allow-remote, or a policy that does not load.

Next