一次工具调用为什么收不到结果
假设你维护一个远程 MCP Server,部署在 https://tools.example.com/mcp,对外暴露一个查询订单状态的工具。客户端发来 tools/call 请求,服务端查完数据库,返回 Content-Type: application/json,body 是一个完整的 JSON-RPC 响应。服务端日志显示 200,响应体也正确。但客户端界面停在“正在调用工具”,直到超时。
这类问题常见于自研客户端或第三方客户端。服务端按规范返回了合法的 JSON 响应,客户端却只认 text/event-stream,把 JSON 响应当成异常或直接丢弃。要理解这个分歧,需要先看清 Streamable HTTP 对“请求的响应”规定了哪几种合法形态。
本文以“远程 MCP Server 返回单次工具结果”为贯穿场景,讨论 JSON 单次响应与 SSE 流式响应的机制差异、客户端的解析假设,以及生产环境需要观察哪些信号。
规范允许的两种响应形态
MCP 使用 JSON-RPC 2.0 编码消息,Streamable HTTP 是它定义的标准传输之一。服务端必须提供一个同时支持 POST 和 GET 的 HTTP 端点,称为 MCP 端点。客户端发送的每一条 JSON-RPC 消息都对应一次新的 HTTP POST。
客户端在 POST 时必须带上 Accept 头,同时列出 application/json 和 text/event-stream 两种内容类型。这一点是规范强制的,客户端不能只声明其中一种。
服务端收到 POST 后,先看 body 里是什么类型的 JSON-RPC 消息:
- 如果 body 是 JSON-RPC 响应或通知,服务端接受就返回
202 Accepted,不带 body;无法接受就返回 HTTP 错误码,可以附带一个没有id的 JSON-RPC 错误响应。 - 如果 body 是 JSON-RPC 请求,服务端必须二选一:返回
Content-Type: text/event-stream开启 SSE 流,或者返回Content-Type: application/json直接给出一个 JSON 对象。
规范原文明确要求客户端必须同时支持这两种情况。也就是说,JSON 单次响应不是兜底方案,而是与 SSE 平级的合法响应形态。
两种形态的差别在于:SSE 流可以在最终响应之前插入服务端发起的请求和通知,JSON 响应则只有那一个 JSON 对象。对于“查一次订单、返回一个结果”这类单次工具调用,JSON 响应已经足够。
请求与响应如何配对
JSON-RPC 用 id 字段把请求和响应配成一对。客户端发 tools/call 时带上 id,比如 "id": 42,服务端返回的 JSON 对象里必须带同一个 id。客户端收到响应后,用 id 找到对应的待处理请求,把结果交给上层。
SSE 流的配对逻辑更复杂。规范要求:如果服务端开启 SSE 流,流中最终应该包含对 POST body 里那个请求的 JSON-RPC 响应。在响应之前,服务端可以发送与本次请求相关的 JSON-RPC 请求和通知。响应发出后,服务端应该关闭流。
这里有一个容易踩的坑:SSE 流里可能同时出现通知、服务端发起的请求和最终响应。客户端不能把流里第一个 JSON 对象就当成工具结果。它必须按 id 匹配,忽略通知,处理服务端请求,直到拿到 id 与自己请求一致的那个响应。
JSON 单次响应没有这个问题。整个 HTTP body 就是一个 JSON-RPC 响应,id 直接对应 POST 里的请求。客户端解析时不需要维护流状态,也不需要区分消息类型。
但 JSON 响应也带来一个约束:服务端无法在同一次 HTTP 交互里发送进度通知或反向请求。如果工具执行需要 30 秒,客户端在这 30 秒里收不到任何中间消息,只能等最终响应或超时。
客户端解析假设导致的失败
回到开头的场景。服务端返回 Content-Type: application/json,客户端却收不到结果,通常有以下几种原因。
(1)客户端硬编码只读 SSE
部分客户端实现时只处理 text/event-stream,收到 application/json 后不进入解析分支,直接把响应体丢掉。这类客户端在规范上不合格,因为规范要求客户端支持两种 Content-Type。
(2)Accept 头不完整
如果客户端 POST 时只写了 Accept: text/event-stream,服务端可能按内容协商规则返回 406,或者仍然返回 JSON 但客户端拒绝接受。规范要求客户端同时列出两种类型,缺失任一项都可能触发服务端的兼容性处理差异。
(3)把通知误当响应
在 SSE 模式下,客户端如果按“收到第一条消息就结束”的逻辑处理,可能把服务端在响应前发来的通知当成工具结果。这类客户端切到 JSON 模式后反而正常,因为 JSON body 里只有响应。
(4)代理或网关改写 Content-Type
中间层如果对响应做缓冲或转换,可能把 application/json 改写成其他类型,或者把 SSE 流缓冲成一次性响应。客户端看到的 Content-Type 与实际语义不符,解析就会失败。
(5)超时设置不匹配
JSON 响应模式下,服务端在工具执行完成前不会发送任何字节。如果客户端或反向代理设置了较短的读超时,连接会在结果返回前被断开。SSE 模式因为有中间事件,连接通常能保持更久。
这几种原因可以通过抓包或服务端访问日志区分。如果服务端日志显示 200 且响应体完整,问题在客户端解析;如果服务端日志显示连接提前关闭,问题在超时或代理。
两种模式的对比与选择
下表对比 JSON 单次响应与 SSE 流式响应在关键维度上的差异。
| 维度 | JSON 单次响应 | SSE 流式响应 |
|---|---|---|
| Content-Type | application/json | text/event-stream |
| 响应体结构 | 一个 JSON-RPC 响应对象 | 多个 SSE 事件,最终包含响应 |
| 能否发送进度通知 | 不能 | 可以,在响应前发送 |
| 能否反向请求客户端 | 不能 | 可以,例如 Sampling、Elicitation |
| 客户端解析复杂度 | 低,直接解析 JSON | 高,需按 id 匹配并区分消息类型 |
| 连接保持时间 | 短,响应即结束 | 长,直到响应发出后关闭 |
| 代理兼容性 | 好,普通 HTTP 响应 | 需代理支持流式转发,不能缓冲 |
| 断线恢复 | 不适用 | 可通过 Last-Event-ID 恢复 |
| 适用场景 | 单次工具调用、无中间反馈 | 长任务、需要进度或反向交互 |
选择哪种模式取决于工具的行为。如果工具在几百毫秒内返回,且不需要向客户端要参数或发进度,JSON 响应更简单,对基础设施要求更低。如果工具执行时间长,或者需要在执行中调用客户端的大模型、向用户追问参数,就必须用 SSE 流,因为这些消息只能通过流发送。
规范允许服务端对同一个端点上的不同请求选择不同模式。服务端可以根据请求内容判断:tools/call 里工具是快工具就返回 JSON,是慢工具就返回 SSE。但客户端必须两种都能处理,否则会在某类工具上失败。
贯穿场景的完整流程
下面的流程图展示订单查询工具在两种模式下的消息流动。假设客户端发送 tools/call,id 为 42。
flowchart TD
A[客户端 POST tools/call id=42] --> B{服务端选择响应模式}
B -->|快工具| C[返回 application/json]
C --> D[body 为 id=42 的响应]
D --> E[客户端按 id 匹配并交付结果]
B -->|慢工具| F[返回 text/event-stream]
F --> G[SSE 事件: 进度通知]
G --> H[SSE 事件: id=42 的响应]
H --> I[服务端关闭流]
I --> E
关键转折点在服务端选择响应模式这一步。选 JSON 时,客户端只收到一个对象,配对靠 id。选 SSE 时,客户端先收到通知,再收到响应,必须按 id 过滤。如果客户端只实现了其中一条路径,就会在另一条路径上丢失结果。
生产环境的可观测指标
要定位“工具结果丢失”这类问题,需要在服务端和客户端两侧采集信号。
服务端侧建议记录:
- 每个 POST 请求的 JSON-RPC 方法名和
id。 - 实际返回的 Content-Type。
- 从收到请求到写出响应头的耗时。这个值在 JSON 模式下等于工具执行时间,在 SSE 模式下通常很短。
- 响应体字节数或 SSE 事件数量。
- 连接是否被客户端提前关闭。
客户端侧建议记录:
- POST 请求的 Accept 头内容。
- 收到的响应 Content-Type。
- 从发出请求到收到匹配
id响应的耗时。 - 解析失败时的原始响应片段。
一个有用的判断规则:如果服务端记录了 200 和完整响应体,客户端却没有对应 id 的结果,问题几乎一定在客户端解析或中间代理。反过来,如果服务端在写出响应前连接就断了,需要检查读超时和代理缓冲配置。
对于 SSE 模式,还要观察流是否在响应发出后正常关闭。规范建议服务端在发送响应后关闭流,如果流一直不关闭,客户端可能继续等待,占用连接资源。
边界条件与失效场景
JSON 响应模式在以下条件下会退化或失败。
工具执行时间超过客户端超时。 JSON 模式没有任何中间字节,客户端只能干等。如果工具执行 60 秒,而客户端读超时是 30 秒,连接会被客户端主动断开,服务端后续写响应时发现连接已关闭。这种情况下要么改用 SSE 模式,要么调整超时。
服务端需要反向调用客户端。 MCP 的 Sampling 和 Elicitation 都要求服务端在工具执行中向客户端发请求。这些消息只能通过 SSE 流发送。如果服务端在 JSON 模式下需要这些能力,只能改成 SSE。
中间代理不支持流式转发。 如果服务端选择 SSE 模式,但代理会缓冲整个响应再转发,客户端会一次性收到所有事件,失去流式效果。更糟的是,某些代理可能因为响应长时间不结束而主动断开。JSON 模式对这类代理更友好。
客户端只实现了一种模式。 这是最常见的失效原因。规范要求客户端支持两种 Content-Type,但实际实现中,部分客户端只处理 SSE,部分只处理 JSON。服务端如果只返回一种,就会在另一类客户端上失败。
会话过期。 规范提到,服务端不应在发送响应前关闭 SSE 流,除非会话过期。如果服务端使用会话管理,会话过期会导致流被关闭,客户端收不到响应。JSON 模式下,会话过期通常表现为 HTTP 错误码,更容易识别。
兼容性建议
服务端如果要同时服务多种客户端,可以考虑以下策略。
默认对单次工具调用返回 JSON 响应,因为它的解析路径最短,对代理和客户端要求最低。只有当工具确实需要进度通知或反向请求时,才返回 SSE 流。
在返回 JSON 响应时,确保 Content-Type 精确为 application/json,不要附加 charset 以外的参数,避免客户端做严格字符串匹配时失败。
在返回 SSE 流时,确保响应头立即写出,不要等工具执行完再写。SSE 的价值在于中间事件,如果响应头延迟写出,客户端无法区分“服务端在处理”和“服务端没响应”。
客户端侧则必须实现两条解析路径。收到 application/json 时,解析 body 为单个 JSON-RPC 消息,按 id 匹配。收到 text/event-stream 时,逐事件解析,忽略通知,处理服务端请求,直到找到匹配 id 的响应。
尚未解决的问题
规范没有规定服务端必须在什么条件下选择 JSON 还是 SSE。这意味着同一个工具在不同服务端实现上可能返回不同形态,客户端必须始终准备好两种路径。
另一个开放问题是错误响应的形态。如果工具执行失败,服务端可以在 JSON 响应里返回 JSON-RPC 错误对象,也可以在 SSE 流里发送错误响应。客户端需要同时处理这两种错误路径,并区分“传输层错误”和“工具执行错误”。
对于需要断线恢复的长任务,SSE 模式配合 Last-Event-ID 是规范提供的机制。但 JSON 模式没有对应的恢复能力,一旦连接断开,客户端只能重新发起请求。如果工具不是幂等的,重新发起可能产生副作用。这是选择 JSON 模式时需要明确的边界。