Introduction
Debugging MCP servers is different from debugging typical web servers because the stdio transport reserves stdout for protocol messages. If you accidentally write debug output to stdout, you corrupt the JSON-RPC message stream and break the connection. Understanding where to send debug output and how to read protocol messages is essential.
Key Concepts
- stderr for debug output: In stdio transport, stdout is reserved for JSON-RPC messages. All debug logs must go to stderr.
- ctx.mcpReq.log(): The SDK's built-in logging method that sends log messages to the MCP host without interfering with the protocol.
- MCP Inspector: A visual debugging tool that shows real-time JSON-RPC traffic between client and server.
- JSON-RPC messages: The raw protocol messages exchanged between client and server, useful for diagnosing connection issues.
Real World Context
You add a console.log('debug: processing query') to your stdio-based server and suddenly the client cannot connect. Hours of frustration later, you realize that console.log writes to stdout, which corrupted the JSON-RPC stream. This is the most common MCP debugging mistake, and understanding it saves hours.
Deep Dive
The stdout Problem
In stdio transport, the MCP client and server communicate through stdin/stdout. Every byte written to stdout is interpreted as part of a JSON-RPC message. A stray console.log produces output like:
debug: processing query
{"jsonrpc":"2.0","id":1,"result":{...}}
The client tries to parse debug: processing query as JSON, fails, and disconnects. This is the single most common MCP debugging issue.
Using stderr for Debug Output
The fix is simple — write debug output to stderr instead of stdout:
typescript// Bad: corrupts the JSON-RPC stream in stdio transport console.log('debug: processing query'); // Good: writes to stderr, visible in terminal but does not affect protocol console.error('debug: processing query'); // Better: use a proper logging function function debug(message: string) { process.stderr.write(`[DEBUG] ${new Date().toISOString()} ${message}\n`); } debug('Processing search query for: typescript');
Note that console.error writes to stderr by default in Node.js, making it safe for debug output. However, a dedicated debug function with timestamps is more useful for real debugging sessions.
Structured Logging with ctx.mcpReq.log()
The MCP SDK provides a logging method on the request context that sends log messages to the MCP host application:
typescriptserver.registerTool('search', { description: 'Search the database', inputSchema: z.object({ query: z.string() }) }, async ({ query }, { ctx }) => { ctx.mcpReq.log('info', `Search started for: ${query}`); const results = await db.search(query); ctx.mcpReq.log('info', `Found ${results.length} results`); if (results.length === 0) { ctx.mcpReq.log('warn', `No results found for query: ${query}`); return { content: [{ type: 'text', text: `No results found for "${query}".` }], isError: true }; } return { content: [{ type: 'text', text: JSON.stringify(results) }] }; });
These log messages appear in the host application's logs (for example, in Claude Desktop's developer console) and are useful for diagnosing issues in production.
Using the MCP Inspector for Debugging
The MCP Inspector is your most powerful debugging tool. It shows the raw JSON-RPC messages exchanged between client and server, making protocol-level issues visible:
bashnpx @modelcontextprotocol/inspector node server.js
In the Inspector UI, you can see every request and response, including error objects, validation failures, and unexpected disconnections. Common issues you can diagnose with the Inspector include the following.
Tool not appearing in the tool list: This usually means the tool registration failed silently. Check the Inspector's initialization messages for errors during server setup.
Validation failures: When a tool call is rejected with -32602, the Inspector shows exactly which parameter failed validation and the error message from Zod.
Connection errors: If the server crashes during startup, the Inspector shows the last message received before the connection dropped, helping you pinpoint the crash location.
Reading JSON-RPC Messages
Understanding the raw protocol helps when the Inspector is not enough. A successful tool call looks like this:
jsonc// Request from client { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "read-file", "arguments": { "path": "/tmp/test.txt" } } } // Response from server { "jsonrpc": "2.0", "id": 1, "result": { "content": [{ "type": "text", "text": "file contents here" }] } }
An error response includes an error object instead of a result. Seeing the raw messages helps you understand exactly what the client sent and what the server returned.
Common Debugging Scenarios
Here is a quick reference for the most frequent issues:
typescript// Scenario 1: Tool not appearing // Check: Is the tool registered before server.connect()? If after, did you send a list_changed notification? // Fix: Register tools before connect(), or send notifications/tools/list_changed after late registration // Scenario 2: Validation always fails // Check: Does your Zod schema match the input format? // Fix: Test the schema independently with schema.parse(testInput) // Scenario 3: Server crashes on startup // Check: Are all imports resolved? Is the transport configured? // Fix: Wrap startup in try/catch and log to stderr try { await server.connect(transport); } catch (err) { console.error('Server failed to start:', err); process.exit(1); }
Each scenario has a specific diagnostic approach. The key is to check the simplest explanation first before diving into protocol-level debugging.
Common Pitfalls
- Using console.log in stdio servers — This corrupts the JSON-RPC stream. Always use
console.errororprocess.stderr.writefor debug output. - Registering tools after server.connect() without notifying clients — While the SDK does support registering tools after connect, clients will not see the new tools unless you send a
notifications/tools/list_changednotification so they refresh their tool list. Register tools before connect when possible for simplicity. - Not checking Zod schemas independently — If validation always fails, test your schema with
schema.parse(testInput)outside the server to see the exact error.
Best Practices
- Use the Inspector first — Before adding log statements, run the Inspector and check the raw messages. Most issues are visible in the protocol traffic.
- Create a debug mode — Add a
--debugflag to your server that enables verbose stderr logging. Keep it silent by default. - Wrap server startup in try/catch — Log startup errors to stderr so you can see why the server failed to initialize.
Summary
- In stdio transport, stdout is reserved for JSON-RPC. Use stderr (
console.error) for debug output. ctx.mcpReq.log()sends structured logs to the MCP host application.- The MCP Inspector shows real-time protocol traffic and is the best first debugging tool.
- Common issues: console.log corrupting stdout, tools registered after connect, Zod schema mismatches.
- Wrap server startup in try/catch and log errors to stderr for visibility.
Code Examples
// WRONG: console.log writes to stdout, corrupting the JSON-RPC stream
console.log('debug info'); // Breaks stdio transport!
// RIGHT: console.error writes to stderr
console.error('debug info'); // Safe for stdio transport
// BEST: dedicated debug function with timestamps
function debug(message: string) {
process.stderr.write(`[DEBUG] ${new Date().toISOString()} ${message}\n`);
}
// ALSO: structured logging via the MCP SDK
ctx.mcpReq.log('info', 'Processing query');{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32602,
"message": "Invalid params: Expected string, received number at 'path'"
}
}