Part 2 of the Journey: Discovering the Patterns - Learning Through Pain Previous: ESM/CommonJS Migration Hell | Next: Error Handling in MCP
The Logger Revolution: Why console.log Will Kill Your MCP Server
The silent killer that breaks stdio communication and how we fixed it across 17 servers
Historical Context (November 2025): Discovered September 2025 during manual server development. This MCP-specific constraint wasn’t documented in early ecosystem materials—we learned it the hard way across 17 production servers.
Date: September 3, 2025 Author: Myron Koch & Claude Code Category: Critical Architecture Lessons
The Mystery
Servers would work perfectly in development. Deploy them as MCP servers? Instant death.
Error: Server communication failed
MCP Inspector: No response
Claude Desktop: Server crashed
No error messages. No stack traces. Just… nothing.
The Killer: console.log
console.log('Starting server...'); // 💀 THIS LINE KILLS YOUR SERVER
That innocent debugging line? It’s writing to stdout.
MCP uses stdio for protocol communication.
Your debug message just corrupted the protocol stream.
How MCP Actually Works
MCP servers communicate via stdio (standard input/output):
stdin → JSON-RPC requests from Claude
stdout ← JSON-RPC responses to Claude
stderr ← Errors and logs (safe to use!)
When you console.log(), you write to stdout. Claude receives:
Starting server...
{"jsonrpc":"2.0","id":1,"result":{...}}
Claude expects pure JSON. It gets text + JSON. Connection dead.
The Death of a Thousand Logs
We found console.log EVERYWHERE:
// In initialization
console.log('Connecting to blockchain...');
// In error handlers
console.error('Transaction failed:', error);
// In debugging
console.debug('Gas price:', gasPrice);
// In success messages
console.log('✅ NFT minted successfully!');
// Even in production
console.log('Server started on port 3000'); // THERE IS NO PORT!
Count in our codebase: 2,847 console statements across 17 servers.
The Fix: Winston Logger
We created a standardized logger that NEVER touches stdout:
import winston from 'winston';
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
),
transports: [
// stderr is safe! MCP ignores it
new winston.transports.Console({
stderrLevels: ['error', 'warn', 'info', 'debug']
}),
// Also write to file for debugging
new winston.transports.File({
filename: 'mcp-server.log',
maxsize: 5242880, // 5MB
maxFiles: 5
})
]
});
// NEVER use console directly
export default logger;
The Great Purge
We built a script to find and destroy all console statements:
// scripts/purge-console.js
const files = glob.sync('servers/**/*.ts');
let totalReplaced = 0;
files.forEach(file => {
let content = fs.readFileSync(file, 'utf8');
// Track what we're replacing
const matches = content.match(/console\.\w+/g) || [];
// Replace all console methods
content = content
.replace(/console\.log/g, 'logger.info')
.replace(/console\.error/g, 'logger.error')
.replace(/console\.warn/g, 'logger.warn')
.replace(/console\.debug/g, 'logger.debug')
.replace(/console\.info/g, 'logger.info');
if (matches.length > 0) {
// Add import if not present
if (!content.includes("import logger")) {
content = `import logger from '../utils/logger.js';\n` + content;
}
fs.writeFileSync(file, content);
totalReplaced += matches.length;
console.log(`Fixed ${file}: ${matches.length} replacements`);
}
});
console.log(`Total replacements: ${totalReplaced}`);
Result: 2,847 console statements replaced in 3 minutes.
The Logger Module Pattern
Every server now follows this pattern:
// src/utils/logger.ts
import winston from 'winston';
const LOG_DIR = process.env.LOG_DIR || './logs';
// Ensure log directory exists
import { mkdirSync } from 'fs';
try {
mkdirSync(LOG_DIR, { recursive: true });
} catch (e) {
// Directory already exists
}
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.timestamp({
format: 'YYYY-MM-DD HH:mm:ss'
}),
winston.format.errors({ stack: true }),
winston.format.splat(),
winston.format.json()
),
defaultMeta: {
service: 'mcp-server',
pid: process.pid
},
transports: [
// Write all logs to stderr (safe for MCP)
new winston.transports.Console({
stderrLevels: ['error', 'warn', 'info', 'debug'],
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
)
}),
// Write to rotating file
new winston.transports.File({
filename: `${LOG_DIR}/error.log`,
level: 'error',
maxsize: 5242880, // 5MB
maxFiles: 5,
tailable: true
}),
new winston.transports.File({
filename: `${LOG_DIR}/combined.log`,
maxsize: 5242880, // 5MB
maxFiles: 5,
tailable: true
})
]
});
// Handle uncaught exceptions
logger.exceptions.handle(
new winston.transports.File({
filename: `${LOG_DIR}/exceptions.log`
})
);
export default logger;
Usage Examples
Before (Broken):
console.log('Fetching balance for', address);
try {
const balance = await getBalance(address);
console.log('Balance:', balance);
} catch (error) {
console.error('Failed to get balance:', error);
}
After (Working):
logger.info('Fetching balance for address', { address });
try {
const balance = await getBalance(address);
logger.info('Balance retrieved', { address, balance });
} catch (error) {
logger.error('Failed to get balance', { address, error });
}
The Benefits Beyond MCP
Switching to Winston gave us superpowers:
1. Structured Logging
logger.info('Transaction sent', {
txHash: tx.hash,
from: tx.from,
to: tx.to,
value: tx.value,
gasUsed: tx.gasUsed,
timestamp: Date.now()
});
2. Log Levels
logger.debug('Detailed info for debugging');
logger.info('Normal operation info');
logger.warn('Something concerning but not critical');
logger.error('Something failed', { error });
3. Contextual Information
const requestLogger = logger.child({
requestId: crypto.randomUUID(),
userId: user.id
});
requestLogger.info('Processing request');
// All logs include requestId and userId automatically
4. Performance Tracking
const timer = logger.startTimer();
await slowOperation();
timer.done({ message: 'Slow operation completed' });
// Logs: "Slow operation completed {"duration":1234}"
The Silent Killers We Found
Beyond console.log, we discovered other stdio killers:
// Process writes
process.stdout.write('Loading...'); // 💀
// Unhandled promise rejections
process.on('unhandledRejection', (err) => {
console.error(err); // 💀 Still using console!
});
// Debug module
import debug from 'debug';
const log = debug('app'); // 💀 Writes to stdout by default
// Progress bars
import ProgressBar from 'progress';
const bar = new ProgressBar(':bar', { total: 10 }); // 💀
// Chalk with console
import chalk from 'chalk';
console.log(chalk.green('Success!')); // 💀 Pretty colors, dead server
The Testing Nightmare
Even tests can kill your server:
// DON'T DO THIS IN TESTS
describe('MCP Server', () => {
it('should fetch balance', async () => {
console.log('Testing balance fetch'); // 💀 Breaks test runner
const result = await fetchBalance();
expect(result).toBeDefined();
});
});
// DO THIS INSTEAD
describe('MCP Server', () => {
it('should fetch balance', async () => {
logger.debug('Testing balance fetch'); // ✅ Safe
const result = await fetchBalance();
expect(result).toBeDefined();
});
});
The MCP Logger Standard
We established server-wide standards:
// Standard log levels and when to use them
logger.debug() // Detailed debugging info
logger.info() // Normal operations
logger.warn() // Warnings but not errors
logger.error() // Errors that need attention
// Standard log format
logger.info('Operation description', {
action: 'what_happened',
resource: 'what_it_affected',
userId: 'who_did_it',
duration: 'how_long',
result: 'success|failure',
metadata: { /* additional context */ }
});
// Standard error logging
logger.error('Operation failed', {
error: error.message,
stack: error.stack,
context: { /* what we were doing */ },
recovery: 'what_happens_next'
});
Debugging Without console.log
How to debug when you can’t use console.log:
1. Use the Logger with DEBUG level
LOG_LEVEL=debug npm run dev
2. Tail the Log Files
tail -f logs/combined.log | grep -i error
3. Use the MCP Inspector’s stderr
npx @modelcontextprotocol/inspector node dist/index.js 2>&1 | tee debug.log
4. Return Debug Info in Responses
if (process.env.DEBUG) {
return {
content: [{
type: 'text',
text: JSON.stringify({
result: data,
debug: {
timing: performance.now() - start,
cache: cacheHit,
query: sqlQuery
}
}, null, 2)
}]
};
}
The Checklist
For every MCP server:
- No console.log, console.error, console.warn anywhere
- Winston logger configured and imported
- All logs go to stderr or files
- Test files use logger, not console
- No third-party libraries writing to stdout
- Error handlers use logger
- Success messages use logger
- Debug statements use logger
- Process event handlers use logger
- No progress bars or spinners
The Lesson
stdout is sacred in MCP.
It’s not yours to use. It belongs to the protocol.
Touch it, and your server dies silently.
Respect the stdio. Use a logger. Stay alive.
References
- Logger module:
/src/utils/logger.ts - Console purge script:
/scripts/purge-console.js - MCP stdio specification: MCP Protocol Docs
- Winston documentation: Winston on GitHub
This is part of our ongoing series documenting architectural patterns and insights from building the Blockchain MCP Server Ecosystem. Sometimes the most important lessons are about what NOT to do.
Related Reading
Prerequisites
- ESM/CommonJS Migration Hell - Understanding the module system is key before tackling runtime issues.
Next Steps
- Error Handling in MCP: Where Do Errors Actually Go? - With logging in place, the next step is to structure the errors themselves.
Deep Dives
- The MCP Inspector Deep Dive: Your Only Debugging Friend - Learn how to use the Inspector to see the stderr output from your new logger.