Skip to main content

Command Palette

Search for a command to run...

One console.log disconnects your MCP server: the stdout bug every stdio server author hits

A stdio MCP server speaks JSON-RPC over its own stdout, so a single stray `console.log` or `print()` corrupts the stream and the client drops the connection with an error that points nowhere. Here is why it keeps shipping in real servers, why your quick manual test never catches it, and StdioLint, a free VS Code extension that flags the write on the line where you type it.

Updated
•8 min read•View as Markdown
One console.log disconnects your MCP server: the stdout bug every stdio server author hits
J
Cloud & Platform Engineer focused on building systems that scale and endure. I explore how infrastructure, automation, and engineering practices come together to support modern software teams.

You wire up a new tool in your MCP server, restart the client, and ask the agent to use it. Instead of an answer you get this:

MCP error -32000: Connection closed

Or, depending on the client, Unexpected token 'a', "adding numbers" is not valid JSON, or just a server that shows as "disconnected" in the sidebar. You check the tool code. It looks fine. You run the server by hand in a terminal and it starts without complaint. Nothing is obviously broken.

The culprit is usually one line you added while debugging:

server.registerTool("add", { /* ... */ }, async ({ a, b }) => {
  console.log("adding numbers", a, b);   // <- this is the bug
  return { content: [{ type: "text", text: String(a + b) }] };
});

That console.log is not a harmless debug message. In a stdio MCP server it is a protocol violation.

TL;DR: in a stdio MCP server, stdout is the JSON-RPC channel. Any console.log, print() or logger that writes to stdout corrupts the stream and the client disconnects. Log to stderr instead, and let StdioLint flag the writes in VS Code before they ship.

Why console.log breaks a stdio MCP server: stdout is the wire

MCP has two main transports. With Streamable HTTP the protocol travels over HTTP and your process output is just logs. With stdio, the client launches your server as a child process and talks to it through the process's own standard input and standard output. Every byte your server writes to stdout is read by the client as the next JSON-RPC message.

The specification is explicit about what that means for you:

The server MUST NOT write anything to its stdout that is not a valid MCP message. (MCP specification, stdio transport)

So when your tool prints adding numbers 2 3, the client receives that text in the middle of its message stream, fails to parse it as JSON-RPC, and in most clients tears the connection down. The same spec also gives you the escape hatch: the server may write to stderr, and clients may capture or ignore it. Logging is fine. Logging to stdout is not.

Why this keeps shipping

If the rule is that simple, why does it bite so many real projects? A few examples from public issue trackers:

Three things make it easy to ship:

  1. Your manual test passes. Running node dist/server.js in a terminal shows your log line right next to the JSON-RPC traffic and nothing crashes. The failure only happens when a real client is parsing stdout.

  2. The write is often not in the server file. Look at the last two issues: the console.log lived in a logger or helper module that the server imports. Reviewing server.ts would never find it.

  3. Defaults hide it. In Node, console.log, console.info and console.debug all go to stdout, while console.error and console.warn go to stderr (Node console docs). pino writes to stdout by default, and winston's Console transport does too unless you set stderrLevels. In Python, print() goes to stdout, but logging.basicConfig() and tqdm default to stderr. Nobody remembers all of that while debugging at midnight.

Why a generic linter doesn't solve it

You might reach for ESLint's no-console or Ruff's T201. They help, but they are the wrong shape for this problem:

  • They flag every console call and print everywhere, including console.error and print(x, file=sys.stderr), which are exactly what you should be using.

  • They have no idea which files belong to an MCP server. Your CLI scripts, build tools and HTTP servers all get the same noise.

  • Their fix is "delete the line", when what you want is "keep the log, send it to stderr".

Runtime tools like MCP Inspector will show you the corruption once it happens, but only on the code paths you actually exercise during that session.

StdioLint: catch it on the line where you type it

StdioLint is a free, open-source VS Code extension I built for exactly this. It is MCP-aware:

  • It finds your stdio server entry files: JS/TS files that start a StdioServerTransport from @modelcontextprotocol/sdk (or fastmcp with transportType: "stdio"), and Python files that use FastMCP / mcp.server and call mcp.run(), where stdio is the default.

  • It follows the local modules they import, a few levels deep, so the console.log in logger.ts is caught too.

  • It ignores everything else: servers that only use Streamable HTTP or SSE, ordinary scripts, and writes inside strings or comments.

