Protect a real MCP server

In this tutorial you will put a real MCP server (the upstream filesystem server) behind mcpgw and write three production-shaped policy rules:

  1. A deny rule that blocks shell_exec outright
  2. A redact rule that strips bearer tokens from any tool’s arguments
  3. A rate-limit rule that caps how often a write tool may fire per session

You will then exercise each rule and see the corresponding decision in the audit log and in Datadog APM.

Prerequisite: complete Tutorial 1 first. We assume the gateway, license, and Datadog Agent OTLP receiver are already working.


Step 1 — Replace the stub with the filesystem MCP server

We will use the reference filesystem MCP server distributed by the MCP project. Stop the stub from Tutorial 1, then in a fresh terminal:

docker run --rm -d --name mcp-fs \
  -p 9100:9100 \
  ghcr.io/modelcontextprotocol/server-filesystem:latest \
  --port 9100 --root /workspace

Quick sanity check:

curl -s -X POST http://localhost:9100 \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | jq '.result.tools[].name'

You should see tools like fs_read, fs_write, and fs_list.

Step 2 — Update mcpgw.yaml with three policy rules

Replace the contents of mcpgw.yaml with:

listen: 0.0.0.0:7332
license:
  path: /etc/mcpgw/license.jwt
default_upstream: filesystem

upstreams:
  - name: filesystem
    url: http://host.docker.internal:9100

routes:
  - match: { tool_prefix: "fs_" }
    upstream: filesystem

policy:
  rules:
    # Rule 1: hard-deny shell-style tools, regardless of arguments.
    - id: deny-shell
      action: deny
      when: { tool_name: "shell_exec" }

    # Rule 2: rate-limit fs_write to roughly 1 call every 10s, with a small
    # burst allowance for legitimate batch operations. Scoped per session, so
    # one runaway agent cannot exhaust another user's allowance.
    - id: rl-fs-write
      action: rate_limit
      when: { tool_name: "fs_write" }
      tokens_per_second: 0.1
      burst: 3

    # Rule 3: redact bearer tokens and OpenAI-style API keys before they
    # reach the upstream. Wildcard match — applies to every tool call.
    - id: redact-secrets
      action: redact
      when: { tool_name: "*" }
      redact:
        - regex: '(sk-[A-Za-z0-9]{20,}|Bearer [A-Za-z0-9._-]+)'
          replacement: "[REDACTED]"

telemetry:
  customer:
    enabled: true
    endpoint: http://host.docker.internal:4318
    service_name: mcpgw-tutorial

audit:
  path: /var/log/mcpgw/audit.jsonl
  max_size_mb: 10
  compress_rotated: false

Rule order matters. mcpgw evaluates rules in YAML order; the first match wins. Deny rules belong above redact rules so a banned tool is rejected outright rather than passed through with redacted content. See Explanation: policy model for the full reasoning.

Step 3 — Reload the policy without dropping connections

If your gateway from Tutorial 1 is still running, you can reload the new policy without a restart:

docker kill --signal=HUP mcpgw

mcpgw catches SIGHUP, re-parses mcpgw.yaml, and atomically swaps the policy engine. Existing in-flight requests keep using the old engine; new requests pick up the new one. Look at the gateway logs and you should see policy reload: ok.

If the gateway is not running, restart the same container as in Tutorial 1.

Step 4 — Trigger the deny rule

curl -s -X POST http://localhost:7332/mcp \
  -H "Content-Type: application/json" \
  -H "Mcp-Session-Id: tutorial-session" \
  -d '{
    "jsonrpc":"2.0","id":1,"method":"tools/call",
    "params":{"name":"shell_exec","arguments":{"cmd":"echo hi"}}
  }' -w '\nHTTP %{http_code}\n'

Expected:

{"jsonrpc":"2.0","id":1,"error":{"code":-32001,"message":"policy_denied"}}
HTTP 403

In the audit log:

tail -1 audit.jsonl | jq '{decision, rule_id, tool}'
{ "decision": "deny", "rule_id": "deny-shell", "tool": "shell_exec" }

In Datadog APM, the corresponding span has mcp.policy.decision = deny and mcp.error.kind = policy_deny.

Step 5 — Trigger the redact rule

curl -s -X POST http://localhost:7332/mcp \
  -H "Content-Type: application/json" \
  -H "Mcp-Session-Id: tutorial-session" \
  -d '{
    "jsonrpc":"2.0","id":2,"method":"tools/call",
    "params":{"name":"fs_read","arguments":{"path":"/etc/hosts","auth":"Bearer abc.def.ghi"}}
  }' -w '\nHTTP %{http_code}\n'

Status 200. The upstream sees auth: "[REDACTED]", never the original token. The audit log records decision: "redact" and the rule id. The span in Datadog has mcp.policy.decision = redact.

This is the most important property of the redact action: the upstream never sees the original secret, and you have an auditable record that redaction occurred.

Step 6 — Trigger the rate-limit rule

Hammer fs_write faster than the bucket allows:

for i in $(seq 1 6); do
  curl -s -X POST http://localhost:7332/mcp \
    -H "Content-Type: application/json" \
    -H "Mcp-Session-Id: tutorial-session" \
    -d "{\"jsonrpc\":\"2.0\",\"id\":$i,\"method\":\"tools/call\",\"params\":{\"name\":\"fs_write\",\"arguments\":{\"path\":\"/tmp/x\",\"content\":\"y\"}}}" \
    -w ' HTTP %{http_code}\n'
done

The first 3 (the burst) succeed. The next 3 return:

HTTP 429
{"jsonrpc":"2.0","id":4,"error":{"code":-32003,"message":"rate_limited"}}

The bucket is keyed per (rule, session). A second session would have its own independent bucket — start the loop with Mcp-Session-Id: another-session to verify.

Step 7 — Read your audit log like an investigator

jq -c 'select(.decision != "allow") | {ts, decision, rule_id, tool, session_id}' audit.jsonl

This is the call you will reach for during incidents. Every blocked, redacted, or rate-limited request is one JSONL line with the deciding rule and the session that triggered it. There is no separate database to query — jq and grep are sufficient for an audit.

What you just learned

  • mcpgw policy rules are evaluated top-down with first-match-wins semantics
  • Deny is binary; redact transparently rewrites the payload; rate-limit imposes per-(rule, session) buckets
  • SIGHUP hot-reloads policy with no connection drop, no in-flight loss
  • The audit log is the source of truth for “what did the gateway decide and why”

Where to go next