dongquoctien

Don't `console.log` in an MCP stdio server

Note · Don't `console.log` in an MCP stdio server

  • #mcp
  • #debugging

The single most common MCP foot-gun. On the default stdio transport, stdout is the JSON-RPC channel. Any stray console.log corrupts the protocol and the client silently disconnects.

// Broken — this writes to stdout, which is the protocol stream
console.log('loaded config', config);

What the Inspector shows you instead of the real error: Unexpected token in JSON. You stare at your tool implementation for an hour.

The fix is a one-line wrapper. stderr is not the protocol stream; you can write whatever you want there.

// Safe — stderr doesn't conflict with the protocol
const log = (...args: unknown[]) =>
  console.error('[my-server]', new Date().toISOString(), ...args);

log('loaded config', config);

For anything more than ad-hoc debugging, also tee to a file so you can tail it from a second terminal:

import { appendFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';

const LOG_FILE = join(homedir(), '.cache', 'my-server', 'debug.log');

const log = (...args: unknown[]) => {
  const line = `${new Date().toISOString()} ${args.map(String).join(' ')}\n`;
  console.error(line.trimEnd());
  try { appendFileSync(LOG_FILE, line); } catch { /* dir may not exist */ }
};

Then:

# Windows
Get-Content -Wait $env:USERPROFILE\.cache\my-server\debug.log
# macOS/Linux
tail -f ~/.cache/my-server/debug.log

This stops being an issue on streamable-http transport — the protocol no longer rides on stdout. But stdio is still the default for local development, and the failure mode is silent enough that you should just bake the wrapper in from day one.