版权与内容来源声明
本文为原创整理。文中涉及官方文档、开源仓库、论文与公开报道的内容,均在附表 A 中标注来源;引用官方原文保持原样,不作改写。文中命令、版本号与界面截图以本文成文时的实测/核验结果为准,标注「待验证」的部分请以你本地环境实际输出为判断依据。本文不推荐任何不合规的软件获取方式,也不对任何收益结果作承诺。转载请注明出处。

先看现场:stdout 上有 2 行,能解析的只有 1 行

客户端不是「没收到结果」。它收到了——只是第一行不是 JSON,于是它在读到第一行的那一刻,就把这条连接判死了。

你自己跑一遍就能看到这个场面:服务端返回的那段响应,字节确实写在管道里,位置就在第二行,一个字符都不缺;客户端那边给出的却只有一句「解析失败」。两边都没有说谎,它们只是对「这一行是什么」的理解不在同一个层面上。

如果你在本地把本文第二章的脚本跑一遍,会看到三种模式下 stdout 分别是 1 行、2 行、1 行,而能按行独立解析成 JSON 的始终是 1 行。差别不在「有没有返回结果」,在于第一行是不是那行结果。这个落差就是整篇文章要拆的东西:一行看起来完全无害的调试输出,为什么能让一个协议调用整体失效。

那行多出来的东西,看起来完全无害

一句 print("[debug] tools/list 开始处理")。它在命令行程序里是最朴素的调试手段,写上几年也不会出事,任何一本入门书都这么教。

但 stdio 传输里没有 HTTP 状态码,没有 Content-Type,没有 Content-Length。两个进程之间只有一根管道,管道里全是字节,接收方要知道「一条消息从哪结束」,唯一的依据就是换行符。所以这根管道不是「控制台」,它是协议信道:写进去的每一个字节,都会被对方当成协议数据去解析,没有例外。

把三个词先说清楚,后面的论证全靠它们:

  • stdio:标准输入、标准输出、标准错误这三个文件描述符(stdin / stdout / stderr)。进程间最古老也最省事的通信方式,不需要端口,不需要 socket,父进程拉起子进程就能用。
  • JSON-RPC:一种用 JSON 表达「调用哪个方法、参数是什么、结果是什么」的远程调用约定。请求是一段 JSON,响应也是一段 JSON,没有别的东西。
  • 帧(framing):在一条连续的字节流上,怎么切出「一条完整消息」。切错了,后面全乱;切对了,一条消息就是一个可解析单元。

客户端只读一行,读到坏的就不再往下读

客户端的读法非常朴素:从 stdout 读一行,json.loads 解析一次,成功就派发出去,失败就报错并终止。它不会去想「下一行可能是好的」——因为在协议的定义里,一行就是一帧,第一帧坏了就等于这次会话坏了。

本文那个实测脚本的统计里,污染模式下「首个不合格帧的行号」是 0 号行。这个 0 很关键:出问题的不是最后一行,是第一行;真正的响应老老实实待在它后面一行,客户端根本走不到那里。也就是说,那行日志不是「覆盖」了响应,也不是「挤掉」了响应,它只是站在了门口,让接收方在看到门里的东西之前就转身走了。

顺带解释一个常见误判:有人看到「可解析行数 = 1」就说「响应到了,问题在客户端」。这个结论是反的。协议要求的是每一帧都合法,不是「至少有一帧合法」。客户端在第一帧失败后终止会话,是符合规范的行为,不是它脆。

为什么这个毛病只在「被拉起」之后才出现

同样的代码,你在终端里直接跑是看不出问题的:stdout 和 stderr 都打在屏幕上,混杂在一起,多一行提示谁也不觉得碍事。一旦客户端把它当子进程拉起来、只接管 stdout,这行日志的身份就从「提示」变成了「协议数据」——字节没变,语义变了。

场景stdout 指向哪谁在解析这根管道多出那一行的后果
终端里直接跑终端屏幕人眼只是多一行提示,看不出问题
被客户端当子进程拉起管道,客户端独占客户端的协议栈,逐行解析第一帧解析失败,会话终止
用 shell 重定向存成文件文件后面按行读它的脚本取决于读的脚本,通常第一行就抛错

这张表的关键是第三列:谁去解析这根管道。同一个字节流,被终端渲染和被协议栈解析,后果完全不同。所谓「本地一直好的,接进客户端就崩」,不需要任何运气成分来解释,它就是这一列换了个人。

把故障压进父子进程:同一段代码,只换输出目标

