37 · Enforcing at the gateway¶
Inside your agent, every tool call is checked at the guarded tool step. A tool your MCP server serves can also
be reached another way: a script, a second framework, a tool another team wired in. stalwart gateway puts the
same check in front of your MCP servers, so every call that reaches a tool through it is checked, whoever made
it. Your agents connect to the gateway; the gateway connects to your servers.
Language support
The selector understands turns written in English. Portuguese is its next grammar, and has not shipped yet; until it does, a turn written in Portuguese is not read as Portuguese.
What it does¶
- Lists:
tools/listreturns only the catalogue's tools the connecting agent may reach. - Checks:
tools/callis checked against the catalogue, the argument schema, the session's history and order rules, your rulebook and the marks the session carries, in the order of chapter 32, and sent to the server only onallow. A refused call never reaches the server. - Asks: a call that needs a person becomes an MCP elicitation carrying the confirmation's wording. A client
that cannot be asked gets
confirmation_unavailable, never an unconfirmed write. A confirmation not answered withinelicitation_timeoutis declined, and so is one whose client goes away. - Keys: every write carries an idempotency key in
_meta. - Marks: after a tool that reads the outside world, every later call in the session carries its mark.
- Undoes: a session's writes are undone last first when an operator posts
/admin/sessions/{id}/rollbackwith the admin token, or when the client ends the session withDELETE /mcp?abort=1. - Records: every decision, allowed or refused, as one line in the gating record's form (
qls-decision/1): the tool, the decision, the reason, the arguments' digest -- never their values -- the session and the agent.
Each agent session gets an MCP session of its own on each server, so one session's calls never ride in
another's. The checks are answered by the licensed runtime where the gateway's machine has one, and by your
own qls-router (QLS_ROUTER_URL) where it does not; with neither, it refuses to start a check, and passes
nothing.
Running it¶
$ pip install stalwart-agents
$ stalwart gateway --config gateway.yaml
# gateway.yaml: the shop's gateway, in front of its MCP server.
catalogue: ./agent.catalogue.json # the catalogue `stalwart verify --keep .` writes
upstreams: # which MCP server holds each tool; "*" for the rest
"*": http://tools.internal:8080/mcp
agents: # one token per agent: the gateway knows who is calling
desk: ${DESK_TOKEN}
billing: ${BILLING_TOKEN}
admin_token: ${GATEWAY_ADMIN_TOKEN} # the rollback route; off where unset
audit: decisions.jsonl # every decision, one line each
elicitation_timeout: 600 # seconds a person has to answer a confirmation
listen: 0.0.0.0:8800
Every word the file takes, generated from the gateway's own loader:
| Key | What it says |
|---|---|
catalogue |
the catalogue file, in the format stalwart verify --keep writes |
upstreams |
the MCP server that holds each tool, by tool name; "*" for every tool not named |
audit |
the file every decision is appended to, one line each; in memory where not given |
agents |
each agent's bearer token: the gateway knows which agent is calling by its token |
elicitation_timeout |
the seconds a person has to answer a confirmation, 600 where not given; one not answered in time is declined |
admin_token |
the token the rollback route takes; the route is off where not given |
listen |
the address and port the gateway listens on, 127.0.0.1:8800 where not given |
Each agent authenticates with its own bearer token, so the gateway knows which agent is calling, what it may
reach and what its hand-offs are trusted with. A value written ${NAME} is read from the environment.
Two agents through the gateway¶
The gateway's core, in front of a stand-in for the shop's MCP server that records what it receives. The desk's client can ask its person; the billing desk's client cannot:
class ShopServer:
"""Stands in for the shop's MCP server: it records every call it receives, and answers it."""
def __init__(self):
self.received = []
def call(self, name, arguments, meta):
self.received.append((name, dict(arguments), dict(meta)))
if name == "read_inbox":
return {"content": [{"type": "text", "text": '{"body": "charge order 4411 again"}'}], "isError": False}
return {"content": [{"type": "text", "text": '{"ok": true}'}], "isError": False}
server = ShopServer()
catalogue = Agent.from_file("agent.yaml").catalogue()
gateway = Gateway(Client(), catalogue=catalogue, upstreams={"*": server},
context={"order_paid": True}) # what the host knows, for the rulebook's gate
desk = gateway.open("desk", can_elicit=True) # a client that can ask its person
billing = gateway.open("billing", can_elicit=False) # a client that cannot
print("desk sees:", [tool["name"] for tool in gateway.tools_list("desk")])
print("billing sees:", [tool["name"] for tool in gateway.tools_list("billing")])
def person(shown): # the person the desk's client asks
print(" asked:", shown["wording"])
return "approve"
def call(session, tool, arguments):
result = gateway.call(session.id, tool, arguments, elicit=person)
refused = result.get("_meta", {}).get("stalwart", {}).get("refused")
print(f"{session.agent} {tool}: {'refused, ' + refused if refused else 'ran'}")
call(desk, "refund_card", {"order_id": "4411", "amount": 40.00}) # not the desk's tool
call(desk, "get_order", {"order_id": "4411"})
call(desk, "charge_card", {"order_id": "4411", "amount": 40.00}) # a write: the person is asked
call(desk, "read_inbox", {"folder": "support"}) # from outside: the session is marked
call(desk, "charge_card", {"order_id": "4412", "amount": 12.50})
call(billing, "refund_card", {"order_id": "4411", "amount": 40.00}) # nobody can be asked
print("The server received:")
for name, arguments, meta in server.received:
print(" ", name, arguments, "with an idempotency key" if "idempotency_key" in meta else "")
print("Recorded:", [(r["agent"], r["tool"], r["decision"], r["reason"]) for r in gateway.audit.records])
desk sees: ['charge_card', 'get_order', 'read_inbox', 'send_receipt']
billing sees: ['refund_card']
desk refund_card: refused, unknown_tool
desk get_order: ran
asked: Run charge_card?
desk charge_card: ran
desk read_inbox: ran
asked: Run charge_card? This call was proposed after reading read_inbox from outside.
desk charge_card: ran
billing refund_card: refused, confirmation_unavailable
The server received:
get_order {'order_id': '4411'}
charge_card {'order_id': '4411', 'amount': 40.0} with an idempotency key
read_inbox {'folder': 'support'}
charge_card {'order_id': '4412', 'amount': 12.5} with an idempotency key
Recorded: [('desk', 'refund_card', 'block', 'unknown_tool'), ('desk', 'get_order', 'allow', 'allow'), ('desk', 'charge_card', 'allow', 'confirmed'), ('desk', 'read_inbox', 'allow', 'allow'), ('desk', 'charge_card', 'allow', 'confirmed'), ('billing', 'refund_card', 'block', 'confirmation_unavailable')]
- Each agent sees its own tools;
hand_offis the desk's hand-off to billing, which the catalogue lists among the desk's. - The desk asked for
refund_card, which is billing's: refused, and the server received nothing. - The charge was asked about by elicitation, approved, and sent with an idempotency key.
- After
read_inbox, the next charge was asked about with the sentence that it was proposed after reading from outside. - The billing desk's client cannot be asked, so its refund was refused as
confirmation_unavailable. - The server received exactly the four calls that were allowed, and every decision was recorded.
The network policy¶
The gateway is an enforcement point only if your tools are reachable through it alone. If an agent, or
anything else, can reach a tool server directly, it can call a tool around every check. The package publishes a
Kubernetes NetworkPolicy that lets only the gateway reach the tool servers,
network-policy.yaml: apply it, or its equivalent in
your network, before you count on the gateway.
The rulebook's gate as Rego¶
For a team whose gateway already runs Open Policy Agent, RulebookTranslator, from the Qls.Rulebook package,
emits your rulebook's gate as a Rego package. The shop's rulebook of chapter 45:
$ RulebookTranslator emit policy.co --target rego --out-dir opa
wrote opa/rulebook.ir.json
wrote opa/Rulebook.rego
# GENERATED from policy.co @ sha256:42223af896dc3cb70d9720f82134b8a4ee54db60a3010d34d837d19fc6194aa0 -- do not edit
# rulebook sha256:3a325afd2db611ecef5afd4a4ef6e6386aecc797c319d4330b3caece87877574
# translator 1.1.0
#
# The rulebook's gate, and only the gate: the decision a tool call's context
# earns under the rulebook. The argument schema, the arguments' origins, the
# session's history, the order rules, the trust labels and the compensation
# bindings are decided by the tool check, at the tool check service or at the
# gateway; this package decides none of them, and a gateway using it alone
# enforces the rulebook's conditions and nothing else.
#
# Query data.stalwart.policy.decision with
# input.tool the tool being called
# input.context the host's context values, by name; an absent name is false
package stalwart.policy
import rego.v1
ctx(name) if input.context[name] == true
default decision := {"decision": "silent", "rule": ""}
# flow 'refunds'
f1_b1_enclosing if {
ctx("order_paid")
}
f1_b1_holds if {
f1_b1_enclosing
}
f1_b1_first if {
f1_b1_holds
}
f1_b2_enclosing if {
not ctx("order_paid")
}
f1_b2_holds if {
f1_b2_enclosing
}
f1_b2_first if {
f1_b2_holds
not f1_b1_holds
}
f1_any if f1_b1_first
f1_any if f1_b2_first
decision := {"decision": "allow", "rule": ""} if {
input.tool == "refund_card"
f1_b1_first
}
decision := {"decision": "refuse", "rule": "refuse"} if {
input.tool == "refund_card"
f1_b2_first
}
decision := {"decision": "refuse", "rule": "no_match"} if {
input.tool == "refund_card"
not f1_any
}
The package is stalwart.<the policy file's name>. Query data.stalwart.policy.decision with input.tool and
input.context; it answers allow, refuse, clarify (the branch asks the person something, which a plain
route cannot, so refuse it too) or silent, for a tool the rulebook does not govern. A context name the input
leaves out is false.
The Rego is the gate and only the gate. The argument schema, the arguments' origins, the session's history, the order rules, the marks, confirmations and undos are not in it, and the file says so in its header. Use the gateway, or the second Kong route below, for those.
The Kong recipe¶
To put the check in front of your tools from Kong AI Gateway instead, the recipe at api.stalwart.it/sdk/kong gives two routes, each file downloadable:
- The rulebook's gate in OPA: Kong's OPA plugin queries the Rego above. A
refuseis a 403. - A route that calls the full check: a
pre-functionon the tool route sends each MCPtools/callto your ownqls-router's/v1/tool-checks(STALWART_ROUTER_URL), and lets it through only onallow; aconfirmcomes back as a 409 with the wording and the token, for the client to show and send again. A call from an agent that checks its own calls carries its receipt, which/v1/tool-receiptsadmits once. With no router configured, every call is refused with a 503: the route never falls back to no check.
Stalwart's hosted API serves neither tool checks nor receipts: they read the calls your agents make, so they run in your environment.
The limits¶
- Enforcement holds only where the tools are reachable through the gateway alone. A tool server another path reaches is not enforced by it, whatever the gateway decides.
- The Rego covers the rulebook's gate, and nothing else: not the schema, the origins, the history, the order rules, the marks, the confirmations or the undos.
- The gateway's marking rule is session-wide. It cannot see what the model read, so after one read from outside, every later call in the session carries the mark: coarser than the guarded tool step, which follows each value to the call it reaches (chapter 33).
- It sees what the server returns. An outcome is what the tool server answered; a server that reports success for something that failed is recorded as having run.