From c34cf3da21e56b31a9888f4c415aedfc9960a7d5 Mon Sep 17 00:00:00 2001 From: arpan Date: Tue, 15 Sep 2026 05:28:14 +0530 Subject: [PATCH] Three things a first-time user hits, found by being one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Not from the suite. I installed the published wheel into an empty venv, built a real LangGraph agent and a real MCP server, and used them the way the READMEs say to. **1. The gateway told MCP clients to call a Python API.** `_awaiting` relayed `str(ApprovalRequired)` verbatim, and that exception carries the decorator's wording: *"run `ctrlrun approve …`, then retry inside `ctrlrun.with_approval(…)`"*. `with_approval` is a context manager. On the one path where the caller may be in any language, and is often a model reading the error as text, we were pointing at something it cannot reach. `gateway-in-5-minutes.mdx` already documents the real next step, and the gateway now says it: a human approves and **this same call runs**, because the approval is bound to the action's hash. Verified against a live gateway, not only in a test. The test asserts `with_approval` is ABSENT, because a message merely mentioning the retry would pass a positive-only check while still sending a Go client looking for Python. **2. Both adapter READMEs fail on copy-paste.** Their examples show `identity=...` as an ellipsis, which is easy to drop, and then the first call dies with `no principal is available`. The error names both fixes, which is why this is a documentation defect and not a code one. Both READMEs now say a principal is required, show the error, and say why it is fail-closed: who is acting is an authorization input, and guessing it would invent the one field a grant is matched against. **3. The README promised a browser demo that does not exist.** Three places: *"Run it in your browser with nothing installed"*, *"a browser demo that runs with no install"*, and a "Try it in your browser" link. `/try-it` redirects to the marketing page; there is no pyodide, no editor, nothing to run. It was the first link in the opening section. Removed, and the local one-liner put in its place, which is true and about as fast. The `/try-it` redirect stays so existing bookmarks do not 404. What was wrong was the promise, not the route. Signed-off-by: arpan --- README.md | 6 +++--- adapters/langgraph/README.md | 13 +++++++++++++ adapters/openai-agents/README.md | 13 +++++++++++++ src/ctrlrun/gateway/server.py | 18 ++++++++++++++++-- tests/test_gateway_server.py | 24 ++++++++++++++++++++++++ 5 files changed, 69 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index f2842cac..1b4fdd2b 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,7 @@ Read them: CTRLRUN_STATE=.ctrlrun/demo/state.db ctrlrun receipts Approval and delegation ids are generated per run; everything else is exactly what the demo prints, and a test fails if the two drift apart. No network, no external service, under a -second. [Run it in your browser](https://docs.ctrlrun.dev/try-it) with nothing installed. +second. `pip install ctrlrun && ctrlrun demo` runs it locally in about the same time. @@ -410,11 +410,11 @@ about your domain gets written down; CTRLRun is what makes it hold. ## Documentation **[docs.ctrlrun.dev](https://docs.ctrlrun.dev/)** is the documentation: concepts, guides, a cookbook, the -full reference, and a browser demo that runs with no install. +full reference. | | | |---|---| -| Start here | [Why](https://docs.ctrlrun.dev/why) · [Protect your first action](https://docs.ctrlrun.dev/get-started/quickstart) · [Try it in your browser](https://docs.ctrlrun.dev/try-it) | +| Start here | [Why](https://docs.ctrlrun.dev/why) · [Protect your first action](https://docs.ctrlrun.dev/get-started/quickstart) | | The ideas, and doing something with them | [Concepts](https://docs.ctrlrun.dev/concepts/outcomes-and-ambiguous) · [Guides](https://docs.ctrlrun.dev/guides/protect-a-function) · [Cookbook](https://docs.ctrlrun.dev/cookbook/index) | | Agents and MCP | [Agents you can't modify](https://docs.ctrlrun.dev/agents-you-cant-modify) · [MCP overview](https://docs.ctrlrun.dev/mcp/overview) · [The gateway in five minutes](https://docs.ctrlrun.dev/mcp/gateway-in-5-minutes) | | Running it for real | [Production](https://docs.ctrlrun.dev/production/index) · [Postgres](https://docs.ctrlrun.dev/production/postgres) · [Recovery](https://docs.ctrlrun.dev/production/recovery) · [Operations](https://docs.ctrlrun.dev/production/operations) | diff --git a/adapters/langgraph/README.md b/adapters/langgraph/README.md index 91ac3f40..351fb576 100644 --- a/adapters/langgraph/README.md +++ b/adapters/langgraph/README.md @@ -57,6 +57,19 @@ def issue_refund(payment_id: str, amount: int) -> str: `interrupt()` — instead of raising past your graph. It is the entire difference this adapter makes. +**Every protected call needs a principal**, and `identity=...` above is where it comes from. In +production that is a provider that verifies a credential; in development it is +`with ctrlrun.context(agent="support-agent"):` around the call. Without one, the action is +denied before the policy is consulted, with a message that names both fixes: + +```text +ActionDenied: stripe.refund: no principal is available; wrap the call in +'with ctrlrun.context(agent=...)', or install an identity provider that answers +``` + +That is fail-closed and deliberate: who is acting is an authorization input, and a library that +guessed it would be inventing the one field a grant is matched against. + Call `issue_refund` from a node, on a graph compiled with a checkpointer: ```python diff --git a/adapters/openai-agents/README.md b/adapters/openai-agents/README.md index e004c280..985f5c0f 100644 --- a/adapters/openai-agents/README.md +++ b/adapters/openai-agents/README.md @@ -50,6 +50,19 @@ if result.interruptions: result = await gate.run(agent, state) ``` +**Every protected call needs a principal**, and `identity=...` above is where it comes from. In +production that is a provider that verifies a credential; in development it is +`with ctrlrun.context(agent="support-agent"):` around the call. Without one, the action is +denied before the policy is consulted, with a message that names both fixes: + +```text +ActionDenied: stripe.refund: no principal is available; wrap the call in +'with ctrlrun.context(agent=...)', or install an identity provider that answers +``` + +That is fail-closed and deliberate: who is acting is an authorization input, and a library that +guessed it would be inventing the one field a grant is matched against. + **The operator constructs the `Control`** — this adapter never does (SPEC-v0.5 §2.3), so the identity provider, the authority document, the environment and the mode are all chosen on the line above, by the person deploying it. diff --git a/src/ctrlrun/gateway/server.py b/src/ctrlrun/gateway/server.py index 2bf3577b..a80fbb46 100644 --- a/src/ctrlrun/gateway/server.py +++ b/src/ctrlrun/gateway/server.py @@ -1013,13 +1013,27 @@ def _over_the_round_bound( ) def _awaiting(self, action: Action, pending: ApprovalRequired, request_id: Any) -> _Response: - """§6.10 — "no" is an answer, and re-asking is not free.""" + """§6.10 — "no" is an answer, and re-asking is not free. + + **The message is written here rather than taken from the exception.** `ApprovalRequired` + carries the decorator's wording, *"run `ctrlrun approve …`, then retry inside + `ctrlrun.with_approval(…)`"*, and `with_approval` is a Python context manager. Relaying + that verbatim told an MCP client, which may be in any language and is often a model + reading the error as text, to call an API it has no access to. The correct next step on + this path is the one `docs/mcp/gateway-in-5-minutes.mdx` already documents: a human + approves and **the agent's next identical call runs**, because the approval is bound to + the hash of what the human saw. + """ record = self._control.store.get_approval(pending.request_id) code, token, status = APPROVAL_REQUIRED data: dict[str, Any] = {"request_id": pending.request_id, "action_hash": action.action_hash} if record is not None: data["expires_at"] = record.expires_at.isoformat() - return _json(status, json_rpc_error(request_id, code, token, str(pending), **data)) + message = ( + f"{action.name} requires approval: a human runs " + f"'ctrlrun approve {pending.request_id}', then this same call runs" + ) + return _json(status, json_rpc_error(request_id, code, token, message, **data)) def _upstream_response( self, diff --git a/tests/test_gateway_server.py b/tests/test_gateway_server.py index 7d328356..78b22821 100644 --- a/tests/test_gateway_server.py +++ b/tests/test_gateway_server.py @@ -694,6 +694,30 @@ def test_T25_an_action_needing_a_human_returns_41002_and_writes_no_receipt(clien assert upstream.calls == [] +def test_the_41002_message_tells_an_mcp_client_what_an_mcp_client_can_do(client, upstream, store): + """The error text is read by a client that is not Python, and often by a model. + + `ApprovalRequired` carries the decorator's wording -- *run `ctrlrun approve …`, then retry + inside `ctrlrun.with_approval(…)`* -- and the gateway used to relay `str(exc)` verbatim. That + told an MCP client to enter a Python context manager it has no access to, on the one path + where the caller may be in any language. `gateway-in-5-minutes.mdx` documents the real next + step and the gateway now says it: a human approves, and the same call runs again. + + Asserting the absence of `with_approval` is the half that matters. A message merely + *mentioning* the retry would pass a positive-only check while still sending a Go client + looking for a Python API. + """ + body = _needs_approval() + + response = client.post("/mcp", content=json.dumps(body).encode(), headers=_headers(body)) + message = _error(response)["message"] + + assert "with_approval" not in message, message + assert "ctrlrun approve" in message, message + assert "this same call runs" in message, message + assert _error(response)["data"]["request_id"] in message, message + + def test_T25_the_identical_call_executes_once_the_approval_is_granted(client, upstream, store): """§6.10 — a client comes back by re-sending the identical `tools/call`, and the gateway finds the granted approval by the action's hash."""