// Created At 2026-08-16// P5
// TypeScript · LLM · Engineering

MCP stdio 协议的 3 个隐秘陷阱:当单元测试全绿,但 MCP Server 无法工作

本文记录了一次真实的 MCP Server 调试经历:story-cli 的自动化测试全部通过,但 MCP Server 在真实环境中完全无法响应任何请求。最终排查出 3 个 Bug,每一个都涉及 Node.js 进程模型与 stdio 协议的底层细节。


TL;DR

如果你正在开发 MCP Server(或者任何基于 stdio 协议的长期运行进程),请记住三条铁律:

  1. 永远不要在 run() 函数中调用 process.exit() —— MCP Server 是长期运行的守护进程,不是 CLI 工具。process.exit() 会在你开始监听 stdin 之前就把进程杀掉。
  2. 永远不要在 stdout 上打印任何调试日志 —— stdout 是 MCP 协议通道,任何非 JSON-RPC 的输出都会污染消息流,导致客户端无法解析任何响应。诊断信息请走 stderr。
  3. 永远在 close 事件中等待所有异步操作完成 —— close 只代表输入流关闭,不代表你的回调已执行完毕。你需要在退出前等待所有 in-flight 的 Promise 结束。

背景:story-cli 的 MCP Server

先介绍一下这个项目。story-cli 是一个零部署、Git 原生的 Markdown 内容管理 CLI。它用简单的目录约定(NN-名称/ 包含 config.json + text.md)管理故事/论文/笔记/教程,自动生成 README,导出 EPUB,中英双语。

在我们的 ROADMAP 中,MCP Server 是 P0 级战略任务——AI 时代的入口。设计原则是:“AI 只负责思考,CLI 负责治理”

我们通过 JSON-RPC 2.0 over stdio 协议暴露了 6 个工具:

MCP 工具 功能
scan_stories 列出所有故事及元数据
read_chapter 读取指定故事的章节内容
write_chapter 将正文写入指定故事(原子写入)
validate 校验所有故事的 config.json 合法性
build 触发 README 重建
import_json 从结构化 JSON 批量导入故事

代码结构非常干净:

src/mcp/
├── protocol.ts   # JSON-RPC 2.0 协议解析/序列化(纯函数,有完整测试)
├── tools.ts      # MCP 工具注册(复用 core/loader.ts 共享逻辑)
└── server.ts     # stdio 服务器启动与请求分发

一切看起来都很完美——直到我们真正去调用它


现象:自动化测试全绿,但真实请求无响应

我们当时有 404 个自动化测试,401 通过。其中 tests/mcp.test.ts 覆盖了协议解析、序列化、工具注册、所有工具的 handler——全部通过

于是我在真实的故事仓库中启动 MCP Server,通过管道发送 JSON-RPC 请求:

echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | node bin/index.ts mcp-server

💀 输出为空。 没有任何响应。

我以为是我的管道写法有问题。换了好几种方式:

# 方式 1:printf
printf '{"jsonrpc":"2.0","id":1,"method":"tools/list"}\n' | node bin/index.ts mcp-server

# 方式 2:文件重定向
printf '{"jsonrpc":"2.0","id":1,"method":"tools/list"}\n' > /tmp/req.json && node bin/index.ts mcp-server < /tmp/req.json

# 方式 3:保持 stdin 打开
{ printf '{"jsonrpc":"2.0","id":1,"method":"tools/list"}\n'; sleep 2; } | node bin/index.ts mcp-server

全部无响应。

更诡异的是,通过 Node.js 的 spawnSync 发送请求时,进程的退出码是 0(看起来”成功了”),但 stdout 和 stderr 都是空白。

那一刻我意识到:这不是调用方式的问题,是我们的 MCP Server 有 Bug。

但 404 个测试全绿啊!怎么会有 Bug?


Bug #1:process.exit() 的幽灵

根因排查

我先去看 CLI 的入口文件 bin/index.ts

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

