MCP stdio 背压会冻结你的 agent 吗?
MCP stdio 背压可能让 agent 因大型工具结果而阻塞。复现管道停滞,限制结果大小,安全读取数据,并测试取消后的恢复。

大型 MCP 工具结果可能冻结 agent 进程,即使协议实现的每一行代码在技术上都有效。问题在于把 stdio 当成无限快速的消息总线。实际上,它是两个进程之间容量有限的字节流,只要任一端停止读取,两个方向都可能停滞。
相比普通命令行程序,这对 agent 工具的影响更大。工具可能生成巨大的搜索结果、编码后的文件、完整的 API 响应,或冗长的命令输出。接下来,agent 可能要对它进行分词、摘要,等待批准,或者只是判断当前上下文已经足够。如果主机在这些工作期间停止读取服务器的 stdout,服务器就可能在响应中途阻塞。阻塞后,它可能无法读取 stdin 中的取消通知或后续请求。
解决方案不是某一个设置。你需要一份让结果保持小巧的响应契约,一个独立于 agent 工作、持续读取 stdout 的传输循环,以及一条能够抵达实际生产数据的取消路径。要用刻意设计的恶意负载测试这三部分。
管道阻塞看起来可能像 agent 出错
stdio 管道阻塞会产生一些容易把团队带偏的症状。agent 在工具调用后像是冻结了。服务器进程仍然存在。CPU 使用率可能很低。超时触发了,但重试也会卡住。有人会怪罪模型运行时、MCP SDK,或者工具实现中的锁。
很多时候,工具其实已经完成了工作。它只是卡在 write() 上,试图发送一份主机已经不再读取的响应。管道容量有限,而且依赖平台。Node 的子进程文档明确说明了 shell 程序员几十年来都知道的事实:当子进程写入的数据超过管道容量,而父进程没有读取时,子进程会一直阻塞,直到管道再次接受更多字节。
这类事故中有两个不同的队列,把它们混为一谈会导致错误修复。
- 操作系统管道保存 MCP 服务器与主机之间的原始 stdout 字节。
- 主机应用队列保存已经解析、等待 agent 代码、UI 代码、日志或上下文组装处理的 JSON-RPC 消息。
增大应用队列无法解决没人读取管道的问题。增大管道或流缓冲区可以推迟阻塞,但也会给服务器更多空间去生成一份 agent 本来就不该收到的响应。结果预算决定哪些内容可以进入对话。背压处理决定当一方速度较慢时该怎么办。两者解决的是不同的问题。
官方 MCP 调试指南还给出了一个简单的诊断边界:本地 stdio 服务器必须让日志避开 stdout,把诊断信息写到 stderr。如果 stdout 中出现横幅、堆栈跟踪或不属于协议数据的进度行,那么在处理大型结果之前,消息帧就已经损坏了。
工作继续时,stdout 必须保持可读
主机必须遵守最重要的一条规则:在服务器进程的整个生命周期内,都要连接并持续运行 stdout 读取器。不要把它绑定到一个等待 agent 完成工具结果处理的 promise 上。不要在批准对话框期间暂停读取。不要等渲染器、数据库写入或模型请求完成后,才接收接下来的字节。
传输循环应当只承担清晰、有限的职责:
- 操作系统提供字节时,立即读取 stdout。
- 把这些字节交给协议帧解析器。
- 将格式错误或超大的帧作为传输故障拒绝。
- 把完整消息交给独立于读取器的有界分发器。
- 当分发器无法接受更多工作时,继续读取,或有意终止子进程。
关键是“独立”。如果读取器在线调用昂贵的消息处理逻辑,它最终一定会变成停止读取的读取器。消息过大时,JSON 解析本身确实可能造成问题,但更常见的错误更早发生:读取器等待一个正在执行无关工作的回调。
主机至少应为每个服务器会话记录这些值:stdout 接收字节数、最大的完整帧、解析失败次数、等待分发容量的时间、发送的取消请求数,以及进程退出情况。这些数字很快就能结束争论。如果 stdout 字节数在结果传输到一半时停止增长,而服务器仍然存活,应怀疑服务器的写入端。如果字节仍在到达,但完整消息的分发停止了,应怀疑主机队列或其消费者。
不要根据工具响应对模型是否有用来控制 stdout 流量。读取器必须接收完整的协议消息,之后才能安全地丢弃、报告或路由它。主机如果在帧尚未结束时判断消息太大,然后停止读取,就是自己制造了死锁。
结果限制需要两道边界
同时设置传输限制和内容限制。单纯计算字符数无法保护进程,因为 JSON 转义、base64 编码和外围响应结构都会改变 stdout 上的字节数。
传输限制是单个完整序列化 JSON-RPC 消息的最大字节数。帧解析器应在解析任意 JSON 之前执行这项限制,以保护主机的内存和解析时间。内容限制是工具在 content 或 structuredContent 中返回的最大有用内容量。工具处理器应在序列化结果之前执行这项限制,以保护 agent 上下文,并让响应保持有意义。
这两种限制都不能只写在工具描述中。模型偶尔还是会请求无界搜索、递归列表或完整文档。服务器必须以可预测的方式处理这种请求。
实用的工具响应应说明省略了什么,以及 agent 如何继续获取。这样比静默截断字符串更好,因为静默截断看起来像完整证据。
{
"jsonrpc": "2.0",
"id": 41,
"result": {
"content": [
{
"type": "text",
"text": "Returned 50 of 4,382 matching records. Results are sorted by updated time. Use cursor \"eyJvZmZzZXQiOjUwfQ\" to continue, or add a narrower path or query."
}
],
"structuredContent": {
"items": [
{"path": "src/auth.ts", "line": 18, "summary": "reads token from environment"}
],
"nextCursor": "eyJvZmZzZXQiOjUwfQ",
"truncated": true,
"totalEstimate": 4382
}
}
}
文本为模型提供普通说明。结构化内容则为客户端提供稳定的继续获取令牌,以及机器可读的截断信号。如果统计所有记录的成本很高或根本无法做到,不要编造总数。只需写 truncated: true,省略计数。虚假的精确度比诚实地返回不完整结果更浪费时间。
不要照搬“只返回文件路径”这一流行建议。它只有在主机和服务器共享文件系统、路径经过授权、agent 能够读取路径,并且工件仍然存在时才有效。在远程或沙盒环境中,这是一项无法兑现的承诺。引用可以有用,但必须配套一个有自己限制的读取或导出操作。
返回决策所需的信息,而不是原始输出
大多数超大响应都来自输出模型照搬命令行的工具。grep -R、git diff、云端列表 API 和数据库查询,对终端前的人类来说都有意义。但把它们包装成 JSON,并不会自动让它们成为好的 agent 接口。
agent 通常只需要足够的证据来选择下一步。给它一组有界的匹配项、相关字段,以及缩小请求范围的方法。完整工件应通过明确的导出或检索路径保存,调用方在那里主动选择分页或受限范围。
对于代码仓库搜索,返回文件路径、行号范围、简短摘录和使用的查询。不要返回大型单体仓库中的每一行匹配内容。对于 HTTP 客户端,返回状态、选定的请求头、有界的正文预览,以及产品能够安全保留时使用的响应句柄。不要把任意下载内容 base64 后放进 content。对于 SSH,返回 stdout 和 stderr 的有上限尾部内容,以及退出状态。命令打印了一个巨大的生成文件,只说明它生成了一个巨大文件。agent 很少需要在即时上下文中看到每个字节。
把限制放在内容膨胀的源头附近。调用远程 API 的服务器应把分页参数和字段选择器传给远程 API。运行进程的服务器应限制子进程捕获量,同时继续读取 stdout 和 stderr。搜索文件的服务器应在达到结果预算后停止,而不是收集所有匹配项再裁剪最终字符串。
这一点很重要,因为收集完成后再截断只能保护 MCP 传输,无法保护负责执行工作的机器。递归命令仍然可能先消耗内存、磁盘和 CPU,响应层之后才丢弃输出。
Sallyport 在这里很有用,因为它让 HTTP 和 SSH 凭据留在 agent 之外,操作通过本地网关执行。但这个边界不会让无界 API 正文或 shell 输出变得安全,因此工具作者仍然需要在操作边界设置明确的输出预算。
在声称修复之前,先复现阻塞
调用工具并检查大型结果最终是否出现,不能测试这种故障。你需要建立一个故意停止读取 stdout 的测试工具,然后证明服务器确实进入阻塞状态。之后,再证明正常主机不会这样运行。
下面这个小型 Node 固件会写入一份有效的 JSON-RPC 响应,负载足够大,当父进程忽略 stdout 时可以超过普通管道容量。它从 stdin 接收一行请求,然后分块写入响应。等待 drain 就是证据:它表示运行时已经对服务器的 stdout 写入器施加了背压。
// oversized-server.mjs
import readline from "node:readline";
import { once } from "node:events";
const rl = readline.createInterface({ input: process.stdin });
for await (const line of rl) {
const request = JSON.parse(line);
const text = "x".repeat(8 * 1024 * 1024);
const response = JSON.stringify({
jsonrpc: "2.0",
id: request.id,
result: { content: [{ type: "text", text }] }
}) + "\n";
for (let start = 0; start < response.length; start += 16 * 1024) {
const chunk = response.slice(start, start + 16 * 1024);
if (!process.stdout.write(chunk)) {
process.stderr.write("stdout backpressure observed\n");
await once(process.stdout, "drain");
}
}
}
现在以管道方式连接 stdout 来启动它,并故意不读取 child.stdout。继续读取 stderr,这样就能看到背压标记。发送一个请求并短暂等待。子进程应该仍然存活,而且不会完成写入。这是预期行为,不是 Node 的缺陷。
// blocked-parent.mjs
import { spawn } from "node:child_process";
const child = spawn(process.execPath, ["oversized-server.mjs"], {
stdio: ["pipe", "pipe", "pipe"]
});
child.stderr.setEncoding("utf8");
child.stderr.on("data", chunk => process.stderr.write(chunk));
child.stdin.write(JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: { name: "large", arguments: {} }
}) + "\n");
setTimeout(() => {
console.error("child still running:", child.exitCode === null);
child.kill("SIGTERM");
}, 1000);
不要把这个父进程模式复制到生产环境。它的作用只是让故障变得明显。用真实的帧解析器替代缺失的 stdout 消费者,再运行同一个固件。子进程应该完成响应,或者客户端应根据明确的限制拒绝响应,但不能因为父进程忽略 stdout 而一直卡住。
使用包含引号、多字节字符和很长连续字符串的负载运行测试。这些情况可以发现把 JavaScript 字符数当成 UTF-8 字节数的帧解析器,也能发现假设每次读取都恰好在消息边界结束的实现。
拒绝巨型帧时,不要重新造成阻塞
帧大小检查有一个陷阱:停止消费超大帧的客户端,会和从未连接读取器的客户端一样让服务器阻塞。客户端需要恢复规则。
如果帧协议提供了清晰的结束标记,就继续读取到该标记,同时丢弃违规帧,然后报告协议违规,并决定会话是否可以继续。如果协议编码无法让你在超过限制后安全恢复帧边界,就终止服务器进程,关闭 stdin,并启动新会话。听起来很严厉,但试图猜测损坏的 JSON 消息在哪里结束,后果更糟。
MCP stdio 在本地字节流上使用 JSON-RPC 消息。实现必须把帧处理当作传输代码,而不是收集无界文本后调用 split("\n") 的便利操作。累积候选消息时保持字节计数。每次收到数据块后,要么找到完整帧边界并分发完整帧,要么在候选帧超过最大值时失败。
不要为了确认消息很大,就先解析一条巨型消息。JSON.parse 需要完整的内存字符串,分配量可能超过输入大小。传输限制存在的目的,就是避免这项工作。
如果你控制两端并使用按换行分隔的 JSON,就应要求每行只有一个 JSON 对象,通过正常 JSON 编码拒绝字符串中的字面换行,并在每个完整序列化对象后准确写入一个分隔符。服务器绝不能把人工诊断信息写入 stdout。MCP 的调试文档也要求本地服务器把日志发送到 stderr,因为 stdout 专门用于协议数据。
读取器还应限制等待应用处理的完整消息数量。如果队列已满,不要让 stdout 永久停止读取。可以拒绝新请求、在支持的地方取消工作,或结束会话。具体的过载动作取决于主机,但让 stdout 一直无人读取绝不是安全的默认选项。
只有取消抵达生产者,取消才有效
agent UI 中的超时不等于取消。它只是改变了 UI 对请求的看法。服务器可能仍在查询数据库、下载响应、运行进程,并向 stdout 写入数 MB 数据。
MCP 为同一方向上已经发出的请求定义了 notifications/cancelled。通知包含原始请求 ID,也可以包含原因。MCP schema 要求接收方停止关联处理、释放资源,并把结果视为不再使用。同时它也提醒,取消可能与完成发生竞争。因此,客户端必须能够处理延迟响应,服务器也必须能够处理已经完成请求的取消通知。
对于 tools/call,客户端可以通过同一个 stdio 会话发送如下通知:
{
"jsonrpc": "2.0",
"method": "notifications/cancelled",
"params": {
"requestId": 41,
"reason": "result exceeded the client budget"
}
}
服务器需要维护一个从请求 ID 到活动任务的映射。每个条目应包含 abort controller 或语言中的等效机制、完成状态,以及它拥有的子进程、HTTP 请求或游标。取消通知到达时,中止任务,停止生成结果内容,清理映射条目。如果正常响应尚未开始写入,就不要发送它。
这里有一个容易被忽略的错误。团队给工具处理器加入了 abort signal,但处理器等待的子进程却忽略了这个信号来读取 stdout。或者他们取消了 HTTP fetch,却留下一个仍在序列化巨大内存数组的转换过程。只有当取消抵达每一个生产者和每一个等待中的操作时,取消才是真实的。
把服务器开始写响应之后视为另一种状态。取消可能在字节已经进入管道后到达,而这些字节无法收回。主机应继续读取足够的数据来保持会话健康,然后忽略与已取消 ID 对应的延迟响应。服务器看到取消后应尽快停止额外工作,但不能保证不会出现延迟字节。
取消测试需要第二个请求
优秀的取消测试证明的是恢复能力,而不只是某个计时器触发了。启动一个输出速度足够慢的工具,让客户端能够在任务活动期间取消它。发送取消通知,然后在同一个 MCP 会话中提交一个小型、无关的请求。第二个请求必须及时完成。
下面的测试流程可以发现最重要的故障:
- 启动一个
tools/call,让处理器分块生成大型响应,或调用一个刻意放慢的生产者。 - 等待测试工具观察到足够的 stdout 字节,确认响应已经开始。
- 针对该请求 ID 发送
notifications/cancelled。 - 确认生产者在规定期限内退出,或报告其中止路径。
- 使用新的 ID 发送一个小请求,例如健康检查工具或有界 echo 工具。
最后一个请求才是测试重点。它能揭示服务器是否仍然卡在写入操作中,stdin 读取器是否被饿死,被取消的任务是否持有全局锁,或者主机是否在决定取消后停止读取 stdout。
还要测试工作开始前、远程 I/O 进行到一半、子进程运行期间,以及最终响应已经开始写入之后的取消。这些是不同的状态。能处理好其中一个状态的实现,可能会在另一个状态失败。
不要断言取消一定会阻止响应。MCP 规范允许竞争条件。应断言的是:响应在取消后到达时,主机仍然保持正确;如果取消及时到达,服务器工作会停止。
进程输出需要独立的读取路径
MCP 服务器经常调用命令行工具。这会在服务器内部增加第二对管道:服务器执行 MCP 工作时,必须消费子进程的 stdout 和 stderr。如果它读取子进程流到达限制后就停止,子进程可能在退出前阻塞。外层 MCP 服务器随后看起来像是忽略了取消,其实它是在等待一个无法继续运行的子进程。
捕获有界预览,但超过预览限制后仍要继续读取。标记输出已截断,并丢弃剩余字节。如果命令支持自己的结果限制,应在启动命令前传入该限制。例如,让搜索工具最多返回指定数量的匹配项,让数据库返回有限页面,或让日志命令只返回尾部内容。达到上限后继续读取是安全网,不是主要的结果策略。
让 stderr 与 stdout 分开。工具可能向 stderr 写入有用诊断,同时返回正常退出状态。分别限制并读取两个流。绝不要把任意进程输出合并到 MCP 服务器的 stdout。外层 stdout 只有一个任务:序列化 MCP 消息。
SSH 命令也遵守同样的规则。远程命令可以持续输出,直到其通道阻塞。应捕获有界数据,持续消费远程流直到完成或取消,并诚实说明丢弃了哪些内容。完整输出属于专门的工件流程,不应放进普通工具结果。
把故障加入发布门禁
结果限制和取消机制很容易在重构中被意外移除。有人可能把流式读取器换成 readFile,把分页 API 调用改成无界调用,或把解析移进 UI 回调。应把大型结果固件保留在测试套件中。
发布门禁应覆盖这些情况:刚好低于传输上限的有效响应、刚好超过上限的响应、巨大的单字段、许多小型内容块、格式错误的 JSON、嘈杂的 stderr、意外的 stdout 日志,以及输出期间的取消。测试必须通过真实的进程启动路径,而不只是内存传输。内存测试无法体现管道背压。
用通俗语言记录预期行为:客户端持续读取 stdout;不会解析超过配置帧限制的内容;会报告有界结果错误;取消能够抵达活动任务;恢复后另一个请求可以继续处理。这样工程师可以改变实现细节,却不会削弱这些保证。
如果只能做一项改动,就做这一项:把 stdout 读取器与 agent 处理分开,然后用一个写入量远超任何合理工具返回量的服务器进行测试。这个测试会把模糊的“agent 冻结”报告变成可以复现、测量,并能阻止其进入下一版本的故障。
常见问题
为什么 MCP 服务器返回大型工具结果后会冻结?
当服务器写入数据的速度超过主机读取 stdout 的速度,或者主机在解析、缓冲或转发结果时阻塞,就会出现这种情况。服务器随后会等待管道获得空间,因此也可能停止读取 stdin。后续请求,包括取消请求,都可能排在这个阻塞之后。
MCP 工具结果的安全最大大小是多少?
MCP 没有一个适用于所有情况的安全数值,因为合适的限制取决于客户端、模型上下文预算、结果编码方式以及具体工作内容。应为完整序列化 JSON-RPC 响应设置字节限制,并为工具内容设置更小的语义限制。截断必须明确说明,同时向调用方提供游标、路径、查询或后续工具,以便获取剩余内容。
增大 stdout 缓冲区能解决 MCP 背压吗?
不能。增大内存缓冲区只会推迟故障,还可能把输出阻塞变成内存暴增。主机必须持续读取管道,服务器也必须避免一开始就构造不合理的大型响应对象。
MCP 工具如何通过 stdio 取消?
MCP 的取消通知是 notifications/cancelled,其中包含原始请求 ID,也可以带上原因。它只是一个提示,并且可能与请求完成发生竞争,因此服务器必须把它连接到真正的中止信号,客户端也必须能够处理已经写出的响应。
agent 忙碌时,MCP 主机是否应该继续读取 stdout?
持续读取 stdout,并把数据送入帧解析器;在字节到达时执行最大帧大小限制,再把完整消息交给独立的、有界工作队列。不要因为 agent 正在处理前一个工具结果,就停止读取 stdout。如果队列已满,应执行明确的过载处理,而不是让传输通道一直无人读取。
MCP 服务器可以把调试输出写入 stdout 吗?
把运行日志写到 stderr,而不是 stdout。stdout 只承载 MCP 协议消息,哪怕一行意外日志也可能破坏消息帧。官方 MCP 调试文档也明确要求本地 stdio 服务器这样处理。
MCP 响应超过限制时,客户端应该怎么做?
达到限制后不要停止读取。继续读取并丢弃数据,直到读完违规帧;如果无法安全恢复帧边界,就终止服务器进程并重新建立会话。仅仅在达到限制时停止读取,会重新造成你原本想避免的管道阻塞。
如何测试 MCP 取消,而不是只测试超时?
分别测试三件事:服务器注意到了取消,子任务停止了,以及客户端在取消请求后仍然可用。仅仅在日志中看到取消通知,几乎不能证明什么。你需要在取消大型请求后,再发送一个小请求,并确认它能及时完成。
MCP 工具应该如何返回大型文件或 API 响应?
使用分页、过滤、摘要、稳定引用和明确的导出工具。返回目录树、代码仓库差异、查询结果或 HTTP 正文的工具,应返回当前决策所需的部分,然后提供继续获取内容的方式。仅仅因为整个文件容易序列化,就把完整工件发送出去,是糟糕的工具设计。
使用操作网关后,还会有 MCP stdio 背压风险吗?
Sallyport 通过本地应用和 sp mcp shim 处理 HTTP 与 SSH 操作,因此 agent 不会收到底层凭据。这能保护机密的使用,但不会改变 stdout 的物理限制。工具和客户端仍然需要结果限制、取消机制,以及能证明大型响应不会卡死会话的测试。