一个具体的卡顿现场
假设你在一台内网服务器上部署了远程 MCP Server,前面用 nginx 做反向代理,对外暴露 https://mcp.example.com/mcp。客户端发起一次 tools/call,工具本身要跑十几秒,比如查询一个慢 SQL 或调用外部接口。你在服务端日志里看到工具已经返回,JSON-RPC 响应也写进了 SSE 流,但客户端界面一直转圈,直到某个超时后才报连接断开。
把 nginx 去掉,客户端直连服务端端口,同一个工具调用几秒内就能收到结果。这个对比说明问题不在 MCP Server,也不在工具实现,而在中间那层代理。代理把本该逐段转发给客户端的字节攒在了自己的缓冲区里,客户端拿不到数据,自然也无法解析出工具结果。
这篇文章只讨论一个机制:Streamable HTTP 下 SSE 流式响应经过反向代理时,为什么会被攒住,以及保活事件和禁用缓冲各自解决什么。
Streamable HTTP 里 SSE 什么时候出现
MCP 用 JSON-RPC 编码消息,规范定义了两种标准传输:stdio 和 Streamable HTTP。stdio 用于本地子进程,Streamable HTTP 用于远程服务。规范要求服务端提供一个同时支持 POST 和 GET 的单一 MCP endpoint,例如 https://example.com/mcp。
客户端发送 JSON-RPC 消息时,每条消息都必须是一个新的 HTTP POST 请求。POST 请求头里的 Accept 必须同时列出 application/json 和 text/event-stream。服务端收到一个 JSON-RPC 请求后,有两种合法回应方式:返回 Content-Type: text/event-stream 开启 SSE 流,或者返回 Content-Type: application/json 一次性给出一个 JSON 对象。客户端必须同时支持这两种情况。
如果服务端选择 SSE 流,规范要求这条流最终应包含对 POST 请求里那个 JSON-RPC 请求的响应。在响应之前,服务端可以先发送与该请求相关的 JSON-RPC 请求和通知。响应发出后,服务端应关闭这条 SSE 流。
这里有一个容易被忽略的细节:规范明确说断开连接不应被解释为客户端取消请求。客户端要取消,应显式发送 CancelledNotification。也就是说,连接断开和请求取消是两件事,代理层的一次空闲超时断开,并不代表客户端放弃了这次工具调用。
工具调用通常需要服务端在流上先发进度通知、再发最终结果,这正是 SSE 存在的意义。但 SSE 是单向的、长连接式的,中间任何一层按“攒够再发”的思路处理,都会破坏它的实时性。
代理为什么会攒住响应
nginx 的 proxy_buffering 指令默认是 on。开启缓冲时,nginx 会尽快从上游服务端读取响应,存进 proxy_buffer_size 和 proxy_buffers 定义的缓冲区。如果整个响应装不下内存,一部分会写到磁盘临时文件。只有缓冲区攒到一定量,或者上游响应结束,nginx 才会把数据发给客户端。
对普通的一次性 JSON 响应,这个行为没有坏处,反而能释放上游连接。但 SSE 流是长时间不结束的,服务端可能几十秒才发一条事件。缓冲开启时,这一条事件可能一直躺在 nginx 的缓冲区里,等不到“攒够”的时机,客户端就一直收不到。
关闭缓冲后,nginx 把响应同步地、收到多少就立刻转发多少给客户端,不再试图先读完整个响应。这正是 SSE 需要的语义。
除了 nginx 自身的缓冲,还有一层来自上游的提示。nginx 文档说明,响应头里的 X-Accel-Buffering: yes 或 no 也能开关缓冲,这个能力可以通过 proxy_ignore_headers 禁用。MDN 的 SSE 示例里,服务端在发送 Content-Type: text/event-stream 之前就设置了 X-Accel-Buffering: no,并配合 Cache-Control: no-cache。这说明在 nginx 后面跑 SSE 服务时,服务端主动声明不缓冲是一种常见做法。
需要注意,proxy_ignore_headers 一旦配置,就会让 nginx 忽略上游的这些头,服务端再怎么声明 X-Accel-Buffering: no 也不起作用。
保活事件解决的是空闲超时
即使关闭了缓冲,SSE 流仍可能被中间层掐断。原因是代理和网关普遍有“连接空闲多久算超时”的判定。nginx 有 proxy_read_timeout,云负载均衡、CDN、API 网关也各有自己的空闲超时。
工具执行期间,服务端可能长时间没有任何字节可发。对代理来说,这条连接就是空闲的。空闲时间超过阈值,代理会主动断开。客户端看到的是连接中断,而工具其实还在跑。
保活事件的作用就是让连接在空闲期也有字节流动。服务端周期性地往 SSE 流里写一条事件,内容可以只是一个注释行或一个心跳事件,不携带业务语义。代理看到有数据经过,就不会把连接判为空闲。
MDN 的示例代码里,服务端每秒发送一个 event: ping 事件,data 是一个带 ISO 8601 时间戳的 JSON 对象。这个例子的循环里还有 connection_aborted() 检查,用来在客户端关闭页面后跳出循环。保活事件本身不要求客户端做业务处理,但客户端必须能容忍收到这类与当前请求无直接关系的事件。
保活周期要小于链路上最短的那个空闲超时。如果代理的空闲超时是 60 秒,保活间隔设成 30 秒比较稳妥;设成 90 秒,代理会先断开。
Content-Type 改写为何让客户端解析失败
SSE 能被客户端识别,前提是响应头里的 Content-Type 是 text/event-stream。有些中间层会“纠正”它认为不规范的响应头,比如把 text/event-stream 改写成 text/plain,或者加上 charset 后变成别的形式。
一旦 Content-Type 不再是 text/event-stream,客户端可能不再按 SSE 解析这条响应。它可能把整段流当成一个普通文本响应,等响应结束后一次性读取。而 SSE 流在工具执行期间不会结束,客户端就一直等,最终超时。
另一种情况是代理把响应做了压缩或分块重排,破坏了 SSE 事件之间必须用空行分隔的格式。SSE 的每条通知是一个以空行结尾的文本块,格式被破坏后,客户端无法切分出完整事件。
排查这类问题时,直接看客户端收到的响应头最有效。用 curl 带 -N 参数请求 MCP endpoint,观察返回的 Content-Type 和事件到达的时间间隔,就能区分是缓冲问题还是 Content-Type 问题。
用流程图串起一次工具调用
下面这张图描述一次 tools/call 从客户端到 MCP Server、再经 nginx 回到客户端的完整路径,以及保活事件和缓冲开关各自在哪个环节起作用。
flowchart TD
A[客户端 POST tools/call] --> B[nginx 反向代理]
B --> C[MCP Server 处理工具]
C --> D{工具是否耗时}
D -- 是 --> E[周期性写保活事件]
D -- 否 --> F[直接写 JSON-RPC 响应]
E --> G{proxy_buffering}
F --> G
G -- on --> H[nginx 攒在缓冲区]
G -- off --> I[nginx 立即转发]
H --> J[客户端长时间收不到]
I --> K[客户端解析 SSE 事件]
K --> L[收到工具结果]
图里的关键转折点在 proxy_buffering 这个判断。缓冲开启时,无论服务端写得多及时,字节都会先停在 nginx 缓冲区,客户端拿不到。保活事件只在缓冲关闭、连接确实空闲时才有意义;缓冲没关,保活事件同样会被攒住。
和 JSON 单次响应模式对比
Streamable HTTP 允许服务端对 POST 请求返回 Content-Type: application/json,用一个 JSON 对象给出结果。这种模式下,HTTP 响应有明确的开始和结束,代理可以正常缓冲、正常压缩,不存在攒住不转发的问题。
| 维度 | SSE 流式响应 | JSON 单次响应 |
|---|---|---|
| 响应结束时机 | 发完 JSON-RPC 响应后关闭流 | 返回单个 JSON 对象即结束 |
| 代理缓冲影响 | 缓冲开启会攒住事件,需关闭 | 缓冲无影响,可正常开启 |
| 空闲超时风险 | 工具耗时长时会被判空闲断开 | 无长空闲,风险低 |
| 中途进度通知 | 支持,可在响应前发通知 | 不支持,只能等最终结果 |
| Content-Type 敏感 | 必须保持 text/event-stream | 只需 application/json |
| 客户端实现复杂度 | 需处理事件切分、保活、重连 | 按普通 HTTP 响应处理 |
| 适用场景 | 长任务、需进度反馈、服务端主动通知 | 短任务、结果一次给出 |
选择哪种模式取决于工具特性。工具能在几秒内返回、也不需要中途通知,JSON 单次响应更省事,代理配置不用特殊处理。工具耗时长、需要进度反馈,或者服务端要在处理过程中向客户端发请求或通知,就必须用 SSE,并相应调整代理配置。
规范还提到,客户端可以发起 HTTP GET 打开一条 SSE 流,让服务端在没有 POST 的情况下主动推送消息。这条 GET 流上的消息应与任何正在进行的客户端请求无关,服务端不得在这条流上发送 JSON-RPC 响应,除非是在恢复之前某个请求关联的流。这说明 GET 流和 POST 流承担不同职责,代理配置要同时覆盖两者。
可观测指标与排查顺序
出现“工具结果迟迟不到”时,按下面的顺序排查能快速定位到具体环节。
(1)看客户端收到的响应头
用 curl -N -H 'Accept: text/event-stream, application/json' 请求 MCP endpoint,观察 Content-Type 是否为 text/event-stream。如果不是,问题在 Content-Type 改写。
(2)看事件到达的时间间隔
如果响应头正确,但事件成批出现、间隔远大于服务端写入间隔,说明代理在缓冲。检查 proxy_buffering 是否为 off,以及 proxy_ignore_headers 是否屏蔽了 X-Accel-Buffering。
(3)看连接是否被中途断开
如果连接在工具执行期间断掉,检查 proxy_read_timeout 和链路上其他空闲超时,确认保活间隔小于最短超时。
(4)看服务端是否真的写了数据
在 MCP Server 侧记录每次向 SSE 流写入的时间和字节数。如果服务端长时间没写,问题在工具实现或服务端逻辑,不在代理。
生产环境建议持续观察这几个信号:SSE 连接的平均存活时长、保活事件的实际发送间隔、工具调用从发起到客户端收到结果的端到端延迟、以及连接被代理断开的次数。这些指标能把“客户端慢”拆成可归因的几段。
部署边界与失效条件
保活加禁用缓冲这套配置不是万能的,有几个前提不成立时会退化。
如果链路上有代理不支持 X-Accel-Buffering,服务端声明无效,必须在那一层显式关闭缓冲。云厂商的托管网关通常不暴露这个开关,只能通过响应头或专门的流式配置项处理。
如果客户端不按 SSE 解析,而是等整个响应结束,即使代理配置正确,客户端也会一直等到流关闭。规范要求客户端同时支持 text/event-stream 和 application/json 两种响应,实现不完整的客户端会在这里出问题。
如果保活间隔设得比代理空闲超时还长,连接仍会被断开,只是断开时间被推迟。保活周期必须小于链路上最短的空闲超时,这个值需要逐层确认,不能只按 nginx 一层估算。
如果工具执行时间超过客户端自身的请求超时,客户端会先放弃。这时服务端即使把结果写进了流,客户端也不再读取。规范提到的流可恢复机制,即服务端给 SSE 事件附加 id、客户端断线后用 Last-Event-ID 请求续传,可以缓解这类问题,但需要服务端实现事件缓存和重投递。
还有一个容易被忽略的边界:规范要求服务端在发完 JSON-RPC 响应后应关闭 SSE 流。如果服务端不关闭,连接会一直挂着,占用代理和客户端的连接资源。对 HTTP/1.1 下的浏览器,同域 SSE 连接数有上限,MDN 提到这个限制是 6,多个标签页会共享这个额度。HTTP/2 下同时流数量由服务端和客户端协商,默认 100,情况好一些。
把这些边界写进部署检查清单,比事后逐个排查连接要省事。核心就三件事:Content-Type 别被改写、缓冲关掉、保活间隔小于最短空闲超时。