要复现它,不需要真装一个 MCP 客户端。一个父进程、一个子进程、一条 stdin 请求就够了,标准库足够,没有第三方依赖。

父子进程加一条请求,就能把信道还原出来

父进程用 subprocess.run 拉起子进程,把一条 tools/list 请求从 input 塞进子进程的 stdin,再用 capture_output=True 把子进程的 stdout 和 stderr 分别收回来。这一步是整个复现的核心——分开收。终端里两根管子混在一起,你永远看不出谁污染了谁;分开收,污染立刻现形。

子进程代码用 -c 塞进一个字符串常量里,是为了让整段脚本自包含:读者复制一整个文件就能跑,不用先建目录、再建两个文件、再对路径。这也是这类最小复现该有的样子——把变量减到只剩你要验证的那一个。

为什么不用 import 而在同进程里调用?因为同进程里没有管道,没有两个文件描述符的分离,也就没有「往错的地方写」这回事。故障的载体是进程之间那根管子,把进程去掉,故障也就跟着没了。

子进程只做两件事:读一行请求,写一行响应

代码不长,逻辑就三条:

  • sys.stdin.readline() 读到换行就返回。请求是 {"jsonrpc": "2.0", "id": 7, "method": "tools/list"} 加一个换行符。
  • 按模式决定那句 debug 去哪:polluted 打到 stdout,fixed 打到 stderr,clean 什么都不打。
  • 响应 json.dumps 之后补一个换行,写进 stdout,然后 flush。

flush 那一步不能省:管道有缓冲区,不 flush 的话父进程可能一直等不到数据。这一条也是 stdio 传输的常见坑,但它和本文的故障是两回事——那个坑是「没写出去」,本文的故障是「写出去了,但写错了管子」。两者的现象很像(客户端都拿不到结果),排查方向完全相反:前者查缓冲,后者查输出目标。

三个模式跑同一份子进程代码

三个模式共用同一段 CHILD 字符串,唯一的差别是那句 debug 的输出目标。clean 是基线,用来确认这条链路本身是通的;polluted 复现故障;fixed 是干预后的样子。父进程的判定逻辑只有四步:按行切、逐行解析、数成功几个、记下第一个失败的行号。

🧪 实测环境:Python 3.13.12 / macOS / 仅用标准库

import json
import subprocess
import sys

CHILD = r'''
import json, sys
mode = sys.argv[1]
req = json.loads(sys.stdin.readline())
if mode == "polluted":
    print("[debug] tools/list 开始处理")          # 调试信息打到 stdout
elif mode == "fixed":
    print("[debug] tools/list 开始处理", file=sys.stderr)  # 改到 stderr
resp = {"jsonrpc": "2.0", "id": req["id"], "result": {"tools": []}}
sys.stdout.write(json.dumps(resp, ensure_ascii=False) + "\n")
sys.stdout.flush()
'''

def probe(mode):
    req = json.dumps({"jsonrpc": "2.0", "id": 7, "method": "tools/list"}) + "\n"
    p = subprocess.run([sys.executable, "-c", CHILD, mode],
                       input=req, capture_output=True, text=True, timeout=20)
    lines = [ln for ln in p.stdout.splitlines() if ln.strip()]
    parsed, first_bad = 0, None
    for i, ln in enumerate(lines):
        try:
            json.loads(ln)
            parsed += 1
        except json.JSONDecodeError:
            if first_bad is None:
                first_bad = i
    return mode, len(lines), parsed, first_bad, p.stderr.strip()

print("模式       stdout 行数  可解析行数  首个不合格帧的行号  stderr 行数")
for mode in ("clean", "polluted", "fixed"):
    m, total, parsed, bad, err = probe(mode)
    print(f"{m:9s} {total:>10d} {parsed:>11d} {str(bad if bad is not None else '-'):>19s} {len(err.splitlines()):>10d}")
模式       stdout 行数  可解析行数  首个不合格帧的行号  stderr 行数
clean              1           1                   -          0
polluted           2           1                   0          0
fixed              1           1                   -          1

逐行读这张输出。clean 那行是基线:stdout 1 行,可解析 1 行,没有不合格帧,stderr 0 行,链路是通的。polluted 那行是故障:stdout 变成 2 行,可解析仍然只有 1 行,不合格帧出现在 0 号行,stderr 还是 0 行——那句话既没丢,也没被覆盖,它只是站错了管子。fixed 那行是干预后:stdout 回到 1 行、可解析 1 行、不合格帧回到「没有」,而 stderr 从 0 行变成 1 行。