const exitCode = await run(process.argv)
process.exit(exitCode)

问题一目了然。

当用户执行 story mcp-server 时:

  1. run(process.argv) 被调用
  2. run() 内部调用 runMcpServer(rootDir) → 调用 startMcpServer() → 开始监听 stdin
  3. run() 立即返回 0(因为 startMcpServer() 是”注册完监听器就返回”的异步模式,不会 block)
  4. process.exit(0) 立即执行 → 进程终止
  5. stdin 中的 JSON-RPC 请求还没来得及被 readline 读取

MCP Server 刚出生就死了。

修复

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

const exitCode = await run(process.argv)

// MCP server 需要保持进程存活持续监听 stdin
// 进程退出由 server.ts 内部的 close/SIGINT 事件处理
if (process.argv[2] !== "mcp-server" && process.argv[2] !== "mcp") {
  process.exit(exitCode)
}

深层教训

这是 CLI 工具转服务化时的第一坑

模式 生命周期 退出时机
CLI 工具 执行完命令就退出 process.exit(exitCode) 是正确做法
长期运行进程(MCP Server / 守护进程) 持续监听输入直到 EOF/信号 退出必须由输入源触发的回调控制

process.exit() 是无条件的、立即的、不可中断的。它不会等待 pending 的 IO、定时器或 Promise。在 MCP Server 的场景下,这个”特性”直接杀死了我们的 server。


Bug #2:console.log 的致命污染

惊喜:修好 Bug #1 后出现了部分响应

修复了 Bug #1 后,我惊喜地发现 tools/list 开始有响应了!但有响应的是:

  • tools/list
  • initialize
  • 未知工具的错误响应 ✅

异步的 tools/call 仍然无响应scan_stories / read_chapter / validate)。

我单独测试 scan_stories

echo '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"scan_stories","arguments":{}}}' | node bin/index.ts mcp-server

还是空。

我换了个思路——直接在 Node 环境中调用 loadStories()

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);
"

输出:

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

找到了! loadStories() 内部用 console.log 输出了”自动计算字数”的诊断日志。

为什么某个 console.log 就能杀死 MCP?

MCP 的 stdio 传输规范是 stdout 是协议专用通道

├── stdin ← 客户端发送 JSON-RPC 请求
├── stdout → 服务器返回 JSON-RPC 响应(协议专用,唯一合法输出)
└── stderr → 日志/警告/错误(人看的,不是协议看的)

当 MCP 客户端发送 scan_stories 请求,MCP Server 处理时先调用了 loadStories()console.log 往 stdout 吐了一行 📊 01-测试故事: ... 日志。此时 stdout 变成了:

📊 01-测试故事: 自动计算字数为 约 13 字...      ← 污染!
{"jsonrpc":"2.0","id":3,"result":{...}}        ← 真正的响应

MCP 客户端(VSCode / Claude Desktop / Cursor)在解析 stdout 时,期望每一行都是合法的 JSON-RPC 消息。结果第一行根本不是 JSON——

客户端直接放弃解析,表现为”无响应”。

顺带一提,MCP 的 stdio 传输还有一个换行符的硬性要求:每条 JSON-RPC 消息必须以 \n(换行符)结尾。如果你的服务器输出了一条不带换行的 JSON,客户端也会解析失败。这就是为什么 MCP 官方文档的 Debugging 页面明确指出:

“Local MCP servers should not log messages to stdout (standard out), as this will interfere with protocol operation.”

——官方早就警告过,但我们直到真实环境踩坑才真正理解这句话。

而且这种 Bug 特别隐蔽:

  • 单测环境中,scan_stories 的 handler 被直接调用,stdout 内容没人解析 → 测试通过
  • 真实环境中,MCP 客户端严格解析 stdout → 立刻崩溃

修复

