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 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/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 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_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 this gateway's tool-check route (STALWART_GATEWAY_URL, asked with the token gateway.yaml gives the agent the route serves, in STALWART_GATEWAY_TOKEN), 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 the gateway's receipt route admits once: the gateway keeps the receipts it has spent where gateway.yaml's storage says. 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.