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.1 would 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/call
  • mcp.tool.name = demo_ping
  • mcp.session.id = tutorial-session
  • mcp.upstream = stub
  • mcp.policy.decision = allow
  • mcp.payload.bytes_in and mcp.payload.bytes_out populated

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