// 修复前
if (!config.wordCount) {
  console.log(locale.autoWordCount(folder, story.wordCount, saveCounts))
}
// 修复后
if (!config.wordCount) {
  // 使用 stderr 输出诊断信息,避免污染 MCP stdio 协议的 stdout 通道
  console.error(locale.autoWordCount(folder, story.wordCount, saveCounts))
}

同时 loadStoryContentAsync 中的 console.log(locale.generatedText(...)) 也一并改掉。

深层教训

stdio 协议中的 stdout 不是给你打日志的。 它是两个进程之间的协议通道。任何额外的输出——哪怕是看起来无害的一行日志——都会导致协议解析失败。

这是一个运行时静默失败的问题:代码不会抛异常,测试不会失败,只有真实客户端会”莫名其妙”不工作。

在 MCP Server 中,stdout = 协议stderr = 日志。永远不要混用。


Bug #3:readline close 的异步竞态

又一个意外

修复了 Bug #2 后,我以为一切搞定了。但测试发现 tools/call 仍然有概率性无响应:有时能收到响应,有时不行。

我盯着 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) => {
    // ... 解析并处理请求
    const response = await handleRequest(request, rootDir, tools)
    if (response) process.stdout.write(serializeMessage(response))
  })

  rl.on("close", () => {
    // 等待 stdout 刷新后再退出(避免输出被截断)
    process.stdout.write("", () => process.exit(0))
  })
  // ...
}

在管道模式下(echo '...' | node bin/index.ts mcp-server),stdin 在读入所有行后立即关闭,触发 close 事件。close 触发时,rl.on("line") 中的异步 await handleRequest() 还没执行完!

时序是这样的:

时间 t0:  stdin 收到 JSON-RPC 请求行
时间 t1:  rl 触发 "line" 事件,进入 async 回调
时间 t2:  async 回调遇到 await handleRequest(),挂起(黄色区域 = 等待异步结果)
时间 t3:  stdin 读完所有行 → 触发 rl "close" 事件
时间 t4:  "close" 回调执行 process.stdout.write("", () => process.exit(0))
时间 t5:  进程退出,await handleRequest() 还没恢复 → 响应永远丢失

这就是异步竞态close 通知”输入流已关闭”,但它不等你的 Promise 完成。

修复

pending Set 跟踪所有 in-flight 请求,在 close 时等待它们全部完成再退出:

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
    }
    // 跟踪 in-flight 请求,确保 stdin 关闭时异步 handler 已完成
    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", () => {
    // 等待所有 in-flight 请求完成后刷新 stdout 再退出(避免输出被截断)
    void Promise.allSettled([...pending]).then(() => {
      process.stdout.write("", () => process.exit(0))
    })
  })
  process.on("SIGINT", () => {
    rl.close()
  })
}

深层教训

在 Node.js 的事件循环中,readlineclose 事件只代表”输入流关闭”,不代表”你的异步回调已执行”

这是所有 stdio 协议服务器的通用问题:stdin EOF 到达时,你可能仍然有 queued 的 Promise。你需要显式地跟踪和等待它们:

  1. 用一个集合维护所有 in-flight 操作
  2. closeSIGINT 时用 Promise.allSettled 等待
  3. 然后再执行 process.exit

启发:测试的分层

这次调试给我最大的启发是测试的分层价值

测试层级 我们之前的覆盖 发现的问题
单元测试(直接调用 handler 函数) ✅ 401 个全绿 无法发现 Bug #1 / #2 / #3
集成测试(调用 startMcpServer 但不走真实进程) ❌ 没有
端到端测试(spawnSync 真实子进程 + 真实 stdin/stdout) ❌ 没有 一次性暴露全部 3 个 Bug

单元测试全绿不代表系统可用。 你需要在真正的进程中启动 server,通过真正的管道发送请求,解析真正的 stdout——因为只有端到端测试能捕捉”进程生命周期”和”协议完整性”这两个层面的问题。

