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 reads turns written in English and in Brazilian Portuguese (pt-BR). The language is chosen, never guessed from the turn: the console's Settings page (Language the selector reads), language on a plan request, or the settings.language of your vocabulary file; with none of them, English. A Portuguese turn may be typed with its accents or without them. European Portuguese is not a grammar the selector has, and a turn in any other language is unread.
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 on the gateway's machine; where it has none, the gateway refuses to start a check, and passes nothing.
In Python the same pieces are in stalwart_agents.gateway. GatewayApp is the ASGI application
stalwart gateway serves, in front of a Gateway; HttpUpstream is one MCP server over streamable HTTP;
GatewaySession is one agent's MCP session, with its check history, its marks and its undo stack; and Audit
is the append-only record, one JSON object a line, in a file where one is named. CONFIRMATION_UNAVAILABLE is
the reason confirmation_unavailable, and gating_decision_id computes a record's identity byte for byte as
the runtime's own audit record does, so a line from the gateway and one from the runtime can be matched.
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 |
rules |
the rules every call is checked against: a rule sheet, its compiled folder or a Colang file, laid over the catalogue |
storage |
where the receipts the gateway admitted a call on are kept, shared by every gateway process: sqlite:///gateway.db beside this file, or postgres://...; in memory where unset |
upstreams |
the MCP server that holds each tool, by tool name; "*" for every tool not named |
egress |
where a call may send: metadata, private and schemes (an address that is not the web's: file:, javascript:, data: ...), each allow or refuse, and allow, the hosts and ranges admitted all the same; each is refuse where not given |
withdrawn |
tools the gateway refuses and leaves out of every agent's list, until the file no longer names them |
audit |
the file every decision is appended to, one line each; in memory where not given |
settings |
the tool check's settings, carried with every check: taint_policy (confirm or refuse) and confirmation_ttl_seconds |
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 |
people_key |
the host's key, as ${NAME}, that a session's person is named with where a server signs each person in on their own |
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.
An agent declared in agent.yaml sends its token with headers on the mcp: source, whose url is the
gateway's. The value is read from the environment each time a request is made, so it is never in the file:
tools:
- mcp: http://127.0.0.1:8800/mcp
headers: {Authorization: "Bearer ${DESK_GATEWAY_TOKEN}"}
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 this gateway's tool-check route (STALWART_GATEWAY_URL, asked with the tokengateway.yamlgives the agent the route serves, inSTALWART_GATEWAY_TOKEN), 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 the gateway's receipt route admits once: the gateway keeps the receipts it has spent wheregateway.yaml'sstoragesays. With no gateway 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.