MCP stdio Server Logging: Keep Logs on stderr, Not stdout
When you build an MCP server with stdio transport, one small implementation detail matters more than it first appears: where should logs go?
In Node.js, it is tempting to write startup messages and debug output with console.log(). But in stdio transport, stdout is used as the MCP JSON-RPC channel. stdout is not just a place for human-readable logs; it is part of the protocol stream between the client and server.
The safe rule is simple: keep MCP stdio server logs on stderr, and keep stdout reserved for protocol messages.
stdout is the stdio transport channel
The MCP specification defines stdio as one of the standard transports. In stdio transport, the client starts the MCP server as a subprocess and exchanges JSON-RPC messages through standard input and standard output.
The shape is:
MCP client
-> server stdin: JSON-RPC request
<- server stdout: JSON-RPC response / notification
That means any extra text written to server stdout becomes mixed with the protocol stream.
Some SDKs and clients may tolerate simple non-JSON lines. In a small local smoke test, a TypeScript SDK client still completed listTools and callTool even when the server wrote a basic console.log() line. That result is useful, but it should not be treated as permission to log to stdout.
Client behavior can differ. Timing can differ. Buffering can differ. A larger log line, progress output, partial write, or different client parser may behave differently. For interoperability, stdout should be treated as reserved for MCP protocol traffic.
Bad example: startup logs with console.log
Avoid this pattern in stdio servers:
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
const server = new Server(
{ name: "example", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
async function main() {
console.log("MCP server started"); // Avoid this in stdio servers
await server.connect(new StdioServerTransport());
}
main();
console.log() writes to stdout. In an MCP stdio server, stdout belongs to JSON-RPC messages.
Tool handlers are another risky place:
server.setRequestHandler(CallToolRequestSchema, async () => {
console.log("tool called"); // Avoid this
return {
content: [{ type: "text", text: "ok" }],
};
});
Now debug output and tool responses can be written around the same time. If a client fails, the symptom may look like a handshake problem, a missing tool, or a broken response rather than an obvious logging mistake.
Good example: write diagnostics to stderr
Use stderr for diagnostics:
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
process.stderr.write("MCP server running on stdio\n");
}
main().catch((err) => {
process.stderr.write(`Fatal: ${String(err)}\n`);
process.exit(1);
});
stderr is often used by CLI tools for human-readable diagnostics while stdout remains machine-readable output. That split maps cleanly to MCP stdio servers.
Tool logs should follow the same rule:
server.setRequestHandler(CallToolRequestSchema, async (request) => {
process.stderr.write(`tool called: ${request.params.name}\n`);
return {
content: [{ type: "text", text: "ok" }],
};
});
stdout stays for JSON-RPC. stderr carries diagnostics.
What was verified
I tested two tiny Node.js MCP servers through an MCP SDK stdio client.
The first server wrote logs to stderr. listTools and callTool both worked.
The second server used console.log() and wrote a simple log line to stdout during startup or tool execution. In this specific TypeScript SDK smoke test, the client still completed the calls.
That result matters because it keeps the claim precise: a simple stdout log may not always break a specific SDK client immediately. The stronger and more portable rule is still to keep stdout clean because the MCP stdio transport uses stdout for protocol messages.
So the lesson is not "every console.log always breaks every client." The lesson is: do not depend on client tolerance for non-protocol stdout.
Implementation checklist
Before shipping an MCP stdio server, check:
- No
console.log()in server startup code - No
console.info()orconsole.debug()if they write to stdout - Startup logs use
process.stderr.write() - Error logs use stderr
- Tool handler debug logs use stderr
- Tool results are returned through MCP
contentresponses - No direct
process.stdout.write()outside the transport
This makes failures easier to diagnose. If the client cannot connect, cannot list tools, or receives a malformed response, you can rule out accidental stdout pollution early.
Applying this to a Markdown knowledge server
The same rule applies to a Markdown knowledge-base MCP server.
If get_article returns an article body, the article should be returned as the MCP tool response. Diagnostic details such as article count, selected slug, or cache status should go to stderr.
stdout: MCP response only
stderr: loaded 12 articles
stderr: get_article slug=mcp-stdio-server-logging-stderr lang=en
That separation keeps the content sent to AI clients separate from the debug information meant for humans.
For the broader architecture of serving Markdown through MCP, see Designing an MCP Markdown Knowledge Server: Why It Should Not Return HTML.
Summary
For MCP stdio servers, do not use stdout for logs.
stdio transport uses stdout as the JSON-RPC channel. Even if one SDK client tolerates a simple log line, relying on that tolerance makes the server less portable and harder to debug.
Keep stdout for protocol messages. Send startup logs, debug output, and errors to stderr. It is a small rule, but it prevents a whole class of confusing MCP integration failures.