Logging pattern, stderr-only, structured, never on stdout
The single most common production bug in homemade MCP servers: a stray console.log corrupts the wire protocol. This recipe is the structured-logging pattern that prevents it.
Logging pattern, stderr-only, structured, never on stdout
stdio MCP servers communicate with the client over stdin/stdout using a JSON-RPC stream. Anything you write to stdout that isn't JSON-RPC corrupts the wire, the client either disconnects or starts misinterpreting bytes. Half of the "MCP server doesn't work" bug reports trace back to a stray console.log. This recipe is the logging pattern that prevents it forever.
Schritt 1: Ban console.log from your codebase
console.log writes to stdout by default in Node. In stdio mode that breaks the protocol. Add this to your ESLint config:
// .eslintrc.json
{
"rules": {
"no-console": ["error", { "allow": ["error", "warn"] }]
}
}
console.error and console.warn write to stderr, those are safe. Everything else, banned.
Schritt 2: A 30-line structured logger that's good enough
You don't need pino or winston for an MCP server. Stderr-only, structured, JSON-per-line:
// src/lib/logger.ts
const LEVELS = { debug: 10, info: 20, warn: 30, error: 40 } as const;
type Level = keyof typeof LEVELS;
const MIN = LEVELS[(process.env.LOG_LEVEL as Level) ?? 'info'] ?? 20;
function emit(level: Level, msg: string, meta?: Record<string, unknown>) {
if (LEVELS[level] < MIN) return;
const line = JSON.stringify({
ts: new Date().toISOString(),
level,
msg,
...meta,
});
process.stderr.write(line + '\n'); // ALWAYS stderr, never stdout
}
export const logger = {
debug: (msg: string, meta?: Record<string, unknown>) => emit('debug', msg, meta),
info: (msg: string, meta?: Record<string, unknown>) => emit('info', msg, meta),
warn: (msg: string, meta?: Record<string, unknown>) => emit('warn', msg, meta),
error: (msg: string, meta?: Record<string, unknown>) => emit('error', msg, meta),
};
Use it everywhere:
import { logger } from './lib/logger.js';
logger.info('contact created', { tenantId, contactId, source: 'api' });
logger.error('Stripe webhook signature mismatch', { eventType, ip: clientIp });
JSON-per-line because: log aggregators (Datadog, Loki, CloudWatch) parse it for free; jq works on it; humans can grep it; nothing breaks the wire because nothing touches stdout.
Schritt 3: Catch unhandled errors so they don't crash silent
An MCP server that crashes mid-call leaves the client hanging. Wrap your entry:
// src/server.ts (bottom)
process.on('unhandledRejection', (err) => {
logger.error('unhandled rejection', { err: String(err) });
// Don't exit, let the SDK report the error to the client.
});
process.on('uncaughtException', (err) => {
logger.error('uncaught exception', { err: err.message, stack: err.stack });
process.exit(1); // Real bugs, let the supervisor restart us.
});
The two policies are different on purpose:
- Unhandled rejections: log + continue. Most are typos in tool handlers; the SDK already returns an error to the client. Don't crash the whole server.
- Uncaught exceptions: log + exit. These are bugs your code has no idea about. Fail fast, let the wrapper restart.
Schritt 4: Log requests + responses (sampled)
Never log every call, your logs become useless. Sample:
const SAMPLE_RATE = 0.05; // 5% of calls
server.setRequestHandler(CallToolRequestSchema, async (req) => {
const start = Date.now();
const sampled = Math.random() < SAMPLE_RATE;
if (sampled) {
logger.info('tool call', {
tool: req.params.name,
// Don't log arguments unless you've audited them for PII!
argKeys: Object.keys(req.params.arguments ?? {}),
});
}
try {
const result = await dispatchTool(req);
if (sampled) {
logger.info('tool result', {
tool: req.params.name,
durationMs: Date.now() - start,
isError: result.isError ?? false,
});
}
return result;
} catch (err) {
logger.error('tool threw', {
tool: req.params.name,
durationMs: Date.now() - start,
err: String(err),
});
throw err;
}
});
Note the deliberate omission: never log raw arguments by default, they may contain PII, API keys, or customer secrets. Log keys, lengths, or hashes instead.
Schritt 5: Verify
Run academy_validate_step. The validator checks the basic package.json shape, observability is a code-quality concern, not something you can lint from outside.
To verify yourself:
# 1. Confirm no rogue stdout writes
grep -r "console.log" src/ && echo "FAIL: console.log in src/" || echo "OK"
# 2. Confirm logger writes to stderr
node -e "const { logger } = require('./dist/lib/logger.js'); logger.info('test', {})" 2>/dev/null
# Should print nothing (it went to stderr).
node -e "const { logger } = require('./dist/lib/logger.js'); logger.info('test', {})" 2>&1 1>/dev/null
# Should print the JSON line.
Common traps
process.stdout.writeanywhere, same bug asconsole.log, just lower-level. Ban it the same way.- Third-party libraries that log to stdout. Stripe SDK, some Supabase clients in debug mode. Set their log target before importing them.
- Debug
console.log"just for now", it ends up in production. The ESLint rule prevents this; don't disable it. - JSON.stringify of circular objects, your logger crashes. Wrap with a try/catch or use a serializer like
safe-stable-stringify. - Logging request bodies. PII landmine. Log shape, not content.
What good looks like
A well-instrumented MCP server lets you grep your stderr stream and immediately see: which tools are being called, how long they take, which ones error, and which tenants hit them. No more, no less. The logger from Step 2 plus the sampling in Step 4 gives you that in 50 lines.
If you ever need more (per-request distributed tracing, metrics export to Prometheus), add it. But until then, less is faster, cheaper, and easier to debug.