一个多行工具结果引发的解析失败
假设你写了一个本地 MCP Server,提供一个 read_log 工具,返回最近若干行日志。服务端把工具结果拼进 JSON-RPC 响应,调用 stdout.write() 一次性写出。客户端收到后按行切分,再逐行 JSON.parse。前几次调用正常,直到某条日志本身包含换行,客户端抛出 Unexpected token 或 Unexpected end of JSON input,后续调用也开始错位。
直觉上,JSON 字符串本来就可以包含转义后的换行,问题似乎出在客户端解析器太脆弱。但 MCP 规范对 stdio 传输有一条明确约束:消息以换行分隔,且不得包含嵌入的换行符。这条约束把“帧边界”和“JSON 语法”绑在了一起。服务端只要把原始多行文本直接拼进消息体,就会同时破坏这两个层面。
本文围绕这个场景,说明规范为什么这样规定、错位在字节层面如何发生、服务端和客户端各自该在哪些位置校验,以及长度前缀等替代帧格式的收益与代价。
规范约束:换行既是分隔符也是禁止项
MCP 使用 JSON-RPC 编码消息,JSON-RPC 消息必须使用 UTF-8 编码。在 stdio 传输中,客户端以子进程形式启动服务器,服务器从标准输入读取消息,通过标准输出发送消息。规范写明:消息以换行符分隔,且不得包含嵌入的换行符;服务器不得向 stdout 写入任何不是有效 MCP 消息的内容;客户端也不得向服务器的 stdin 写入无效消息。
这条规定包含两层含义。第一层是分帧:stdout 是一个连续的字节流,没有消息边界,接收方需要一个约定来切出单条消息。换行符承担了这个分隔符角色。第二层是语法:既然换行符被用作分隔符,消息体内部就不能再出现未转义的换行,否则接收方无法区分“消息内的换行”和“消息结束的换行”。
JSON 本身允许字符串值包含转义后的换行。RFC 8259 定义的 JSON 字符串是一串 Unicode 字符,控制字符必须转义。也就是说,"line1\nline2" 是合法 JSON,而 "line1 后跟一个真实换行再跟 line2" 不是。MCP 的 stdio 约束与 JSON 语法在这里方向一致:消息体应当是单行文本,内部换行以 \n 两个字符的形式存在。
JSON-RPC 2.0 规范对传输保持无关,它只定义请求、响应、通知和错误对象的结构。响应对象必须包含 jsonrpc、id,以及 result 或 error 之一。工具结果通常放在 result 里,具体形状由 MCP 的工具调用约定决定。无论 result 内部嵌套多深,整个响应对象在 stdio 上都必须序列化成一行。
错位是怎么发生的:从字节流到解析器
要理解失败,需要把服务端写出和客户端读入分开看。
服务端常见写法是把工具返回的文本直接放进结果对象,再对整个对象做一次序列化。如果序列化库正确转义,换行会变成 \n,消息仍是单行。问题往往出在序列化之后又做了字符串拼接,或者工具结果本身已经是一段“预格式化 JSON 文本”,服务端把它当作已序列化内容塞进外层模板。此时真实换行进入了输出流。
假设服务端输出如下字节序列(为便于阅读,这里用 ⏎ 表示真实换行):
{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"line1⏎line2"}]}}⏎
客户端按 ⏎ 切分,得到两段。第一段是 {"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"line1,第二段是 line2"}]}}。第一段缺少闭合引号、括号和花括号,解析器报语法错误。第二段不是完整对象,同样失败。
更麻烦的是消息粘连。如果服务端连续写出两条响应,而第一条响应内部带换行,客户端可能把第一条的尾部与第二条的头部拼成一段。此时解析器可能不报错,而是解析出一个字段缺失或 id 错误的对象。客户端按 id 匹配请求时找不到对应项,表现为工具调用一直挂起,或者结果被派发给错误的调用。
flowchart TD
A[工具返回多行文本] --> B[服务端拼入 JSON-RPC 响应]
B --> C{消息体是否含真实换行}
C -- 否 --> D[stdout 写出单行消息]
C -- 是 --> E[stdout 写出多行字节流]
D --> F[客户端按换行切分]
E --> F
F --> G{每段是否为完整 JSON}
G -- 是 --> H[按 id 匹配并交付结果]
G -- 否 --> I[解析错误或消息粘连]
I --> J[调用挂起或结果错配]
图中的关键转折在 C 节点。服务端是否让真实换行进入消息体,决定了后续切分能否得到完整 JSON。E 到 I 这条路径不是客户端“太严格”,而是帧格式被破坏后接收方缺少恢复所需的信息。
服务端:序列化边界与写入边界
服务端要保证两件事:消息体内部没有未转义换行,且每次写入 stdout 的内容恰好是一条完整消息。
第一件事依赖序列化。把工具结果作为字符串值交给 JSON 序列化库,由库负责转义控制字符,是更稳妥的做法。不要先手工拼出 JSON 文本,再把这段文本嵌入外层模板。手工拼接容易漏掉转义,也容易在结果包含引号、反斜杠或 Unicode 控制字符时产生非法 JSON。
第二件事依赖写入。即使消息体是单行,服务端也不应把一条消息拆成多次 write 调用后假设客户端会按调用边界聚合。stdio 是字节流,客户端的读取缓冲区大小不受服务端控制。一次 write 可能对应客户端多次 read,多次 write 也可能被合并。服务端应在内存中构造完整消息,再一次性写出,并在末尾追加换行符。
伪代码示意如下,重点在“先序列化,再追加分隔符”:
// 示意逻辑,非特定语言 API
function sendResponse(id, resultText) {
message = {
jsonrpc: "2.0",
id: id,
result: { content: [{ type: "text", text: resultText }] }
}
line = jsonSerialize(message) // 库负责转义 \n、引号等
stdout.write(line + "\n") // 单次写入完整帧
stdout.flush()
}
这里的关键状态变化是:resultText 中的真实换行在 jsonSerialize 之后变成两个字符 \ 和 n,消息体不再包含控制字符。追加的换行符是帧分隔符,不属于 JSON 内容。
还有一个容易忽略的边界:日志。规范允许服务器向 stderr 写日志,客户端可以捕获、转发或忽略。但服务器不得向 stdout 写任何非 MCP 消息。如果服务端把调试日志打到 stdout,客户端会把它当作消息的一部分切分,同样造成错位。日志应走 stderr,且 stderr 的写入不应阻塞主流程,这是另一个独立问题。
客户端:按行切分的假设与校验点
客户端按行切分本身符合规范,因为它依赖的正是“消息以换行分隔”这一约定。问题在于,客户端不能假设每一行都一定合法,也不能假设一次读取就能拿到完整行。
读取层面,客户端需要维护一个缓冲区。每次从 stdout 读到字节后追加到缓冲区,再按换行符切出完整行。最后一段没有换行符的残留要留在缓冲区,等待下一次读取。如果把每次 read 的结果直接当消息解析,长消息会被截断,短消息会被拆散。
解析层面,客户端应在三个位置设置校验点。第一,切出的每一行在解析前检查是否为空行,空行不构成消息。第二,解析后检查顶层是否为对象,且 jsonrpc 字段为 "2.0"。第三,按 id 匹配待处理请求时,如果找不到对应项,记录原始行内容而不是静默丢弃。
伪代码示意:
// 示意逻辑,非特定语言 API
buffer = ""
onData(chunk) {
buffer += decodeUtf8(chunk)
while (index = buffer.indexOf("\n")) >= 0 {
line = buffer.slice(0, index)
buffer = buffer.slice(index + 1)
if (line.trim() == "") continue
try {
msg = jsonParse(line)
dispatch(msg)
} catch (e) {
logParseFailure(line, e)
}
}
}
这段逻辑没有处理 \r\n。如果服务端在 Windows 上以文本模式写入,换行可能变成两个字符。客户端切分时应同时兼容 \n 和 \r\n,或者在协议层明确只接受 \n。规范没有展开这一点,工程上通常由客户端做兼容,服务端保持只写 \n。
对比:换行分隔、JSON 转义与长度前缀
面对多行工具结果,有三种常见处理方式。它们的差别不在 JSON 语法,而在帧边界由谁定义、接收方需要多少状态。
| 方案 | 帧边界 | 多行文本处理 | 接收方复杂度 | 主要风险 |
|---|---|---|---|---|
| 换行分隔 + 正确转义 | 换行符 | 序列化时转义为 \n | 低,按行切分 | 服务端漏转义或混入日志即错位 |
| 换行分隔 + 手工拼接 | 换行符 | 原始文本直接进入消息体 | 低,但假设不成立 | 消息体含真实换行,解析失败或粘连 |
| 长度前缀 | 显式字节长度 | 长度覆盖完整消息,内部可含换行 | 中,需先读长度再读正文 | 长度计算错误、编码不一致 |
第一行是规范要求的目标状态。换行分隔的收益是实现简单:客户端用文本行读取即可,调试时肉眼可读,管道工具也能直接处理。代价是服务端必须严格转义,任何绕过序列化库的拼接都会破坏帧。
第二行是常见故障来源。它看起来只是“少转义了一个字符”,实际破坏的是接收方切分消息的能力。客户端无法从一段非法 JSON 中可靠恢复出原始消息边界,只能报错或丢弃。
第三行是替代帧格式。长度前缀在消息前放一个字段,声明后续正文的字节数,接收方先读长度再读固定字节数。这样消息体内部可以包含换行,不需要转义换行本身。代价是接收方要维护“读长度”和“读正文”两个状态,长度字段的编码和字节序要约定清楚,UTF-8 多字节字符按字节计数而不是字符计数。对于 MCP stdio,规范已经选定换行分隔,服务端和客户端不应自行改用长度前缀,否则无法与遵循规范的实现互通。
在质量、延迟和吞吐上,三种方案的差异主要是常数级。换行分隔的切分成本与消息长度线性相关,长度前缀省去扫描换行但增加一次长度解析。真正的差别在失败模式:换行分隔的失败通常是解析错误,容易观测;长度前缀的失败可能是长度与实际正文不一致,接收方会阻塞等待更多字节,表现为超时而非报错。
可观测指标与定位顺序
出现错位时,先确认问题在哪一侧。可观测信号按从外到内的顺序排列。
服务端侧,记录每次写出的消息字节数和是否以换行结尾。如果消息字节数异常大,或者单条消息内出现多个换行,说明序列化边界被绕过。可以在开发环境对每条输出做一次自校验:按换行切分后,每一段都应能解析为 JSON 对象。这个校验不应在生产环境对每条消息执行完整解析,成本较高,但可以采样。
客户端侧,记录切分出的行数、解析失败次数、解析失败时的原始行前缀,以及按 id 匹配失败的次数。解析失败次数上升通常对应服务端输出格式变化;匹配失败次数上升更可能是消息粘连,即两条消息被拼成一条或一条被拆成两条。
还有一个指标是单次工具调用的端到端延迟分布。错位不一定立即报错,可能表现为客户端等待一个永远不会到达的 id,直到超时。如果超时集中在返回长文本的工具上,应优先检查该工具结果是否包含换行、制表符或未转义引号。
定位顺序可以是:先看客户端解析失败日志中的原始行,判断是截断还是拼接;再看服务端同一时间窗口的输出字节数和换行计数;最后检查该工具的结果构造路径,确认是否经过序列化库。
适用边界与仍未解决的问题
换行分隔 JSON 帧适合消息体可控、实现简单的本地 stdio 场景。它的前提是服务端严格通过序列化库生成消息,且 stdout 只用于 MCP 消息。只要有一个工具绕过这个前提,故障就会出现在客户端,排查路径却横跨两个进程。
规范没有规定客户端在遇到非法行时应该重试、丢弃还是终止连接。不同客户端的选择可能不同,这给互操作留下空间,也留下不确定性。工程上通常的做法是记录并跳过非法行,同时保持缓冲区继续处理后续数据,但这只是实现选择,不是规范结论。
长度前缀方案在需要传输大量含换行文本、且不希望依赖转义的场景下更有优势,但它要求双方都偏离 MCP stdio 的现有约定。在协议已经选定换行分隔的前提下,更现实的改进方向是服务端在开发阶段加入输出自校验,客户端在解析失败时保留足够上下文。这两项都不改变协议,却能显著缩短定位时间。