◈Home ▸Blog ◇Contact
Home/Blog/Why a Stray print() Kills Your MCP stdio Serv…
ArticleLLM EngineeringMCPDebuggingTransports

Why a Stray print() Kills Your MCP stdio Server

11 min readBy Miloš Mitrović

If your stdio MCP server disconnects on startup, hangs before the first tool result, or throws a JSON parse error in the client log, the usual cause is that something in your process wrote to stdout that was not an MCP message. Stdout is the protocol channel, not a log sink, and the client's line parser has no way to tell your banner apart from a response. The fix is not to hunt every print by hand but to move the protocol channel onto a private file descriptor and point descriptor 1 at stderr, so nothing in your dependency tree can reach it.

Key takeaways

  • The MCP spec reserves stdout entirely: a stdio server must not write anything there that is not a valid MCP message, and messages are newline delimited with no embedded newlines.
  • A write without a trailing newline is worse than one with it, because it prefixes the next response and corrupts a message that was generated correctly.
  • Symptoms split cleanly. A parse error means interleaved text, an immediate close means output before initialize, and a silent stall usually means block buffering rather than pollution.
  • Redirect at the file descriptor level, not only at sys.stdout or console.log, because C extensions and child processes write to descriptor 1 directly.
  • Ship a one line CI probe that asserts the first byte a freshly spawned server emits is {. It catches a new noisy dependency before your users do.

What actually breaks when your server writes to stdout

The client is reading your stdout as a stream of newline delimited JSON-RPC messages, so any other bytes on that stream are a framing error. The MCP transports specification is explicit about it: messages are delimited by newlines and must not contain embedded newlines, the server may write UTF-8 to stderr for logging, and the server must not write anything to stdout that is not a valid MCP message. There is no escape hatch, no comment syntax, no ignore rule. JSON-RPC 2.0 itself says nothing about framing, so the framing contract is entirely the transport's, and stdio's framing contract is one message per line.

Three shapes of pollution produce three different failures.

  • A line with a newline. print("loading model") emits one line that fails to parse. Most clients log the error and either drop that line or close the connection. If it happens before initialize completes, you get a connection failure with no useful message.
  • A write without a newline. sys.stdout.write("[warn] ...") with no terminator prefixes whatever the transport writes next. Your response was serialized correctly and is still destroyed. This is the version that shows up as an intermittent failure on one tool call out of twenty.
  • A multi-line blob. A traceback, a progress bar, or a framework banner produces several broken lines at once, and on some clients enough consecutive parse failures trips a hard disconnect.

The sources are nearly always the same handful: a debug print left in a tool handler, a logging config whose default handler points at stdout, a library that greets you on import, a CLI helper invoked with subprocess that inherits your descriptors, or a native extension that writes a warning with printf before Python ever sees it.

Which symptom maps to which cause

Read the symptom first, because it narrows the search to one of three classes before you touch any code.

What you seeMost likely cause
Client logs a JSON parse error quoting recognisable textDirect pollution. The quoted text names the culprit.
Server exits or is closed immediately after spawnOutput on stdout before the initialize response, or a crash. Check stderr first.
Works when you pipe input by hand, fails under the clientBuffering. Your terminal is line buffered, a pipe is block buffered.
Only one tool fails, and always the same oneA write inside that handler or in a module it imports lazily.
Fails only on the GPU box or only on first runNative library output on descriptor 1 (CUDA notices, tokenizer warnings, model download progress).
Truncated or interleaved JSON, non deterministicConcurrent writers: a background thread, a signal handler, or a child process sharing descriptor 1.
A message arrives, then nothing, then a client timeoutBuffering or a blocked write, not pollution. See the section below.

How to find the offending write in five minutes

Run the server yourself with a single initialize request on stdin and look at the first bytes it produces, because that removes the client from the picture entirely.

REQ='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"0.0.0"}}}'

# 1. What are the first bytes on stdout? Should be a single JSON object.
printf '%s\n' "$REQ" | python -m my_server 2>/dev/null | head -c 300

# 2. Show only the lines that are not protocol messages.
printf '%s\n' "$REQ" | python -m my_server 2>/dev/null \
  | grep -vE '^\{"jsonrpc"' | head

If step 2 prints anything, you have your answer and usually your search string. If stdout looks clean by hand but the client still fails, the write is happening inside a specific tool call, so append a tools/call request to the same stdin file and repeat.

When the text does not appear in your own source, the write is below Python. Trace descriptor 1 directly:

strace -f -e trace=write -o /tmp/mcp.trace python -m my_server < probe.jsonl
grep -n 'write(1,' /tmp/mcp.trace | grep -v jsonrpc

Every hit is a write to descriptor 1 from somewhere in the process tree, including C extensions and child processes, and the -f flag follows forks. On macOS the equivalent is dtruss -f -t write.

Once you know the shape of the bug, freeze it with a CI check rather than a code review habit:

first=$(printf '%s\n' "$REQ" | timeout 15 python -m my_server 2>/dev/null | head -c 1)
if [ "$first" != "{" ]; then
  echo "stdout pollution: first byte was '$first'" >&2
  exit 1
fi

For interactive work the MCP Inspector shows the same failure with the raw message log attached, which is faster once you are past the first diagnosis.

How to fence off stdout in Python

Move the protocol channel to a private descriptor at process start, then point descriptor 1 at stderr so that every later write, from any layer, is harmless. This has to be the first thing your entrypoint does, before any project import, because the whole point is to run ahead of the import time banner.

# guard.py  -  import this on the first line of your entrypoint
import builtins
import functools
import io
import logging
import os
import sys

sys.stdout.flush()

# 1. Keep a private handle on the descriptor the client reads.
_protocol_fd = os.dup(1)

# 2. Descriptor 1 now points at stderr. C printf, child processes that
#    inherit descriptors, and anything writing to fd 1 land on stderr.
os.dup2(2, 1)

# 3. Hand the SDK back a sys.stdout bound to the private descriptor.
sys.stdout = io.TextIOWrapper(
    io.BufferedWriter(io.FileIO(_protocol_fd, "w", closefd=False)),
    encoding="utf-8",
    line_buffering=True,
)

# 4. Make the common mistake harmless.
builtins.print = functools.partial(builtins.print, file=sys.stderr)
logging.basicConfig(
    stream=sys.stderr,
    level=os.environ.get("LOG_LEVEL", "INFO"),
)

Step 2 is the part people skip, and it is the part that matters once you have native dependencies. contextlib.redirect_stdout rebinds the Python object only, so a C extension calling printf writes straight past it; os.dup2 operates on the descriptor, which is the level those writes use. The Python SDK stdio transport reads sys.stdin and sys.stdout, and the object built in step 3 exposes a .buffer attribute, so it works whether the transport takes the text stream or the binary one underneath it.

line_buffering=True is not cosmetic. Messages are newline delimited, so flushing on newline is exactly the framing guarantee the transport needs, and it stops responses from sitting in an 8 KB buffer while the client waits.

One hole remains: a library that calls sys.stdout.write directly still reaches the protocol stream, since after step 3 that is what sys.stdout is. That is rare compared to print and to descriptor level writes, and the CI probe above catches it on the way in. If you are standing up a new server, the same guard belongs in the entrypoint from day one, alongside whatever FastMCP scaffolding you are using.

How to do the same in Node

In Node the dominant source is console.log, so patch the console before any other import and give the transport its own handle on descriptor 1.

// guard.js  -  imported first in the entrypoint
import fs from "node:fs";

// A dedicated stream over the descriptor the client is reading.
export const protocolOut = fs.createWriteStream(null, { fd: 1, autoClose: false });

// Route the noisy console methods to stderr.
console.log = console.info = console.debug = console.warn =
  (...args) => console.error(...args);
import { protocolOut } from "./guard.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";

const transport = new StdioServerTransport(process.stdin, protocolOut);
await server.connect(transport);

// Only after the transport holds its own handle:
process.stdout.write = process.stderr.write.bind(process.stderr);

Order matters in the second block. If you divert process.stdout.write before the transport has a separate stream, you divert the protocol output too and the client sees silence. The TypeScript SDK stdio transport accepts the input and output streams and defaults to process.stdin and process.stdout; if your version does not expose them, the console patch alone still covers the large majority of cases, since almost nothing in a Node server writes to the descriptor directly.

Note also that Node's stdout is not uniformly synchronous. Per the process I/O documentation, writes to files and TTYs are synchronous, while pipes are asynchronous on Linux and macOS and synchronous on Windows. Under an MCP client your stdout is a pipe, which is exactly the case where a write queued at exit can be lost. Do not call process.exit() with pending protocol output.

Where your logs should actually go

Send engineering diagnostics to stderr and send anything the user or model should see through the protocol. The spec permits arbitrary UTF-8 on stderr and leaves clients free to capture, forward, or ignore it, which makes stderr the right place for stack traces, timings, and startup detail. For messages that belong in the conversation, the logging utility defines notifications/message with a syslog style severity, and clients can set a minimum level with logging/setLevel.

Two practical limits on protocol logging. It only exists after initialization completes, so anything at import time or during startup has to go to stderr regardless. And it depends on the client honouring the capability, so it is not a substitute for a log you can read when the server will not start. For long tool calls, progress notifications are the better channel and are worth wiring up properly; the mechanics are covered in streaming progress from a long running MCP tool.

The buffering failure that looks like a timeout

If the server produces correct JSON when you run it by hand but stalls under the client, suspect buffering rather than pollution. Python block buffers stdout when it is a pipe and line buffers it when it is a TTY, so a response can sit unflushed until the buffer fills or the process exits, and the client gives up first. Set PYTHONUNBUFFERED=1 or pass the -u flag in the command your client config launches, and keep line buffering on the protocol stream as shown above.

{
  "command": "python",
  "args": ["-u", "-m", "my_server"],
  "env": { "PYTHONUNBUFFERED": "1", "LOG_LEVEL": "DEBUG" }
}

Wrapper scripts are the other trap. A shell wrapper that pipes your server through tee or any filter adds a buffering layer the client cannot see, and one that emits a line of its own (an activation notice, a version banner) pollutes stdout before the server starts. Keep wrappers to exec in the last line so the server inherits the descriptors directly, and send any wrapper output to stderr. If you are editing the launch config by hand, the local and remote server setup guide covers the field layout.

Trade-offs and what I would do

Discipline alone is enough only for a server with no dependencies worth worrying about. If the whole tree is yours and your test suite spawns the server for at least one end to end call, a lint rule against print plus the CI probe will hold. The moment you import an ML runtime, a cloud SDK, a headless browser, or anything that shells out, that assumption expires, usually on a machine that is not yours, and the failure surfaces as a support ticket about a server that will not connect.

My default is the descriptor level guard in every stdio server, written before the first tool. It costs about fifteen lines, never needs revisiting, and turns a class of production failures into nothing. The residual risk (a library calling sys.stdout.write directly) is narrow, visible in the CI probe, and cheap to patch when it appears.

The transport choice is the larger decision. Stdio is the right default for local servers: no ports, no auth, process lifetime tied to the client, and the spec tells clients to support it wherever possible. But if your server wraps a toolchain that scribbles on stdout and you cannot control it (a vendor binary, a notebook kernel, a training framework), the argument for Streamable HTTP gets strong fast, because stdout stops being the protocol channel and the entire failure mode disappears. You pay for that with a listening port, authorization, and a deployment story. I would not switch transports purely to dodge a print statement, but I would switch without hesitation for a server that embeds someone else's process.

One last habit worth adopting. Treat the first byte of stdout as a contract and test it, the same way you would test a status code. It is a two line check, it runs in under a second, and it is the only thing standing between a routine dependency bump and a server that no client can talk to.

Sources

M
Miloš Mitrović
Email Marketing for Ecommerce

Have a question or a project?

Whether it is about this post or a system you want built, I'm happy to talk.

Get in touch

404

Post not found. It may have been moved or the link is incorrect.

← Back to the blog
Summarize with AI
ChatGPT, Perplexity, and Grok open with the prompt ready to run. Claude, Gemini, and Copilot open a chat with the prompt copied; press Ctrl+V (Cmd+V on Mac) to paste. The full text is included, so it works even without web access.