MCP stdio Protocol's 3 Hidden Traps: When All Unit Tests Pass but the MCP Server Won't Respond A developer debugging a Node.js MCP Server found three hidden traps in the stdio protocol that caused all 404 unit tests to pass while the server failed to respond in real environments. The root causes were an immediate process.exit() call, a missing check for the --watch flag, and mishandling of the 'close' event, which together prevented the server from listening or responding. The developer outlines iron rules for building long-running stdio processes, including avoiding process.exit() in run() functions and waiting for in-flight promises before exiting. This article records a real MCP Server debugging session: every automated test of story-cli passed, yet in a real environment the MCP Server couldn't respond to any request at all. The root cause turned out to be 3 bugs, each touching low-level details of the Node.js process model and the stdio protocol. If you're building an MCP Server or any long-running process that speaks a stdio protocol , remember three iron rules: process.exit inside a run function --watch modes, and any other long-running command are not one-shot CLI tools. process.exit kills the process before it even starts listening. If you must make an exception, extract the "long-running" abstraction e.g. isLongRunning instead of enumerating specific commands. close event close only means the input stream closed, not that your callbacks have finished. You need to wait for all in-flight Promises before exiting.First, a quick introduction to the project. story-cli is a zero-deployment, Git-native Markdown content management CLI . It manages stories/papers/notes/tutorials with a simple directory convention NN-名称/ — "NN-name/" — containing config.json + text.md , auto-generates READMEs, exports EPUB, and is bilingual Chinese/English . On our roadmap, the MCP Server was a P0-level strategic task — the gateway to the AI era. The design principle: "AI does the thinking, the CLI does the governance." We exposed 6 tools over JSON-RPC 2.0 over stdio: | MCP tool | Purpose | |---|---| scan stories | List all stories and their metadata | read chapter | Read a chapter's content from a story | write chapter | Write body text to a story atomic write | validate | Validate the config.json of every story | build | Trigger a README rebuild | import json | Bulk-import stories from structured JSON | The code structure was clean: src/mcp/ ├── protocol.ts JSON-RPC 2.0 protocol parsing/serialization pure functions, fully tested ├── tools.ts MCP tool registration reuses shared logic from core/loader.ts └── server.ts stdio server startup and request dispatch Everything looked perfect — until we actually called it . At the time we had 404 automated tests, 401 passing . tests/mcp.test.ts covered protocol parsing, serialization, tool registration, and every tool handler — all passing . So I started the MCP Server against a real story repository and sent a JSON-RPC request through a pipe: echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | node bin/index.ts mcp-server 💀 Empty output. No response at all. I thought my pipe syntax was wrong. I tried several variations: approach 1: printf printf '{"jsonrpc":"2.0","id":1,"method":"tools/list"}\n' | node bin/index.ts mcp-server approach 2: file redirection printf '{"jsonrpc":"2.0","id":1,"method":"tools/list"}\n' /tmp/req.json && node bin/index.ts mcp-server < /tmp/req.json approach 3: keep stdin open { printf '{"jsonrpc":"2.0","id":1,"method":"tools/list"}\n'; sleep 2; } | node bin/index.ts mcp-server Still nothing. Even weirder: when sending the request through Node.js's spawnSync , the process exit code was 0 it looked "successful" , but both stdout and stderr were empty. At that moment I realized: this isn't a calling convention problem — our MCP Server has a bug. But 404 tests were green How could there be a bug? I first looked at the CLI entry file bin/index.ts : js /usr/bin/env node import { run } from "../src/cli.ts" const exitCode = await run process.argv process.exit exitCode The problem was obvious at a glance. When a user runs story mcp-server : run process.argv is invoked run , runMcpServer rootDir is called → startMcpServer starts listening on stdin run returns 0 immediately startMcpServer is an async pattern that "registers listeners and returns" — it doesn't block process.exit 0 executes immediately The MCP Server died the moment it was born. js /usr/bin/env node import { run } from "../src/cli.ts" const exitCode = await run process.argv // An MCP server needs to stay alive and keep listening on stdin. // Process exit is handled by the close/SIGINT events inside server.ts. if process.argv 2 == "mcp-server" && process.argv 2 == "mcp" { process.exit exitCode } ⚠️ Note: this fix looked fine at the time, but later that same day testing exposed itslimitation— see "Bug 1.5: The Same Bug Returns" below. This is the first trap when turning a CLI tool into a service: | Mode | Lifecycle | When to exit | |---|---|---| CLI tool | Exits after the command finishes | process.exit exitCode is the right thing | Long-running process MCP Server / daemon | Keeps listening for input until EOF/signal | Exit must be driven by a callback triggered by the input source | process.exit is unconditional, immediate, and uninterruptible. It doesn't wait for pending I/O, timers, or Promises. In the MCP Server scenario, that "feature" killed our server outright. After fixing Bug 1, I kept testing the MCP Server. That same day, I wanted to check the performance of story build --watch : story build --watch The output said 「👀 监听模式已启动,文件变更自动重建...」 "👀 watch mode started, auto-rebuilding on file changes..." , but the process exited immediately — --watch mode never actually started watching files. I tried modifying a story file: echo "新内容" "01-测试故事/text.md" Nothing happened. The README was never updated. I looked back at the fix in bin/index.ts : if process.argv 2 == "mcp-server" && process.argv 2 == "mcp" { process.exit exitCode } What this logic says is: "for every command except mcp-server and mcp, call process.exit ." But build --watch is also a long-running process It needs to keep watching files until it receives SIGINT . Only the two MCP Server commands were exempted — build --watch wasn't on the whitelist, so it got killed by process.exit immediately too. The first MCP Server bug was fixed, and the same ghost reappeared on build --watch. The right fix isn't to enumerate even more commands — it's to extract the essential property of " which commands are long-running ": js /usr/bin/env node import { run } from "../src/cli.ts" const exitCode = await run process.argv // Long-running processes need to stay alive; exit is handled by internal close/SIGINT events: // - MCP server: keeps listening on stdin; exit is controlled by server.ts's close/SIGINT // - build --watch: keeps watching file changes; exit is controlled by build.ts's SIGINT const isLongRunning = process.argv 2 === "mcp-server" || process.argv 2 === "mcp" || process.argv 2 === "build" && process.argv 3 === "--watch" || process.argv 2 === "b" && process.argv 3 === "--watch" if isLongRunning { process.exit exitCode } This was the biggest lesson of the whole session: | Fix approach | Code shape | Problem | |---|---|---| Enumerate instances at the time | if cmd == "mcp-server" && cmd == "mcp" | Adding one more long-running command means coming back to edit this line | Extract an abstraction final | const isLongRunning = ... | Any new command just expresses its property inside this set | When you see an "exclusion list" in code if cmd == "A" && cmd == "B" , it means you're enumerating specific commands instead of expressing the essential property of "which commands are long-running". The moment a new long-running command appears like --watch , the same bug returns. Checklist : if your CLI is ever going to add a "keep-listening" feature watch / serve / daemon , check the isLongRunning list in bin/index.ts first — it must include the new command. After fixing Bug 1, I was pleasantly surprised to see tools/list respond But only these responded: tools/list ✅ initialize ✅Meanwhile, the async tools/call still got no response scan stories / read chapter / validate .I tested scan stories on its own: echo '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"scan stories","arguments":{}}}' | node bin/index.ts mcp-server Still empty. I tried a different angle — calling loadStories directly in Node: js node --experimental-strip-types -e " import { loadStories } from './src/core/loader.ts'; const { stories } = await loadStories '/tmp/test-story-cli' ; console.log 'STORIES:', stories.length ; " Output: 📊 01-测试故事: 自动计算字数为 约 13 字(未写回,使用 --save-counts 持久化) 📊 02-二创故事: 自动计算字数为 约 13 字(未写回,使用 --save-counts 持久化) 📊 03-English-Story: 自动计算字数为 ~7 words(未写回,使用 --save-counts 持久化) STORIES: 3 Found it Inside loadStories , a console.log was printing "auto-computed word count" diagnostic lines. console.log Kill MCP? MCP's stdio transport spec says stdout is the protocol-dedicated channel : ├── stdin ← client sends JSON-RPC requests ├── stdout → server returns JSON-RPC responses protocol-dedicated, the only legal output └── stderr → logs/warnings/errors for humans, not for the protocol When an MCP client sends a scan stories request, the server calls loadStories while handling it, and console.log dumps a 📊 01-测试故事: ... line to stdout. Now stdout looks like: 📊 01-测试故事: 自动计算字数为 约 13 字... ← pollution {"jsonrpc":"2.0","id":3,"result":{...}} ← the real response MCP clients VSCode / Claude Desktop / Cursor expect every line of stdout to be a valid JSON-RPC message when parsing. The first line isn't JSON at all — the client gives up on parsing, which looks like "no response". As a side note, MCP's stdio transport also has a hard requirement about newlines: every JSON-RPC message must end with \n . If your server outputs JSON without a trailing newline, the client also fails to parse it. That's why the official MCP docs' Debugging page states it plainly: "Local MCP servers should not log messages to stdout standard out , as this will interfere with protocol operation." — The docs warned us all along; we only truly understood it after stepping on it in a real environment. And this kind of bug is especially sneaky: scan stories 's handler is called directly and nobody parses stdout → tests pass // Before if config.wordCount { console.log locale.autoWordCount folder, story.wordCount, saveCounts } // After if config.wordCount { // Use stderr for diagnostics so we don't pollute the stdout channel of the MCP stdio protocol. console.error locale.autoWordCount folder, story.wordCount, saveCounts } The console.log locale.generatedText ... inside loadStoryContentAsync was changed the same way. In a stdio protocol, stdout is not for logging. It's the protocol channel between two processes. Any extra output — even a single seemingly harmless log line — breaks protocol parsing. This is a silent runtime failure : the code doesn't throw, tests don't fail, and only real clients mysteriously stop working. In an MCP Server, stdout = protocol , stderr = logs . Never mix them. After fixing Bug 2, I thought everything was done. But testing showed tools/call still responded intermittently : sometimes a response came back, sometimes not. I stared at the old code in src/mcp/server.ts : export function startMcpServer rootDir: string, tools: RegisteredTool : void { const rl = createInterface { input: process.stdin, terminal: false } rl.on "line", async line = { // ... parse and handle the request const response = await handleRequest request, rootDir, tools if response process.stdout.write serializeMessage response } rl.on "close", = { // Wait for stdout to flush before exiting avoid truncated output process.stdout.write "", = process.exit 0 } // ... } In pipe mode echo '...' | node bin/index.ts mcp-server , stdin closes immediately after all lines are read, which fires the close event. When close fires, the async await handleRequest inside rl.on "line" hasn't finished yet Here's the timing: t0: stdin receives the JSON-RPC request line t1: rl fires the "line" event and enters the async callback t2: the async callback hits await handleRequest and suspends shaded area = waiting for the async result t3: stdin finishes reading all lines → rl fires the "close" event t4: the "close" callback runs process.stdout.write "", = process.exit 0 t5: the process exits while await handleRequest is still suspended → the response is lost forever This is an async race : close says "the input stream is closed", but it doesn't wait for your Promises to finish. Track all in-flight requests with a pending Set, and wait for all of them on close before exiting: export function startMcpServer rootDir: string, tools: RegisteredTool : void { const rl = createInterface { input: process.stdin, terminal: false } const pending = new Set