Recipe-Inhalt ist auf Englisch. Englisches Original lesen →
← Alle Recipes
Phase 6 · Build Your Own MCP Server·5 steps

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.

5 steps0%
Du liest ohne Account. Mit Login speichern wir Step-Fortschritt + Notes.

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.write anywhere, same bug as console.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.

Testing MCP tools, vitest + inDatabase. Postgres pool, idemp