Your MCP Server "Won't Connect"? Stop Printing to Stdout.
In a stdio MCP server, stdout belongs to JSON-RPC. One stray log line and the client drowns you in unrecognized_keys errors.
You built an MCP server. It starts cleanly in a terminal. uv run my-mcp prints
your startup banner, no traceback, exit code 0. Then you point Claude Desktop at
it and the client throws a wall of schema errors and reports the server as failed.
Nothing in your code “crashed.” So what happened?
You logged to stdout. And in a stdio MCP server, stdout isn’t yours.
Stdout is the wire, not a log
A stdio MCP server speaks JSON-RPC over exactly two pipes: it reads requests from stdin and writes responses to stdout. That’s the transport. stdout is not a place to print things — it is the protocol channel. The client is on the other end parsing every line it receives as a JSON-RPC message.
So the moment anything that isn’t a JSON-RPC message lands on stdout — a
structlog line, a stray print(), a library’s “loaded model successfully”
banner, a warning from a dependency — the client dutifully tries to parse it as
protocol and rejects the whole exchange:
unrecognized_keys: ["warning", "event", "timestamp", "level"]
invalid_value ... path: ["jsonrpc"]
If you’ve seen a burst of invalid_union / unrecognized_keys errors and a server
that “won’t connect” despite starting perfectly in a shell, look at those key
names. event, timestamp, level — those aren’t protocol fields. Those are
your log fields. Your logger is talking on the wire, and the client can’t tell
your debug output apart from a malformed response.
This is why the terminal fools you. In a terminal, stdout is just your screen, so a log line looks harmless. Under the client, stdout is a socket with a strict grammar, and the same line is a protocol violation.
The fix: everything that isn’t JSON-RPC goes to stderr
stderr is free. The client doesn’t parse it, so it’s the correct destination for
every diagnostic byte your server emits. If you use structlog, point its logger
factory at sys.stderr and you’re done:
import sys
import structlog
structlog.configure(
processors=[
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.JSONRenderer(),
],
logger_factory=structlog.PrintLoggerFactory(file=sys.stderr),
)
Two rules follow from this, and they’re absolute:
- No
print()in any runtime path. A bareprint()writes to stdout by definition. One of them, buried in an error branch you rarely hit, will corrupt the protocol the day it fires. If you must,print(..., file=sys.stderr)— but prefer the logger. - Watch your dependencies. Libraries that print banners or progress bars to stdout will poison the channel just as effectively as your own code. Import them knowing that, and silence anything chatty.
A nice side effect of the JSONRenderer above: it escapes non-ASCII, so your
stderr logs stay clean even on a Windows cp1252 console that would otherwise
choke on a stray emoji or box-drawing character. One fewer platform surprise.
Catch it before the client does
The reason this bug is so common is that unit tests never see it. Your tests import functions and assert on return values — they never launch the server as a subprocess and read its stdout the way a real client does. So the one class of bug that actually breaks the integration is the one your suite is blind to.
Add a tiny stdio smoke test: spawn the server as a subprocess, send an
initialize then a tools/list request, and assert that what comes back on stdout
is clean, parseable JSON-RPC with the tools you expect. If a log line is leaking,
this test fails immediately — and it catches a couple of its cousins (import-time
crashes, empty-config startup failures) in the same run. Run it before you ever
tell someone the server works.
The one-line takeaway
In a stdio MCP server, stdout is the protocol and stderr is for you. Send one
byte of logging to the wrong pipe and the client will tell you the server “won’t
connect” while giving you no obvious reason why. Route every log and every print
to stderr, and the mystery disappears.