One print statement breaks an MCP server, and the client will not tell you

30 July 2026

There is a tool for this on the site

Here is a Model Context Protocol server that works perfectly when you test it and fails the moment a real client connects:

const server = new Server({ name: "my-server", version: "1.0.0" });
console.log("Server running on stdio");
await server.connect(new StdioServerTransport());

The bug is line two. Not the transport, not the handler registration, not the schema. The log line.

stdout is the wire, not a place you also write to

An MCP server over stdio has no socket and no port. The client spawns it as a child process and the two talk over that process’s stdin and stdout, exchanging newline-delimited JSON. That is the entire transport.

So console.log does not write to a log. It writes into the middle of the message stream, between one JSON-RPC frame and the next, and the client reads:

Server running on stdio
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18",...

The first line is not valid JSON. The client fails to parse it and, in most implementations, does not resynchronise: the reader is now offset from the frame boundaries and everything after that point fails too. One line of entirely reasonable logging takes out the whole session.

The protocol is explicit about this. The server must not write anything to stdout that is not a valid MCP message. stderr is available and clients read it separately, which is exactly what you want.

Why it survives every test you ran

This is the part worth internalising, because it is what makes the bug expensive rather than merely annoying.

A manual run only exercises the paths you took. Run npx @modelcontextprotocol/inspector against a server and it can look flawless for weeks. The noisy line often lives somewhere you did not go: an error branch, a cache miss, a retry, a dependency that only prints on a cold start. The first time a real client hits that path, a server that has been working stops.

The startup banner is the least dangerous version precisely because it always fires, so somebody notices on day one. The ones that get you are conditional.

And it is frequently not your code. Your own console.log is findable with a grep. These are not:

  • A package that prints a deprecation warning when it is imported.
  • A bundler leaving a banner in the built output.
  • A Python library writing a progress bar to stdout because it detected no TTY and fell back to plain text.
  • npm or npx printing an install notice on first run, which is a different problem producing an identical symptom.
  • A native module printing to fd 1 from C, which no language-level logging configuration will catch.

Anything in the process tree that writes to file descriptor 1 is in your message stream.

How it shows up

Four symptoms, all of which get misattributed:

“Server disconnected” with no further detail. The most common. The client failed a parse and gave up.

The tool list is empty but the server is running. A pollution line landed in the initialize response, so the handshake never completed. The process is alive, which makes it look like a capability problem rather than a framing one.

It works in the inspector and fails in Claude Desktop. Different code paths, different environment, and one of them hits the print.

It worked yesterday. A dependency update added a warning on import. Nothing in your code changed.

The wrong instinct: quieting the logger

The reflex is to turn the log level down, or to strip logging in production builds. It appears to work, and it leaves you with a server you cannot debug and a bug that returns the moment somebody raises the level to investigate something else.

Route, do not silence. stderr is not a second-class channel here. Clients read it and surface it as server logs, so a server that logs generously to stderr is easier to debug than one that logs nothing:

// Node: console.error goes to stderr
console.error("Server running on stdio");
# Python: be explicit, and configure the root logger once
import sys, logging
logging.basicConfig(stream=sys.stderr, level=logging.INFO)
print("starting", file=sys.stderr)
// Go: log already writes to stderr. One of the few correct defaults.
log.Println("server running on stdio")

Change the logger’s destination once, at configuration, rather than fixing call sites individually. A codebase that logs to stdout in twelve places will grow a thirteenth.

For a belt-and-braces fix in Node, reassign the stream before anything else loads, which catches dependencies you do not control:

// index.js, before any other import
console.log = console.error;

Crude, and it works. The MCP SDKs increasingly do something equivalent internally, but a dependency that grabs process.stdout.write directly will still get through, which is why the session capture below is worth having.

Three more framing rules that produce the same symptom

One message per line, and no embedded newlines. Pretty-printing a message for readability is a framing error rather than a formatting preference: the reader takes the first line, fails to parse it, and treats the rest as further broken frames. Serialize compactly and format the log afterwards when you need to read it.

Docker needs -i and must not have -t. -i keeps stdin open, which the transport requires. -t allocates a TTY, which echoes input back and rewrites line endings, and both land in the stream. -it is muscle memory from interactive shells and is exactly wrong here. Use -i --rm alone.

The handshake is three messages, not two. initialize from the client, its result from the server, then notifications/initialized from the client. Servers are entitled to refuse work until the third arrives, so a client that skips it gets an empty tool list from a server that is behaving correctly.

Diagnosing it from a capture

You cannot see this in the client’s UI, so capture the stream. The straightforward way is to wrap the command:

#!/bin/sh
# save as /usr/local/bin/mcp-capture, chmod +x, use it as the config's command
exec "$@" 2>>/tmp/mcp-stderr.log | tee -a /tmp/mcp-stdout.log

Point the client’s command at that wrapper with the real command as its arguments. /tmp/mcp-stdout.log is then exactly what the client saw.

Read it with one rule: every line must be a complete JSON object. The first line that is not is your bug, and everything after it is collateral. A line starting with a timestamp, a bracket, INFO, npm, or a bare sentence is the one.

Two things the capture also reveals that logs never do:

  • A request with no matching response, which is almost always an uncaught rejection in an async handler. The promise rejects, nobody awaited it, and no response is ever written. Wrap the dispatch rather than the individual handlers.
  • An id reused within the session. MCP requires request ids to be unique for the whole session, not merely unique among the requests currently in flight. A counter that resets per connection produces this, and the consequence is a late response matched against the wrong caller: a client receives another request’s answer as its own, silently.

When stdio is the wrong transport entirely

This whole class of problem is specific to stdio. It is worth knowing when to leave it behind:

stdioStreamable HTTP
Framing hazardstdout is shared with loggingNone; the body is the message
DeploymentOne process per client, localOne service, many clients
AuthInherits the user’s environmentProper token-based auth
DebuggingCapture and read the streamOrdinary HTTP tooling
Right forLocal tools, filesystem access, developer machinesShared services, remote data, anything multi-user

If your server is a remote API wrapper being used by a team, the framing problems above are a symptom of having picked the local transport for something that is not local. Streamable HTTP has no stdout to corrupt.

Note that SSE is the superseded HTTP transport. It used two endpoints and a long-lived event stream; Streamable HTTP replaced it with a single endpoint that answers with either JSON or a stream. Servers written since implement only the latter, so a config saying "type": "sse" against a current server produces a connection that opens and never completes a handshake.

When the print statement is not the problem

Two cases where this advice sends you the wrong way:

The process exits immediately with nothing on stdout at all. That is not framing, it is a crash on import: a missing module, an environment variable read at load time, a syntax error in a config file. Check stderr and the exit code first. The tell is that initialize was sent and nothing came back, rather than something malformed coming back.

The server is fine and the config is wrong. A relative command path, npx without -y waiting on an install prompt with no terminal to answer it, or server entries under a key the client does not read. These produce “server disconnected” too, and no amount of stream capture helps because the process never started. Rule the config out before you go looking for a print statement.

The short version

  • stdout is the transport. Anything else written there corrupts it.
  • Route logs to stderr, once, at the logger. Clients read stderr and surface it.
  • It survives testing because the noisy line is usually on a path you did not take, and is often in a dependency.
  • One message per line, compact. docker -i, never -t.
  • Capture the stream when you are stuck: the first line that is not a complete JSON object is the answer.
  • If the process never wrote anything at all, it is a crash or a config problem, not framing.

Paste a capture into the MCP stdio session debugger and it names the polluting lines, the requests that were never answered, the reused ids and an out-of-order handshake, which are the four things a session log can prove and a client’s error message cannot.