teachyou.ai academy
← All posts
MCP

MCP Server Logging and Observability for Production Agents

Ira Menon · May 25, 2026 · 15 min read

Why MCP servers fail silently in production

Your MCP server worked perfectly in the demo. You wired up three tools, connected Claude to a database, and watched it query customer records like magic. Then you shipped it, and two weeks later a user reported that "the agent just doesn't do anything" for certain requests. You had no logs to look at. No trace of which tool was called, what arguments it received, or where the request died in the pipeline.

This is the single most common failure mode in early MCP deployments: teams treat the Model Context Protocol server like a local script instead of a production service. An MCP server sits between an LLM and the outside world — databases, APIs, file systems, internal tools. When it works, it's invisible. When it breaks, you need to answer questions fast: which tool call failed, what was the input, how long did it take, and did the model even receive a usable error message back.

Observability for MCP servers is not optional infrastructure you bolt on later. It's the difference between debugging a production incident in five minutes versus five hours of guessing. This article walks through what to actually log, how to structure it, how tracing works across the MCP boundary, and what metrics matter once your server is handling real traffic from agents instead of your own manual testing.

The three failure surfaces unique to MCP

Before diving into implementation, it helps to understand why MCP logging is genuinely different from typical API logging. There are three surfaces where things go wrong, and each needs its own visibility.

The transport layer. MCP servers run over stdio, Server-Sent Events, or streamable HTTP. Each transport has its own failure modes — a stdio server that crashes silently leaves the host application hanging with no stack trace the user ever sees. An SSE connection that drops mid-stream looks, from the agent's perspective, like the tool simply never responded.

The protocol layer. JSON-RPC messages flow both directions — initialize, tools/list, tools/call, resources/read, notifications. A malformed response here doesn't throw a normal exception; it just breaks the handshake, and most client SDKs surface that as a generic "connection closed" error with zero context.

The tool execution layer. This is where your actual business logic runs — the database query, the API call, the file read. Errors here are the most common and the easiest to log well, but teams often only log this layer and ignore the other two, which is why "it just doesn't do anything" bugs are so hard to trace: the failure happened in the transport or protocol layer, and there's nothing in the tool-execution logs to explain it.

Good MCP observability means instrumenting all three layers, not just the one that feels like "your code."

It also helps to separate two mindsets that get conflated in most write-ups on this topic: debugging and monitoring. Debugging is what you do after someone tells you something is broken — you already have a rough time window and maybe a user report, and you're reconstructing what happened. Monitoring is what tells you something is broken before anyone reports it. A lot of teams build only the debugging half, usually because it maps more naturally onto "add some print statements" instincts, and then discover months later that they have no way to know their p99 latency has quietly tripled or that one specific tool has been failing for 20% of calls since last Tuesday. Both halves matter, and they use overlapping but distinct tooling — logs and traces for debugging, metrics and alerts for monitoring.

Structured logging: the non-negotiable foundation

The single highest-leverage change you can make to an MCP server is switching from print() statements or ad-hoc string logs to structured JSON logging. Structured logs are queryable. print("tool called") is not.

Here's a minimal structured logger for a Python MCP server built on the official SDK:

import logging
import json
import time
import uuid
from contextvars import ContextVar

request_id_var: ContextVar[str] = ContextVar("request_id", default="")

class JSONFormatter(logging.Formatter):
    def format(self, record):
        payload = {
            "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
            "level": record.levelname,
            "message": record.getMessage(),
            "request_id": request_id_var.get(),
            "logger": record.name,
        }
        if hasattr(record, "extra_fields"):
            payload.update(record.extra_fields)
        if record.exc_info:
            payload["exception"] = self.formatException(record.exc_info)
        return json.dumps(payload)

def get_logger(name: str) -> logging.Logger:
    logger = logging.getLogger(name)
    handler = logging.StreamHandler()
    handler.setFormatter(JSONFormatter())
    logger.addHandler(handler)
    logger.setLevel(logging.INFO)
    return logger

log = get_logger("mcp.server")

def log_tool_call(tool_name: str, arguments: dict, duration_ms: float, status: str, error: str = None):
    extra = {
        "tool_name": tool_name,
        "arguments_keys": list(arguments.keys()),
        "duration_ms": round(duration_ms, 2),
        "status": status,
    }
    if error:
        extra["error"] = error
    record = logging.LogRecord(
        name="mcp.tool", level=logging.INFO, pathname="", lineno=0,
        msg=f"tool_call:{tool_name}", args=(), exc_info=None
    )
    record.extra_fields = extra
    log.handle(record)

