MCP server disconnects over stdio: print statements corrupt JSON-RPC
Our internal MCP server passed every test in the inspector but kept failing in the desktop client with an 'is not valid JSON' error. A startup banner and a debug print went to stdout, which is the protocol channel in stdio transport. Logging to stderr fixed it.
We built a small Model Context Protocol server that exposes our deployment history and on-call schedule to AI assistants used by the platform team. In development it worked: the MCP inspector listed the tools and every call returned results. Configured in a desktop AI client, it showed as failed within a second of startup. The client log contained:
[error] [deploy-history] Unexpected token 'S', "Starting d"... is not valid JSON
[info] [deploy-history] Server transport closed unexpectedly
[error] [deploy-history] Server disconnected.
On some machines it connected but broke the first time someone called the recent_deploys tool.
Reproducing it
We launched the server by hand the same way the client does and watched raw stdout:
$ python -m deploy_mcp 2>/dev/null | head -3
Starting deploy-history MCP server v0.3.1
That first line is not JSON. The client reads stdout expecting JSON-RPC messages, gets a banner, and gives up. The tool-call failure was the same issue in a different spot: recent_deploys had a leftover print(rows) from debugging.
What we ruled out
- Python environment differences. The client used the same virtualenv path;
which pythonin the launch command matched. - Protocol version mismatch. The initialise exchange never happened, so the server and client did not get as far as negotiating versions.
- The inspector being more lenient. It was not more lenient; we had started it with a different entry point during development that did not print the banner.
Root cause
With the stdio transport, the server's stdout is the protocol channel. Every byte written there must be part of a valid JSON-RPC message. Our server printed a startup banner with print(), and a library we used logged its own warnings through a handler attached to sys.stdout. The client parsed those lines as protocol messages and closed the connection.
What is happening under the hood
MCP over stdio is simple: the client starts the server as a subprocess, writes JSON-RPC requests to its stdin, and reads newline-delimited JSON-RPC messages from its stdout. The specification says the server must not write anything to stdout that is not a valid MCP message, and may write logs to stderr, which clients usually capture into their own log files.
Many tools write to stdout by default: print() in Python, console.log in Node.js, some logging configurations, progress bars, and third-party libraries that print deprecation notices. In a normal CLI that is harmless. In an MCP stdio server it is protocol corruption.
The HTTP-based transports do not have this problem, because protocol messages travel over HTTP and stdout is free for logs.
Environment
| Component | Version / value |
|---|---|
| Runtime | Python 3.12.10 |
| SDK | mcp Python SDK 1.8 (FastMCP) |
| Transport | stdio |
| Client | Desktop AI assistant with MCP support, plus MCP Inspector for testing |
| OS | macOS 15.4 and Ubuntu 24.04 |
Minimal reproduction
from mcp.server.fastmcp import FastMCP
import logging, sys
logging.basicConfig(stream=sys.stdout, level=logging.INFO) # wrong stream
mcp = FastMCP("deploy-history")
@mcp.tool()
def recent_deploys(service: str, limit: int = 5) -> list[dict]:
rows = query_deploys(service, limit)
print(rows) # corrupts the stream
return rows
if __name__ == "__main__":
print("Starting deploy-history MCP server v0.3.1") # corrupts the stream
mcp.run() # stdio by default
Who is affected
Every stdio MCP server in any language. Node.js servers that use console.log are the most common case; console.error writes to stderr and is safe.
The fix
All diagnostics go to stderr, configured once at startup, before any import that might log.
import logging
import sys
logging.basicConfig(
stream=sys.stderr,
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
log = logging.getLogger("deploy_mcp")
from mcp.server.fastmcp import FastMCP # noqa: E402 - after logging config
mcp = FastMCP("deploy-history")
@mcp.tool()
def recent_deploys(service: str, limit: int = 5) -> list[dict]:
"""Return the most recent deployments for a service, newest first."""
rows = query_deploys(service, limit)
log.debug("recent_deploys service=%s rows=%d", service, len(rows))
return rows
if __name__ == "__main__":
log.info("starting deploy-history MCP server v0.3.2")
mcp.run()
As a safety net for third-party code we cannot control, we redirect accidental prints to stderr while keeping the real stdout for the SDK. This only works if the SDK has already captured the original stream, so we tested it against our SDK version before relying on it. In the end we did not need it: fixing our own logging and setting the one noisy library's logger to stderr was enough.
For Node.js servers on the team, the equivalent rule is: console.error for logs, never console.log.
Alternatives and trade-offs
- Use the Streamable HTTP transport. Stdout becomes irrelevant and the server can be shared by several clients. It needs auth and a running process, which is more than we wanted for a local tool.
- Send logs to the client through MCP logging notifications. The protocol supports structured log messages to the client. Useful for user-visible diagnostics; we still keep stderr for crash traces.
How to test the fix
A test that starts the server as a subprocess and asserts that every stdout line is valid JSON:
import json, subprocess
def test_stdout_is_pure_jsonrpc():
proc = subprocess.Popen(["python", "-m", "deploy_mcp"], stdin=subprocess.PIPE,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
init = {"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {"protocolVersion": "2025-03-26", "capabilities": {},
"clientInfo": {"name": "test", "version": "0"}}}
proc.stdin.write(json.dumps(init) + "\n"); proc.stdin.flush()
line = proc.stdout.readline()
assert json.loads(line)["id"] == 1
proc.kill()
We also used the MCP Inspector with the exact command line from the client config, not a development shortcut.
Confirming in production
After rollout, the desktop client connected on all 14 team machines. Client logs showed our stderr messages in the server log file, and no parse errors over two weeks of daily use.
Regression guard
- The stdout purity test runs in CI and calls each tool once with fixture data.
- A ruff rule (
T201, noprint) is enabled for the server package. - The README for internal MCP servers has one rule at the top: stdout belongs to the protocol.
Security implications
Stdout leaks are also data leaks: the debug print(rows) was sending raw deployment records, including committer emails, into whatever the client did with unparseable output. Logs on stderr end up in the client's log directory, so we also stopped logging full records and log counts and ids instead. MCP servers expose internal data to an AI client, so we kept the tools read-only and scoped the database user to the two tables they need.
Prevention checklist
- In stdio servers, stdout is protocol only.
- Configure logging to stderr before importing anything else.
- Ban
printandconsole.login server code with a linter. - Test with the exact launch command the client uses.
- Keep MCP tools least-privilege and log identifiers, not payloads.
References
- MCP specification, transports (stdio) — https://modelcontextprotocol.io/specification/2025-03-26/basic/transports
- MCP Python SDK — https://github.com/modelcontextprotocol/python-sdk
- MCP Inspector — https://modelcontextprotocol.io/docs/tools/inspector
- Debugging MCP servers — https://modelcontextprotocol.io/docs/tools/debugging
Add a label
Embed this post
<iframe src="https://hibug.me/mcp-stdio-server-stdout-logging-breaks-protocol" width="700" height="420" frameborder="0" loading="lazy"></iframe>
I build and run the platforms other engineers ship on: CI/CD pipelines, Kubernetes clusters, GPU inference services, LLM features in production, and the cloud bills that come with all of it. I have also spent years writing and operating automated trading systems, where a rounding bug or a missed websocket message is not a log line but a position you did not intend to hold. Each post here starts from a real failure: the alert, the exact error, what we ruled out, the root cause, and how we proved the fix. Same path every time: Investigate, Explain, Fix, Verify. No hype, no price predictions, just engineering.
Related Stories
Receipt OCR with a vision LLM cost 6x the estimate: full-resolution photos
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.