Skip to content

09 · Observability: Logging & Tracing MCP Calls

Once you're running several servers behind retries, caching, and confirmation gates, "it isn't working" stops being something you can debug by reading code — you need to see what actually happened across every layer of a real request. This lesson covers structured logging and the bare minimum of tracing needed to answer "which server, which tool, how long, what went wrong" after the fact.

The stdout rule, again, because it matters here specifically

Level 1 covered this as a gotcha; it's worth restating because observability code is exactly where it tends to slip back in. A stdio server's stdout carries the JSON-RPC protocol stream — every print() statement you add for debugging corrupts it. Every example below logs to stderr or a proper logging handler, never print():

import logging
import sys

logging.basicConfig(
    stream=sys.stderr,
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
logger = logging.getLogger("mcp-server")

Structured logging of every tool call

Free-text log lines are fine for a human staring at a terminal; they're painful to query once you have more than a handful of log entries. Log structured fields instead — even a simple JSON line per event is enough to grep, filter, and eventually feed into a real log aggregator:

import json
import time
from mcp.server.fastmcp import FastMCP, Context

mcp = FastMCP("github-server")

def log_event(event: str, **fields):
    logger.info(json.dumps({"event": event, "ts": time.time(), **fields}))

@mcp.tool()
async def list_issues(repo: str, ctx: Context) -> list[dict]:
    start = time.monotonic()
    log_event("tool_call_start", tool="list_issues", repo=repo)
    try:
        result = await fetch_issues(repo)
        log_event("tool_call_success", tool="list_issues", repo=repo,
                   duration_ms=round((time.monotonic() - start) * 1000), count=len(result))
        return result
    except Exception as e:
        log_event("tool_call_error", tool="list_issues", repo=repo,
                   duration_ms=round((time.monotonic() - start) * 1000), error=str(e))
        raise

The three events — start, success (with duration and a size/count signal), error (with the actual exception) — are the minimum set that lets you answer "is this tool slow," "is this tool failing," and "how often is it even being called," purely from log lines, without attaching a debugger.

Correlating a request across client and server

A single user turn can fan out into several tool calls across several servers. Without a shared identifier, correlating "which of these five log lines across three processes belong to the same user request" is guesswork. Generate a request ID on the client side and thread it through:

import contextvars
import uuid

request_id_var = contextvars.ContextVar("request_id", default=None)

async def call_tool_traced(session, tool_name, arguments):
    request_id = str(uuid.uuid4())
    request_id_var.set(request_id)
    # MCP's _meta field on requests is the standard place to carry this kind
    # of cross-cutting context without it being treated as a tool argument
    result = await session.call_tool(
        tool_name, arguments, meta={"requestId": request_id}
    )
    return result

On the server, pull it back out of the request metadata and include it in every log line for that call:

@mcp.tool()
async def list_issues(repo: str, ctx: Context) -> list[dict]:
    request_id = ctx.request_meta.get("requestId", "unknown")
    log_event("tool_call_start", tool="list_issues", repo=repo, request_id=request_id)
    ...

Now grep request_id across every server's logs reconstructs the full fan-out for one user turn, even when the tool calls landed on different processes.

A minimal tracing setup

Full distributed tracing (OpenTelemetry spans, a trace backend like Jaeger) is the production-grade version of this, and is worth adopting once you have more than a couple of servers — but the core idea is simple enough to build a minimal version of yourself first, to understand what a tracing backend is actually doing for you:

class Span:
    def __init__(self, name: str, request_id: str, parent: "Span | None" = None):
        self.name = name
        self.request_id = request_id
        self.parent = parent
        self.start = time.monotonic()
        self.end: float | None = None

    def finish(self):
        self.end = time.monotonic()
        log_event("span", name=self.name, request_id=self.request_id,
                   duration_ms=round((self.end - self.start) * 1000),
                   parent=self.parent.name if self.parent else None)

async def traced_tool_call(session, tool_name, arguments, request_id, parent_span=None):
    span = Span(f"tool_call:{tool_name}", request_id, parent_span)
    try:
        return await session.call_tool(tool_name, arguments, meta={"requestId": request_id})
    finally:
        span.finish()

A real tracing backend adds sampling, proper span context propagation across process boundaries, and a UI for visualizing the resulting tree — but the fundamental unit, a named span with a start, an end, a parent, and a shared request ID, is exactly what the toy version above captures.

Exercise

Wire the log_event structured logging into the MultiServerClient from lesson 1, so every call routed through it logs tool_call_start, tool_call_success/tool_call_error, and includes a request ID generated once per user-facing call and threaded through every underlying tool call it fans out to (even when one user action triggers calls to two different servers). Confirm, by grepping the resulting log output, that you can reconstruct the full set of tool calls belonging to a single multi-server request.