最后一列的变化最能说明问题:日志没有消失,它换了一根管子。 这正是修复的正确形态——不是把调试信息删掉换一个「看起来干净」的输出,而是把它送回它该待的地方。

这段代码能复现故障,靠的只是那 1 行

把 CHILD 里三个分支并排看,差异只有一处:print(...) 末尾那个 file=sys.stderr。行数没变,缩进没变,返回的 JSON 一个字没变,变的只是「这句话写进哪根管子」。

第一件事:消息的边界是换行,不是括号

规范原文写得很直接(MCP 规范 stdio 传输页,协议版本 2026-07-28):

“The server reads JSON-RPC messages from stdin and writes JSON-RPC messages to stdout.”

这句话把两件事绑在一起:读和写都走标准流,而且只有标准流。它没有写「也可以顺便打印点别的」,也没有给「少量调试信息」开口子。

子进程那边 readline() 能工作,前提就是一条消息到换行为止。这个前提在客户端那侧同样成立——客户端也是按行读。所以父进程那四步判定(按行切、逐行解析)不是随便定的,它就是在模仿客户端的读法。你用它测出来的结论,和真实客户端看到的东西是同一个东西。

第二件事:stdout 是数据面,stderr 是控制面

同一页上还有两句,把两根管子的职责划得非常干净:

“The server MAY write UTF-8 strings to stderr for any logging purposes including informational, debug, and error messages.”

“The server MUST NOT write anything to its stdout that is not a valid MCP message.”

MAY 和 MUST NOT 摆在一起看,结论就很硬:日志有专门的出口,并且必须走那个出口。规范没有给「就一行」「本地调试用」「上线前会删」留任何例外——协议不看你写了多少,只看你写在哪。

  • 数据面(stdout):只跑协议消息,一个字节都不许多。客户端对这根管子的假设是「每一行都能解析」。
  • 控制面(stderr):随便写,客户端按约定不去解析它,最多转进自己的日志文件。
通道允许写什么规范怎么约束违反了会怎样
stdin客户端发来的请求,一行一帧服务端只读读法错了会阻塞,不会污染响应
stdout合法的 MCP 消息,一行一帧MUST NOT 写任何非 MCP 消息客户端第一帧解析失败,会话终止
stderr任意 UTF-8 文本(信息、调试、错误)MAY,且不受帧约束不影响协议,最多混进客户端日志

第三件事:判定标准是「每行都能独立解析」

父进程那段统计没有任何黑魔法:按行切、逐行 json.loads、数成功几个、记下第一个失败的行号、再数 stderr 有几行。这四步合起来就是一个最小版的协议合规检查器,十几行,标准库,不需要装任何 MCP 相关的东西。

它也顺手解释了为什么这类问题在代码评审里很难被看出来:评审看的是「这段逻辑对不对」,而故障出在「这段输出写到哪」。前者有类型检查、有单测、有返回值可以断言;后者只有一个隐式的约定——你不能往这根管子里写别的。隐式约定最容易被一句「先打个日志看看」破掉。

为什么换行是这个协议的硬边界

因为在这个传输上,除了换行没有第二个可用的分隔符。没有长度前缀,没有分隔符转义,也没有「一条消息一个连接」。

规范把这条边界写成了 MUST NOT

“Messages are delimited by newlines, and MUST NOT contain embedded newlines.”

这句把因果关系说全了:前半句是机制(换行分帧),后半句是机制成立的前提(消息内部不许有换行)。只要允许内嵌换行,「按行切」就不再等价于「按消息切」,接收方无法仅凭字节流判断边界,只能猜。

换一种做法要付什么代价,也能算出来:加长度前缀,就得先解析一个头部再解析正文,还要处理头部本身损坏的情况;用 EOF 分隔,就得一条消息一个连接,连接建立的开销全摊到每次工具调用上。换行分帧是这几条里实现成本最低的一条,它把代价转移到了约束上——不许在消息里换行。约束越简单,越容易被一行 print 破掉,这是这类协议的固有弱点,不是某个实现的问题。

缩进两格,是最容易把信道写坏的写法

第二个实测块换了个方向:不看日志,看格式化。同一个响应对象,用两种方式序列化,再按换行切帧,数一数切出来几帧、有几帧能独立解析。

🧪 实测环境:Python 3.13.12 / macOS / 仅用标准库

import json

