◉CLAUDE LABJP
●2.1.301 — Adds the vim % motion, LaTeX math rendering in the VS Code panel, and a retry-watchdog limit for usage-limit waits (10/15)●2.1.299 — Conversations over 200K tokens left idle about 54 minutes get compacted; MCP allow rules now match only the server they name●SONNET 4.5 — Retires on the Claude API on 11/30, 45 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST MODE — Opus 5.5 fast mode is out of research preview and no longer needs the beta header (10/15)●NEW — I Put a Ten-Line Promise File at the To…●2.1.301 — Adds the vim % motion, LaTeX math rendering in the VS Code panel, and a retry-watchdog limit for usage-limit waits (10/15)●2.1.299 — Conversations over 200K tokens left idle about 54 minutes get compacted; MCP allow rules now match only the server they name●SONNET 4.5 — Retires on the Claude API on 11/30, 45 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST MODE — Opus 5.5 fast mode is out of research preview and no longer needs the beta header (10/15)●NEW — I Put a Ten-Line Promise File at the To…
Articles/Claude Code
⟐ Claude Code/2026-04-24Intermediate

Why Your Custom MCP Server's Logs Disappear — Writing to stderr in Claude Code

If you're building your own MCP server and your console.log lines never appear — while Claude Code silently reports the tool as unresponsive — you're probably polluting stdio. Here's how to spot it and rewrite your logs to keep the protocol intact.

claude-code132mcp20troubleshooting92loggingstdio

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-*.log

Each 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.js

Your 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.stderr

Now 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.

Share

Thank You for Reading

Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

⟐ Claude Code2026-05-24
When Claude Code's Sequential Edits Cascade into Failure — Fixing 'old_string not found' and 'not unique' on the Same File
Run several Edits against the same file in one turn and the second or third one suddenly fails with 'not found' or 'not unique'. The cause is order-dependence inside a single context snapshot, and a Read + scoped patches usually rescue it.
⟐ Claude Code2026-05-10
When Claude Code's Read Doesn't Reach the End of a File — Catching the PARTIAL view Notice and Sizing offset/limit
When Read returns only part of a large file, Claude Code says so with a PARTIAL view notice. Here is how to catch it, and how to size offset and limit by byte budget rather than line count, with measurements from my own sandbox.
⟐ Claude Code2026-05-10
Why Claude Code's Glob and Grep Return Zero Results
When Claude Code's Glob or Grep tool returns no matches even though the files clearly exist, the cause is almost always one of four things. Here's how to triage.