A few deliberate choices here matter more than the code itself. Notice that we log arguments_keys — the *names* of the arguments — rather than the raw argument values. This is intentional and leads into a point worth its own section: logging tool arguments verbatim is one of the fastest ways to leak sensitive data into your log pipeline.

Every log line also carries a request_id pulled from a context variable, set once per incoming JSON-RPC request. This is what lets you filter your log aggregator down to "everything that happened during this one tool call" instead of scrolling through an interleaved firehose from concurrent requests.

Resist the urge to log at DEBUG level everywhere "just in case." Verbose logging feels safe during development, but in production it does two things badly: it costs money if you're paying per-GB ingested at your log vendor, and it buries the signal you actually need under noise nobody reads. A better default is INFO for lifecycle events (tool call started, tool call finished, connection opened), WARNING for recoverable problems (a retry succeeded, a fallback path was taken), and ERROR reserved for things that actually need a human to look at them. Keep DEBUG available behind an environment variable you can flip on for a specific server instance during an active investigation, rather than leaving it on everywhere by default.

Correlating requests across the MCP boundary

The hardest observability problem in agentic systems is correlation. A single user prompt might trigger the model to call three different tools across two different MCP servers, each running as a separate process. Without a shared identifier, you cannot reconstruct what happened.

The fix is to generate a correlation ID at the earliest possible point — ideally in the host application before the first tools/call request goes out — and propagate it through every layer. If you control both the client and server side, pass it as a custom field in the tool call metadata. If you don't control the client, generate one server-side per request and at minimum make sure it flows through to any downstream calls your tool makes (database queries, outbound HTTP requests, subprocess invocations).

from mcp.server import Server
from mcp.types import TextContent
import time

server = Server("data-tools")

@server.call_tool()
async def handle_tool_call(name: str, arguments: dict) -> list[TextContent]:
    request_id = str(uuid.uuid4())
    request_id_var.set(request_id)
    start = time.perf_counter()

    log.info(f"tool_call_start", extra={"extra_fields": {
        "request_id": request_id,
        "tool_name": name,
    }})

    try:
        result = await dispatch_tool(name, arguments, request_id=request_id)
        duration_ms = (time.perf_counter() - start) * 1000
        log_tool_call(name, arguments, duration_ms, "success")
        return [TextContent(type="text", text=result)]
    except Exception as exc:
        duration_ms = (time.perf_counter() - start) * 1000
        log_tool_call(name, arguments, duration_ms, "error", error=str(exc))
        raise

Passing request_id down into dispatch_tool means that if that function fires off a Postgres query or calls a third-party API, those downstream logs (and any tracing spans) carry the same identifier. When something breaks, you grep one ID and see the entire causal chain — the tool call, the SQL query, the external API response — in chronological order, even if they're written by different subsystems.

Tracing: seeing the shape of a multi-tool request

Logs tell you *what* happened. Traces tell you *how long* each step took and *how steps relate to each other*, which matters enormously once an agent starts chaining multiple tool calls in a single reasoning loop.

OpenTelemetry is the natural fit here because it's transport-agnostic and most observability backends (Grafana, Honeycomb, Datadog, self-hosted Jaeger) already speak its wire format. The core idea: wrap each tool invocation in a span, and make the span hierarchy reflect the actual call graph.

from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter

trace.set_tracer_provider(TracerProvider())
exporter = OTLPSpanExporter(endpoint="http://localhost:4317", insecure=True)
trace.get_tracer_provider().add_span_processor(BatchSpanProcessor(exporter))
tracer = trace.get_tracer("mcp-server")

async def dispatch_tool(name: str, arguments: dict, request_id: str) -> str:
    with tracer.start_as_current_span(f"tool.{name}") as span:
        span.set_attribute("mcp.request_id", request_id)
        span.set_attribute("mcp.tool_name", name)
        span.set_attribute("mcp.arg_count", len(arguments))

        if name == "query_orders":
            with tracer.start_as_current_span("db.query"):
                return await run_order_query(arguments)
        elif name == "fetch_shipping_status":
            with tracer.start_as_current_span("http.request"):
                return await call_shipping_api(arguments)
        raise ValueError(f"unknown tool: {name}")