msg = {"jsonrpc": "2.0", "id": 1,
       "result": {"tools": [{"name": "read_file", "description": "读工作目录内的文件"},
                            {"name": "run_shell", "description": "执行一条本地命令"}]}}

for label, payload in (("单行输出", json.dumps(msg, ensure_ascii=False)),
                       ("缩进两格", json.dumps(msg, indent=2, ensure_ascii=False))):
    frames = [ln for ln in payload.splitlines() if ln.strip()]
    ok = 0
    for ln in frames:
        try:
            json.loads(ln)
            ok += 1
        except json.JSONDecodeError:
            pass
    print(f"{label}:{len(payload.encode())} 字节,按换行切成 {len(frames)} 帧,可独立解析 {ok} 帧")
单行输出:185 字节,按换行切成 1 帧,可独立解析 1 帧
缩进两格:267 字节,按换行切成 16 帧,可独立解析 0 帧

185 字节的单行版本,切出 1 帧,1 帧可解析;267 字节的缩进版本,切出 16 帧,0 帧可解析。一个完全合法的 JSON 文档,被换行切成了 16 个不合法的片段。 注意字节数的涨幅:从 185 到 267,多了八十来个字节,全是空格和换行——它们在别的传输里叫排版,在这里叫噪声。

这是最容易骗过自己的地方。json.dumps(obj, indent=2) 在任何「自己定义边界」的传输里都是好习惯:HTTP 有 Content-Length 兜底,消息体里换多少行都不影响解析;写日志文件更是越可读越好。但在 stdio 上,缩进用的那些换行,和帧边界用的是同一个字符。你没法告诉接收方「这个换行是排版用的,那个换行才是分帧用的」,字节流里没有这种注释。

反过来,这也给了一个很好用的代码气味信号:读别人的 MCP 服务实现时,看到 json.dumps(..., indent=2) 直接写 stdout,基本可以判定它没在真正的客户端里跑过——因为跑过一次就会立刻失败,不会留到你看代码。

日志库也会往 stdout 写,而且更难发现

不是只有 print 会污染信道。很多日志库的默认输出目标是标准输出,配一个 handler 就开始了;有些框架的「调试模式」会把整个请求体打到控制台;还有一些命令行的进度条、启动横幅、版本提示,默认也走 stdout。这类写法比手写 print 更难排查,因为那句输出看起来完全正当——它甚至不是你写的。

不用猜的检查方法只有一个:把服务的 stdout 重定向到文件,然后逐行跑 json.loads,只要有一行失败,信道就被污染了。这件事适合做成启动自检,而不是等客户端报错再去翻代码。成本很低:一次读文件、一次循环,比排查一次「客户端说解析失败但服务端日志显示成功」便宜太多。

这份资料里有一份 MCP 传输层的对照笔记:把 stdio 与另外几种传输的信道约束、帧边界、日志去向并排列了一张表,配合本章理解「为什么换行不能随便加」会很直观。放在资料包里,扫码即可获取:

配套资料获取

干预前后对比:把日志挪到 stderr,行数和帧数才对得上

修复本身只有一行;真正需要判断的是「这行东西本来就该不该出现在 stdout 上」。这个判断比修复重要,因为前者能覆盖以后所有同类问题,后者只解决眼前这一处。

四种写法的结果摆在一起

把前面两个实测的结果合并成一张对照表,四种写法、四个维度,结论一眼可见:

写法stdout 切出的帧可独立解析的帧客户端结果需要动的地方
单行 JSON,不打日志1 帧1 帧正常拿到结果无
单行 JSON + 一行 debug 打到 stdout2 帧1 帧(第一帧坏)第一帧失败,会话终止那行改到 stderr
单行 JSON + 一行 debug 打到 stderr1 帧1 帧正常拿到结果无,日志仍在
缩进两格的 JSON 直接写 stdout16 帧0 帧每一帧都失败去掉 indent 或换传输

四行里有三行是「不能」,而失败方式并不一样:第二行是第一帧就坏,客户端立刻终止;第四行是每一帧都坏,客户端同样在第一帧终止,但即使它宽容地跳过坏帧,也拿不到完整消息——因为一条消息被拆成了 16 份,跳过哪一份都不对。这两种失败的排查路径也不同:前者去翻「谁多打了一行」,后者去翻「谁开了美化输出」。

还有一个容易搞反的点:修复之后 stdout 的行数没有变多,stderr 的行数从 0 变成 1。日志没有消失,它回到了该待的那根管子上。如果有人跟你说「把日志删了就好了」,那不是修复,那是把可观测性一起删了——下次真出问题,你连「请求进来了没有」都不知道。

