{"slug": "mcp-stdio-protocol-s-3-hidden-traps-when-all-unit-tests-pass-but-the-mcp-server", "title": "MCP stdio Protocol's 3 Hidden Traps: When All Unit Tests Pass but the MCP Server Won't Respond", "summary": "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.", "body_md": "This article records a real MCP Server debugging session: every automated test of\n\n`story-cli`\n\npassed, 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.\n\nIf you're building an MCP Server (or any long-running process that speaks a stdio protocol), remember three iron rules:\n\n`process.exit()`\n\ninside a `run()`\n\nfunction`--watch`\n\nmodes, and any other long-running command are not one-shot CLI tools. `process.exit()`\n\nkills the process before it even starts listening. If you must make an exception, extract the \"long-running\" abstraction (e.g. `isLongRunning`\n\n) instead of enumerating specific commands.`close`\n\nevent`close`\n\nonly 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`\n\nis a **zero-deployment, Git-native Markdown content management CLI**. It manages stories/papers/notes/tutorials with a simple directory convention (`NN-名称/`\n\n— \"NN-name/\" — containing `config.json`\n\n+ `text.md`\n\n), auto-generates READMEs, exports EPUB, and is bilingual (Chinese/English).\n\nOn 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.\"**\n\nWe exposed 6 tools over JSON-RPC 2.0 over stdio:\n\n| MCP tool | Purpose |\n|---|---|\n`scan_stories` |\nList all stories and their metadata |\n`read_chapter` |\nRead a chapter's content from a story |\n`write_chapter` |\nWrite body text to a story (atomic write) |\n`validate` |\nValidate the config.json of every story |\n`build` |\nTrigger a README rebuild |\n`import_json` |\nBulk-import stories from structured JSON |\n\nThe code structure was clean:\n\n```\nsrc/mcp/\n├── protocol.ts   # JSON-RPC 2.0 protocol parsing/serialization (pure functions, fully tested)\n├── tools.ts      # MCP tool registration (reuses shared logic from core/loader.ts)\n└── server.ts     # stdio server startup and request dispatch\n```\n\nEverything looked perfect — **until we actually called it**.\n\nAt the time we had **404 automated tests, 401 passing**. `tests/mcp.test.ts`\n\ncovered protocol parsing, serialization, tool registration, and every tool handler — **all passing**.\n\nSo I started the MCP Server against a real story repository and sent a JSON-RPC request through a pipe:\n\n```\necho '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}' | node bin/index.ts mcp-server\n```\n\n💀 **Empty output.** No response at all.\n\nI thought my pipe syntax was wrong. I tried several variations:\n\n```\n# approach 1: printf\nprintf '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}\\n' | node bin/index.ts mcp-server\n\n# approach 2: file redirection\nprintf '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}\\n' > /tmp/req.json && node bin/index.ts mcp-server < /tmp/req.json\n\n# approach 3: keep stdin open\n{ printf '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}\\n'; sleep 2; } | node bin/index.ts mcp-server\n```\n\n**Still nothing.**\n\nEven weirder: when sending the request through Node.js's `spawnSync`\n\n, the process exit code was 0 (it looked \"successful\"), but both stdout and stderr were empty.\n\nAt that moment I realized: **this isn't a calling convention problem — our MCP Server has a bug.**\n\nBut 404 tests were green! How could there be a bug?\n\nI first looked at the CLI entry file `bin/index.ts`\n\n:\n\n``` js\n#!/usr/bin/env node\nimport { run } from \"../src/cli.ts\"\n\nconst exitCode = await run(process.argv)\nprocess.exit(exitCode)\n```\n\nThe problem was obvious at a glance.\n\nWhen a user runs `story mcp-server`\n\n:\n\n`run(process.argv)`\n\nis invoked`run()`\n\n, `runMcpServer(rootDir)`\n\nis called → `startMcpServer()`\n\nstarts listening on stdin`run()`\n\nreturns 0 immediately`startMcpServer()`\n\nis an async pattern that \"registers listeners and returns\" — it doesn't block)`process.exit(0)`\n\nexecutes immediately**The MCP Server died the moment it was born.**\n\n``` js\n#!/usr/bin/env node\nimport { run } from \"../src/cli.ts\"\n\nconst exitCode = await run(process.argv)\n\n// An MCP server needs to stay alive and keep listening on stdin.\n// Process exit is handled by the close/SIGINT events inside server.ts.\nif (process.argv[2] !== \"mcp-server\" && process.argv[2] !== \"mcp\") {\n  process.exit(exitCode)\n}\n```\n\n⚠️\n\nNote: this fix looked fine at the time, but later that same day testing exposed itslimitation— see \"Bug #1.5: The Same Bug Returns\" below.\n\nThis is the **first trap** when turning a CLI tool into a service:\n\n| Mode | Lifecycle | When to exit |\n|---|---|---|\nCLI tool |\nExits after the command finishes |\n`process.exit(exitCode)` is the right thing |\nLong-running process (MCP Server / daemon) |\nKeeps listening for input until EOF/signal | Exit must be driven by a callback triggered by the input source\n|\n\n`process.exit()`\n\nis 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.\n\nAfter fixing Bug #1, I kept testing the MCP Server. That same day, I wanted to check the performance of `story build --watch`\n\n:\n\n```\nstory build --watch\n```\n\nThe output said 「👀 监听模式已启动，文件变更自动重建...」 (\"👀 watch mode started, auto-rebuilding on file changes...\"), but **the process exited immediately** — `--watch`\n\nmode never actually started watching files.\n\nI tried modifying a story file:\n\n```\necho \"新内容\" > \"01-测试故事/text.md\"\n```\n\nNothing happened. The README was never updated.\n\nI looked back at the fix in `bin/index.ts`\n\n:\n\n```\nif (process.argv[2] !== \"mcp-server\" && process.argv[2] !== \"mcp\") {\n  process.exit(exitCode)\n}\n```\n\nWhat this logic says is: **\"for every command except mcp-server and mcp, call process.exit().\"**\n\nBut `build --watch`\n\nis also a **long-running process**! It needs to keep watching files until it receives `SIGINT`\n\n. Only the two MCP Server commands were exempted — `build --watch`\n\nwasn't on the whitelist, so it got killed by `process.exit()`\n\nimmediately too.\n\n**The first MCP Server bug was fixed, and the same ghost reappeared on build --watch.**\n\nThe right fix isn't to enumerate even more commands — it's to extract the essential property of \"**which commands are long-running**\":\n\n``` js\n#!/usr/bin/env node\nimport { run } from \"../src/cli.ts\"\n\nconst exitCode = await run(process.argv)\n\n// Long-running processes need to stay alive; exit is handled by internal close/SIGINT events:\n// - MCP server: keeps listening on stdin; exit is controlled by server.ts's close/SIGINT\n// - build --watch: keeps watching file changes; exit is controlled by build.ts's SIGINT\nconst isLongRunning =\n  process.argv[2] === \"mcp-server\" ||\n  process.argv[2] === \"mcp\" ||\n  (process.argv[2] === \"build\" && process.argv[3] === \"--watch\") ||\n  (process.argv[2] === \"b\" && process.argv[3] === \"--watch\")\n\nif (!isLongRunning) {\n  process.exit(exitCode)\n}\n```\n\nThis was the **biggest lesson** of the whole session:\n\n| Fix approach | Code shape | Problem |\n|---|---|---|\nEnumerate instances (at the time) |\n`if (cmd !== \"mcp-server\" && cmd !== \"mcp\")` |\nAdding one more long-running command means coming back to edit this line |\nExtract an abstraction (final) |\n`const isLongRunning = ...` |\nAny new command just expresses its property inside this set |\n\nWhen you see an \"exclusion list\" in code (`if (cmd !== \"A\" && cmd !== \"B\")`\n\n), 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`\n\n), the same bug returns.\n\n**Checklist**: if your CLI is ever going to add a \"keep-listening\" feature (watch / serve / daemon), check the `isLongRunning`\n\nlist in `bin/index.ts`\n\nfirst — it must include the new command.\n\nAfter fixing Bug #1, I was pleasantly surprised to see `tools/list`\n\nrespond! But only these responded:\n\n`tools/list`\n\n✅`initialize`\n\n✅Meanwhile, the **async tools/call still got no response** (\n\n`scan_stories`\n\n/ `read_chapter`\n\n/ `validate`\n\n).I tested `scan_stories`\n\non its own:\n\n```\necho '{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"scan_stories\",\"arguments\":{}}}' | node bin/index.ts mcp-server\n```\n\nStill empty.\n\nI tried a different angle — calling `loadStories()`\n\ndirectly in Node:\n\n``` js\nnode --experimental-strip-types -e \"\nimport { loadStories } from './src/core/loader.ts';\nconst { stories } = await loadStories('/tmp/test-story-cli');\nconsole.log('STORIES:', stories.length);\n\"\n```\n\nOutput:\n\n```\n📊 01-测试故事: 自动计算字数为 约 13 字（未写回，使用 --save-counts 持久化）\n📊 02-二创故事: 自动计算字数为 约 13 字（未写回，使用 --save-counts 持久化）\n📊 03-English-Story: 自动计算字数为 ~7 words（未写回，使用 --save-counts 持久化）\nSTORIES: 3\n```\n\n**Found it!** Inside `loadStories()`\n\n, a `console.log`\n\nwas printing \"auto-computed word count\" diagnostic lines.\n\n`console.log`\n\nKill MCP?\nMCP's stdio transport spec says **stdout is the protocol-dedicated channel**:\n\n```\n├── stdin  ← client sends JSON-RPC requests\n├── stdout → server returns JSON-RPC responses (protocol-dedicated, the only legal output)\n└── stderr → logs/warnings/errors (for humans, not for the protocol)\n```\n\nWhen an MCP client sends a `scan_stories`\n\nrequest, the server calls `loadStories()`\n\nwhile handling it, and `console.log`\n\ndumps a `📊 01-测试故事: ...`\n\nline to stdout. Now stdout looks like:\n\n```\n📊 01-测试故事: 自动计算字数为 约 13 字...      ← pollution!\n{\"jsonrpc\":\"2.0\",\"id\":3,\"result\":{...}}        ← the real response\n```\n\nMCP 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 —\n\n**the client gives up on parsing, which looks like \"no response\".**\n\nAs 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:\n\n\"Local MCP servers should not log messages to stdout (standard out), as this will interfere with protocol operation.\"\n\n— The docs warned us all along; we only truly understood it after stepping on it in a real environment.\n\nAnd this kind of bug is especially sneaky:\n\n`scan_stories`\n\n's handler is called directly and nobody parses stdout → tests pass\n\n```\n// Before\nif (!config.wordCount) {\n  console.log(locale.autoWordCount(folder, story.wordCount, saveCounts))\n}\n// After\nif (!config.wordCount) {\n  // Use stderr for diagnostics so we don't pollute the stdout channel of the MCP stdio protocol.\n  console.error(locale.autoWordCount(folder, story.wordCount, saveCounts))\n}\n```\n\nThe `console.log(locale.generatedText(...))`\n\ninside `loadStoryContentAsync`\n\nwas changed the same way.\n\n**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.\n\nThis is a **silent runtime failure**: the code doesn't throw, tests don't fail, and only real clients mysteriously stop working.\n\nIn an MCP Server,\n\n`stdout = protocol`\n\n,`stderr = logs`\n\n. Never mix them.\n\nAfter fixing Bug #2, I thought everything was done. But testing showed `tools/call`\n\nstill responded **intermittently**: sometimes a response came back, sometimes not.\n\nI stared at the old code in `src/mcp/server.ts`\n\n:\n\n```\nexport function startMcpServer(rootDir: string, tools: RegisteredTool[]): void {\n  const rl = createInterface({ input: process.stdin, terminal: false })\n\n  rl.on(\"line\", async (line) => {\n    // ... parse and handle the request\n    const response = await handleRequest(request, rootDir, tools)\n    if (response) process.stdout.write(serializeMessage(response))\n  })\n\n  rl.on(\"close\", () => {\n    // Wait for stdout to flush before exiting (avoid truncated output)\n    process.stdout.write(\"\", () => process.exit(0))\n  })\n  // ...\n}\n```\n\nIn pipe mode (`echo '...' | node bin/index.ts mcp-server`\n\n), stdin closes immediately after all lines are read, which fires the `close`\n\nevent. **When close fires, the async await handleRequest() inside rl.on(\"line\") hasn't finished yet!**\n\nHere's the timing:\n\n```\nt0:  stdin receives the JSON-RPC request line\nt1:  rl fires the \"line\" event and enters the async callback\nt2:  the async callback hits await handleRequest() and suspends (shaded area = waiting for the async result)\nt3:  stdin finishes reading all lines → rl fires the \"close\" event\nt4:  the \"close\" callback runs process.stdout.write(\"\", () => process.exit(0))\nt5:  the process exits while await handleRequest() is still suspended → the response is lost forever\n```\n\nThis is an **async race**: `close`\n\nsays \"the input stream is closed\", but it doesn't wait for your Promises to finish.\n\nTrack all in-flight requests with a `pending`\n\nSet, and wait for all of them on `close`\n\nbefore exiting:\n\n```\nexport function startMcpServer(rootDir: string, tools: RegisteredTool[]): void {\n  const rl = createInterface({ input: process.stdin, terminal: false })\n  const pending = new Set<Promise<void>>()\n\n  rl.on(\"line\", (line) => {\n    const trimmed = line.trim()\n    if (!trimmed) return\n    let request: JsonRpcRequest\n    try {\n      request = parseRequest(trimmed)\n    } catch (e) {\n      const code =\n        (e as Error & { code?: number }).code ?? JsonRpcErrorCode.InternalError\n      process.stdout.write(\n        serializeMessage(makeErrorResponse(null, code, (e as Error).message)),\n      )\n      return\n    }\n    // Track in-flight requests so we know the async handler has finished when stdin closes.\n    const task = (async () => {\n      const response = await handleRequest(request, rootDir, tools)\n      if (response) process.stdout.write(serializeMessage(response))\n    })()\n    pending.add(task)\n    task.finally(() => pending.delete(task))\n  })\n\n  rl.on(\"close\", () => {\n    // Wait for all in-flight requests to finish, then flush stdout before exiting (avoid truncated output).\n    void Promise.allSettled([...pending]).then(() => {\n      process.stdout.write(\"\", () => process.exit(0))\n    })\n  })\n  process.on(\"SIGINT\", () => {\n    rl.close()\n  })\n}\n```\n\nIn Node.js's event loop, **readline's close event only means \"the input stream closed\", not \"your async callbacks have run\"**.\n\nThis is a universal problem for every stdio protocol server: when stdin hits EOF, you may still have queued Promises. You need to track and wait for them explicitly:\n\n`close`\n\nor `SIGINT`\n\n, wait with `Promise.allSettled`\n\n`process.exit`\n\nThe biggest insight from this session was **the value of layered testing**:\n\n| Test layer | Our previous coverage | What it would catch |\n|---|---|---|\nUnit tests (calling handler functions directly) |\n✅ 401 all green | Can't catch Bug #1 / #2 / #3 |\nIntegration tests (calling `startMcpServer` without a real process) |\n❌ none | — |\nEnd-to-end tests (spawnSync a real child process + real stdin/stdout) |\n❌ none | All 3 bugs at once |\n\n**Green unit tests don't mean the system works.** You need to start the server in a real process, send requests through real pipes, and parse real stdout — because only end-to-end tests can catch problems at the \"process lifecycle\" and \"protocol integrity\" levels.\n\n```\n// tests/mcp-server.test.ts (the end-to-end test we added)\nfunction sendRequests(dir: string, requests: string[]) {\n  const input = `${requests.join(\"\\n\")}\\n`\n  const result = spawnSync(process.execPath, [binPath, \"mcp-server\"], {\n    cwd: dir,\n    input,\n    encoding: \"utf-8\",\n    timeout: 5000,\n  })\n  return {\n    stdout: result.stdout || \"\",\n    stderr: result.stderr || \"\",\n    status: result.status ?? -1,\n  }\n}\n\ntest(\"MCP server responds to async tools/call (scan_stories)\", () => {\n  const { stdout, stderr } = sendRequests(dir, [\n    '{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"scan_stories\",\"arguments\":{}}}',\n  ])\n  // stderr must not contain JsonRpcResponse content → guards against console.log/stdout pollution\n  assert.ok(!stderr.includes(\"jsonrpc\"))\n\n  // Split by lines and filter empty lines instead of JSON.parse(stdout.trim()).\n  // If stdout contains multiple lines, trim() only strips leading/trailing whitespace\n  // and the inner newlines make JSON.parse fail.\n  const lines = stdout\n    .split(\"\\n\")\n    .map((l) => l.trim())\n    .filter(Boolean)\n  assert.ok(lines.length >= 1, \"there should be at least one JSON-RPC response\")\n  // Take the last line (or look up the line by id when multiple responses are requested)\n  const response = JSON.parse(lines[lines.length - 1] ?? \"{}\")\n  // ...\n})\n```\n\nThis test starts the MCP Server in a **real child process**, sends JSON-RPC requests through **real pipes**, and validates the stdout contents. If anyone ever adds a `console.log`\n\nto `loadStories`\n\n, this test fails immediately.\n\nFollow-up (same day): after fixing Bug #1.5, we added an end-to-end regression test for`build --watch`\n\n(`tests/watch.test.ts`\n\n) — it uses spawnSync to start a real child process and asserts \"the process stays alive\" + \"the README is rebuilt within 5 seconds of editing a story\". If we'd had that test back then, Bug #1.5 would have been caught the day it was fixed, instead of surfacing by accident during a later performance check. That's test layering proven once again:unit tests can't cover process lifecycle — only end-to-end tests can.\n\n```\n# 1. Create a test repository\nmkdir -p /tmp/test-story-cli && cd /tmp/test-story-cli\nnode /path/to/story-cli/bin/index.ts init\nnode /path/to/story-cli/bin/index.ts new \"测试故事\"\n\n# 2. Start the MCP Server (find the problem)\necho '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}' | node /path/to/story-cli/bin/index.ts mcp-server\n# → empty output (Bug #1)\n\n# 3. After fixing #1 → tools/list responds, but scan_stories doesn't (Bug #2's stdio pollution)\n\n# 4. Verify loadStories behavior in isolation\nnode --experimental-strip-types -e \"\nimport { loadStories } from './src/core/loader.ts';\nawait loadStories('/tmp/test-story-cli');\n\"\n# → see the 📊 logs appearing on stdout\n\n# 5. After fixing #2 → sometimes responds, sometimes not (Bug #3's async race)\n\n# 6. Verify repeatedly via the end-to-end test\nnode --test tests/mcp-server.test.ts\n# → 7 tests pass\n```\n\nEverything above is \"post-mortem\" debugging. If you integrate ** MCP Inspector** (MCP's official debugging tool) during development, many of these problems can be caught before release:\n\n```\nnpx @modelcontextprotocol/inspector node /path/to/story-cli/bin/index.ts mcp-server\n```\n\nMCP Inspector launches a visual web UI that lets you:\n\nIt's the \"X-ray machine\" of MCP Server development — I recommend every MCP Server developer run everything through the Inspector before CI/CD.\n\nThere are also third-party helpers in the community (like\n\n`mcp-stdio-guard`\n\nfor catching stdout pollution), but the Inspector as the official tool covers most scenarios.\n\nAn MCP Server has to handle not only protocol traps (#1 / #2 / #3) but also traps in the **AI interaction layer**:\n\nWhen `create_story`\n\ncreates a directory, it converts spaces in the title to hyphens (e.g. `\"AI 创作的故事\"`\n\n→ `02-AI-创作的故事`\n\n), but the LLM may pass back the original space form (`\"02-AI 创作的故事\"`\n\n) — `safeFolder`\n\nhas to match both variants to hit the right directory.\n\n**Protocol-layer traps and interaction-layer traps — we stepped on all of them the same day.**\n\nIf you only take away three sentences, plus one lesson about fixing bugs themselves:\n\n`process.exit()`\n\nbelongs only to one-shot CLI commands.`close`\n\n≠ all work finished.`pending`\n\nSet + `Promise.allSettled`\n\nto explicitly wait for async work.`if (cmd !== \"A\" && cmd !== \"B\")`\n\n, you're enumerating specific commands — when a new long-running command appears, the same bug recurs somewhere new.What these four problems have in common: **none of them can be caught by unit tests**; they only surface in real process environments. So — after writing your handlers, don't forget to write a `spawnSync`\n\nend-to-end test.\n\nNote that these four iron rules are **language-agnostic** — whether you build a stdio server in Node.js, Python, or Go, the same four traps exist: `process.exit()`\n\n/ stdout pollution / un-awaited async work / enumerating instead of abstracting. This article uses Node.js only because our project happens to be on the Node stack.\n\nThis article is based on a real debugging session from the story-cli project. Repository: [story-cli](https://github.com/yuelinghuashu/story-cli)", "url": "https://wpnews.pro/news/mcp-stdio-protocol-s-3-hidden-traps-when-all-unit-tests-pass-but-the-mcp-server", "canonical_source": "https://dev.to/yuelinghuashu/mcp-stdio-protocols-3-hidden-traps-when-all-unit-tests-pass-but-the-mcp-server-wont-respond-53l6", "published_at": "2026-08-23 10:22:22+00:00", "updated_at": "2026-08-23 10:43:11.766498+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools", "ai-agents"], "entities": ["story-cli", "Node.js", "MCP Server", "JSON-RPC 2.0"], "alternates": {"html": "https://wpnews.pro/news/mcp-stdio-protocol-s-3-hidden-traps-when-all-unit-tests-pass-but-the-mcp-server", "markdown": "https://wpnews.pro/news/mcp-stdio-protocol-s-3-hidden-traps-when-all-unit-tests-pass-but-the-mcp-server.md", "text": "https://wpnews.pro/news/mcp-stdio-protocol-s-3-hidden-traps-when-all-unit-tests-pass-but-the-mcp-server.txt", "jsonld": "https://wpnews.pro/news/mcp-stdio-protocol-s-3-hidden-traps-when-all-unit-tests-pass-but-the-mcp-server.jsonld"}}