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

# Protect an existing MCP server in five minutes

> Put the gateway in front of an MCP server you already run: name its tools in a policy, start ctrlrun gateway.

You run an MCP server and an agent that calls it. In production the gateway is a process
between them, started with one command; here the same gateway object is driven in process
against a stand-in upstream, so the recipe runs offline and shows exactly what the agent gets
back. [The gateway in five minutes](/mcp/gateway-in-5-minutes) has the production commands.

## The policy

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

## The code

```python runnable file=main.py theme={null}
import json
from collections.abc import Mapping
from pathlib import Path
from typing import Any

from ctrlrun import Control, Policy, SQLiteStateStore
from ctrlrun.gateway.mcp import CURRENT_REVISION
from ctrlrun.gateway.outcome import COMPLETE, UpstreamResult
from ctrlrun.gateway.server import Gateway, GatewayConfig

HERE = Path(__file__).resolve().parent
STATE = HERE / ".ctrlrun"
STATE.mkdir(exist_ok=True)
for name in ("state.db", "state.db-wal", "state.db-shm"):
    (STATE / name).unlink(missing_ok=True)

upstream_calls: list[dict] = []


def upstream(
    body: bytes, headers: Mapping[str, str], *, fresh: bool
) -> tuple[Any, bytes, int, dict[str, str]]:
    """The MCP server, as the gateway sees it: a stand-in that answers every tools/call."""
    request = json.loads(body)
    upstream_calls.append(request)
    reply = {
        "jsonrpc": "2.0",
        "id": request["id"],
        "result": {"content": [{"type": "text", "text": "ok"}]},
    }
    return (
        UpstreamResult(result_type=COMPLETE),
        json.dumps(reply).encode(),
        200,
        {"content-type": "application/json"},
    )


store = SQLiteStateStore(STATE / "state.db")
control = Control(Policy.from_file(HERE / "ctrlrun.yaml"), store)
config = GatewayConfig(upstream="http://localhost:8000/mcp", alias="ops", principal="oncall-agent")
gateway = Gateway(config, control, upstream)


def call(tool: str, arguments: dict, rpc_id: int) -> dict:
    body = json.dumps(
        {
            "jsonrpc": "2.0",
            "id": rpc_id,
            "method": "tools/call",
            "params": {"name": tool, "arguments": arguments},
        }
    ).encode()
    headers = {
        "MCP-Protocol-Version": CURRENT_REVISION,
        "Mcp-Method": "tools/call",
        "Mcp-Name": tool,
        "Content-Type": "application/json",
    }
    response = gateway.handle(body, headers)
    return {"status": response.status, **json.loads(response.body)}


first = call("restart_deployment", {"cluster": "prod-eu", "name": "checkout"}, 1)
print("restart_deployment:", first["status"], first["result"]["content"][0]["text"])

again = call("restart_deployment", {"cluster": "prod-eu", "name": "checkout"}, 2)
print(
    "the same restart again:",
    again["status"],
    again["error"]["code"],
    again["error"]["data"]["error"],
)
if "error" not in again:
    raise SystemExit("a duplicate restart reached the upstream")

held = call("delete_namespace", {"cluster": "prod-eu", "name": "checkout"}, 3)
print(
    "delete_namespace:",
    held["status"],
    held["error"]["code"],
    held["error"]["data"]["error"],
    "request",
    held["error"]["data"]["request_id"][:4] + "…",
)
if "error" not in held:
    raise SystemExit("a namespace delete reached the upstream without a human")

unknown = call("drop_database", {"name": "prod"}, 4)
print(
    "drop_database (not in the policy):",
    unknown["status"],
    unknown["error"]["code"],
    unknown["error"]["data"]["error"],
)

print("calls that reached the upstream:", len(upstream_calls))
store.close()
```

## What the agent sees

```text theme={null}
restart_deployment: 200 ok
the same restart again: 409 -41004 ctrlrun.duplicate_effect
delete_namespace: 403 -41002 ctrlrun.approval_required request apr_…
drop_database (not in the policy): 403 -41001 ctrlrun.denied
calls that reached the upstream: 1
```

One call reached the server. The other three came back as JSON-RPC errors with codes a client
can act on, never as tool results the model would read as text and retry.

## The receipt

```bash runnable theme={null}
ctrlrun receipts --last 4
```

Every `tools/call` that reaches a decision has a receipt named `mcp.ops.<tool>`, denied ones
included — which is why four protected calls above leave three receipts and not four: the one
waiting on a human is not decided yet.

## When an AMBIGUOUS appears

An upstream that commits and then drops the connection comes back as `-41010`,
`ctrlrun.upstream_ambiguous`, and the identical call is refused with `-41005` until a human
runs `ctrlrun resolve restart:prod-eu:checkout --committed` or `--failed`. For an upstream whose
in-band error means it did nothing, set `mcp: {not_executed_on_error: true}` on the action and
such errors become `FAILED` rather than unknown.

## Next

* [The gateway in five minutes](/mcp/gateway-in-5-minutes): the production commands and the full code table.
* [Put the gateway in front of MCP](/guides/gateway-in-front-of-mcp) · [Get started](/get-started/quickstart) · [Why](/why).


## Related topics

- [Cookbook](/cookbook/index.md)
- [The gateway in five minutes](/mcp/gateway-in-5-minutes.md)
- [CTRLRun and MCP](/mcp/overview.md)
- [Put the gateway in front of MCP](/guides/gateway-in-front-of-mcp.md)
- [CTRLRun](/index.md)
