My MCP stdio server worked perfectly every time I tested it. Then I used it for a week from Claude Code and logged every call: 212 tool calls, 41 failures. That's 19%, from a server with six tools and no complicated logic.
None of the 41 were bugs in my tools. Every one came from bytes landing on stdout that weren't JSON-RPC. One was a debug print(). The other 40 were sneakier, and they're the reason I'm writing this.
TL;DR
- An MCP stdio server talks to its client over stdout. Each message is one line of JSON. Any other byte on stdout corrupts the stream.
-
print(),console.log(), and child processes that inherit your stdout (subprocess.run(["git", "pull"])) all write to that pipe. - A
print(..., end="")doesn't break its own line. It glues itself onto the next JSON response, so a valid reply gets thrown away and the call hangs until timeout. - Fix: send logs to stderr, capture subprocess output, and at startup, point file descriptor 1 at stderr while keeping a private handle for the protocol.
- Add a smoke test that asserts every stdout line parses as JSON. It would have caught all 41 failures.
What was I building?
A personal "second brain" MCP server in Python using the official mcp SDK. Six tools: search my notes repo, read a note, list recent notes, append to a daily log, grep across projects, and summarize a git diff. Claude Code launched it as a stdio subprocess, like most local MCP servers.
I wrapped every tool handler in a decorator that logged the tool name, duration, and whether the client got a response. After seven days I had a spreadsheet I didn't like.
| Cause | Failed calls |
|---|---|
git pull output leaking from a subprocess |
23 |
Progress text with end="" glued onto a response |
14 |
print(e) in an exception handler |
4 |
| Total | 41 of 212 |
Why does print() break an MCP stdio server?
Because in the stdio transport, stdout is the protocol. The MCP spec says messages are newline-delimited JSON-RPC, and the server must not write anything to stdout that isn't a valid MCP message. Logging is supposed to go to stderr.
The client reads stdout line by line and parses each line as JSON. When a line isn't JSON, what happens next depends on the client: a parse error in the logs, a dropped message, or a dead connection. In my setup it was mostly the first two, which is worse than a crash. A crash you notice. A silently dropped message looks like a slow tool.
Failure 1: How can a subprocess corrupt MCP stdout?
A child process inherits its parent's file descriptors. If you call subprocess.run() without capturing output, the child writes straight into fd 1, which in an MCP stdio server is the protocol pipe.
My search tool refreshed the notes repo when the local index was more than 10 minutes old:
def refresh_repo(path: str) -> None:
subprocess.run(["git", "-C", path, "pull", "--ff-only"], check=True)
git pull prints Already up to date. or a Fast-forward summary to stdout. My Python code never touched sys.stdout, so I didn't suspect it. But git's output went to the same pipe as my JSON, and the client choked on Already up to date.
This is why the failures looked random. They only happened on calls that triggered a refresh, which depended on how long I'd been away from the keyboard. 23 of my 41 failures were this one line.
A grep -rn "print(" . finds nothing here. You have to grep for subprocess, os.system, and anything that shells out.
Failure 2: Why did my valid responses disappear?
This one cost me two evenings. The indexer printed progress like this:
print(f"indexing {len(files)} files...", end="")
No newline. That text sat in a buffer, and when the SDK wrote the next response, the two came out as one line:
indexing 1840 files...{"jsonrpc":"2.0","id":7,"result":{...}}
The progress text isn't a separate bad line the client can skip. It turns a correct response into an unparseable one. Response id: 7 never arrives, so the client waits for it until the request times out. From the outside, the tool just hung.
Exactly where the stray bytes land depends on buffering, which is why it didn't happen every time. 14 failures, all on calls that rebuilt the index.
Failure 3: The obvious one
except Exception as e:
print(e)
return error_result(str(e))
It only ran when a tool already failed, so the user saw "tool broke" either way. Four calls. This is the bug every MCP tutorial warns you about, and it was the smallest share of my failures.
Why didn't I catch this in testing?
Because I tested by running the server in a terminal and pasting JSON into it. In a terminal, stdout and stderr go to the same screen, so progress text and JSON show up mixed together and look perfectly normal. Your eyes forgive what a JSON parser won't.
Also, my manual tests never waited 10 minutes between calls, so the git pull path never ran.
How do you fix stdout leaks in an MCP stdio server?
Take stdout away from everyone except the transport. Grab a private copy of fd 1 at startup, then point fd 1 at stderr. Every print(), every library that writes to stdout, and every child process now writes to stderr. Only the MCP transport keeps the real pipe.
import io
import os
import sys
import anyio
from mcp.server.stdio import stdio_server
# Keep a private handle to the real stdout for the protocol.
_proto_fd = os.dup(1)
# Point fd 1 at stderr so print(), libraries, and child processes can't reach the pipe.
os.dup2(2, 1)
sys.stdout = sys.stderr
proto_out = anyio.wrap_file(
io.TextIOWrapper(os.fdopen(_proto_fd, "wb"), encoding="utf-8")
)
async def main() -> None:
async with stdio_server(stdout=proto_out) as (read, write):
await server.run(read, write, server.create_initialization_options())
In the version of the Python SDK I was on, stdio_server() takes optional stdin/stdout arguments. Check your version's signature. In Node, the same idea works by saving the stream before anything else loads, but console.error for logs gets you most of the way there.
I also fixed each leak at the source, because hiding a bug isn't the same as fixing it:
-
logging.basicConfig(stream=sys.stderr, level=logging.INFO). Python's default handler already writes to stderr, but being explicit keeps a future refactor from changing it. - Every
subprocess.rungotcapture_output=True, text=True. If I want the output, I log it to stderr myself. - The progress indicator became a
logger.debugcall.
How do you test that an MCP server keeps stdout clean?
Start the real server as a subprocess, drive it with real JSON-RPC, and fail if any stdout line isn't JSON. This is about 30 lines and runs in CI in under two seconds:
import json, subprocess, sys
proc = subprocess.Popen(
[sys.executable, "server.py"],
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, text=True,
)
def send(msg):
proc.stdin.write(json.dumps(msg) + "\n")
proc.stdin.flush()
def recv():
line = proc.stdout.readline()
try:
return json.loads(line)
except json.JSONDecodeError:
sys.exit(f"stdout pollution: {line!r}")
send({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
"protocolVersion": "2025-06-18", "capabilities": {},
"clientInfo": {"name": "smoke", "version": "0"}}})
recv()
send({"jsonrpc": "2.0", "method": "notifications/initialized"})
send({"jsonrpc": "2.0", "id": 2, "method": "tools/list"})
tools = recv()["result"]["tools"]
for i, tool in enumerate(tools, start=3):
send({"jsonrpc": "2.0", "id": i, "method": "tools/call",
"params": {"name": tool["name"], "arguments": SAMPLE_ARGS[tool["name"]]}})
assert recv()["id"] == i, f"{tool['name']} lost its response"
proc.terminate()
print("stdout clean")
The assert on the id catches the glued-line case: a lost response means the next message you read has the wrong ID. To make the git pull path run, set SAMPLE_ARGS so at least one call forces a refresh. Put your slow paths in the test, not just the fast ones.
What happened after the fix?
The next week I logged 188 tool calls and had zero framing failures. Two calls still failed, both real errors (a note path that didn't exist), and both came back as proper JSON-RPC errors instead of hanging.
Total cost of the bug: three evenings of debugging, roughly 40 tool calls that I retried by hand, and one change to how I think about stdio. Stdout isn't a console in a stdio server. It's a wire, and anything you drop on it is traffic.
So why does one print() break an MCP stdio server?
An MCP stdio server uses stdout as the JSON-RPC transport, and the client parses every line as a protocol message. A single print(), a console.log(), or a subprocess that inherits your stdout adds non-JSON bytes to that stream. At best the client logs a parse error. At worst, as with print(..., end=""), the stray text merges with a valid response, the response is lost, and the tool call hangs until timeout. Send all logs to stderr, capture subprocess output, redirect fd 1 to stderr at startup while keeping a private handle for the transport, and add a smoke test that fails on any stdout line that isn't JSON.
Written by the developer behind Preterview, an interview prep platform.
Top comments (0)