Skip to content

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/list returns only the catalogue's tools the connecting agent may reach.
  • Checks: tools/call is 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 on allow. 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 within elicitation_timeout is 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}/rollback with the admin token, or when the client ends the session with DELETE /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_off is 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:

  1. The rulebook's gate in OPA: Kong's OPA plugin queries the Rego above. A refuse is a 403.
  2. A route that calls the full check: a pre-function on the tool route sends each MCP tools/call to your own qls-router's /v1/tool-checks (STALWART_ROUTER_URL), and lets it through only on allow; a confirm comes 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-receipts admits 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.