My MCP server connected fine. Tools showed up. I used it for about twelve minutes. Then, in the middle of a tool call, the client dropped the server and refused to bring it back.
I had changed one line. I had added console.log("fetching", url) inside a tool handler, because I wanted to see which URL it was hitting.
That log line was the bug. Not a symptom of the bug. The bug. MCP server stdout is a reserved protocol channel, and I had just dumped a sentence of English into it.
TL;DR
- An MCP server running over stdio speaks newline-delimited JSON-RPC on stdout. That stream belongs to the protocol, not to you.
- Any
console.log,print(), banner, spinner, orechothat lands on stdout injects a non-JSON line and the client's parser blows up. Connection dies. - Logs at import time break the handshake ("server failed to start"). Logs inside a tool handler break the session twenty minutes in, which is why it feels random.
- Fix in Node: alias
console.logtoconsole.errorat the top of your entry file. Fix in Python:logging.basicConfig(stream=sys.stderr)and never call bareprint(). - stderr is free. Use it for everything. Or use the HTTP transport, where stdout is yours again.
Why does one console.log break an MCP server over stdio?
Because stdio transport is not a terminal, it is a wire. The client spawns your server as a child process and uses the pipes directly: it writes JSON-RPC requests to your stdin and reads JSON-RPC responses from your stdout, one message per line.
The contract is strict on purpose. Every line on stdout must be a valid JSON-RPC message, and a message must not contain an embedded newline. There is no framing header, no length prefix, no escape hatch. The newline is the frame.
So when your handler runs console.log("fetching", url), the stream your client is reading turns into this:
{"jsonrpc":"2.0","id":3,"method":"tools/list","result":{...}}
fetching https://api.example.com/v1/thing
{"jsonrpc":"2.0","id":4,"result":{...}}
The client reads line two, tries to parse it as JSON, and fails. Depending on the client, you get a hard parse error, a schema validation error, or a silent transport teardown. All three look like "the server died for no reason."
The tell is beautiful once you know it: the error message contains your own log text. If you see Unexpected token 'f', "fetching h"... is not valid JSON, stop reading stack traces. You are looking at your own print statement being fed to a JSON parser.
Why did my MCP server work for twenty minutes and then fail?
Timing. Where the stray write happens decides what the failure looks like, and this is the part that eats whole afternoons.
Write on import and you corrupt the handshake. The server never initializes, the client marks it failed, and you get a clean, immediate, honest error. Annoying but findable.
Write inside a tool handler and the server starts perfectly. Tools list fine. Everything is green. The stream only gets poisoned the first time that specific branch of that specific tool executes, which might be an hour into a session, and only when a particular argument is passed. That is the version that makes you suspect rate limits, the model, your network, anything except the debug line you added.
Same root cause. Completely different-looking bug.
What else writes to MCP server stdout without you noticing?
Your own console.log is the easy case. These are the ones people miss:
- A dependency that prints on import. Plenty of libraries announce themselves, warn about a GPU, or draw a progress bar. Straight to stdout, at import time, before your code runs.
-
Child processes with inherited stdio.
spawn(cmd, args, { stdio: "inherit" })inside a tool handler wires the child's stdout directly into your protocol stream. Use"pipe"and capture the output as a string. -
Shell wrapper scripts. If your server command is a
.shfile, everyechoin it is on stdout. So is the output of a version manager switching runtimes, and anything your shell profile prints when sourced. - Package-manager wrappers as the launch command. Running the server through a script runner means lifecycle hooks, env-loader banners, and update notices all get a shot at stdout before your process exists.
-
Spinners and progress bars. They call
process.stdout.writedirectly, so patchingconsolealone will not save you.
The rule that covers all five: the command the client launches must produce nothing on stdout except MCP messages. Not your file, the whole command.
How do I fix console.log in an MCP server?
Node, first lines of your entry file, before any other import that might log:
for (const m of ["log", "info", "warn", "debug", "trace"]) {
console[m] = (...args) => console.error(...args);
}
This is safe because the stdio transport writes to process.stdout directly rather than going through console. You are only redirecting the human-facing helpers, not the wire.
Python:
import logging, sys
logging.basicConfig(stream=sys.stderr, level=logging.INFO)
log = logging.getLogger("my-mcp-server")
logging's default handler already writes to stderr, so the main job is refusing to use print(). If you must, print(msg, file=sys.stderr).
For a noisy third-party import, redirect at the file-descriptor level, which catches C extensions that bypass sys.stdout entirely:
import os, sys, contextlib
@contextlib.contextmanager
def stdout_to_stderr():
saved = os.dup(1)
os.dup2(2, 1)
try:
yield
finally:
os.dup2(saved, 1)
os.close(saved)
with stdout_to_stderr():
import chatty_library
And if you actually want the client to see your logs, there is a supported path for it: MCP has a logging capability, and the server sends notifications/message entries through the protocol. Those show up in the client instead of vanishing into stderr. That is the difference between debug output and telemetry.
How do I test that my MCP server's stdout is clean?
Run the exact launch command and assert every stdout line parses as JSON. Sixty seconds, no client involved:
your-launch-command 2>/dev/null | while IFS= read -r line; do
printf '%s' "$line" | jq -e . >/dev/null 2>&1 || echo "NOT JSON: $line" >&2
done
Feed it a request on stdin to exercise startup, then call your tools through the MCP Inspector (npx @modelcontextprotocol/inspector) with the same pipe in place to catch handler-time writes.
Then make it impossible to regress. ESLint with no-console set to allow only error and warn catches the common case, and a one-line CI grep for console.log and bare print( in your server source catches the rest. A stray debug line is a protocol violation in this codebase, so treat it like one.
Should I just use the HTTP transport instead?
If stdout discipline keeps biting you, yes, and the trade-off is honest. With streamable HTTP, your server is a normal HTTP process: stdout is yours, logging is boring again, and you can run it once and attach several clients. The cost is that you now own a port, a lifecycle, and auth. stdio costs you nothing to run locally and demands one rule in return.
Pick based on who runs the server. Local dev tool used by one person: stdio, with the console patch on line one. Anything shared or long-lived: HTTP.
So why does one console.log kill an MCP server? Because the stdio transport reserves stdout for newline-delimited JSON-RPC messages, and a log line is a line on that stream like any other. The client parses it, fails, and tears down the connection. Send every human-readable message to stderr instead, patch console.log to console.error at the top of your entry file, verify with a pipe that JSON-parses each stdout line, and the mystery disconnects stop.
Written by the developer behind Preterview, an interview prep platform.
Top comments (0)