作者使用 Claude Code 时发现 MCP stdio server 的 212 次工具调用中有 41 次失败,根源是 print() 等写入 stdout 破坏了 JSON-RPC 协议流。
我的 MCP stdio 服务器在每次测试时都运行良好。然后我在 Claude Code 中使用了它一周,并记录了每次调用:212 次工具调用,41 次失败。比例是 19%,而这个服务器只有六个工具,没有任何复杂的逻辑。
这 41 次失败中没有一次是工具本身的 bug。每一次都是因为有非 JSON-RPC 的字节落到了 stdout 上。其中一次是一个 debug print(),另外 40 次则更隐蔽——这正是我要写这篇文章的原因。
MCP stdio 服务器通过 stdout 与客户端通信。每条消息都是一行 JSON。stdout 上的任何其他字节都会破坏这个数据流。
print()、console.log() 以及继承了你 stdout 的子进程(如 subprocess.run(["git", "pull"]))都会往那个管道里写东西。
一个 print(..., end="") 不会破坏它自己那一行,但它会把自己粘到下一个 JSON 响应上,导致一个有效的回复被丢弃,调用一直挂起直到超时。
修复方法:把日志发送到 stderr,捕获子进程输出,并在启动时将文件描述符 1 指向 stderr,同时保留一个私有句柄用于协议通信。
添加一个冒烟测试,断言每条 stdout 行都能被解析为 JSON。这个测试本可以捕获全部 41 次失败。
用官方的 mcp SDK 在 Python 中实现的一个个人"第二大脑"MCP 服务器。六个工具:在笔记仓库中搜索、读取一条笔记、列出最近笔记、向每日日志追加内容、在项目中 grep,以及总结一个 git diff。Claude Code 启动它作为一个 stdio 子进程,和大多数本地 MCP 服务器一样。
我用一个装饰器包装了每个工具处理器,记录工具名称、耗时以及客户端是否收到了响应。七天后我有了一张不喜欢的电子表格。
因为在 stdio 传输模式下,stdout 就是协议本身。MCP 规范规定消息是换行分隔的 JSON-RPC,服务器不能往 stdout 写入任何不是有效 MCP 消息的内容。日志应该发送到 stderr。
客户端逐行读取 stdout,并将每行解析为 JSON。当某一行不是 JSON 时,接下来发生什么取决于客户端:日志里出现解析错误、消息被丢弃、或者连接断开。在我的设置中主要是前两种,这比崩溃更糟糕。崩溃了你还能注意到。被静默丢弃的消息看起来就像是一个慢工具。
子进程会继承父进程的文件描述符。如果你不捕获输出地调用 subprocess.run(),子进程直接写入 fd 1,而在 MCP stdio 服务器中,fd 1 就是协议管道。
我的搜索工具在本地索引超过 10 分钟时会刷新笔记仓库:
def refresh_repo(path: str) -> None:
subprocess.run(["git", "-C", path, "pull", "--ff-only"], check=True)
git pull 会往 stdout 打印 Already up to date. 或者一个 Fast-forward 摘要。我的 Python 代码从未触碰 sys.stdout,所以我没有怀疑它。但 git 的输出和我的 JSON 走了同一条管道,客户端在遇到 Already up to date. 时就卡住了。
这就是为什么这些失败看起来是随机的。它们只在触发刷新的调用时发生,而这取决于我离开键盘多久。41 次失败中有 23 次就是这一行文字。
grep -rn "print(" . 在这里找不到任何东西。你得 grep subprocess、os.system 以及任何会 shell 出去的东西。
这个浪费了我两个晚上。索引器这样打印进度:
print(f"indexing {len(files)} files...", end="")
没有换行。那段文字停留在缓冲区中,当 SDK 写入下一个响应时,两条内容变成了一行:
indexing 1840 files...{"jsonrpc":"2.0","id":7,"result":{...}}
这段进度文本不是客户端可以跳过的单独坏行,它把一个正确的响应变成了无法解析的东西。响应 id: 7 永远不到达,所以客户端一直等待直到请求超时。从外面看,这个工具就是挂起了。
杂散字节具体落在哪里取决于缓冲机制,这就是为什么不是每次都发生。14 次失败,全部发生在重建索引的调用上。
except Exception as e:
print(e)
return error_result(str(e))
它只在工具已经失败时才会运行,所以用户无论如何都会看到"工具坏了"。四次调用。这是每个 MCP 教程都警告过的 bug,但它只占我失败总数的最小份额。
因为我是通过在终端中运行服务器并粘贴 JSON 来测试的。在终端里,stdout 和 stderr 显示在同一屏幕上,所以进度文本和 JSON 混在一起看起来完全正常。你的眼睛会原谅 JSON 解析器不会原谅的东西。
另外,我的手动测试从未在调用之间等待 10 分钟,所以 git pull 路径从未运行过。
把 stdout 从所有人手里夺走,只留给传输层。在启动时获取 fd 1 的一个私有副本,然后将 fd 1 指向 stderr。现在每个 print()、每个写入 stdout 的库、以及每个子进程都写到 stderr。只有 MCP 传输保留真正的管道。
import io
import os
import sys
import anyio
from mcp.server.stdio import stdio_server
# Keep a private handle to the real stdout for the protocol.
_proto_fd = os.dup(1)
# Point fd 1 at stderr so print(), libraries, and child processes can't reach the pipe.
os.dup2(2, 1)
sys.stdout = sys.stderr
proto_out = anyio.wrap_file(
io.TextIOWrapper(os.fdopen(_proto_fd, "wb"), encoding="utf-8")
)
async def main() -> None:
async with stdio_server(stdout=proto_out) as (read, write):
await server.run(read, write, server.create_initialization_options())
在我使用的 Python SDK 版本中,stdio_server() 接受可选的 stdin/stdout 参数。检查你版本的签名。在 Node 中,相同的思路是是在任何其他东西加载前保存流,但用 console.error 记录日志能让你走完大部分路程。
我还从源头修复了每个泄漏,因为隐藏 bug 不等于修复 bug:
logging.basicConfig(stream=sys.stderr, level=logging.INFO)。Python 的默认处理器已经写入 stderr,但显式指定可以防止未来的重构改变它。
每个 subprocess.run 都加了 capture_output=True, text=True。如果我想要输出,我自己把它记录到 stderr。
进度指示器改成了 logger.debug 调用。
把真正的服务器作为子进程启动,用真实的 JSON-RPC 驱动它,如果任何 stdout 行不是 JSON 就失败。这大概 30 行代码,在 CI 中运行不到两秒:
import json, subprocess, sys
proc = subprocess.Popen(
[sys.executable, "server.py"],
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, text=True,
)
def send(msg):
proc.stdin.write(json.dumps(msg) + "\n")
proc.stdin.flush()
def recv():
line = proc.stdout.readline()
try:
return json.loads(line)
except json.JSONDecodeError:
sys.exit(f"stdout pollution: {line!r}")
send({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
"protocolVersion": "2025-06-18", "capabilities": {},
"clientInfo": {"name": "smoke", "version": "0"}}})
recv()
send({"jsonrpc": "2.0", "method": "notifications/initialized"})
send({"jsonrpc": "2.0", "id": 2, "method": "tools/list"})
tools = recv()["result"]["tools"]
for i, tool in enumerate(tools, start=3):
send({"jsonrpc": "2.0", "id": i, "method": "tools/call",
"params": {"name": tool["name"], "arguments": SAMPLE_ARGS[tool["name"]]}})
assert recv()["id"] == i, f"{tool['name']} lost its response"
proc.terminate()
print("stdout clean")
对 id 的 assert 捕获了粘行情况:丢失的响应意味着你读取的下一条消息有错误的 ID。为了让 git pull 路径运行起来,设置 SAMPLE_ARGS 使得至少有一个调用强制触发刷新。把你的慢路径放到测试里,而不只是快路径。
下一周我记录了 188 次工具调用,零次帧错误。仍有两次调用失败,都是真实的错误(一个不存在的笔记路径),而且都作为正确的 JSON-RPC 错误返回了,而不是挂起。
这个 bug 的总代价:三个晚上的调试、大约 40 次我手动重试的工具调用,以及一次对我看待 stdio 的方式的改变。在 stdio 服务器中,stdout 不是控制台。它是一根导线,任何你丢上去的东西都是流量。
MCP stdio 服务器使用 stdout 作为 JSON-RPC 传输,客户端把每一行解析为协议消息。单个 print()、console.log() 或者继承你 stdout 的子进程都会向数据流中添加非 JSON 字节。最好的情况是客户端记录一个解析错误。最坏的情况是,像 print(..., end="") 那样,杂散文本与有效响应合并,响应丢失,工具调用挂起直到超时。把所有日志发送到 stderr,捕获子进程输出,在启动时将 fd 1 重定向到 stderr,同时保留一个私有句柄用于传输,并添加一个冒烟测试在任何不是 JSON 的 stdout 行上失败。
Written by the developer behind Preterview, an interview prep platform.