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:
- A deny rule that blocks
shell_execoutright - A redact rule that strips bearer tokens from any tool’s arguments
- 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
SIGHUPhot-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
- How-to: Run mcpgw behind a load balancer — preserve real client IP for rate-limit identity
- Reference: policy rule schema — every option for
policy.rules[] - Explanation: redact vs JSONPath — why redact is regex-over-body in v1