AI 技术
#MCP#Streamable HTTP#SSE#JSON-RPC#传输协议

MCP Streamable HTTP 的 JSON 响应模式:服务端不返回 SSE 流时,工具结果为何会丢失

远程 MCP Server 返回单次工具结果时,服务端可以直接用 application/json 响应,而不必开启 SSE 流。这篇文章解释规范允许的两种响应形态、客户端对 Content-Type 的解析假设、请求与响应的配对方式,以及 JSON 单次响应与 SSE 流式响应在兼容性、可观测性和失败模式上的差异。

一次工具调用为什么收不到结果

假设你维护一个远程 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-Typeapplication/jsontext/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 模式时需要明确的边界。

资料来源

  1. MCP Specification - Transports
  2. MCP TypeScript SDK - Streamable HTTP
  3. SSE与Streamable HTTP:MCP 背后的传输技术 | morty的个人博客
  4. MCP协议Streamable HTTP - LanternOps