一个被 406 挡住的工具调用
设想一个企业内部的远程 MCP Server,部署在网关后面,同时提供两类工具:search_docs 返回一份检索结果,run_report 需要几十秒生成报表并持续推送进度通知。服务端实现了 Streamable HTTP 传输,对同一个 MCP 端点同时接受 POST 和 GET。客户端是一个自研的 Agent 运行时,用 HTTP 库手写请求,开发者觉得“我只要最终结果,不需要流”,于是把 POST 请求的 Accept 写成 application/json。
请求发出后,服务端返回 406 Not Acceptable,search_docs 一次都没跑起来。开发者把 Accept 改成 */* 后请求通过,但 run_report 的进度通知全部丢失,客户端直到最后才拿到一个 JSON 对象,中间几十秒没有任何反馈。这两个现象来自同一个机制:MCP 规范要求客户端在 POST 请求中同时声明两种媒体类型,服务端再依据这个声明决定用 SSE 流还是单个 JSON 对象回应。
规范对 Accept 的硬性要求
MCP 规范 2025-06-18 版的 Transports 章节写得很直接:客户端必须使用 HTTP POST 向 MCP 端点发送 JSON-RPC 消息,并且必须包含一个 Accept 头,列出 application/json 和 text/event-stream 作为支持的内容类型。这不是建议,是 MUST。规范同时规定,当 POST 请求体包含 JSON-RPC 请求时,服务端必须返回两种形态之一:Content-Type: text/event-stream 启动 SSE 流,或者 Content-Type: application/json 返回一个 JSON 对象。客户端必须同时支持这两种情况。
把这两条放在一起看,Accept 头承担的是能力声明,不是偏好表达。客户端告诉服务端“这两种响应我都能处理”,服务端才有权在两者之间选择。如果客户端只声明 application/json,它等于告诉服务端“SSE 流我处理不了”。此时服务端若仍想返回 SSE 流,就违反了 HTTP 内容协商的基本约定;若它没有可用的 JSON 单次响应路径,唯一符合语义的回应就是 406。
这里容易混淆的一点是:Accept 描述的是响应表示(representation)的媒体类型,而不是请求体的类型。请求体是 JSON-RPC,Content-Type: application/json 描述它;响应可能是 JSON,也可能是 SSE 流,由 Accept 约束。两个头各管一个方向,不能互相替代。
服务端如何在两种响应形态之间选择
规范没有规定服务端必须选哪一种,只要求二选一。实际实现通常按工具语义分流:只返回单个结果的工具走 JSON 单次响应,需要进度通知、日志或长时间运行的工具走 SSE 流。分流依据是请求体里的 JSON-RPC 方法名和参数,Accept 头则决定这条分流路径是否被允许。
flowchart TD
A[客户端 POST JSON-RPC 请求] --> B{Accept 是否同时含\napplication/json 与 text/event-stream}
B -- 否 --> C[406 Not Acceptable]
B -- 是 --> D{工具是否需要流式进度}
D -- 否 --> E[Content-Type: application/json\n返回单个 JSON-RPC 响应]
D -- 是 --> F[Content-Type: text/event-stream\n先推进度通知,再推最终响应]
E --> G[客户端解析 JSON 并结束]
F --> H[客户端逐事件读取\n收到响应后关闭流]
图中第一个判断是协议层校验,第二个判断是业务层分流。两者顺序不能颠倒:先确认客户端能接受哪些形态,再决定用哪种形态。若把业务分流放在前面,服务端可能已经决定走 SSE,却发现客户端只接受 JSON,这时只能中途改道或报错,前者会丢失已推送的通知,后者就是 406。
SSE 分支的行为在规范里有更细的约束。流应当最终包含针对 POST 请求体中每个 JSON-RPC 请求的响应;服务端可以在响应之前发送 JSON-RPC 请求和通知,这些消息应当与原始请求相关;除非会话过期,服务端不应当在发出响应之前关闭流;响应发出后,服务端应当关闭流。断线不应被解释为客户端取消请求,取消要通过显式的 CancelledNotification。这些规则意味着 SSE 流不是“可选加速”,而是一条有明确起止条件的消息通道。
只声明 application/json 的三种失败形态
第一种是直接拒绝。服务端在协议层校验 Accept,发现缺少 text/event-stream,返回 406。这种失败最干净,客户端能立刻看到状态码,但错误信息往往只写“Not Acceptable”,开发者容易误以为是认证或路径问题。
第二种是响应形态降级。部分服务端实现比较宽松,看到 Accept: application/json 就把所有工具都按 JSON 单次响应处理。search_docs 正常返回,run_report 也能返回最终结果,但中间的进度通知被丢弃。客户端表现为长时间无响应,超时后才拿到结果,或者干脆在等待期间被上层判定为卡死。这种降级不会报错,排查成本比 406 高得多。
第三种是协商结果与客户端解析能力错位。客户端声明了两种类型,但代码里只写了 JSON 解析分支,没写 SSE 事件循环。服务端按工具语义返回 text/event-stream,客户端把整个流当成一个 JSON 文档去解析,得到语法错误。规范要求客户端必须支持两种形态,这条要求针对的正是这种实现缺口。
| 客户端 Accept | 服务端行为 | 可观察结果 | 根因 |
|---|---|---|---|
仅 application/json | 严格校验后返回 406 | 请求失败,工具未执行 | 缺少 text/event-stream 声明 |
仅 application/json | 宽松实现降级为 JSON | 流式工具无进度,延迟到最终结果 | 服务端放弃 SSE 分支 |
| 两种都声明,但客户端只解析 JSON | 按工具返回 SSE | 解析报错或事件被丢弃 | 客户端缺少 SSE 读取分支 |
| 两种都声明,客户端也支持 SSE | 按工具分流 | 单次工具走 JSON,长任务走流 | 符合规范 |
*/* | 取决于服务端实现 | 行为不确定 | 未表达明确能力边界 |
*/* 那一行值得单独说明。通配符在 HTTP 语义上表示“任何类型都可接受”,但它没有告诉服务端客户端真的实现了 SSE 解析。服务端可能因此选择 SSE,而客户端并没有准备好。工程上通常建议显式列出两种类型,而不是用通配符省事。
与 GET 流的 Accept 要求不同
同一个 MCP 端点还支持 GET,用于服务端主动向客户端推送消息。规范对 GET 的 Accept 要求是列出 text/event-stream,不需要 application/json。服务端对 GET 必须返回 text/event-stream,或者返回 405 表示此端点不提供 SSE 流。
POST 和 GET 的差异来自用途:POST 承载客户端发起的请求,响应可能是单次 JSON 也可能是流;GET 只用于打开服务端到客户端的推送通道,响应只能是流。把两者的 Accept 混用会出问题。比如客户端在 GET 上也带 application/json,严格实现可能认为协商失败;反过来,在 POST 上只带 text/event-stream,单次 JSON 响应路径就被排除了。
实现中的校验点
客户端侧最关键的校验点是在构造请求时统一注入 Accept,而不是让每个调用点各自拼头。一个可用的做法是在 HTTP 客户端封装层写死 Accept: application/json, text/event-stream,并对响应按 Content-Type 分支处理:application/json 走单次解析,text/event-stream 进入事件循环。伪代码示意如下:
// 示意逻辑,非具体库 API
response = http.post(mcpEndpoint, body=jsonRpcRequest,
headers={
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream"
})
if response.contentType == "application/json":
return parseJsonRpc(response.body)
if response.contentType == "text/event-stream":
for event in readSseEvents(response):
dispatch(event)
if event.isResponseFor(requestId):
break
close(response)
这段逻辑里有两个边界条件。其一,SSE 分支必须在收到对应请求的响应后主动结束读取,不能等连接自然关闭,否则长连接会一直占着。其二,dispatch 要能处理响应之前到达的通知和请求,这些消息与原始请求相关,但不是最终结果。
服务端侧的校验点是在路由层检查 Accept 是否同时包含两种类型,不满足就返回 406,并可在响应体里说明缺少的类型。这个检查应放在业务分流之前,避免已经进入工具执行后再回退。对于宽松实现,至少要在日志里记录“因 Accept 缺失而放弃 SSE 分支”的事件,否则降级行为不可见。
可观测指标与定位顺序
生产环境里,这类问题往往表现为“某个工具偶发超时”或“某些客户端拿不到进度”。可以观察的信号包括:按 Accept 头取值分组的请求量,406 响应计数,响应 Content-Type 的分布,SSE 流的平均持续时长,以及流内首个事件与最终响应之间的间隔。
定位时先看 406 是否集中在特定客户端版本,这通常指向 Accept 构造错误。再看同一工具在不同客户端上的 Content-Type 分布,如果流式工具对部分客户端返回了 application/json,说明服务端发生了降级。最后检查客户端是否记录了 SSE 解析异常,这能区分“服务端没发流”和“客户端不会读流”。
适用边界与遗留问题
这套协商机制的前提是服务端确实实现了两种响应形态。如果服务端只支持 JSON 单次响应,那么客户端声明两种类型也不会得到流,此时 Accept 的约束力只体现在“不拒绝”上。反过来,如果服务端只支持 SSE,客户端只声明 application/json 就会稳定收到 406,没有降级空间。
规范没有规定服务端在两种形态都可用时必须优先选哪一种,也没有规定 406 响应体应该包含什么。不同实现的宽松程度不同,这给跨客户端互操作留下了差异。工程上通常的做法是客户端始终声明两种类型并实现两条解析路径,服务端在日志中显式记录协商结果,把隐式降级变成可观测事件。至于 406 是否应该附带机器可读的缺失类型说明,目前仍是实现层面的选择。