With this in place, a slow response stops being a mystery. You open the trace for a given request_id and immediately see, visually, that tool.fetch_shipping_status took 4.2 seconds because the nested http.request span shows a downstream API that's degraded — versus the model itself being slow, versus your own database query being the bottleneck. Without spans, all three of those look identical from the outside: "the tool call took 4 seconds."

If you're running multiple MCP servers behind one agent, propagate the W3C traceparent header (or its stdio equivalent, passed as a field in the tool arguments) so spans from different servers stitch into one trace instead of three disconnected ones.

What to actually log (and what never to log)

Teams tend to overcorrect in one of two directions: logging nothing useful, or logging everything including secrets. Neither works. Here's a concrete list.

Always log:

  • Tool name, request ID, and timestamp for every tools/call
  • Duration in milliseconds, broken down by sub-operation where practical
  • Success/failure status and, on failure, the error type (not necessarily the full stack trace at INFO level — save that for DEBUG or a separate error channel)
  • Argument *shape* — key names, types, sizes — rather than full values
  • Which MCP capability was exercised: tool call, resource read, prompt fetch
  • Rate limit or quota state if your server enforces any
  • Auth context: which client/API key/session initiated the call, without logging the raw credential

Never log, under any circumstance:

  • Raw API keys, OAuth tokens, or session secrets passed as tool arguments
  • Full customer PII (names, emails, SSNs, card numbers) in plaintext — hash or redact
  • Complete database query results — log row counts and query duration, not row contents
  • Full file contents when a tool reads a file — log the path and byte size

A practical middle ground for argument logging is a redaction function that structurally understands your tool schemas:

SENSITIVE_KEYS = {"password", "token", "api_key", "ssn", "card_number", "secret"}

def redact_arguments(arguments: dict) -> dict:
    redacted = {}
    for key, value in arguments.items():
        if key.lower() in SENSITIVE_KEYS:
            redacted[key] = "***REDACTED***"
        elif isinstance(value, str) and len(value) > 200:
            redacted[key] = f"<string:{len(value)} chars>"
        elif isinstance(value, dict):
            redacted[key] = redact_arguments(value)
        else:
            redacted[key] = value
    return redacted

Call this before logging arguments anywhere, and treat it as part of your tool schema contract — when you add a new tool with a sensitive field, add its key name to SENSITIVE_KEYS in the same pull request. This is the kind of thing that's trivial to do upfront and genuinely painful to retrofit after a redacted-log audit finds six months of plaintext tokens sitting in your aggregator.

Metrics that actually predict incidents

Logs and traces are for investigating a specific incident after you know something is wrong. Metrics are for finding out something is wrong before a user tells you. For an MCP server, four metric families cover almost everything that matters.

  1. Tool call latency, as a histogram, tagged by tool name. Track p50, p95, and p99 separately — a tool that's fast most of the time but occasionally takes 30 seconds will hide inside an average but show up immediately in p99.
  2. Tool call error rate, tagged by tool name and error type. A spike in error_type=timeout for one specific tool points you straight at a degrading downstream dependency.
  3. Connection lifecycle counts — connections opened, closed cleanly, closed with error, and current active connections. For stdio and SSE transports, silent connection drops are a common source of "agent stopped responding" tickets, and this metric is what surfaces them.
  4. Protocol-level countsinitialize calls, tools/list calls, tools/call calls, and malformed-request counts. If tools/list volume suddenly spikes relative to tools/call, something client-side is misbehaving, possibly reconnecting in a loop.

A lightweight Prometheus-style implementation:

from prometheus_client import Histogram, Counter, Gauge

tool_call_duration = Histogram(
    "mcp_tool_call_duration_seconds", "Tool call duration",
    ["tool_name"], buckets=[0.05, 0.1, 0.5, 1, 2, 5, 10, 30]
)
tool_call_errors = Counter(
    "mcp_tool_call_errors_total", "Tool call errors",
    ["tool_name", "error_type"]
)
active_connections = Gauge(
    "mcp_active_connections", "Currently open MCP connections"
)

def record_tool_call(tool_name: str, duration_s: float, error_type: str = None):
    tool_call_duration.labels(tool_name=tool_name).observe(duration_s)
    if error_type:
        tool_call_errors.labels(tool_name=tool_name, error_type=error_type).inc()