It reports four rules in the Problems panel:

Rule What it catches Example
SL001 A direct stdout write console.log(x), process.stdout.write(s), print(x), sys.stdout.write(s)
SL002 A logger or stream bound to stdout logging.basicConfig(stream=sys.stdout), pino(), winston Console() without stderrLevels, rich Console()
SL003 A startup banner before the transport starts print("Server starting...") right before mcp.run()
SL004 A stdout write inside a tool, resource or prompt handler print(...) inside an @mcp.tool() function

Every finding comes with a Quick Fix that keeps your log and moves it to stderr: console.log becomes console.error, print(x) becomes print(x, file=sys.stderr) (adding import sys when needed), pino(opts) becomes pino(opts, pino.destination(2)), and so on.

Here is the tool from the top of this post, before and after the fix:

// Before: SL004 - console.log inside an MCP handler corrupts the stream
server.registerTool("add", { /* ... */ }, async ({ a, b }) => {
  console.log("adding numbers", a, b);
  return { content: [{ type: "text", text: String(a + b) }] };
});

// After one click on the Quick Fix
server.registerTool("add", { /* ... */ }, async ({ a, b }) => {
  console.error("adding numbers", a, b);
  return { content: [{ type: "text", text: String(a + b) }] };
});

And the Python version:

import sys
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("demo")

@mcp.tool()
def greet(name: str) -> str:
    print("greeting", name, file=sys.stderr)   # was print("greeting", name): SL004
    return f"hi {name}"

if __name__ == "__main__":
    mcp.run()

A few details it gets right, because they matter in real code:

  • console.log = console.error at the top of the entry file is honored for the whole server.

  • Code under if __name__ == "__main__": in an imported Python module is skipped, since it does not run on import.

  • A transport chosen at runtime, like mcp.run(transport=args.transport), counts as stdio, because the stdio path has to be clean too.

  • Intentional writes can be silenced with # stdiolint: ignore SL001 or // stdiolint: ignore.

Privacy and limits

StdioLint reads your source files and analyzes them in memory. It makes no network requests, collects no telemetry, runs no processes and executes nothing, so it is safe in untrusted workspaces.

It is a static, heuristic tool, and I would rather be upfront about what that means. It cannot see output printed by a dependency at import time, output from native extensions, or writes through a stdout reference stored in a variable and used elsewhere. It follows relative imports and package-local Python imports, but not tsconfig path aliases or computed dynamic imports. For those cases, keep MCP Inspector in your loop.

FAQ

Why does my MCP server say "Connection closed" or "server disconnected"? With the stdio transport, a common cause is output on stdout that is not a JSON-RPC message: a console.log, a print(), a startup banner or a logger configured for stdout. The client fails to parse it and closes the connection.

Can I use console.log in an MCP server? Not in a stdio server. Use console.error (or console.warn), which write to stderr. In HTTP / SSE servers stdout is not the protocol channel, so console.log is fine there.

Where should MCP server logs go? To stderr. The MCP specification allows servers to write UTF-8 log output to stderr, and clients may capture, forward or ignore it. In Python, use print(..., file=sys.stderr) or the logging module, whose default handler already writes to stderr.

Does print() break a FastMCP server? Yes, when it runs over stdio, which is FastMCP's default for mcp.run(). print() writes to stdout unless you pass file=sys.stderr.

Try it

If you maintain an MCP server, open it with StdioLint installed and run StdioLint: Scan Workspace. If it finds nothing, you just saved yourself a future "server disconnected" bug report. If it finds something, you just found it before your users did.

Issues and suggestions are very welcome on GitHub.

More from this blog

J

Jay Tank's Engineering Blog

67 posts

Deep dives on running fintech & Web3 infrastructure at scale - AWS, Kubernetes, CI/CD, edge security, observability, and Bitcoin Lightning. Practical architecture breakdowns and open-source DevOps tools from a senior platform engineer.