Ir para o conteúdo

37 · Aplicando no gateway

Idiomas

O seletor entende turnos escritos em inglês. O português é a sua próxima gramática e ainda não foi lançado; até lá, um turno escrito em português não é lido como português.

Dentro do seu agente, toda chamada de ferramenta é verificada na etapa de ferramenta protegida. Uma ferramenta que o seu servidor MCP serve também pode ser alcançada de outro jeito: um script, um segundo framework, uma ferramenta que outra equipe conectou. stalwart gateway coloca a mesma verificação na frente dos seus servidores MCP, de modo que toda chamada que chega a uma ferramenta por ele é verificada, quem quer que a tenha feito. Os seus agentes se conectam ao gateway; o gateway se conecta aos seus servidores.

O que ele faz

  • Lista: tools/list devolve só as ferramentas do catálogo que o agente conectado pode alcançar.
  • Verifica: tools/call é verificado contra o catálogo, o schema dos argumentos, o histórico e as regras de ordem da sessão, o seu rulebook e as marcas que a sessão carrega, na ordem do capítulo 32, e enviado ao servidor só com allow. Uma chamada recusada nunca chega ao servidor.
  • Pergunta: uma chamada que precisa de uma pessoa vira uma elicitação MCP com o texto da confirmação. Um cliente que não pode perguntar recebe confirmation_unavailable, nunca uma escrita sem confirmação. Uma confirmação não respondida dentro de elicitation_timeout é recusada, e o mesmo vale para uma cujo cliente vai embora.
  • Chaveia: toda escrita leva uma chave de idempotência em _meta.
  • Marca: depois de uma ferramenta que lê o mundo de fora, toda chamada seguinte na sessão carrega a sua marca.
  • Desfaz: as escritas de uma sessão são desfeitas, a última primeiro, quando um operador envia /admin/sessions/{id}/rollback com o token de administração, ou quando o cliente encerra a sessão com DELETE /mcp?abort=1.
  • Registra: cada decisão, permitida ou recusada, como uma linha no formato do registro de gating (qls-decision/1): a ferramenta, a decisão, o motivo, o digest dos argumentos -- nunca os valores --, a sessão e o agente.

Cada sessão de agente ganha uma sessão MCP própria em cada servidor, de modo que as chamadas de uma sessão nunca vão nas de outra. As verificações são respondidas pelo runtime licenciado quando a máquina do gateway tem um, e pelo seu próprio qls-router (QLS_ROUTER_URL) quando não tem; sem nenhum dos dois, ele se recusa a iniciar uma verificação, e nada passa.

Rodando o gateway

$ 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

Cada palavra que o arquivo aceita, gerada a partir do próprio carregador do gateway:

Chave O que diz
catalogue o arquivo do catálogo, no formato que stalwart verify --keep escreve
upstreams o servidor MCP que guarda cada ferramenta, pelo nome da ferramenta; "*" para toda ferramenta não nomeada
audit o arquivo ao qual cada decisão é acrescentada, uma linha cada; em memória quando não informado
agents o bearer token de cada agente: o gateway sabe qual agente está chamando pelo token
elicitation_timeout os segundos que uma pessoa tem para responder a uma confirmação, 600 quando não informado; uma não respondida a tempo é recusada
admin_token o token que a rota de reversão aceita; a rota fica desligada quando não informado
listen o endereço e a porta em que o gateway escuta, 127.0.0.1:8800 quando não informado

Cada agente se autentica com o seu próprio bearer token, de modo que o gateway sabe qual agente está chamando, o que ele pode alcançar e com o que as transferências dele são confiadas. Um valor escrito ${NOME} é lido do ambiente.

Dois agentes pelo gateway

