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

*Originally published at [webofmike.com](https://webofmike.com/tool-call-guardrails/?utm_source=hashnode&utm_medium=syndication&utm_campaign=tool-call-guardrails) on 2026-10-22. The demo repo and every command in it were run before publishing.*

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](https://webofmike.com/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](https://webofmike.com/agentgateway-standalone-demo/) for LLM chat messages, and their default `scope` only covers the [system prompt](https://webofmike.com/mcp-discovery-prompt-injection/) 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](https://webofmike.com/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).

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

---

*Canonical version, with machine-readable markdown at `https://webofmike.com/tool-call-guardrails/index.md`: https://webofmike.com/tool-call-guardrails/*

