小白 / Xiaobai
开发者 · 产品构建者
持续构建 AI 工程系统、开发者工具与长期数字资产。
关于作者与 XBSTACK →
MCP StreamableHTTPClientTransport 请求一直 Pending:SSE 断开后为什么只等 Timeout?
MCP StreamableHTTPClientTransport 的 POST 收到 SSE 后,如果流在 JSON-RPC 响应前 EOF/报错,请求可能一直 pending 到 timeout。XBSTACK 在 SDK 1.29.0/1.30.0 复现 Issue #2739,并给出规避与版本边界。
如果你在 v1 @modelcontextprotocol/sdk 里使用 StreamableHTTPClientTransport,遇到一种很怪的现象:POST 已经拿到 text/event-stream,SSE 明明已经断了,client.onerror 甚至已经报错,但 client.ping() / client.request() 仍然一直 pending,直到 request timeout 才失败,先不要把它简单归因于网络慢或 Server 没有返回。
我在 2026 年 9 月 1 日用 Node.js 22.18.0 独立重跑了上游 Issue #2739 的生命周期边界。@modelcontextprotocol/sdk 1.29.0 和 npm 当前最新的 1.30.0 都能复现:错误 SSE 在约 3—4ms 就触发 transport error,但请求仍要等约 300ms 的测试 timeout;clean EOF 连 onerror 都没有,同样只在 timeout 时结束。对照组改成 application/json 后约 1ms 正常完成。
这篇只解决这一条故障:请求级 SSE 已经失去返回 JSON-RPC 结果的能力,为什么对应请求没有立即失败,以及当前能怎么安全规避。
先判断是不是本文这个问题
下面几个条件越接近,你遇到 #2739 同类生命周期问题的概率越高:
- 使用的是 v1
@modelcontextprotocol/sdk的StreamableHTTPClientTransport; - POST response 的
Content-Type是text/event-stream; - JSON-RPC request 已经发出,Server 也接受了 POST;
- 请求级 SSE 在匹配
result/errorevent 到达前提前error或 EOF; - error 模式下可能很快看到
client.onerror,但调用 Promise 没有同步 reject; - 最终错误只是
MCP error -32001: Request timed out。
如果你遇到的是初始化失败、HTTP 401/403、-32700 Parse error、反向代理把 SSE buffering 住、Server 根本没有接受 POST,或者主动调用取消后才出现 teardown,那不是本文这条最小边界。比如 #2739 自己就专门说明,它和带主动取消语义的 #2691 不是同一条路径。
我怎样复现:只构造三种 POST response
为了排除真实网络、反向代理、OAuth、模型 API 和业务 Tool 的影响,我没有启动真正的远程 MCP Server,而是给 StreamableHTTPClientTransport 注入自定义 fetch。initialize 正常返回 JSON,只有 ping response 做三组控制:
A. error SSE
POST ping -> text/event-stream -> controller.error()
B. clean EOF
POST ping -> text/event-stream -> controller.close()
C. JSON control
POST ping -> application/json -> matching JSON-RPC result
测试 timeout 固定为 300ms。这样可以回答一个非常具体的问题:如果 transport 在几毫秒内已经知道这条 SSE response leg 没了,请求 Promise 会不会立即结束?
核心代码可以压缩成下面这样:
if (message.method === 'ping') {
const body = new ReadableStream({
start(controller) {
queueMicrotask(() => controller.error(new Error('response leg lost')));
// 换成 controller.close() 即 clean EOF 控制
},
});
return new Response(body, {
headers: { 'content-type': 'text/event-stream' },
});
}
然后记录 client.onerror 的时间与 client.ping({ timeout: 300 }) 最终 settle 的时间。完整最小复现保存在本站实验资产 experiments/mcp-streamable-http-sse-pending-repro/,发布后的 GitHub 仓库也会保留同一份代码和原始日志。
1.29.0:SSE 4ms 就报错,请求却 304ms 才结束
先跑 @modelcontextprotocol/sdk 1.29.0,Node.js 是 22.18.0。实际输出:
error:
onerror = 4ms
request rejected = 304ms
final error = MCP error -32001: Request timed out
eof:
onerror = none
request rejected = 302ms
final error = MCP error -32001: Request timed out
json-control:
request resolved = 1ms
这里最关键的不是 304ms 这个绝对数字,因为生产 timeout 可能是几秒甚至更长。真正的问题是两个时钟明显脱节:transport 在 4ms 就已经知道 SSE 出错,但 JSON-RPC request 并没有在 4ms 左右 reject。
clean EOF 更隐蔽。流正常结束并不等于匹配 JSON-RPC response 已经到达,但这个最小场景里既没有 onerror,也没有 request result,调用方只能等 timeout 才知道失败。
最新 1.30.0 仍然能复现
上游 Issue #2739 打开于 2026 年 8 月 30 日,作者报告的是 Node 24.18.0 + SDK 1.26.0 / 1.29.0。为了避免写成“昨天的问题今天已经修了”,我又检查 npm 当前版本,@modelcontextprotocol/sdk 最新正式版已经是 1.30.0,然后把同一个实验原样重跑。
结果仍然一致:
| 场景 | 1.29.0 | 1.30.0 | 应有判断 |
|---|---|---|---|
error SSE 触发 onerror | ~4ms | ~3ms | transport 很快知道 stream 已失败 |
| error SSE request reject | ~304ms | ~306ms | 仍等 request timeout |
| clean EOF request reject | ~302ms | ~302ms | 仍等 request timeout,且无 onerror |
| JSON control | ~1ms | ~1ms | 正常立即完成 |
所以截至 2026-09-01,我可以确认的是:这个 v1 lifecycle gap 至少在我本机的 1.29.0 和 1.30.0 都存在。 我没有独立重跑 1.26.0;1.26.0 的证据只来自上游 Issue,不能把它写成本站实测。
根因不在“timeout 太长”,而在 request-scoped lifecycle 没有闭环
上游 Issue 给出的原因定位与本地现象是吻合的:v1 send() 启动 _handleSseStream() 后,没有把这条 request-scoped stream 的完整生命周期作为一个可 await 的 request outcome 返回给 Protocol;reader error 会进入 global onerror,但没有直接 settle 对应 request;clean EOF 甚至不会产生同样的 transport error。
于是出现了这个状态:
JSON-RPC request pending
│
├── transport.send() 已返回
│
├── request-scoped SSE 已 error / EOF
│
├── 没有 matching JSON-RPC result/error
│
└── Protocol 只剩 request timeout 可以结束 Promise
这也是为什么单纯把“网络错误监听”写得更完整不够。真正需要的是:哪个 SSE response leg 属于哪个 outbound request,以及这个 leg 在没有交付完整 result/error 前消失时,谁负责 reject 那个 pending request。
当前怎样规避:先区分“普通 JSON result”和“真的需要 SSE”
如果你控制 Server,而且这个请求本身只是返回一个普通 JSON-RPC result,并不需要 request-scoped streaming,那么一个可验证的规避是:直接返回 application/json。
我的对照组在 1.29.0 / 1.30.0 都约 1ms 完成。上游 Issue 也指出 JSON response path 不同:send() 会 await response.json(),body 解析失败或结果到达时可以直接形成 request outcome。
示意:
return new Response(
JSON.stringify({
jsonrpc: '2.0',
id: message.id,
result: {},
}),
{ headers: { 'content-type': 'application/json' } },
);
但不要把这句话理解成“以后 MCP 都别用 SSE”。如果这个 response 确实需要持续流式发送 event,改成 JSON 会改变业务语义,只是绕开问题,不是修复问题。
第二层兜底是始终设置有限 request timeout。如果你的生产默认 timeout 很长,可以按 Tool SLA、上游 HTTP timeout 和业务可接受延迟设置更合理的上限。它能防止 Promise 永久挂住,却不能修复 lifecycle:3ms 已知失败却再等 30 秒,本质仍然是故障放大,只是放大的上限变小了。
真正的 SDK 修复应该满足什么
我没有在本地 fork SDK 冒充“已经修复上游”。从 #2739 的问题边界看,一个完整修复至少要满足四件事:
- POST 创建 request-scoped SSE 时,transport 能知道它承载哪些 JSON-RPC request ID;
- 所有匹配 response 正常到达后,request lifecycle 正常 resolve;
- 非 resumable SSE 在 response 到齐前 error/EOF,要立即 reject 对应 pending request;
- caller timeout / abort 发生后,resumed or reconnect chain 不能继续脱离原请求生命周期运行。
这和“看到 SSE error 就全局关 Client”不是一回事。一个 Client 可能同时有多个请求;正确实现需要 request-scoped ownership,而不是把某一条 response leg 的故障扩大成整条连接的粗暴 teardown。
不要把这个结论外推到 MCP 2026-07-28 / TypeScript SDK v2
这里必须单独划边界。#2739 的最小复现使用的是 v1 @modelcontextprotocol/sdk,协议返回 2025-11-25,也就是 initialize / initialized 的 2025-era 路径。
MCP 在 2026-07-28 已经把协议核心改成 stateless request/response,并移除传统 initialize/session 作为新协议默认模式;TypeScript SDK v2 也把 2026-era request cancellation、per-request stream 等生命周期做了新的分层。官方迁移文档明确说明,在 2026-07-28 Streamable HTTP 连接上,取消 in-flight request 会关闭对应 request SSE response stream。
因此这篇文章不能写成“所有 MCP Streamable HTTP 都有这个 Bug”。正确结论是:
截至 2026-09-01,v1
@modelcontextprotocol/sdk的 2025-eraStreamableHTTPClientTransport在 #2739 描述的 request-scoped SSE premature EOF/error 边界上,最新 1.30.0 仍可本地复现 pending-until-timeout;v2 / 2026-07-28 路径需要独立测试。
如果你正在迁移新协议,可以先看站内的 MCP Streamable HTTP 远程部署与 2026-07-28 迁移说明,不要拿本文 v1 workaround 反向设计新架构。若最终错误表现为 JSON-RPC 消息解析失败,转到 MCP -32700 Parse Error 排查;如果请求能正常结束但 Tool 结果被截断,则属于另一条 MCP Tool Call Result Truncated 排障路径。完整 MCP 内容关系可以从 MCP 工程与生产实践专题 进入。
生产环境排查顺序
遇到类似“Tool 调用像卡死,最后只报 timeout”时,我建议按这个顺序看:
- 先看 HTTP status 和 Content-Type。 确认 POST 到底返回 JSON 还是
text/event-stream; - 记录 transport error 时间。 如果
onerror很早发生而 request timeout 很晚,关注 lifecycle 脱节; - 确认 SSE 是否真的收到匹配 JSON-RPC id。 不能因为 HTTP 200 就认为 request 完成;
- 区分 error 与 clean EOF。 clean EOF 可能没有显式 transport error;
- 确认有没有主动 abort / DELETE / timeout。 有取消语义时要与 #2691 等其他路径区分;
- 能返回 JSON 的普通结果优先做 JSON A/B。 如果 JSON 立即 settle,而 SSE 只等 timeout,问题范围会明显缩小;
- 保留有限 timeout。 作为最后兜底,而不是当成根因修复。
最终判断
这不是一个“把 timeout 从 30 秒改成 5 秒”就算解决的小问题。对生产 MCP Client 来说,真正危险的是观测和调用状态不一致:监控已经看到 SSE 断开,调用栈却还把请求当成 running。并发一高,这种无意义 pending 会占住业务任务、重试预算和上游状态判断。
当前我的使用决策是:
- 仍维护 v1 Client:给 request 设置明确 timeout,并对不需要流式的结果优先使用 JSON response;
- 真的依赖 request-scoped SSE:不要把 JSON workaround 当正式修复,跟踪 #2739 的 SDK lifecycle 修复;
- 新项目或正在迁移 2026-07-28:按 v2/新协议重新测试,不要直接继承这篇 v1 结论;
- 线上排障:同时记录 transport error timestamp 和 JSON-RPC request settle timestamp,这两个时间差本身就是最有价值的诊断证据。
本次最小复现、版本矩阵和原始日志会同步到 GitHub,后续上游如果合并修复,我会用同一组三场景重新跑回归,并在本文更新正式修复版本。
继续按 MCP 生产部署路径读,而不是堆 guide / tutorial
MCP 内容统一按协议理解、本地 Server、远程部署、OAuth、安全治理、stdio/JSON-RPC 排障和工具对比来承接,避免站内关键词互相抢。
继续阅读
返回专题 →AI 工程周报
只发真正改变工程判断的变化、故障、实验和新资产。
参与讨论
问题、验证与勘误
登录后可发表评论。所有新评论先进入审核;审核期间仅评论者本人和管理员可见,通过后才公开。