O núcleo do gateway, na frente de um substituto do servidor MCP da loja que registra o que recebe. O cliente do balcão de atendimento pode perguntar à sua pessoa; o cliente do balcão de cobrança não pode:

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')]
  • Cada agente vê as suas próprias ferramentas; hand_off é a transferência do balcão de atendimento para o de cobrança, que o catálogo lista entre as do atendimento.
  • O atendimento pediu refund_card, que é da cobrança: recusado, e o servidor não recebeu nada.
  • A cobrança no cartão foi perguntada por elicitação, aprovada e enviada com uma chave de idempotência.
  • Depois de read_inbox, a cobrança seguinte foi perguntada com a frase de que foi proposta depois de ler algo de fora.
  • O cliente da cobrança não pode perguntar, então o reembolso dele foi recusado como confirmation_unavailable.
  • O servidor recebeu exatamente as quatro chamadas que foram permitidas, e cada decisão foi registrada.

A política de rede

O gateway só é um ponto de aplicação se as suas ferramentas forem alcançáveis só por ele. Se um agente, ou qualquer outra coisa, consegue alcançar um servidor de ferramentas diretamente, consegue chamar uma ferramenta contornando todas as verificações. O pacote publica uma NetworkPolicy do Kubernetes que deixa só o gateway alcançar os servidores de ferramentas, network-policy.yaml: aplique-a, ou o equivalente na sua rede, antes de contar com o gateway.

O portão do rulebook como Rego

Para uma equipe cujo gateway já roda o Open Policy Agent, o RulebookTranslator, do pacote Qls.Rulebook, emite o portão do seu rulebook como um pacote Rego. O rulebook da loja do capítulo 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
}

O pacote é stalwart.<o nome do arquivo da política>. Consulte data.stalwart.policy.decision com input.tool e input.context; ele responde allow, refuse, clarify (o ramo pergunta algo à pessoa, o que uma rota simples não consegue fazer, então recuse também) ou silent, para uma ferramenta que o rulebook não governa. Um nome de contexto que a entrada omite é falso.

O Rego é o portão e só o portão. O schema dos argumentos, as origens dos argumentos, o histórico da sessão, as regras de ordem, as marcas, as confirmações e os desfazeres não estão nele, e o arquivo diz isso no cabeçalho. Use o gateway, ou a segunda rota do Kong abaixo, para esses.

A receita do Kong

Para colocar a verificação na frente das suas ferramentas a partir do Kong AI Gateway, a receita em api.stalwart.it/sdk/kong dá duas rotas, com cada arquivo disponível para baixar:

  1. O portão do rulebook no OPA: o plugin OPA do Kong consulta o Rego acima. Um refuse é um 403.
  2. Uma rota que chama a verificação completa: uma pre-function na rota das ferramentas envia cada tools/call MCP para o /v1/tool-checks do seu próprio qls-router (STALWART_ROUTER_URL), e só a deixa passar com allow; um confirm volta como um 409 com o texto e o token, para o cliente mostrar e enviar de novo. Uma chamada de um agente que verifica as próprias chamadas leva o seu recibo, que /v1/tool-receipts admite uma vez. Sem roteador configurado, toda chamada é recusada com um 503: a rota nunca recorre à ausência de verificação.

A API hospedada da Stalwart não serve verificações de ferramentas nem recibos: eles leem as chamadas que os seus agentes fazem, então rodam no seu ambiente.

Os limites

  • A aplicação só vale onde as ferramentas são alcançáveis só pelo gateway. Um servidor de ferramentas que outro caminho alcança não é protegido por ele, decida o gateway o que decidir.
  • O Rego cobre o portão do rulebook, e nada mais: nem o schema, nem as origens, nem o histórico, nem as regras de ordem, nem as marcas, nem as confirmações, nem os desfazeres.
  • A regra de marcação do gateway vale para a sessão inteira. Ele não consegue ver o que o modelo leu, então, depois de uma leitura de fora, toda chamada seguinte na sessão carrega a marca: mais grosseira que a etapa de ferramenta protegida, que segue cada valor até a chamada a que ele chega (capítulo 33).
  • Ele vê o que o servidor devolve. Um resultado é o que o servidor de ferramentas respondeu; um servidor que relata sucesso para algo que falhou é registrado como tendo rodado.