The Kong recipe
Two ways to put the check in front of your tools from Kong AI Gateway. Every file the recipe applies can be downloaded on its own:
| File | What |
|---|---|
kong-tool-check.yaml |
route 2: the tool route with its pre-function |
pre-function.lua |
the pre-function itself, as the route carries it |
kong-opa.yaml |
route 1: the OPA plugin's settings |
opa-input.example.json |
the input OPA is asked with |
network-policy.yaml |
the Kubernetes NetworkPolicy that makes Kong the only way to the tools |
Both ways put Kong between your agents and your tool servers. The check in route 2 is your
own enterprise qls-router, which you run in your own environment at a URL you choose:
tool checks and receipts read the calls your agents make, so Stalwart's hosted API
(api.stalwart.it) does not serve them. Neither way works unless the
tools are reachable through Kong alone, so apply the network policy in
network-policy.yaml (with Kong's pods as the gateway), or its equivalent.
1. The rulebook's gate in OPA
Emit your rulebook's gate as a Rego package:
$ RulebookTranslator emit policy.co --target rego --out-dir ./opa
wrote ./opa/rulebook.ir.json
wrote ./opa/Rulebook.rego
The package is stalwart.<the policy file's name> (stalwart.policy here), and its
decision is {"decision": "allow" | "refuse" | "clarify" | "silent", "rule": ...}
for input.tool under input.context. Load Rulebook.rego into the OPA server Kong's
OPA plugin queries, set opa_path in kong-opa.yaml to your package, and apply it.
opa-input.example.json shows the input. A refuse is a 403; treat clarify as a
call that needs a person, which this route cannot ask for, so refuse it too.
The Rego is the gate and only the gate. The argument schema, argument origins, the
session's history and order rules, trust labels, confirmations and undos are not in it,
and the emitted file's header says so. Use route 2, or stalwart gateway, for those.
2. A route that calls the full check
kong-tool-check.yaml puts a pre-function on the tool route
(pre-function.lua). It reads each MCP
tools/call (or a plain {"tool", "arguments"} request); every other MCP message --
initialize, tools/list, a notification -- goes on unchecked, since it runs nothing.
A call from an agent that checks its own calls -- a stalwart-agents agent whose
tool source says receipts: true, or a LangGraph agent on our guarded tool node --
carries its receipt: the agent's session history, ending on the check that allowed this
call, in the MCP _meta.stalwart or the X-Stalwart-Receipt header. The route sends it
to your router's POST /v1/tool-receipts, which says whether the history's token verifies and its last
entry allows exactly this call. The call is admitted on the agent's own check, so a write
the person approved in the agent is not asked about a second time; a receipt for other
arguments, another tool or a call already reported is a 403.
The agent is the one signed into the history's last entry: the route sends no agent of its
own, and a receipt cannot be presented as another agent's. A receipt admits one call. The
router spends it on the first call it admits, and keeps only digests of the history token
and of that call. Presented again, the receipt is a 403 receipt_spent, unless it is that
same call -- its arguments and idempotency key unchanged -- and sending it again is safe: a
read, a tool declared idempotent, or a write whose key rides in an argument. That is the
agent's own retry. A replay of a write under a fresh key is refused.
A call without a receipt is checked in full at your router's POST /v1/tool-checks, and
goes through only on allow. confirm comes back
to the client as a 409 with the confirmation's wording and token; the client shows them to
a person, then sends the call again with the token in X-Stalwart-Confirmation. The
session's history travels in X-Stalwart-History, and each answer's entry and token come
back in X-Stalwart-Entry and X-Stalwart-History-Token for the client to add to it, so
no state is kept in Kong.
Set STALWART_ROUTER_URL (your qls-router's base URL, such as
https://qls-router.internal:8080) and QLS_API_KEY in Kong's environment. The route asks
/v1/tool-checks and /v1/tool-receipts under it. With STALWART_ROUTER_URL unset, every
call is refused with a 503 router_not_configured: the route never falls back to another
check.
How the recipe is tested
The package's own suite reads both configurations, and runs pre-function.lua itself in an
emulated access phase: MCP calls from an agent with and without a receipt, a tampered receipt,
the messages that call nothing, an unreachable check, and a configuration that names no router.