> ## Documentation Index
> Fetch the complete documentation index at: https://ctrlrun.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# The gateway in five minutes

> You already run an MCP server. Two commands put CTRLRun between the agent and it.

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.

```text theme={null}
before    agent  ──▶  http://localhost:8000/mcp
after     agent  ──▶  http://127.0.0.1:8900/mcp  (CTRLRun)  ──▶  http://localhost:8000/mcp
```

**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`.

<Steps>
  <Step title="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.

    ```yaml runnable theme={null}
    schema: ctrlrun.policy/v2

    actions:
      mcp.ops.get_deployment:
        decision: allow
      mcp.ops.restart_deployment:
        effect: "restart:{cluster}:{name}"
        decision: allow
      mcp.ops.delete_namespace:
        effect: "namespace:{cluster}:{name}"
        decision: approve
      mcp.ops.grant_role:
        effect: "grant:{principal}:{role}"
        rules:
          - when: { role_in: [reader, viewer] }
            decision: allow
          - decision: approve
    ```
  </Step>

  <Step title="Start it, and repoint the agent">
    ```bash theme={null}
    ctrlrun gateway --upstream http://localhost:8000/mcp --alias ops --principal deploy-agent
    ```

    ```text theme={null}
    CTRLRun gateway  →  http://localhost:8000/mcp  as mcp.ops.*
    listening on http://127.0.0.1:8900/mcp   environment: production
    identity: fixed principal deploy-agent   authority: none
    1 action(s) have no effect: template and get no reservation:
      mcp.ops.get_deployment
    That is right for a read, and wrong for anything that changes the world.
    ```

    Point the agent's MCP client at `http://127.0.0.1:8900/mcp`. That is the change.
  </Step>

  <Step title="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.

    ```json theme={null}
    {"jsonrpc": "2.0", "id": 12,
     "error": {"code": -41001, "message": "mcp.ops.delete_namespace: denied (policy)",
               "data": {"error": "ctrlrun.denied"}}}
    ```

    ```json theme={null}
    {"jsonrpc": "2.0", "id": 13,
     "error": {"code": -41002, "message": "mcp.ops.delete_namespace: a human must approve",
               "data": {"error": "ctrlrun.approval_required", "request_id": "apr_…"}}}
    ```

    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.

    | Code     | Token                        | HTTP | Meaning                                               |
    | -------- | ---------------------------- | ---- | ----------------------------------------------------- |
    | `-41001` | `ctrlrun.denied`             | 403  | the policy denies this action for everyone            |
    | `-41002` | `ctrlrun.approval_required`  | 403  | a human must approve; `data.request_id`               |
    | `-41003` | `ctrlrun.approval_denied`    | 403  | a human said no                                       |
    | `-41004` | `ctrlrun.duplicate_effect`   | 409  | this effect already committed                         |
    | `-41005` | `ctrlrun.ambiguous_effect`   | 409  | this effect's outcome is unknown; a human resolves it |
    | `-41007` | `ctrlrun.no_principal`       | 403  | no principal for this request                         |
    | `-41010` | `ctrlrun.upstream_ambiguous` | 502  | the reply was lost after the first byte               |
    | `-41012` | `ctrlrun.unauthorized`       | 403  | this principal holds no grant                         |

    A relayed result carries `_meta["com.ctrlrun/receipt"]` with the receipt id.
  </Step>

  <Step title="Choose the principal, and read the note">
    | Flag                         | Principal comes from                                                              | Worth                                                                                                 |
    | ---------------------------- | --------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
    | `--principal NAME`           | a fixed name                                                                      | one agent behind one gateway: exact                                                                   |
    | `--principal-header X-Agent` | a header a proxy sets                                                             | exactly what the proxy is worth: if the agent can set the header, the agent chooses its own authority |
    | `--identity-jwt …`           | a verified bearer token; algorithms, issuer, audience and token type all required | the issuer's word, verified                                                                           |

    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`.
  </Step>
</Steps>

## 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

* [Put the gateway in front of MCP](/guides/gateway-in-front-of-mcp): the longer guide, with the lost-reply case.
* [CTRLRun and MCP](/mcp/overview) · [Get started](/get-started/quickstart) · [Why](/why).


## Related topics

- [Protect an existing MCP server in five minutes](/cookbook/protect-an-mcp-server.md)
- [Approve from your assistant](/mcp/approve-from-your-assistant.md)
- [CTRLRun and MCP](/mcp/overview.md)
- [Approve in Slack](/guides/approvals-in-slack.md)
- [Cookbook](/cookbook/index.md)
