# 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 上。不要在批准对话框期间暂停读取。不要等渲染器、数据库写入或模型请求完成后，才接收接下来的字节。

传输循环应当只承担清晰、有限的职责：

1. 操作系统提供字节时，立即读取 stdout。
2. 把这些字节交给协议帧解析器。
3. 将格式错误或超大的帧作为传输故障拒绝。
4. 把完整消息交给独立于读取器的有界分发器。
5. 当分发器无法接受更多工作时，继续读取，或有意终止子进程。

关键是“独立”。如果读取器在线调用昂贵的消息处理逻辑，它最终一定会变成停止读取的读取器。消息过大时，JSON 解析本身确实可能造成问题，但更常见的错误更早发生：读取器等待一个正在执行无关工作的回调。

主机至少应为每个服务器会话记录这些值：stdout 接收字节数、最大的完整帧、解析失败次数、等待分发容量的时间、发送的取消请求数，以及进程退出情况。这些数字很快就能结束争论。如果 stdout 字节数在结果传输到一半时停止增长，而服务器仍然存活，应怀疑服务器的写入端。如果字节仍在到达，但完整消息的分发停止了，应怀疑主机队列或其消费者。

不要根据工具响应对模型是否有用来控制 stdout 流量。读取器必须接收完整的协议消息，之后才能安全地丢弃、报告或路由它。主机如果在帧尚未结束时判断消息太大，然后停止读取，就是自己制造了死锁。

## 结果限制需要两道边界

同时设置传输限制和内容限制。单纯计算字符数无法保护进程，因为 JSON 转义、base64 编码和外围响应结构都会改变 stdout 上的字节数。

传输限制是单个完整序列化 JSON-RPC 消息的最大字节数。帧解析器应在解析任意 JSON 之前执行这项限制，以保护主机的内存和解析时间。内容限制是工具在 `content` 或 `structuredContent` 中返回的最大有用内容量。工具处理器应在序列化结果之前执行这项限制，以保护 agent 上下文，并让响应保持有意义。

这两种限制都不能只写在工具描述中。模型偶尔还是会请求无界搜索、递归列表或完整文档。服务器必须以可预测的方式处理这种请求。

实用的工具响应应说明省略了什么，以及 agent 如何继续获取。这样比静默截断字符串更好，因为静默截断看起来像完整证据。

```json
{
  "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 写入器施加了背压。

```js
// 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 的缺陷。

```js
// 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 会话发送如下通知：

```json
{
  "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 会话中提交一个小型、无关的请求。第二个请求必须及时完成。

下面的测试流程可以发现最重要的故障：

1. 启动一个 `tools/call`，让处理器分块生成大型响应，或调用一个刻意放慢的生产者。
2. 等待测试工具观察到足够的 stdout 字节，确认响应已经开始。
3. 针对该请求 ID 发送 `notifications/cancelled`。
4. 确认生产者在规定期限内退出，或报告其中止路径。
5. 使用新的 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 冻结”报告变成可以复现、测量，并能阻止其进入下一版本的故障。
