DEV Community

yuelinghuashu
yuelinghuashu

Posted on Originally published at moongate.top

MCP stdio Protocol's 3 Hidden Traps: When All Unit Tests Pass but the MCP Server Won't Respond

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.


TL;DR

If you're building an MCP Server (or any long-running process that speaks a stdio protocol), remember three iron rules:

  1. Never call process.exit() inside a run() function — MCP Servers, --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.
  2. Never print debug logs to stdout — stdout is the MCP protocol channel. Any output that isn't JSON-RPC pollutes the message stream and makes the client unable to parse any response. Diagnostics belong on stderr.
  3. Always wait for all async work in the close eventclose only means the input stream closed, not that your callbacks have finished. You need to wait for all in-flight Promises before exiting.

Background: story-cli's MCP Server

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
Enter fullscreen mode Exit fullscreen mode

Everything looked perfect — until we actually called it.


The Symptom: All Automated Tests Green, But Real Requests Get No Response

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
Enter fullscreen mode Exit fullscreen mode

💀 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
Enter fullscreen mode Exit fullscreen mode

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?


Bug #1: The Ghost of process.exit()

Root Cause Investigation

I first looked at the CLI entry file bin/index.ts:

#!/usr/bin/env node
import { run } from "../src/cli.ts"

const exitCode = await run(process.argv)
process.exit(exitCode)
Enter fullscreen mode Exit fullscreen mode

The problem was obvious at a glance.

When a user runs story mcp-server:

  1. run(process.argv) is invoked
  2. Inside run(), runMcpServer(rootDir) is called → startMcpServer() starts listening on stdin
  3. run() returns 0 immediately (because startMcpServer() is an async pattern that "registers listeners and returns" — it doesn't block)
  4. process.exit(0) executes immediately → the process terminates
  5. The JSON-RPC request sitting in stdin never gets read by readline

The MCP Server died the moment it was born.

The Fix

#!/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)
}
Enter fullscreen mode Exit fullscreen mode

⚠️ Note: this fix looked fine at the time, but later that same day testing exposed its limitation — see "Bug #1.5: The Same Bug Returns" below.

The Deeper Lesson

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.


Bug #1.5: The Same Bug Returns — process.exit()'s Second Ghost

The Symptom

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

Nothing happened. The README was never updated.

Root Cause: The Whitelist-of-Commands Flaw

I looked back at the fix in bin/index.ts:

if (process.argv[2] !== "mcp-server" && process.argv[2] !== "mcp") {
  process.exit(exitCode)
}
Enter fullscreen mode Exit fullscreen mode

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 Fix: Extract the "Long-Running" Abstraction

The right fix isn't to enumerate even more commands — it's to extract the essential property of "which commands are long-running":

#!/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)
}
Enter fullscreen mode Exit fullscreen mode

The Deeper Lesson: Fix Bugs by Extracting an "Abstraction", Not Enumerating "Instances"

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.


Bug #2: The Fatal Pollution of console.log

The Surprise: After Fixing Bug #1, Some Responses Appeared

After fixing Bug #1, I was pleasantly surprised to see tools/list respond! But only these responded:

  • tools/list
  • initialize
  • Error responses for unknown tools ✅

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
Enter fullscreen mode Exit fullscreen mode

Still empty.

I tried a different angle — calling loadStories() directly in Node:

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);
"
Enter fullscreen mode Exit fullscreen mode

Output:

📊 01-测试故事: 自动计算字数为 约 13 字(未写回,使用 --save-counts 持久化)
📊 02-二创故事: 自动计算字数为 约 13 字(未写回,使用 --save-counts 持久化)
📊 03-English-Story: 自动计算字数为 ~7 words(未写回,使用 --save-counts 持久化)
STORIES: 3
Enter fullscreen mode Exit fullscreen mode

Found it! Inside loadStories(), a console.log was printing "auto-computed word count" diagnostic lines.

Why Can a Single 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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  • In unit tests, scan_stories's handler is called directly and nobody parses stdout → tests pass
  • In a real environment, the MCP client strictly parses stdout → immediate breakage

The Fix

// Before
if (!config.wordCount) {
  console.log(locale.autoWordCount(folder, story.wordCount, saveCounts))
}
Enter fullscreen mode Exit fullscreen mode
// 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))
}
Enter fullscreen mode Exit fullscreen mode

The console.log(locale.generatedText(...)) inside loadStoryContentAsync was changed the same way.

The Deeper Lesson

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.


Bug #3: The Async Race on readline close

Another Surprise

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))
  })
  // ...
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

This is an async race: close says "the input stream is closed", but it doesn't wait for your Promises to finish.

The Fix

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<Promise<void>>()

  rl.on("line", (line) => {
    const trimmed = line.trim()
    if (!trimmed) return
    let request: JsonRpcRequest
    try {
      request = parseRequest(trimmed)
    } catch (e) {
      const code =
        (e as Error & { code?: number }).code ?? JsonRpcErrorCode.InternalError
      process.stdout.write(
        serializeMessage(makeErrorResponse(null, code, (e as Error).message)),
      )
      return
    }
    // Track in-flight requests so we know the async handler has finished when stdin closes.
    const task = (async () => {
      const response = await handleRequest(request, rootDir, tools)
      if (response) process.stdout.write(serializeMessage(response))
    })()
    pending.add(task)
    task.finally(() => pending.delete(task))
  })

  rl.on("close", () => {
    // Wait for all in-flight requests to finish, then flush stdout before exiting (avoid truncated output).
    void Promise.allSettled([...pending]).then(() => {
      process.stdout.write("", () => process.exit(0))
    })
  })
  process.on("SIGINT", () => {
    rl.close()
  })
}
Enter fullscreen mode Exit fullscreen mode

