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

# Approve from your assistant

> Run ctrlrun mcp-operator and answer a pending approval from an MCP client, with the answer recorded under your name.

`ctrlrun mcp-operator` exposes the operator's own commands as MCP tools. The person who has to
answer an approval asks their assistant what is waiting, reads the action and its arguments, and
answers — with no checkout, no shell and no store path. The answer goes through the same two
store calls `ctrlrun approve` and `ctrlrun deny` make, so there is one approval record and one
place its state changes.

```text theme={null}
approver's assistant  ──▶  ctrlrun mcp-operator  ──▶  the approval record the agent is waiting on
```

**Prerequisites:** `pip install "ctrlrun[gateway]"`, and a directory holding the `ctrlrun.yaml`
and the store your agents already use. It speaks MCP revision `2026-07-28` and accepts
`2025-11-25`, `2025-06-18` and `2025-03-26`.

## From the assistant on your own machine

If the person answering is the one at the keyboard, there is nothing to proxy. Desktop
assistants, Cursor and the editors launch an MCP server as a subprocess and speak to it on stdin and stdout,
and `--stdio` is that server:

```json theme={null}
{
  "mcpServers": {
    "ctrlrun-operator": {
      "command": "uvx",
      "args": ["ctrlrun", "mcp-operator", "--stdio"],
      "env": { "CTRLRUN_CONFIG": "/path/to/your/ctrlrun.yaml" }
    }
  }
}
```

The core install is enough. `CTRLRUN_CONFIG` names the policy your agents run against, and the
store is found beside it.

It opens no socket. The approver is the **account the process runs as**, read from the real
uid and from nothing the client sends or sets. Answers are recorded as `mcp-operator:<your
login>` with the issuer `os-login:<host>`, so the evidence says how the answer arrived. Two
things to know first:

* **The login has no expiry.** This process holds `approve`, `deny` and `resolve` under your
  name for as long as it runs, so the confirmation your client shows before a write is the human
  step. Leave it on for these three tools.
* **Root is an account, not a person.** Under `sudo` or as root in a container, writes are
  refused and reads still answer.

A login carries no roles, so a control naming an `approver_role` refuses over stdio. Header,
JWT and origin flags are refused by name: a flag that cannot take effect is one you would
believe took effect.

## Start it behind a proxy

For an approver who is not at the keyboard of the machine the store is on:

```bash theme={null}
ctrlrun mcp-operator --principal-header x-approver --user-header x-approver-user
```

Over HTTP it binds `127.0.0.1:8901` and there is **no flag that changes that**; `--stdio` binds
nothing at all. Its read tools answer without a credential, so it must not be the process that
opens a port to a network; put a proxy in front of it on the same host, terminating
authentication there and overwriting both headers on every request. That proxy is what makes
the headers worth anything.

There is no `--principal` either. A fixed name would attribute every approval to the same
string whoever gave it, and an approver that distinguishes nobody is not attribution. With
`--identity-jwt` the approver comes from a verified bearer token instead, and
`--identity-jwt-user-claim` says which claim names the person.

## Point a client at it

```json theme={null}
{
  "mcpServers": {
    "ctrlrun-operator": {
      "url": "http://127.0.0.1:8901/mcp",
      "headers": { "x-approver": "approver-console", "x-approver-user": "dana" }
    }
  }
}
```

In a real deployment the client sends a bearer token and the proxy sets those two headers from
whoever it authenticated. Headers a client sets for itself are a demonstration, not a control.

## A transcript

Asking what is waiting needs no credential:

```json theme={null}
--> {"method": "tools/call", "params": {"name": "list_pending_approvals", "arguments": {}}}
<-- {"pending": [{
      "request_id": "apr_27a80d93bab5178f0b1b12ced9621bf6",
      "action": "stripe.refund",
      "action_hash": "sha256:0397f9a2a696c7d877f726525ff6d5f84a2bcd5fe6148e0c4f9f07851fddfa3b",
      "arguments": {"amount": 248000, "payment_id": "pi_3QxNq"},
      "resource": "payment:pi_3QxNq",
      "principal": {"agent": "refund-agent", "user": "customer-ops"},
      "expires_at": "2026-09-06T18:10:20.950Z",
      "expires_in_seconds": 899
    }]}
```

Answering does. Without one:

```json theme={null}
--> {"method": "tools/call", "params": {"name": "approve", "arguments": {"request_id": "apr_27a8…"}}}
<-- {"error": {"code": -41007, "message": "no principal could be derived from the request",
               "data": {"error": "ctrlrun.no_principal"}}}
```

With one:

```json theme={null}
<-- {"status": "granted",
     "request_id": "apr_27a80d93bab5178f0b1b12ced9621bf6",
     "action_hash": "sha256:0397f9a2a696c7d877f726525ff6d5f84a2bcd5fe6148e0c4f9f07851fddfa3b",
     "approver": "mcp-operator:dana",
     "expires_at": "2026-09-06T18:10:20.950Z"}
```

The agent then runs the refund it was waiting on, and the receipt says who let it:

```json theme={null}
{"action": "stripe.refund", "decision": "approve", "result": "committed",
 "approval_id": "apr_27a80d93bab5178f0b1b12ced9621bf6", "approver": "mcp-operator:dana",
 "effect_key": "refund:pi_3QxNq", "attempt": 1}
```

## What it will not do

* **Make an agent act.** No tool proposes, executes or resumes an action, and there is no
  auto-approve, dry-run or development mode.
* **Approve an action other than the one the request names.** The grant carries the
  `action_hash` stored when the request was created; change an argument and the call is refused.
* **Take an answer from a machine.** A credential naming an agent and no person is `-41013`.
* **Take a name from the client that launched it.** Over `--stdio` the approver is the OS login
  of the real uid. `clientInfo`, the environment and every argument are the client's word, and
  none of them is used.
* **Take an expired credential.** `-41014`, distinct from `-41007`, because refreshing a token
  and obtaining one are different fixes.
* **Decide whether you were allowed to answer.** It authenticates *who* answered and records it.
  Any human whose credential the provider verifies can answer any pending request, exactly as
  any human who can run `ctrlrun approve` can. That is attribution, not authorization.

## If it did not work

* Exits at start naming `--user-header` or `--identity-jwt-user-claim`: a write tool refuses a
  credential that names no person, so a configuration that could never write is refused early.
* Exits at start naming loopback: there is no `--allow-remote`; put a proxy in front instead.
* Exits at start naming a flag beside `--stdio`: there are no headers over a pipe, so a header,
  JWT or origin flag cannot take effect and is refused rather than ignored.
* Your client reports output that is not JSON: the startup block goes to stderr, and a client
  that shows it there is showing you information, not an error. A release from before
  `--stdio` existed has no such mode at all; upgrade.
* `-41003` with reason `expired`: the request timed out. The agent proposes it again.
* `-32020`: a mirrored MCP header disagrees with the body. The body is believed and the request
  is refused rather than guessed at.

## Next

* [ctrlrun and MCP](/docs/mcp/overview) · [The gateway in five minutes](/docs/mcp/gateway-in-5-minutes).
* [Get started](/docs/get-started/quickstart) · [Why](/docs/why).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.