The most frustrating thing about building your own MCP server isn't a loud error message — it's the silence. You add console.log("reached here") to a handler, nothing shows up anywhere, and Claude Code just tells you the tool isn't responding. No clue which side broke, no trace of what happened. Nine times out of ten, the culprit is the same: you're accidentally writing to the MCP transport itself.
A stdio-based MCP server uses standard input and output as its JSON-RPC channel. That means every console.log you sprinkle into your handler is, from Claude Code's perspective, a malformed JSON response. The parser throws it out, the handshake breaks, and your server goes silent while technically still "connected." That's the whole story behind most "it just doesn't work" reports.
Start by confirming the symptom
Before fixing logs, verify that your server really is running over stdio. Open ~/.claude.json (project section) or .mcp.json and look for an entry like this:
{
"mcpServers": {
"my-server": {
"command": "node",
"args": ["./mcp/server.js"]
}
}
}If you see type: "http" or a url field instead, your server is running over the network and this article doesn't apply. Only stdio transports collide with stdout writes.
Now check the server state with /mcp inside Claude Code, or claude mcp list on the command line. The tell-tale sign is a server that shows connected but never returns any tool results. A crashed server would say failed; what you're seeing is "process alive, protocol broken."
The cause: stdout is the transport
Node's console.log ultimately calls process.stdout.write. Python's print defaults to the same place. MCP's stdio transport uses those exact file descriptors to exchange newline-delimited JSON-RPC messages. So a harmless-looking console.log("user id:", userId) turns into this from the parser's viewpoint:
user id: 42
{"jsonrpc":"2.0","result":{...},"id":1}
The first line isn't valid JSON. The parser either errors out or, worse, silently discards everything that follows. You were trying to add a breadcrumb, and instead you deleted the response that was supposed to come right after it.
The fix: route logs to stderr
Standard error isn't used by the MCP protocol, so you can write anything you like there without consequence. Claude Code captures each server's stderr stream and saves it to a log file — which is exactly the place you want your debug output to land.
For Node.js, the change is trivial:
// Bad: silently breaks the server
console.log("Tool called:", toolName);
// Good: writes to stderr
console.error("Tool called:", toolName);
// Or, more explicit
process.stderr.write(`[mcp] tool=${toolName}\n`);For Python, pass file=sys.stderr and flush eagerly:
import sys
# Bad: pollutes stdout
print(f"Tool called: {tool_name}")
# Good: routes to stderr
print(f"Tool called: {tool_name}", file=sys.stderr, flush=True)The flush=True matters more than it looks. If the server crashes mid-request, any buffered log line is lost — and those are exactly the lines you need to diagnose what went wrong.
Watch out for logging libraries
Winston, pino, Bunyan, loguru — every mainstream logger defaults to stdout unless you say otherwise. "I never wrote console.log, so I'm safe" isn't enough; your logger might still be corrupting the channel behind your back.
// pino: point destination at file descriptor 2 (stderr)
import pino from "pino";
const logger = pino({}, pino.destination(2));
logger.info("server started"); // safe# loguru: remove the default sink and add stderr explicitly
from loguru import logger
import sys
logger.remove()
logger.add(sys.stderr)
logger.info("server started")I once shipped a pino-based server with the default config and got "works on my machine, breaks for everyone else" reports within hours. Verifying the logger's sink is worth putting on your pre-release checklist.
Finding your logs inside Claude Code
Once you're writing to stderr, look for the captured output here:
# macOS
ls -lt ~/Library/Logs/Claude/mcp-server-*.log
# Linux (typical path)
ls -lt ~/.cache/claude/logs/mcp-server-*.logEach server name appears in the filename, so tail -f on the right file gives you a live view of incoming requests interleaved with your own traces. From there you're back in familiar territory — regular server debugging.
When you want a faster inner loop, skip Claude Code entirely and drive the server by hand:
# Send an initialize request manually
echo '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"0.1"}},"id":1}' | node ./mcp/server.jsYour terminal will show the JSON-RPC response on stdout and your logs on stderr, side by side. It's the fastest way to tell which stream is saying what.
Pitfalls I've actually hit
A few of the more subtle ways this bites you in practice:
Third-party SDKs write to stdout silently. Some OpenTelemetry instrumentations and certain database drivers print a startup banner to stdout on initialization. If you suspect this, hook process.stdout.write at the very top of your entrypoint and redirect unexpected writes to stderr for diagnosis.
Unhandled rejection output gets redirected. Node's default is to print uncaught exceptions to stderr, but some frameworks re-attach them to stdout. Adding an explicit process.on("uncaughtException", err => console.error(err)) at the top of your entrypoint keeps things predictable.
Don't silence stderr in production. It's tempting to strip all logs for performance, but the moment something breaks in front of a real user, you'll wish you had them. Gate log volume with a level switch (MCP_LOG_LEVEL=error via env) instead of muting the stream outright.
A quick diagnostic you can run in 30 seconds
Before touching code, confirm that stdout is in fact the broken channel. Start your server from a terminal and pipe stdin from a tiny initialize handshake, but this time capture the two streams separately:
echo '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"0.1"}},"id":1}' \
| node ./mcp/server.js \
> /tmp/mcp.stdout 2> /tmp/mcp.stderrNow inspect the two files. /tmp/mcp.stdout should contain exactly one well-formed JSON object — nothing else, no banner text, no newline counts above one. /tmp/mcp.stderr can contain whatever logs you like. If stdout has anything non-JSON, you've just identified the source of the problem without guessing. This is almost always faster than reading through the code.
Async and worker gotchas
Logging inside async code paths introduces a second class of silent failures. If you spawn a child process or worker and forget to pipe its streams, that child's stdout can leak onto the parent's stdout:
// Bad: the child's stdout is inherited, which is the MCP channel
const child = spawn("convert", args, { stdio: "inherit" });
// Good: isolate stdout, forward stderr if you want the logs
const child = spawn("convert", args, {
stdio: ["ignore", "ignore", "inherit"],
});Python's subprocess.run(..., stdout=subprocess.PIPE) handles this cleanly, but check_output and some legacy wrappers may not. If you shell out from a tool handler, auditing how child streams are wired is the second thing to look at after your own loggers.
Testing the log path before you ship
Add a minimal smoke test to your CI: launch the server, send a single initialize request, assert that stdout contains exactly one JSON object and nothing else. You can do this in ten lines:
import { spawn } from "child_process";
const p = spawn("node", ["./mcp/server.js"]);
p.stdin.write(JSON.stringify({
jsonrpc: "2.0", method: "initialize",
params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "t", version: "0.1" } },
id: 1,
}) + "\n");
let out = "";
p.stdout.on("data", (c) => (out += c));
setTimeout(() => {
const lines = out.trim().split("\n");
if (lines.length !== 1) throw new Error("stdout pollution detected");
JSON.parse(lines[0]);
p.kill();
}, 500);Running this on every pull request catches a new console.log before it reaches a user. The test is cheap, deterministic, and the failure mode is exactly the one that's hardest to debug in the wild.
What to do today
If you already have a custom MCP server, open it in your editor and search for console.log and print( right now. Replace every hit with console.error or print(..., file=sys.stderr). If you're using a logger, check that its destination is set to stderr. That single pass eliminates most "server appears dead" symptoms I've seen — whatever's left becomes a normal debugging problem, because you can finally see what the server is saying.
For related issues, the Claude Code MCP connection troubleshooting guide and the /doctor self-diagnosis walkthrough are good next stops when the problem isn't (or isn't only) logging.