AI & Machine Learning Bug Report

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.

Ryan Pham Ryan Pham @ryan_bugs · May 15, 2025 · 7 min read · 11 Views
Banner: MCP stdio server breaks because log lines are mixed into the JSON-RPC stream on stdout

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 python in 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, no print) 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 print and console.log in 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

Add a label

#python #llm #agents #mcp #json-rpc
Written by
Ryan Pham @ryan_bugs

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.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.