Guide · Debugging
Debugging stdio transport MCP servers
The stdio transport is the default MCP server mode for local tooling: the MCP client (Claude Desktop, Cursor, etc.) spawns your server as a child process and communicates over its stdin and stdout pipes. This creates a constraint that catches almost every developer the first time: stdout is the wire. Any byte you write to stdout — including debug output, startup messages, or log lines — is parsed as a JSON-RPC message by the client. Writing non-JSON output to stdout silently corrupts the session and causes mysterious connection failures. This guide covers the correct debugging patterns for stdio-mode servers.
TL;DR
Never write to stdout in a stdio-mode MCP server. Use stderr for all debug output. Set the DEBUG environment variable to gate verbose logging — DEBUG=mcp:* enables built-in SDK logging. Use tee and jq to intercept the live stdio stream for one-off protocol inspection. Look at the Claude Desktop MCP log at ~/Library/Logs/Claude/mcp-server-<name>.log for client-side events.
Why stdout corruption happens
In the MCP stdio transport, the protocol framing is newline-delimited JSON (NDJSON). The client reads stdout line by line and parses each line as a JSON-RPC message. When a non-JSON line appears — a startup banner, a log line, a debug print — the client's JSON parser fails and the session enters an undefined state. Depending on the client implementation, this results in:
- Silent connection failure (most common — the client closes the connection without showing an error)
- A JSON parse error in the client log
- The server appearing to connect but all tool calls failing
- The server appearing connected but the tool list being empty
The failure is particularly confusing because it happens during startup — before any tool is called — and the error appears on the client side, not in the server output. The server may be running correctly while the client shows it as disconnected.
// WRONG — corrupts the JSON-RPC stream
console.log('Server starting...'); // stdout — breaks the client
console.log({ tools: registeredTools }); // stdout — breaks the client
process.stdout.write('Ready\n'); // stdout — breaks the client
// CORRECT — stderr is not part of the JSON-RPC transport
console.error('Server starting...');
process.stderr.write('Server starting...\n');
// With a structured logger (pino configured to write to stderr):
import pino from 'pino';
const logger = pino({}, pino.destination(2)); // 2 = stderr fd
logger.info('Server starting');
The rule applies to all output — even a single stray character on stdout during initialization will break the session. Remove all console.log calls, even temporary ones you added for quick debugging.
The DEBUG environment variable pattern
The MCP TypeScript SDK and Node.js ecosystem use the DEBUG environment variable convention (from the debug npm package) to enable verbose logging that writes to stderr. Enable it for built-in SDK logging:
# Enable MCP SDK internal debug logging (writes to stderr)
DEBUG=mcp:* node dist/index.js
# Enable only specific namespaces
DEBUG=mcp:server node dist/index.js
DEBUG=mcp:transport node dist/index.js
# Enable debug logging for your own code too
DEBUG=mcp:*,myserver:* node dist/index.js
In your server code, use the debug package (or a structured logger behind a DEBUG check) for verbose output that's safe to leave in production code — it only emits when DEBUG is set:
import createDebug from 'debug';
const debug = createDebug('myserver:tools');
server.tool('search_documents', schema, async (args) => {
debug('search called with args: %O', args); // only emits if DEBUG=myserver:*
// ...
});
The debug package writes to stderr automatically — it knows it's not a JSON-RPC transport. Set the DEBUG environment variable in your Claude Desktop config temporarily when debugging, then remove it.
Inspecting the live stdio stream with tee
When you need to see the raw JSON-RPC protocol traffic — not just your server logs, but every message exchanged — you can intercept the stdio pipes using tee. This lets you observe the protocol without using the MCP Inspector UI, which is useful in CI or terminal-only environments:
# Create named pipes for interception
mkfifo /tmp/mcp-stdin /tmp/mcp-stdout
# In one terminal: start your server with pipes connected to tee
node dist/index.js \
< <(tee /tmp/stdin-log.ndjson < /tmp/mcp-stdin) \
> >(tee /tmp/stdout-log.ndjson > /tmp/mcp-stdout)
# In another terminal: run an MCP client that connects to the pipes
# (or use the MCP SDK test client)
# Format the captured logs with jq for readability
tail -f /tmp/stdout-log.ndjson | jq '.'
tail -f /tmp/stdin-log.ndjson | jq '.'
A simpler approach for one-off debugging: redirect the server output to a file while running the server directly in a terminal, then inspect the output file:
# Run the MCP Inspector which handles the pipes for you
npx @modelcontextprotocol/inspector node dist/index.js
# Then use Inspector's Protocol tab to see all JSON-RPC messages
For most debugging tasks, the MCP Inspector's Protocol tab is simpler than manual pipe interception. Use manual tee/jq interception when you need to capture a long session or automate protocol inspection in a script.
Debugging the initialize handshake
The MCP initialize handshake is the first thing that happens when a client connects. If it fails, no tools are ever sent and the client shows the server as disconnected. The handshake sequence is:
- Client sends
initializerequest withprotocolVersion,capabilities, andclientInfo - Server responds with
initializeresult (itsprotocolVersion,capabilities,serverInfo) - Client sends
initializednotification (no response expected) - Client sends
tools/listrequest - Server responds with the tool manifest
Failures at each step have distinct causes:
| Step that fails | Symptom | Likely cause |
|---|---|---|
| Before initialize (no response) | Client times out; server appears to start but immediately disconnect | Server crashed on startup; stdout pollution; wrong entry point |
| initialize response malformed | Client disconnects with a parse error | Handler throws during capability negotiation; missing required fields in response |
| protocolVersion mismatch | Client rejects the connection or downgrades | Server returning an unsupported protocol version string |
| tools/list empty | Client connects; no tools visible | Tools registered after the server is started (registration is async but initialization is sync); registration errors silently caught |
To debug step-by-step: run npx @modelcontextprotocol/inspector node dist/index.js, open the Protocol tab, and watch each message in sequence. The first missing or malformed message identifies the failing step.
Matching config.json to your actual server binary
Most stdio debugging issues in Claude Desktop come from a mismatch between the server configuration in claude_desktop_config.json and what the actual server process needs. Common mismatches:
// claude_desktop_config.json
{
"mcpServers": {
"my-server": {
// WRONG: points to source file (requires tsx to be installed globally)
"command": "node",
"args": ["src/index.ts"],
// CORRECT: points to compiled output
"command": "node",
"args": ["/absolute/path/to/my-server/dist/index.js"],
// ALSO CORRECT: use npx with the package name
"command": "npx",
"args": ["my-mcp-server"],
"env": {
// Missing env vars here cause the server to crash on startup silently
"DATABASE_URL": "postgres://localhost/mydb",
"API_KEY": "your-key-here"
}
}
}
}
Key rules for Claude Desktop config:
- Use absolute paths for
args— Claude Desktop doesn't inherit your shell'sPATHor working directory. Relative paths fail silently. - Run
npm run buildbefore testing — if the TypeScript source changed butdist/hasn't been rebuilt, the old binary runs. - List all required environment variables in
env— the server process doesn't inherit your shell environment. A missingDATABASE_URLcauses a startup crash that looks like a connection failure. - Check the log at
~/Library/Logs/Claude/mcp-server-<name>.log— Claude Desktop writes the server's stderr output to this file. The startup error is almost always there.
Reading the Claude Desktop MCP log
Claude Desktop writes each MCP server's stderr output to a per-server log file. This is the most reliable way to diagnose stdio-mode server failures:
# macOS
tail -f ~/Library/Logs/Claude/mcp-server-my-server.log
# Windows (PowerShell)
Get-Content "$env:APPDATA\Claude\logs\mcp-server-my-server.log" -Wait
# Cursor (macOS)
tail -f ~/Library/Logs/Cursor/mcp-server-my-server.log
The log file name matches the key in your config's mcpServers object. If the server never started, the file may not exist or may be empty — check that the path in your config resolves correctly by running the exact command manually in a terminal.
Related pages
FAQ
My server works in the MCP Inspector but fails in Claude Desktop. Why?
The most common cause is the environment. The Inspector inherits your shell environment (including PATH, HOME, environment variables); Claude Desktop spawns the server in a minimal environment without your shell's profile. Check: (1) all required environment variables are in claude_desktop_config.json under env; (2) the command and args use absolute paths, not shell-resolved names; (3) the server binary was rebuilt after the last code change. Run the exact command + args combination from a fresh terminal (without your shell profile) to simulate the Claude Desktop environment.
How do I debug a startup crash in a stdio MCP server?
Run the server command directly in a terminal: node /absolute/path/to/dist/index.js with all the same environment variables set. This shows you the exact crash message that Claude Desktop suppresses. Check the Claude Desktop log file (~/Library/Logs/Claude/mcp-server-<name>.log) for the stderr output from the crash. Common startup crashes: missing environment variables, database connection failures, port binding errors (for HTTP servers started in the wrong mode), and TypeScript compilation errors (the compiled JS references source maps that don't exist).
Can I add console.log temporarily just for one debugging session?
No — there's no safe way to use console.log temporarily in a stdio server. Even a single character on stdout corrupts the session. Instead, use console.error (safe, writes to stderr) or add a debug flag: if (process.env.DEBUG_VERBOSE) console.error('...'). You can also use the MCP Inspector in a separate terminal to test the server interactively — it starts its own server process and shows you the protocol output, so you don't need to touch the Claude Desktop session.
Why does my server connect but show no tools in Claude Desktop?
The most common cause is that tool registration happens inside an async callback that hasn't completed before the server starts accepting connections. Move all server.tool() calls before server.connect(transport) — tool registration must be synchronous and complete before the server connects. If tool registration requires async data (e.g., fetching tool definitions from a database), fetch the data first, then register tools synchronously with the fetched data, then connect.
How does AliveMCP probe a stdio-mode server?
AliveMCP cannot probe stdio-mode servers directly — stdio transport requires spawning a local process. AliveMCP probes HTTP-mode servers (SSE or Streamable HTTP). If your server is stdio-only (local tooling for Claude Desktop), consider adding an HTTP health endpoint or exposing the same tools via an additional HTTP transport for production monitoring. For servers used in local development only, use the MCP Inspector for manual verification rather than continuous uptime monitoring.