Lesson 7 - Step 4 of 5

Logging to stderr

Learn to debug your MCP server without breaking the protocol.

stderr: Your Debug Channel

Since stdout is reserved exclusively for JSON-RPC messages, you need another way to output debug information. That's where stderr comes in.

diagram
┌─────────────────────────────────────────────────────────────┐
│                    MCP Server Process                       │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  stdout ─────► JSON-RPC messages ─────► MCP Client (parsed) │
│                                                             │
│  stderr ─────► Debug logs ─────► Terminal/Log file (ignored)│
│                                                             │
└─────────────────────────────────────────────────────────────┘

The MCP client reads stdout for protocol messages but ignores stderr. This makes stderr perfect for logging, debugging, and diagnostics.

Basic stderr Logging

The simplest approach is direct writes to sys.stderr:

python
import sys

def log(message):
    """Write a debug message to stderr."""
    sys.stderr.write(f"[DEBUG] {message}\n")
    sys.stderr.flush()

# Usage
log("Server starting...")
log(f"Received method: {method}")
log(f"Tool call result: {result}")
Always Flush stderr

Like stdout, stderr can be buffered. Always call flush() to ensure your debug messages appear immediately. This is especially important when debugging crashes or hangs.

Using Python's Logging Module

For production servers, Python's logging module provides more control over log levels, formatting, and output:

python
import sys
import logging

# Configure logging to stderr
logging.basicConfig(
    level=logging.DEBUG,
    format='%(asctime)s [%(levelname)s] %(message)s',
    handlers=[
        logging.StreamHandler(sys.stderr)
    ]
)

logger = logging.getLogger("mcp-server")

# Usage
logger.info("Server starting...")
logger.debug(f"Received method: {method}")
logger.warning("Client sent deprecated parameter")
logger.error(f"Tool execution failed: {error}")

Log Levels for MCP Servers

Use appropriate log levels to filter output based on verbosity needs:

Level Use For
DEBUG Detailed message content, internal state
INFO Server lifecycle events, method calls
WARNING Deprecated usage, recoverable issues
ERROR Failures that affect operation

Structured Logging

For complex debugging, consider structured logging that outputs JSON to stderr:

python
import sys
import json
from datetime import datetime

def log_structured(level, message, **extra):
    """Write a structured JSON log entry to stderr."""
    entry = {
        "timestamp": datetime.utcnow().isoformat(),
        "level": level,
        "message": message,
        **extra
    }
    sys.stderr.write(json.dumps(entry) + "\n")
    sys.stderr.flush()

# Usage
log_structured("INFO", "Tool called",
    tool="echo",
    arguments={"message": "hello"},
    duration_ms=5
)

# Output:
# {"timestamp":"2024-01-15T10:30:45.123456","level":"INFO","message":"Tool called","tool":"echo","arguments":{"message":"hello"},"duration_ms":5}

Complete Logging Module

Here's a production-ready logging setup for MCP servers:

python
#!/usr/bin/env python3
"""MCP Server Logging Module."""

import sys
import logging
import os

def setup_logging(name="mcp-server"):
    """
    Configure logging for an MCP STDIO server.

    Args:
        name: Logger name (appears in log output)

    Returns:
        Configured logger instance
    """
    # Get log level from environment (default: INFO)
    level_name = os.environ.get("MCP_LOG_LEVEL", "INFO").upper()
    level = getattr(logging, level_name, logging.INFO)

    # Create logger
    logger = logging.getLogger(name)
    logger.setLevel(level)

    # Clear any existing handlers
    logger.handlers.clear()

    # Create stderr handler
    handler = logging.StreamHandler(sys.stderr)
    handler.setLevel(level)

    # Create formatter
    formatter = logging.Formatter(
        '%(asctime)s [%(name)s] %(levelname)s: %(message)s',
        datefmt='%Y-%m-%d %H:%M:%S'
    )
    handler.setFormatter(formatter)

    # Add handler to logger
    logger.addHandler(handler)

    return logger

# Usage in your server:
# from logging_module import setup_logging
# logger = setup_logging("my-mcp-server")
# logger.info("Starting server...")
# logger.debug(f"Request: {request}")

Redirecting print() to stderr

If you have existing code that uses print(), you can redirect it to stderr to prevent protocol corruption:

python
import sys

# Option 1: Use print's file parameter
print("Debug message", file=sys.stderr)

# Option 2: Redirect all print() calls to stderr
# Do this BEFORE any other imports that might print
original_stdout = sys.stdout
sys.stdout = sys.stderr

# Now print() goes to stderr
print("This won't break the protocol!")

# Restore stdout for JSON-RPC output
def write_response(response):
    original_stdout.write(response + "\n")
    original_stdout.flush()
Caution: Library Output

Some third-party libraries print warnings or progress to stdout. Either:

  • Configure the library to use stderr
  • Suppress the library's output entirely
  • Redirect stdout before importing the library

Viewing stderr in Claude Desktop

When running your server with Claude Desktop, stderr output appears in the application logs. You can view these for debugging:

bash
# macOS - View Claude Desktop logs
tail -f ~/Library/Logs/Claude/mcp*.log

# Linux - View Claude Desktop logs
tail -f ~/.config/Claude/logs/mcp*.log

# Windows - View Claude Desktop logs
Get-Content "$env:APPDATA\Claude\logs\mcp*.log" -Wait

Debugging Tips

  • Log request IDs: Include the message ID in logs to trace request/response pairs
  • Log timestamps: Essential for understanding timing issues
  • Use environment variables: Control log verbosity without code changes
  • Log on entry/exit: Log when functions start and complete
  • Include context: Log relevant data like method names and parameters