判断与停手:什么条件下必须改,什么条件下不用管

判断依据不是「有没有人反馈过」,是stdout 上是否只有合法 MCP 消息。这条可以当场验,不需要等故障发生。

  • 服务被别的进程拉起、由对方解析 stdout:必须改。这是 stdio 传输的默认形态,也是绝大多数 MCP 客户端的用法。
  • 只在终端里手工跑、输出直接给人看:多一行日志不会造成协议失败。这种情况下不必为了「规范」去改,但要记住这个差别——它一旦被客户端接管,同一行字就变成故障。
  • 日志已经在 stderr,但响应用了 indent=2:同样必须改,而且优先级更高。它不属于偶发,是每次调用都失败,只是失败得比较均匀,容易被误判成「客户端不兼容」。
  • 单行 JSON、日志全在 stderr:不用再折腾。这时候该做的是把「逐行 json.loads」加进启动自检,防止以后有人加一句 print 又把它带回去。

停手的标准同样具体:把 stdout 重定向到文件,逐行解析,全部成功就可以停。只要还有一行失败,就说明信道里混了非协议数据,继续查那一行是谁写的、为什么写在那儿。这个检查不需要任何工具,一行 shell 加几行 Python 就够,值得放进每次发版前的清单里。

配套的启动自检脚本和一份排障清单:把「逐行解析 stdout」写成几行可复用的代码,再加一张按现象定位原因的对照表(第一帧坏 / 每帧都坏 / 完全没输出分别查什么)。放在资料包里,扫码即可获取:

配套资料获取

附表 A:本文引用事实与出处对照表

事实出处(文档名 + 发布方 + 链接)本文位置
服务端从 stdin 读 JSON-RPC 消息、向 stdout 写 JSON-RPC 消息MCP 规范 stdio 传输页,Model Context Protocol 官方文档,https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/stdio (2026-11-08 抓取)第三章「第一件事:消息的边界是换行,不是括号」
消息以换行分隔,且消息内部不得内嵌换行同上(原文:Messages are delimited by newlines, and MUST NOT contain embedded newlines)第四章「规范把这条边界写成了 MUST NOT」
服务端可以把任意 UTF-8 字符串写到 stderr 作为日志(信息、调试、错误)同上第三章「第二件事:stdout 是数据面,stderr 是控制面」
服务端不得向 stdout 写任何不是合法 MCP 消息的内容同上第三章「第二件事」及第五章的判断清单
实测:clean / polluted / fixed 三种模式下 stdout 分别为 1 / 2 / 1 行,可解析均为 1 行,污染模式首个不合格帧在 0 号行,修复模式 stderr 为 1 行本文实测脚本 work/exp1108/e01_stdio.py(Python 3.13.12 / macOS / 仅标准库)第二章末实测块 A 及其后的逐行解读
实测:同一响应对象单行序列化为 185 字节、切 1 帧、1 帧可解析;缩进两格为 267 字节、切 16 帧、0 帧可解析本文实测脚本 work/exp1108/e01b_pretty.py(Python 3.13.12 / macOS / 仅标准库)第四章实测块 B 及其后的对比

写在最后:这篇用到的资料

写这篇文章时,把相关的官方文档和源码又翻了一遍,顺手也整理了几份配套的东西:

  • 大模型学习路线图:从零基础到能自己动手做 Agent,按阶段说明每一步该学什么、哪些可以先跳过
  • 《LangChain + LangGraph + MCP 智能体开发实战》视频课:7 个模块,从私有化部署、Embedding+RAG 到 MCP+Agent 全流程
  • AI 大模型知识库(在线可查):Agent Skills 从入门到落地、Claude Skills 完全指南等专题,按目录浏览即可
  • 640 套 AI 大模型行业报告 + 经典 PDF 书籍:看行业落地案例和别人怎么做的时候用得上
  • 大模型零基础到精通教学视频:跟着敲一遍,比只读文档快得多

资料是我自己整理的,放在下面这个码上,扫码即可获取:

配套资料获取
课程目录:私有化部署与 LangChain
课程总目录:7 个模块
在线知识库:Agent Skills 创建全流程
课程目录:LangGraph 开发智能体
在线知识库:Claude Skills 完全指南

添加时备注「AI」,优先通过。

资料按「先路线、再动手、最后查漏」的顺序整理好了,建议先看学习路线那一份,照着它挑一条适合自己当前基础的路径再往下看。

Logo

更多推荐