The Deeper Lesson

In Node.js's event loop, readline's close event only means "the input stream closed", not "your async callbacks have run".

This 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:

  1. Maintain a set of all in-flight operations
  2. On close or SIGINT, wait with Promise.allSettled
  3. Only then call process.exit

The Takeaway: Test Layering

The biggest insight from this session was the value of layered testing:

Test layer Our previous coverage What it would catch
Unit tests (calling handler functions directly) ✅ 401 all green Can't catch Bug #1 / #2 / #3
Integration tests (calling startMcpServer without a real process) ❌ none
End-to-end tests (spawnSync a real child process + real stdin/stdout) ❌ none All 3 bugs at once

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.

// tests/mcp-server.test.ts (the end-to-end test we added)
function sendRequests(dir: string, requests: string[]) {
  const input = `${requests.join("\n")}\n`
  const result = spawnSync(process.execPath, [binPath, "mcp-server"], {
    cwd: dir,
    input,
    encoding: "utf-8",
    timeout: 5000,
  })
  return {
    stdout: result.stdout || "",
    stderr: result.stderr || "",
    status: result.status ?? -1,
  }
}

test("MCP server responds to async tools/call (scan_stories)", () => {
  const { stdout, stderr } = sendRequests(dir, [
    '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"scan_stories","arguments":{}}}',
  ])
  // stderr must not contain JsonRpcResponse content → guards against console.log/stdout pollution
  assert.ok(!stderr.includes("jsonrpc"))

  // Split by lines and filter empty lines instead of JSON.parse(stdout.trim()).
  // If stdout contains multiple lines, trim() only strips leading/trailing whitespace
  // and the inner newlines make JSON.parse fail.
  const lines = stdout
    .split("\n")
    .map((l) => l.trim())
    .filter(Boolean)
  assert.ok(lines.length >= 1, "there should be at least one JSON-RPC response")
  // Take the last line (or look up the line by id when multiple responses are requested)
  const response = JSON.parse(lines[lines.length - 1] ?? "{}")
  // ...
})
Enter fullscreen mode Exit fullscreen mode

This 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 to loadStories, this test fails immediately.

Follow-up (same day): after fixing Bug #1.5, we added an end-to-end regression test for build --watch (tests/watch.test.ts) — 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.


Appendix 1: The Complete Debugging Flow (for Reference)

# 1. Create a test repository
mkdir -p /tmp/test-story-cli && cd /tmp/test-story-cli
node /path/to/story-cli/bin/index.ts init
node /path/to/story-cli/bin/index.ts new "测试故事"

# 2. Start the MCP Server (find the problem)
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | node /path/to/story-cli/bin/index.ts mcp-server
# → empty output (Bug #1)

# 3. After fixing #1 → tools/list responds, but scan_stories doesn't (Bug #2's stdio pollution)

# 4. Verify loadStories behavior in isolation
node --experimental-strip-types -e "
import { loadStories } from './src/core/loader.ts';
await loadStories('/tmp/test-story-cli');
"
# → see the 📊 logs appearing on stdout

# 5. After fixing #2 → sometimes responds, sometimes not (Bug #3's async race)

# 6. Verify repeatedly via the end-to-end test
node --test tests/mcp-server.test.ts
# → 7 tests pass
Enter fullscreen mode Exit fullscreen mode

Appendix 2: The MCP Inspector Debugging Tool

Everything 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:

npx @modelcontextprotocol/inspector node /path/to/story-cli/bin/index.ts mcp-server
Enter fullscreen mode Exit fullscreen mode

MCP Inspector launches a visual web UI that lets you:

  • View the full tool list / parameter schemas (catch registration problems)
  • Call each tool one by one and inspect the raw responses (catch stdout pollution)
  • Check the protocol-layer communication logs (catch handshake failures / newline issues)

It's the "X-ray machine" of MCP Server development — I recommend every MCP Server developer run everything through the Inspector before CI/CD.

There are also third-party helpers in the community (like mcp-stdio-guard for catching stdout pollution), but the Inspector as the official tool covers most scenarios.

Appendix 3: Format Drift in the AI Interaction Layer

An MCP Server has to handle not only protocol traps (#1 / #2 / #3) but also traps in the AI interaction layer:

When create_story creates a directory, it converts spaces in the title to hyphens (e.g. "AI 创作的故事"02-AI-创作的故事), but the LLM may pass back the original space form ("02-AI 创作的故事") — safeFolder has to match both variants to hit the right directory.

Protocol-layer traps and interaction-layer traps — we stepped on all of them the same day.


Summary: Three Iron Rules + One Meta-Lesson

If you only take away three sentences, plus one lesson about fixing bugs themselves:

  1. process.exit() belongs only to one-shot CLI commands. Long-running processes (MCP Servers / watch mode / daemons) must have their exit controlled by input/signal callbacks. When making exceptions, extract the "long-running" abstraction — don't enumerate specific commands.
  2. stdout is a protocol channel, not a log channel. In any stdio protocol server, non-protocol output is pollution. Diagnostics belong on stderr.
  3. close ≠ all work finished. Use a pending Set + Promise.allSettled to explicitly wait for async work.
  4. Fix bugs by extracting an "abstraction", not by enumerating "instances". When you see an exclusion list like if (cmd !== "A" && cmd !== "B"), 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 end-to-end test.

Note 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() / 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.


This article is based on a real debugging session from the story-cli project. Repository: story-cli

Top comments (0)