一个请求,两个流:排查 MCP Streamable HTTP 中的 45 秒死锁
一个请求,两条流:调试 MCP Streamable HTTP 中一个 45 秒死锁
一次工具调用精确卡住 45 秒,然后失败。用户收到一个权限弹窗,但晚了 45 秒,用户回答了,回答也没任何作用。没有崩溃,也没有错误日志记录传入过程。这就是几天前提交到 mcp-go#932 的 bug 报告,该问题是在一个使用 Codex Desktop 和基于 mcp-go 构建的服务器的设置中观察到的。mcp-go 是 Model Context Protocol 一个广泛使用的 Go 实现。我维护了几个与 MCP 相关的包,并且已经在 mcp-go 上打开了两个关于传输层的 PR,所以我开始复现这个 bug。最终修复以一个 PR 提交,目前正在审核中。这类 bug 的影响范围比这个库本身更大;随着下一个 MCP 规范在 7 月 28 日发布,这种情况只会更常见,而不是更少。以下是完整的追踪过程。
60 秒理解征询机制
MCP 服务器不只是响应请求。在请求处理过程中,服务器可以向客户端提问并等待答案。最常见的形式就是征询(Elicitation):工具处理函数判断需要用户确认,于是向客户端发送一个征询/创建请求,客户端显示弹窗,然后工具调用根据用户的回答继续执行。所以在一个 tools/call 内部,存在一个反向的嵌套往返通信。服务器到客户端的请求是普通的 JSON-RPC,而这个问题的关键纯粹在于传输层:那个嵌套请求走哪条 HTTP 连接?
两条流,两条规则
MCP 的 Streamable HTTP 传输给客户端提供了两种接收服务器消息的方式。
第一种是 POST 请求本身。当客户端 POST 一个 tools/call 时,服务器可以用 Content-Type: text/event-stream 响应并保持连接打开。在最终响应事件之前,服务器可以在这个流上推送其他消息。规范中有一句话说明哪些消息属于这里:服务器可以在发送 JSON-RPC 响应之前发送 JSON-RPC 请求和通知。这些消息应该与原始客户端请求有关。
第二种是独立的 GET 请求。客户端可以打开一个长寿命的 SSE 流,此时没有正在进行的请求,因此服务器可以随时到达客户端。规范也有类似的规则:这些消息应该与客户端任何同时进行的 JSON-RPC 请求无关。
一个请求,两条流:调试 MCP Streamable HTTP 中长达 45 秒的死锁
由正在进行的 tools/call 触发的追问,在消息关联性上几乎与原始请求密不可分。两段描述指向同一个结论:它应该走 POST 流。但 mcp-go 却把它发到了 GET 流。
循环等待
当客户端从当前正在读取的流中分发服务器请求时,这个路由选择就变成了一个带计时器的死锁:
- 客户端发起 POST
tools/call,开始从该 POST 的 SSE 流读取响应。 - 工具处理器调用
RequestElicitation,阻塞等待回答。 - 服务器将
elicitation/create排队到独立的 GET 流上。 - 客户端此时没有消费 GET 流——它正在等待 POST 流。
- 服务器等待客户端,客户端等待服务器,双方僵持。
- 服务器端超时(报告中约 45 秒)后,工具调用失败。
- 客户端最终处理到排队的追问,弹出弹窗。
- 回答到达时,服务器早已遗忘这个请求——这是我最喜欢的细节:超时触发时挂起的请求记录已被删除,因此迟到的回答连一个用户可见的错误都不会产生,只会返回 400 到虚空。
客户端是否应该一直同时读取 GET 流?这是个合理的问题。issue 中谨慎地表示客户端端的原因尚未确认。但规范中的 SHOULD 之所以存在,正是为了让服务器不把一个用户可见的请求押在客户端的流切换能力上。服务器本有机会消除这种歧义,但它没有。
服务器为什么错了
在 mcp-go 的服务器内部,POST 处理器运行了一个小型转发协程,在处理器执行期间将排队的通知推送到该 POST 的 SSE 流上。但所有发往客户端的请求都通过只有 GET 处理器才会消费的通道传递:
// POST 处理器:将通知推送到此 POST 流上
case nt := <-session.notificationChannel:
writeSSEEvent(w, nt)
// GET 处理器:唯一读取服务器到客户端请求的地方
case elicitationReq := <-session.elicitationRequestChan:
writeSSEEvent(w, jsonrpcRequestFor(elicitationReq))
这里没有任何粗心大意的代码。
通知已经有了请求作用域的路径,请求也有了一个可用的投递路径——前提是存在一个GET流,且客户端能及时读取它。只有当这些假设遇到一个串行处理注意力的客户端时,漏洞才会暴露出来。
修复方案:按请求路由,而非按连接
改动让POST处理程序拥有一个请求作用域的发送器,并将其放入处理程序的上下文中。当某个工具处理程序触发一个elicitation(信息索取请求)时,投递优先使用引发该请求的那个流:
type requestScopedSSE struct {
requests chan <- mcp.JSONRPCRequest
done <- chan struct{}
register func()
}
func (r *requestScopedSSE) trySend(req mcp.JSONRPCRequest) bool {
select {
case <-r.done:
// POST 已经结束
return false
default:
}
r.register() // 让这个 POST 的响应可路由
select {
case r.requests <- req:
return true
case <-r.done:
return false
default:
return false
}
}
RequestElicitation 构建 JSON-RPC 请求,首先尝试作用域发送器;如果没有正在进行的 POST 可用或者该 POST 已经结束,则回退到旧的 GET 流通道。转发器的 goroutine 增加了一个新的 case,像处理通知一样将作用域请求写入 POST 流。
回归测试在真实的 HTTP 上运行整个循环,而且完全没有打开 GET 流:先 POST 一个 tools/call,从 POST 自身的 SSE 流中读取 elicitation/create,用另一个 POST 来回答它,然后从原始流中读取最终的工具结果。在 master 分支上该测试会超时。加上修复后,它在半秒内通过。
代码审查中发现了我漏掉的什么?
PR 审查中发现了两个边界情况,而且都是真实问题。
第一,响应需要找到回程的路。待处理的 elicitation 是按会话对象跟踪的;在没有 GET 流的情况下,同一会话 ID 的两个并发 POST 会得到两个独立的临时会话对象。每个对象都从 1 开始维护自己的请求 ID 计数器。两个计数器都从 1 开始,意味着一个 POST 的 elicitation 的回答可能被解析到另一个 POST 的待处理条目上。这不是超时,而是投递错误。
计数器被移到了服务器端:
// 之前:每个会话对象自己维护
requestID := s.requestIDCounter.