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

## Start it

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

It binds `127.0.0.1:8901` and there is **no flag that changes that**. 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 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.
* `-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](/mcp/overview) · [The gateway in five minutes](/mcp/gateway-in-5-minutes).
* [Get started](/get-started/quickstart) · [Why](/why).


## Related topics

- [CTRLRun and MCP](/mcp/overview.md)
- [Use the docs from your editor](/mcp/use-the-docs-from-your-editor.md)
- [Approve in Slack](/guides/approvals-in-slack.md)
- [CTRLRun](/index.md)
- [CTRLRun and framework human-in-the-loop](/compare/framework-hitl.md)
