agentgateway can now reject or rewrite an MCP tools/call request before the tool runs, and mask the result before the client sees it, from CEL expressions in one config file, with no sidecar service. The commit that adds it
landed on main two days ago and isn’t in a tagged release yet. It closes a gap from three days earlier: Prisma AIRS
can scan MCP tool events, but hooking it into agentgateway needed a webhook adapter
because agentgateway’s own guardrails only ran on LLM chat messages, the prompt going in, the text coming back, not the tool call itself.
Defensive security research. This post analyses publicly disclosed findings so the controls that stop them can be tested. It describes mechanisms, not procedures, and links the primary sources for anyone verifying the work.
themsquared/tool-call-guardrails
is a toy customer-support MCP server, lookup_customer and run_support_macro, with three guardrail processors in front of it in one config.yaml: one rejects a destructive tool call before it runs, one masks PII out of the arguments on the way in, one masks a secret out of the result on the way back.
Why this is a different layer than a prompt guardrail
agentgateway already has prompt guards
for LLM chat messages, and their default scope only covers the system prompt and chat messages, not tool output, unless you add toolOutput to the list explicitly. Even with that scope set, the guard is still downstream of the tool call: the tool has already run, the data has already moved, and the guard is filtering what the model sees afterward.
MCP guardrails
(mcpGuardrails under mcp.policies) run at the MCP method layer instead. A processor can run in the request phase, before agentgateway forwards the call to the upstream MCP server, in the response phase, after the server replies, or full (both). A request-phase rejection means the tool is never invoked at all, not filtered after the fact. And because it is keyed on the MCP method (tools/call, tools/list, and so on) rather than an LLM’s chat schema, it applies the same way no matter what’s driving the agent, an LLM behind agentgateway’s own router, a different LLM entirely, or a human testing with the MCP Inspector.
The policy, in one file
config:
adminAddr: "off" # only needed if something else on the box already holds the admin port
mcp:
port: 13000
policies:
mcpGuardrails:
processors:
# 1. Reject before anything else runs.
- kind: expression
methods: {"tools/call": request}
condition: >-
mcp.params.name == 'run_support_macro' &&
has(mcp.params.arguments) &&
has(mcp.params.arguments.raw_command) &&
mcp.params.arguments.raw_command.contains('rm -rf')
reject: "raw_command contains a destructive shell pattern; this macro requires human review"
# 2. Mask PII in arguments before the upstream tool ever sees them.
- kind: expression
methods: {"tools/call": request}
condition: >-
mcp.params.name == 'run_support_macro' &&
has(mcp.params.arguments) && has(mcp.params.arguments.macro)
transform: >-
mcp.params.with(p, p.merge({
'arguments': p.arguments.merge({
'macro': p.arguments.macro.regexReplaceAll(
'[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+[.][A-Za-z]{2,}', '[EMAIL]'
)
})
}))
# 3. Mask secrets in what the tool hands back.
- kind: expression
methods: {"tools/call": response}
condition: has(mcp.result.content)
transform: >-
mcp.result.with(r, r.merge({
'content': r.content.map(item,
item.type == 'text'
? item.merge({'text': item.text.regexReplaceAll('sk-live-[A-Za-z0-9-]+', '[REDACTED_KEY]')})
: item
)
}))
targets:
- name: support-tools
stdio:
cmd: node
args: ["mcp-server/server.js"]
mcp.params is the live request’s MCP params object, arguments included. mcp.result is the response’s MCP result object. Processors run in the order they’re listed; a reject in an earlier processor stops the chain, and a transform in one processor is what the next processor (and eventually the upstream server or the client) sees. That ordering matters here: the reject check runs before the mask, so a call that should never have been sent is refused on its original text, not on a redacted copy of it.
Three calls through the gateway
The support server’s run_support_macro tool takes a raw_command field, an internal escape hatch some macros pass straight to a backend shell. A call with rm -rf in that field never reaches the tool:
$ npx -y @modelcontextprotocol/inspector --cli http://localhost:13000/mcp \
--method tools/call --tool-name run_support_macro \
--tool-arg customer_id=cust-1002 --tool-arg macro=reset_password \
--tool-arg "raw_command=rm -rf /data/cust-1002"
{
"content": [{"type": "text", "text": "raw_command contains a destructive shell pattern; this macro requires human review"}],
"isError": true
}
A macro call that happens to carry an email address gets the address masked before the tool runs, so it’s never written into whatever backend the macro touches:
$ npx -y @modelcontextprotocol/inspector --cli http://localhost:13000/mcp \
--method tools/call --tool-name run_support_macro \
--tool-arg customer_id=cust-1002 \
--tool-arg "macro=escalate to [email protected]"
{
"content": [{"type": "text", "text": "Ran macro 'escalate to [EMAIL]' for cust-1002"}]
}
And lookup_customer, which (realistically, carelessly) stores an API key in a customer’s notes field, has that key redacted before the result leaves the gateway:
$ npx -y @modelcontextprotocol/inspector --cli http://localhost:13000/mcp \
--method tools/call --tool-name lookup_customer --tool-arg customer_id=cust-1001
{
"content": [{"type": "text", "text": "Jordan Lee (Pro). Notes: Escalated 2026-09-02, resolved. Backup API key on file: [REDACTED_KEY]. Do not quote this to the customer."}]
}
None of these three calls touch an LLM. The MCP Inspector
CLI talks tools/call straight to agentgateway’s MCP endpoint, which is the point: the guardrail is enforced on the protocol, not bolted onto a chat completion.
First pass at the secret regex left half the key exposed. sk-live-[A-Za-z0-9]+ looks like it should catch sk-live-demo-7f3a9c2b1e, and it does, partially: the character class doesn’t include -, so regexReplaceAll matched only sk-live-demo and stopped at the hyphen, handing back [REDACTED_KEY]-7f3a9c2b1e. The fix is one character, sk-live-[A-Za-z0-9-]+, but it’s the kind of mistake that’s invisible until you test against a key shaped like a real one, not sk-live-abc123.
This is not in a release yet
The CEL expression processor landed on main on October 6, 2026, three days before this post, in 276f10d
, on top of an earlier gRPC-based mcpGuardrails hook. The most recent tagged release, v1.6.0
, published October 2, predates both. Building from source is the only way to run this today:
git clone https://github.com/agentgateway/agentgateway.git
cd agentgateway && cargo build --release
Everything in this post runs against that build. Treat the exact CEL surface (regexReplaceAll, .with(), .merge()) as subject to change before it ships in a tagged release; the mechanism, request-phase rejection and transform, response-phase transform, in-process CEL with no sidecar, is the part worth taking away.
What’s still open
The three processors in this repo’s config.yaml are all kind: expression: fast, local, and limited to what a regex or a CEL comparison can decide. agentgateway also exposes a remote gRPC processor (kind: remote), the same shape the Prisma AIRS integration
used for LLM-side guardrails, for a case this demo doesn’t cover: a run_support_macro argument that reads as fine text but is actually a prompt-injection payload aimed at whatever agent calls the tool next. CEL can reject a literal rm -rf; it can’t judge intent. That’s the next adapter to build against this hook, not this one.
Repo again: themsquared/tool-call-guardrails .