# 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.

- Canonical URL: https://webofmike.com/tool-call-guardrails/
- Author: Mike Moore (https://webofmike.com/about/)
- Published: 2026-10-22
- Last modified: 2026-10-22
- Tags: MCP, AI Gateways, Security, AI Agents
- Cite as: Mike Moore, "MCP Tool-Call Guardrails: Gating Agents Before the Tool Runs", Web of Mike (webofmike.com), 2026-10-22. https://webofmike.com/tool-call-guardrails/


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](https://github.com/agentgateway/agentgateway/commit/276f10dc68430762674d6d64f3baecead963ac78) landed on `main` two days ago and isn't in a tagged release yet. It closes a gap from three days earlier: [Prisma AIRS](https://docs.paloaltonetworks.com/prisma-airs) can scan MCP tool events, but hooking it into agentgateway needed [a webhook adapter](/prisma-airs-agentgateway/) because agentgateway's own guardrails only ran on LLM chat messages, the prompt going in, the text coming back, not the tool call itself.

[themsquared/tool-call-guardrails](https://github.com/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](/agentgateway-standalone-demo/) 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](https://github.com/agentgateway/agentgateway/commit/276f10dc68430762674d6d64f3baecead963ac78) (`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

```yaml
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:

```bash
$ 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:

```bash
$ 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 priya.shah@example.com"
{
  "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:

```bash
$ 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](https://github.com/modelcontextprotocol/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`](https://github.com/agentgateway/agentgateway/commit/276f10dc68430762674d6d64f3baecead963ac78), on top of an earlier gRPC-based `mcpGuardrails` hook. The most recent tagged release, [v1.6.0](https://github.com/agentgateway/agentgateway/releases/tag/v1.6.0), published October 2, predates both. Building from source is the only way to run this today:

```bash
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](/prisma-airs-agentgateway/) 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](https://github.com/themsquared/tool-call-guardrails).

