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.
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:
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,denyandresolveunder 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
sudoor as root in a container, writes are refused and reads still answer.
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: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
A transcript
Asking what is waiting needs no credential: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_hashstored 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
--stdiothe 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 approvecan. That is attribution, not authorization.
If it did not work
- Exits at start naming
--user-headeror--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
--stdioexisted has no such mode at all; upgrade. -41003with reasonexpired: 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.