// tests/mcp-server.test.ts(我们新增的端到端测试)
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 能响应异步 tools/call(scan_stories)", () => {
  const { stdout, stderr } = sendRequests(dir, [
    '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"scan_stories","arguments":{}}}',
  ])
  // stderr 不应包含 JsonRpcResponse 内容 → 防止 console.log/stdout 污染
  assert.ok(!stderr.includes("jsonrpc"))

  // 按行分割 + 过滤空行,而不是直接 JSON.parse(stdout.trim())。
  // 如果 stdout 混入了多行输出,trim() 只去首尾空白,中间换行会导致 JSON.parse 失败。
  const lines = stdout
    .split("\n")
    .map((l) => l.trim())
    .filter(Boolean)
  assert.ok(lines.length >= 1, "应至少有一条 JSON-RPC 响应")
  // 取最后一条(如果请求了多个响应,也可以按 id 查找对应行)
  const response = JSON.parse(lines[lines.length - 1] ?? "{}")
  // ...
})

这个测试会在真实子进程中启动 MCP Server,通过真正的管道发送 JSON-RPC 请求,并验证 stdout 的内容。如果未来有人往 loadStories 加一个 console.log,这个测试会立即失败。


附 1:完整的排查流程(供参考)

# 1. 创建测试仓库
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. 启动 MCP Server(发现问题)
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | node /path/to/story-cli/bin/index.ts mcp-server
# → 空输出(Bug #1)

# 3. 修复 #1 后 → tools/list 有响应,但 scan_stories 无响应(Bug #2 的 stdio 污染)

# 4. 单独验证 loadStories 的行为
node --experimental-strip-types -e "
import { loadStories } from './src/core/loader.ts';
await loadStories('/tmp/test-story-cli');
"
# → 看到 📊 日志出现在 stdout

# 5. 修复 #2 后 → 有时有响应有时没(Bug #3 的异步竞态)

# 6. 通过端到端测试反复验证
node --test tests/mcp-server.test.ts
# → 7 tests pass

附 2:补充调试工具 MCP Inspector

以上是”事后排查”的思路。如果你在开发阶段就接入 MCP Inspector(MCP 官方调试工具),很多问题可以在发布前被提前发现:

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

MCP Inspector 会启动一个可视化 Web 界面,让你:

  • 查看所有工具列表 / 参数 schema(发现注册问题)
  • 逐个调用工具并观察原始响应(发现 stdout 污染)
  • 检查协议层通信日志(发现握手失败 / 换行符问题)

它是 MCP Server 开发的”X 光机”——推荐所有 MCP Server 开发者在 CI/CD 前先过一遍 Inspector。

社区还有一些第三方辅助工具(如 mcp-stdio-guard 用于捕获 stdout 污染),但 Inspector 作为官方工具足以覆盖大部分场景。


总结:三条铁律

如果你只带走三句话:

  1. process.exit() 只属于 CLI 工具。长期运行的 server 进程必须由输入流/信号回调控制退出。
  2. stdout 是协议通道,不是日志通道。MCP Server 中任何非 JSON-RPC 的 stdout 输出都是污染。
  3. close ≠ 所有操作完成。用 pending Set + Promise.allSettled 显式等待异步操作。

这三个 Bug 的共同点是:它们无法通过单元测试发现,只能在真实进程环境中暴露。所以——写完 handler 后,别忘了写一个 spawnSync 端到端测试。

请注意,这三条铁律是语言无关的——无论你是用 Node.js、Python 还是 Go 开发 MCP Server,process.exit() / stdout 污染 / 异步未等待 这三类坑都存在。本文以 Node.js 为例,只是因为我们的项目恰好是 Node 栈。

你遇到过类似的 stdio 协议坑吗?欢迎在评论区分享你的故事。


本文基于 story-cli 项目的真实调试经历撰写。项目地址:story-cli

如果这篇文章对你有帮助,可以请我喝杯咖啡 ☕️
Ali PayWechat Pay
© 2026 MOONGATE