Your first traced MCP call
In this tutorial you will run mcpgw locally, point an MCP client at it, and watch the resulting tool call appear as a span in Datadog APM. By the end you will understand the basic flow: client → mcpgw → upstream MCP server, with telemetry forking off to your Datadog Agent.
This is a learning exercise. The configuration we use is intentionally minimal so you can see the moving parts; do not copy this config to production. For a production-shaped config, work through Tutorial 2 next.
You will need
- Docker
- A Datadog Agent reachable from Docker host with the OTLP/HTTP receiver enabled on port 4318
- A license JWT from mcpgw.dev (the free tier is sufficient for this tutorial)
- About 15 minutes
Step 1 — Pull the image and place your license
mkdir -p ~/mcpgw-tutorial && cd ~/mcpgw-tutorial
docker pull ghcr.io/seanfraserio/mcpgw:latest
echo "<paste-your-jwt-here>" > license.jwt
chmod 0600 license.jwt
The chmod 0600 matters. mcpgw refuses to start if the license file is world-readable. This is the same posture as ~/.ssh/id_ed25519 — license JWTs are functionally credentials.
Step 2 — Run a tiny upstream MCP server
For the tutorial we use a stub server that always responds with pong. This keeps the focus on the gateway’s behavior.
Save this as upstream.go in your tutorial directory:
package main
import (
"fmt"
"net/http"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"pong"}]}}`)
})
http.ListenAndServe(":9100", nil)
}
Run it in a second terminal:
go run upstream.go
Leave that terminal open. It is your stand-in for a real MCP server.
Step 3 — Write the gateway config
Save as mcpgw.yaml:
listen: 0.0.0.0:7332
license:
path: /etc/mcpgw/license.jwt
# Required: catches initialize, tools/list, ping, resources/list — MCP messages
# without a tool name. Without it, spec-compliant clients fail at handshake.
default_upstream: stub
upstreams:
- name: stub
url: http://host.docker.internal:9100
routes:
- match: { tool_prefix: "demo_" }
upstream: stub
policy:
rules: [] # no policy rules yet — Tutorial 2 adds them
telemetry:
customer:
enabled: true
endpoint: http://host.docker.internal:4318 # your Datadog Agent
service_name: mcpgw-tutorial
audit:
path: /var/log/mcpgw/audit.jsonl
max_size_mb: 10
compress_rotated: false
Why
host.docker.internal? It is Docker Desktop’s alias for the host machine. From inside the mcpgw container,127.0.0.1would resolve to the container itself, not your laptop where the upstream and Agent are running.
Step 4 — Start the gateway
touch audit.jsonl
docker run --rm --name mcpgw \
-p 7332:7332 \
-v $PWD/license.jwt:/etc/mcpgw/license.jwt:ro \
-v $PWD/mcpgw.yaml:/etc/mcpgw/mcpgw.yaml:ro \
-v $PWD/audit.jsonl:/var/log/mcpgw/audit.jsonl \
ghcr.io/seanfraserio/mcpgw:latest
In a third terminal, verify health:
curl -sf http://localhost:7332/healthz && echo "alive"
curl -sf http://localhost:7332/readyz && echo "ready"
Both should print their suffix. If /readyz returns 503, your license is expired beyond the grace window — re-mint it at mcpgw.dev.
Step 5 — Make a tool call
Open a fourth terminal (we are nearly done):
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":"demo_ping","arguments":{}}
}'
Expected output:
{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"pong"}]}}
Step 6 — See the span in Datadog APM
Open Datadog APM and filter by service mcpgw-tutorial. Within ~10 seconds you should see a span named mcp.tools.call with these attributes:
mcp.method=tools/callmcp.tool.name=demo_pingmcp.session.id=tutorial-sessionmcp.upstream=stubmcp.policy.decision=allowmcp.payload.bytes_inandmcp.payload.bytes_outpopulated
If the span does not appear, see How-to: Enable Datadog tracing for OTLP receiver troubleshooting.
Step 7 — Inspect the audit log
tail -1 audit.jsonl | jq .
You will see a JSONL record with decision: "allow", the session id, the rule id (empty because no rule fired), and request/response byte counts. The audit log is your tamper-evident system of record — every request mcpgw processes leaves exactly one line, even rejected ones.
What you just learned
- mcpgw runs as a single container and listens on a single port
- Every JSON-RPC message it parses is policy-checked, forwarded, traced, and audited
- The OTLP exporter ships spans to your existing Datadog Agent — there is no second telemetry pipeline
- The audit log is local-only and append-only by default
Where to go next
- Tutorial 2 — Protect a real MCP server with policy — add deny, redact, and rate-limit rules and see them fire
- Reference: configuration schema — every key in
mcpgw.yaml - Explanation: architecture — what happens between client and upstream