Plinth / guides / mcp-logs-stdout-vs-stderr

Why your MCP server's logs are corrupting the protocol

~5 min read · Debugging · stdout vs stderr

Your MCP server worked, then you added a log line to debug something, and now the client connects and immediately drops — or tools silently stop appearing. Nothing in your code threw. The culprit is almost always the same: you logged to stdout, and on the stdio transport, stdout isn't yours to write to.

stdout is the wire

When an MCP client launches a local server, it doesn't open a socket. It spawns your process and communicates with it over the process's standard streams. The client writes JSON-RPC requests to your stdin and reads JSON-RPC responses from your stdout. That's the entire transport. stdout is the protocol channel.

So when you do this:

print("calling weather API...")        # Python
console.log("calling weather API...")  # Node

…you inject the bytes calling weather API...\n directly into the JSON-RPC stream. The client's parser hits a line that isn't valid JSON-RPC, the framing desyncs, and depending on the client you get a hard disconnect, dropped messages, or tools that never register.

Why it's so easy to miss It often works the first time. If your one stray print happens before the handshake, or the client is lenient, you get away with it. Add a second log line, or move it inside a tool handler, and it breaks — which makes it feel intermittent and maddening to debug.

The fix: everything to stderr

stderr is a separate stream the MCP transport doesn't touch. The client typically pipes it to its own logs or ignores it — either way it can't corrupt the protocol. So all diagnostic output goes there.

Python

import logging, sys

logging.basicConfig(
    stream=sys.stderr,          # not stdout
    level=logging.INFO,
    format='{"level":"%(levelname)s","msg":"%(message)s"}',
)
log = logging.getLogger("mcp")
log.info("calling weather API")   # safe

And ban bare print() in server code — it defaults to stdout. If a dependency insists on printing, redirect it: sys.stdout = sys.stderr at startup is a blunt but effective seatbelt for stdio servers.

Node / TypeScript

// console.log -> stdout (BAD on stdio transport)
// console.error -> stderr (safe)
console.error(JSON.stringify({ level: "info", msg: "calling weather API" }));

If you use a logger like pino, point its destination at file descriptor 2 (stderr). Audit any library that might log on its own.

How to catch it before users do

Add a test that runs the server over the real stdio transport and asserts that stdout contains only well-formed JSON-RPC frames. The moment a stray write sneaks in, the test fails — instead of a user filing a "tools disappeared" bug you can't reproduce.

Rule of thumb: on an MCP stdio server, treat stdout as read-only. The only thing that writes to it is the protocol layer. Everything you want to see goes to stderr.

Get a server that already enforces this

The Plinth MCP Server Starter Kit ships structured JSON logging pinned to stderr in both the Python and TypeScript templates, plus a test suite — 42 tests over the real protocol — that keeps stdout clean as you add your own tools.

Get the kit — $39