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

> Source: <https://dev.to/yuelinghuashu/mcp-stdio-protocols-3-hidden-traps-when-all-unit-tests-pass-but-the-mcp-server-wont-respond-53l6>
> Published: 2026-08-23 10:22:22+00:00

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<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()
  })
}
```

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:

`close`

or `SIGINT`

, wait with `Promise.allSettled`

`process.exit`

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] ?? "{}")
  // ...
})
```

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.

```
# 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
```

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
```

MCP Inspector launches a visual web UI that lets you:

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.

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.**

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

`process.exit()`

belongs only to one-shot CLI commands.`close`

≠ all work finished.`pending`

Set + `Promise.allSettled`

to explicitly wait for async work.`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](https://github.com/yuelinghuashu/story-cli)