Wire an alert on mcp_tool_call_errors_total rate-of-change rather than absolute count — a server handling ten calls a minute and a server handling ten thousand need very different absolute thresholds, but "error rate jumped 5x in the last ten minutes" is a threshold that scales with traffic automatically.

One metric worth calling out on its own: track how often the model calls a tool with arguments that fail your input validation before the tool logic even runs. This sounds like a minor edge case, but in practice it's an early warning system for a specific and common failure mode — your tool description or parameter schema is ambiguous, and the model keeps guessing wrong. A spike in validation-rejection rate for one tool, right after you update its description, is a strong signal that the new wording made things worse rather than better. Most teams only find this out anecdotally, days later, when someone notices the agent "seems worse" at a task. A dedicated counter tells you the same day.

Debugging the handshake and transport failures

Most MCP debugging guides focus on tool execution because that's the part that maps cleanly onto normal backend logging. But a meaningful fraction of real production issues happen during initialize or in the transport itself, and these deserve deliberate handling rather than being an afterthought.

Log the full capability negotiation at startup — what the client requested, what your server advertised back — at INFO level once per connection. This single log line has saved more debugging time than any other in practice, because version mismatches between client and server expectations are a recurring source of "some tools just aren't showing up" reports, and the negotiation log tells you immediately whether the server even offered the tool the client is looking for.

For stdio transports specifically, wrap your main loop so that any unhandled exception gets logged to stderr *before* the process exits, not after — a process that dies mid-write can lose buffered log output if you're not flushing explicitly:

import sys
import traceback

async def main():
    try:
        await run_server()
    except Exception:
        sys.stderr.write(traceback.format_exc())
        sys.stderr.flush()
        raise

For SSE and streamable HTTP transports, log every connection open and close with the reason — client disconnect, server-initiated close, idle timeout, or error — because "connection closed" without a reason code is close to useless when you're trying to distinguish a network blip from a bug in your own keep-alive logic.

It's also worth logging the negotiated protocol version explicitly, not just assuming it. MCP has evolved its spec over time, and clients built against an older version of the SDK can connect to a newer server (or vice versa) and silently fall back to a reduced feature set instead of failing outright. If a client reports that a resource-subscription feature "isn't working," the first thing worth checking is whether the negotiated version even supports it for that particular client — and you can only check that quickly if it was logged at connection time instead of needing to be reconstructed from a support ticket and a guess.

Putting it together: a minimal observability checklist

If you're retrofitting an existing MCP server rather than building one from scratch, prioritize in this order, since each layer catches a different class of bug and later layers depend on the discipline established by earlier ones.

  1. Switch all logging to structured JSON with a consistent schema across every log line
  2. Add a request ID generated at the start of every tools/call and thread it through every downstream operation
  3. Add duration tracking and success/failure status to every tool invocation
  4. Add argument redaction before anything gets logged, using a schema-aware allowlist or denylist
  5. Add basic counters for tool calls, errors, and active connections, exported to whatever metrics backend your team already uses
  6. Add OpenTelemetry spans once you have more than two or three tools that call out to slow dependencies
  7. Add explicit logging around initialize and transport lifecycle events, not just tool execution

You do not need all seven steps before shipping anything. Steps one through four alone will resolve the majority of "why did this fail" questions you'll get from users and teammates. Steps five through seven are what let you catch degradation before someone files a ticket about it.

Closing thoughts

MCP servers occupy a strange middle ground: they look like small, simple programs — a handful of tool functions and some JSON-RPC plumbing — but they run in production, under real concurrent load, connected to systems that fail in ordinary ways: slow databases, flaky third-party APIs, network partitions. Treating them with the same observability discipline you'd apply to any other production service isn't overhead, it's the thing that turns a two-hour incident into a two-minute log query.

The patterns here — structured logs with correlation IDs, distributed tracing across tool calls, metrics that catch degradation early, and disciplined redaction of sensitive arguments — aren't MCP-specific tricks. They're standard production engineering practice, applied to a protocol that's young enough that most teams haven't gotten around to applying it yet. Get there first, and your agent deployments will be the ones that fail loudly and recover fast instead of failing silently and taking hours to diagnose.

If you want to go deeper on the mechanics of building these servers correctly from the ground up — transport selection, tool schema design, auth patterns, and yes, wiring in the logging and tracing covered here — that's exactly what we walk through hands-on in Building & Integrating MCP Servers.