MCP Tool-Call Guardrails: Gating Agents Before the Tool Runs

agentgateway can now reject or mask an MCP tools/call before the tool runs, and mask the result before the client sees it. No sidecar service required.

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 .

Frequently asked questions

What is an MCP tool-call guardrail?

A policy that runs on the resolved tools/call request and response at the MCP protocol layer, not on the chat messages going into an LLM. agentgateway’s mcpGuardrails processors see mcp.params (the tool name and arguments) on the request phase and mcp.result on the response phase, and can reject, or rewrite, either one before it reaches the upstream MCP server or the client.

How is this different from a prompt guardrail?

A prompt guardrail scans text flowing through an LLM’s chat API, and by default does not see tool results at all, unless its scope explicitly names toolOutput. An MCP guardrail runs at the protocol layer where the tool is actually invoked, so it can refuse a tools/call outright before the tool executes, and it works for any MCP client, not just ones routed through an LLM endpoint.

Do I need to run a separate service for MCP guardrails in agentgateway?

Not for CEL-based rules. The expression processor (kind: expression) evaluates CEL against mcp.params and mcp.result in-process, in the gateway itself. agentgateway also supports a remote gRPC processor for logic too complex for CEL, but the expression kind needs nothing extra running.

Can an MCP guardrail reject a tool call outright?

Yes. A request-phase processor with a reject condition stops the chain and the tool is never invoked; the caller gets a tool result with isError: true instead of whatever the tool would have done. A response-phase processor can only transform or pass through, since the tool has already run by then.