拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

MCP传输层进化:从HTTP+SSE双端点到Streamable HTTP按需流式

MCP传输层进化:从HTTP+SSE双端点到Streamable HTTP按需流式

1. HTTP 传输在 MCP 里的旧账:双端点为什么难用

MCP(Model Context Protocol)走到今天,传输层其实换过一次大思路。最早大家玩 stdio,本地进程间通信,一条 stdin、一条 stdout,简单粗暴。后来要接远程服务,官方搬出了 HTTP+SSE 的老方案:客户端向一个 HTTP 端点 POST 请求,再单独连一个 SSE 端点接收服务器消息。这套东西有人叫它“双端点模式”,对用过早期 MCP Server 的人来说,记忆里多少都有点阴影。

先说双端点疼在哪,痛点不在“两个 URL 本身”,而在“两个 URL 之间的状态关系”。客户端要发送 JSON-RPC 请求,得记住 POST 地址;要接收工具回调、资源更新、日志通知,又得维护一条独立的 SSE 长连接。两端点之间没有强绑定,谁先连谁后连、断了怎么重连、两个连接是不是同一会话,全靠实现各自发挥。你连好了 POST,再开 GET 收流,中间一断,SSE 那头收不到任何提示,整个调试过程非常考验耐心。

更麻烦的是门禁和机制容易出偏差。因为接收通道是 SSE,很多服务端就得把“响应”和“通知”拆成两类逻辑,客户端则很容易漏掉某一种消息。比如你调一个 tools/call,服务器明明处理完了,结果因为响应走的是 SSE 流,客户端收到一半连接断了,界面就卡住,重试又重复执行一次。类似的问题在真实项目里特别常见,也是后来社区天天在各种群里的 MCP 连接问题的发源地。

Streamable HTTP 本质上是在给这笔旧账做清理。它在 2024 年底到 2025 年在 MCP spec 里被正式提为重点,核心思路就两条:第一,把通信收敛到单一 HTTP 端点;第二,让服务器自己决定“这次响应要不要流式”。名字叫 Streamable,细读的话其实分两个词:Streamable 是指响应可以升级成流,HTTP 是指它仍然站在 HTTP 语义上,而不是另起炉灶。

1.1 旧方案的 “两个 URL” 到底怎么折磨人

我手头曾经维护过一个内部的 MCP Server,早期实现就是照搬 HTTP+SSE。表面上看不大:POST 路径/mcp,SSE 路径/mcp/events,配置里写两个地址。真正跑起来问题全冒出来了。

第一个就是跨代理和网关的负担加倍。你部署在 Nginx 后面,/mcp走普通的 HTTP 转发,/mcp/events必须给 SSE 单独开 buffering off、proxy_read_timeout 拉长。两台机器负载均衡的话,还得额外保证 POST 和 SSE 进了同一个后端,不然会话状态就丢了。我那时为了这个,把整个服务都按 session 粘到单机上,扩容直接作废。

第二个是客户端适配分裂。不同客户端对双端点的叫法不一样,有的文档写sseEndpoint,有的写eventsUrl,有的那么默认。我记得当时为了接一个开源客户端,憋了一个下午,发现人家只支持新式的单一端点,双端点的能力直接被砍掉。二选一不超向下兼容真的受罪。

所以当我切换到 Streamable HTTP 之后,第一个感觉就是“总算能把配置从两个 URL 变成一个 URL 了”。不是那种看得见的性能提升,但你维护配置、写客户端、设代理时,省下来的成本是肉眼可见的。

1.2 从 HTTP+SSE 到 Streamable HTTP,变的不是 HTTP,是“流”的位置

很多人有个误区,觉得 Streamable HTTP 就是把 SSE 塞进了 HTTP 的某个角落。其实不是。它的重点在“把流式变成响应的一种可选项”。

在老方案里,SSE 是客户端的主动选择——我连一个/events端点,就是为了让你持续推送给我的。服务器端没有多少决定权,只能被动地往这条流里写东西。用户侧遇到的最大问题是:如果你只有一次性的请求响应,根本不必开流,但旧协议长连接就一直占着资源。

在 Streamable HTTP 里,服务器被赋予了判断权。它看到请求后,自己决定用普通 JSON 回复,还是用text/event-stream流式回复。这个判断可以精细到“单个请求”:比如tools/list这种一锤子买卖,结果不大,直接 JSON 返回就好;tools/call如果执行时间长、有进度要报,那就升级为流,边执行边发事件,发完结果再结束。

这样流式就不再是个“永远存在的连接”,而是“按需拉起、用完即走”的能力。这才是 Streamable 的核心,也是这篇标题里“按需流式”四个字真正的分量所在。

2. 单一端点:把整套协议焊在一个 URL 上

Streamable HTTP 最直观的改变就是端点数量:从两个变一个。规范里叫 endpoint,实践中你通常把它配置成一个完整 URL,比如https://api.example.com/mcp,所有 JSON-RPC 消息都往这个地址发。

这个“单一”听起来是简化,但实际把三件事绑定到了一处:请求入口、响应出口、会话保持。客户端只需维护一个 base URL,所有 tools/list、tools/call、resources/read、prompts/get 都等价地 POST 到这个 URL 上,不区分“业务路径”。有些新上手的朋友会问:那我多个工具是不是要多个端点?不需要。MCP 的方法都放在 JSON-RPC 的 method 字段里,路径永远固定在端点,方法不同就走不同逻辑。

2.1 POST 和 GET 在同一个 URL 上的分工

很多人刚看到 Streamable HTTP 的协议会有点懵:同一个端点,为什么又有 POST 又有 GET?这两个分别承担什么职责?

规范的逻辑很清晰:

  • POST:客户端向服务器发送 JSON-RPC 消息,也可以兼作创建会话的入口。绝大多数请求都走这里。
  • GET:客户端可选地建立一个 SSE 流,用于接收服务器后续主动推送的通知、事件或——在某些实现里——异步响应。

这两个方法不是对立的。POST 负责“发”,GET 负责“听”。但是在 Streamable HTTP 的设定下,POST 的响应本身也可以变形为 SSE 流,这就让 GET 的“听”显得不那么必要了。实际使用中,很多服务端实现完全不依赖 GET,所有交互都通过 POST 的响应去携带流事件,照样能跑。

我个人建议初学实现时先只做 POST。把“收到请求、回 JSON”、“收到请求、回 SSE 流”这两套响应逻辑跑通了,再回头补 GET 的监听流,理解起来顺得多。那些把 GET 和 POST 混在一个 handler 里的服务端框架,你只要记住一个原则:方法不同、路径相同、处理逻辑分流,不会乱。

2.2 会话保持:MCP-Session-Id 是单一端点下的暗线

单一端点看着简洁,但有个潜在问题:服务器怎么知道“同一个人”是不是又来了?老方案里靠的是两个 URL 的绑定关系,新方案把这个问题全部压给了会话 ID。

在 Streamable HTTP 中,当客户端第一次 POST 消息时,服务器可以决定是否创建一个会话。如果创建,就在响应头里带上Mcp-Session-Id。客户端收到后,后续所有请求都得在请求头里带上这个 ID,服务器靠它识别上下文。服务器的会话状态可以放在内存里,也可以放到 Redis 之类的地方,反正客户端只认这个头。

这里有几个细节容易踩。

第一,会话 ID 不是强制要求的。如果服务器不做有状态会话,它可以不返回该头,客户端也不会强求。无状态场景下每次请求都是独立 JSON-RPC,反而更简单。

第二,一旦服务器返回了Mcp-Session-Id,客户端就得始终携带,否则服务器完全有理由返回 400 或 404,表示“我不认识你”。所以调试连接问题时,你第一件事就是看请求头带没带这个值。

第三,会话 ID 跟 HTTP 连接本身没有必然关系。连接断了,但会话 ID 还在,客户端重新 POST 时把 ID 带上,服务器就能接着上下文继续,不用重新握手。这个机制在“按需流式”里尤其有用——流中断了,会话没断,重连成本很低。

2.3 “单一端点”不是“单一路径”,别把业务路由和协议搞混

有些朋友会把“单一端点”理解成“只能有一个 handler 一个路由”,进而把所有的业务逻辑都堆在一个函数里。这个误会挺常见的。

正确理解是:协议层面,你的服务只暴露一个 URL;但服务端内部完全可以按 JSON-RPC 的 method 字段自己分派。比如tools/list进 A 函数,tools/call进 B 函数,resources/read进 C 函数。这跟你用传统 HTTP 路由没有本质区别,唯一区别是这些方法不在 URL 上体现,而在请求体里体现。

用 Express 举例,你可以这样写:

const express = require('express'); const app = express(); app.use(express.json()); app.post('/mcp', async (req, res) => { const message = req.body; switch (message.method) { case 'initialize': return handleInitialize(req, res); case 'tools/list': return handleToolsList(req, res); case 'tools/call': return handleToolsCall(req, res); default: return sendJsonRpcError(res, message.id, -32601, 'Method not found'); } }); app.listen(3000);

URL 永远都是/mcp,路由逻辑在服务端内部展开。这样设计的价值是:对于任何客户端,接入成本仅仅是“知道一个端点”,剩下的交给协议本身。我在给团队写接入文档时,最喜欢画这么一张简单的概念图:客户端 → 统一端点 → handler 转发 → 各业务模块,通篇不用提路径,新同学理解起来也輕鬆。

3. 按需流式:在 JSON 直返与 SSE 流之间当机立断

“按需流式”是 Streamable HTTP 里最值得琢磨的部分。它改变的不仅是一个响应格式,而是“服务器如何表达自己正在干活”的方式。

在纯 HTTP 请求-响应模型里,客户端发出请求后只有等到完整响应回来才知道结果。如果工具执行要 20 秒,客户端这一侧就是干等 20 秒,中间没有半点信息。在 sse-only 的老模型里,服务器可以随时往流里推事件,但只能推,不能直接答复。Streamable HTTP 就想了一个两全方案:响应可以是普通 JSON,也可以是 SSE 流。

那服务器到底什么时候选择哪一种?

3.1 判断逻辑:结果简单直返,过程丰富开流

我自己的经验是,判断标准主要看三点:响应数据量、执行耗时、是否需要中途通知。

  • 响应数据量小,比如 tools/list 这种,结果通常就几个 JSON 对象,直接返回 200 +application/json是最划算的。
  • 执行耗时长,比如让模型调用一个爬虫、跑一个脚本、分析一个大文件,你不可能让客户端屏气凝神等结果,这时候就该考虑流式。
  • 需要中途通知,比如模型在流畅思考过程中要输出进度日志、token 增量、脚本执行过程,这些不属于最终结果,但用户需要实时看到,只有流式能干这件事。

在代码层面上,这个判断最终体现在 Content-Type 上:

  • 如果响应直接返回:Content-Type: application/json,body 里是 JSON-RPC 响应对象。
  • 如果响应升级为流:Content-Type: text/event-stream,body 里是由 SSE 格式编码的一系列事件。

客户端的 Accept 头通常会写成application/json, text/event-stream,意思是“我两种都能接受,你挑合适的”。如果客户端只写application/json,你强行返回 SSE,那就会出大问题。这是很多协议实现者容易忽略的一环。

3.2 流式响应里的混合帧:一次升级,多种事件

按需流式的关键在于:一旦服务器决定把一次 POST 变成 SSE 流,它就把“最终响应”和“过程事件”统一塞进了同一条流里。常见的帧类型有:

  • 进度通知,比如{"jsonrpc":"2.0","method":"notifications/progress","params":{...}}
  • 日志消息,比如{"jsonrpc":"2.0","method":"notifications/message","params":{...}}
  • 最终结果,比如{"jsonrpc":"2.0","id":1,"result":{...}}
  • 错误消息,比如{"jsonrpc":"2.0","id":1,"error":{...}}

这些统统是 JSON-RPC 消息,只是一条条用data:前缀通过 SSE 发出去。客户端的解析器看到一条识别一条,看到最终结果后就把这个流关掉。

我给一个实际例子。某个工具调用需要分三步:读取数据库、处理数据、生成报告。服务器可以把前两步的进度作为通知帧发出,最后把报告内容作为结果帧发出:

event: message data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progress":0.3,"total":1}} event: message data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progress":0.7,"total":1}} event: message data: {"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"处理完成,报告已生成"}]}}

客户端则根据帧的 id 和 method 判断是通知还是响应。这块如果自己写解析器,要留意区分“带 id 的消息是请求/响应”“不带 id 的可能是通知”,否则中间的通知帧会把结果帧覆盖掉。

3.3 客户端怎么选路:Accept 头、响应头与超时管理

其实路由判定权在服务器,但客户端也有责任把自己的能力声明清楚。

标准流程是这样的。客户端 POST 一条 JSON-RPC 消息到端点,请求头带上Accept: application/json, text/event-stream,然后看响应头的 Content-Type。如果是application/json,就直接解析 body 里的 JSON-RPC 响应;如果是text/event-stream,就逐行读 SSE 帧,遇到最终响应帧后结束读取。

这里有个非常关键的细节:流式响应开始后,HTTP 连接会持续到流结束。所以客户端的超时设置不能按普通 HTTP 请求那样固定秒数。一旦服务器正在执行长任务,中间可能隔几十秒都没有一帧数据,客户端太早超时,流就断了。现实中一些连接报错就是这么来的——客户端设置的 timeout 太短,直接把正在进行的流掐断。

正确的做法是“空闲超时”而不是“总超时”。比如你设置 60 秒内没有收到任何一帧才算超时,而不是整个流最长只允许 60 秒。这个区别在接入 OpenAI、Anthropic 等外部大模型耗时场景里尤其重要,我见过太多团队死磕服务端,最后发现是客户端超时策略写错了。

4. 服务端最小实现:从零写一个 Streamable HTTP MCP server

环顾完协议设计,直接进入动手环节。我用 Python 写一个最小化的 Streamable HTTP server,尽量少依赖,代码能跑,逻辑清晰。你不需要引入完整的 MCP SDK,也可以理解这套协议的骨架。

4.1 框架选择:为什么我随手拿起 FastAPI

技术选型上,我偏爱 FastAPI,原因是:异步支持好,SSE 响应写起来不别扭,类型提示对调试友好。如果你更习惯 Express、Koa,或者 Go 的 net/http,也无妨,协议本身是语言无关的,关键是把三个行为做对:POST 处理、SESSION 头识别、流式响应切换。

依赖就两个:

pip install fastapi uvicorn sse-starlette

sse-starlette不是必须的,但它封装了 SSE 的格式和心跳,能少写不少边界代码。如果你在别的框架里,也可以手写text/event-stream响应,只是自己要管好换行符和心跳。

4.2 核心实现:initialize、tools/list、tools/call 三条主线

先定义一个简单的“数据库”,用内存字典模拟工具状态:

from fastapi import FastAPI, Request, Response from fastapi.responses import JSONResponse, StreamingResponse from sse_starlette.sse import EventSourceResponse import json app = FastAPI() SESSIONS = {} def jsonrpc_result(req_id, result): return {"jsonrpc": "2.0", "id": req_id, "result": result} def jsonrpc_error(req_id, code, message): return {"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": message}} def get_session(req: Request): session_id = req.headers.get("mcp-session-id") return session_id, SESSIONS.get(session_id)

端点统一挂在/mcp,先处理initialize。这个方法的职责是握手,它要返回协议版本、服务器能力和实现信息。第一次握手后,服务器可以生成会话 ID:

@app.post("/mcp") async def mcp_endpoint(req: Request): body = await req.json() method = body.get("method") req_id = body.get("id") session_id, session = get_session(req) if method == "initialize": sid = session_id or f"session-{len(SESSIONS) + 1}" served = {"protocolVersion": "2025-06-18", "capabilities": {"tools": {}}, "serverInfo": {"name": "minimal-demo", "version": "1.0.0"}} response = JSONResponse(jsonrpc_result(req_id, served)) response.headers["mcp-session-id"] = sid SESSIONS[sid] = {"initialized": True} return response

接下来是tools/list,直接返回工具清单。这个响应一定是简单的 JSON 直返,不值得流式:

if method == "tools/list": tools = [ { "name": "echo", "description": "把输入文本返回给客户端", "inputSchema": {"type": "object", "properties": {"text": {"type": "string"}}, "required": ["text"]} } ] return JSONResponse(jsonrpc_result(req_id, {"tools": tools}))

重头戏是tools/call。为了演示“按需流式”,我把echo工具的执行过程故意分成两步,先发一个进度通知,再回最终结果。这正是流式响应发挥价值的地方:

if method == "tools/call": tool_name = body.get("params", {}).get("name") arguments = body.get("params", {}).get("arguments", {}) if tool_name != "echo": return JSONResponse(jsonrpc_error(req_id, -32602, "Unknown tool")) async def event_generator(): # 第一步:先推送一个进度通知 yield {"event": "message", "data": json.dumps({"jsonrpc": "2.0", "method": "notifications/progress", "params": {"progress": 0.5, "total": 1}})} # 第二步:返回最终结果 yield {"event": "message", "data": json.dumps(jsonrpc_result(req_id, { "content": [{"type": "text", "text": arguments.get("text", "")}], "isError": False }))} return EventSourceResponse(event_generator())

这里有个容易忽略的细节:最终结果帧里的id必须和原始请求里的id保持一致。因为客户端可能同时在多个请求间切换,它靠id区分哪个响应对应哪个调用。如果你在流式响应里把id写丢了,客户端大概率会判定协议错误。

还有很多 MCP server 在 tools/call 里支持_meta或进度 token,我这边故意简化了,目的是让你先看清“流式帧”长什么样。真实生产里你还要考虑鉴权、限流、工具执行出错后的错误帧等,但骨架就是上面这套。

4.3 GET 监听流:什么时候真的需要它

前面说过,POST 响应本身就能流式,所以 GET 监听流有点像“plan B”。但有两个场景我建议你认真考虑实现 GET:

  • 服务器需要向所有已连接的客户端广播事件,比如“某个资源被外部修改了,请所有客户端刷新”。这种多客户端推送没法靠单个 POST 的流解决,必须让每个客户端先建立自己的 GET 流。
  • 客户端希望即使不发起新请求,也能随时接收服务器的通知,比如订阅日志。

实现起来也很简单:

@app.get("/mcp") async def mcp_sse(req: Request): session_id = req.headers.get("mcp-session-id") async def event_generator(): # 这里可以从消息队列订阅该 session 的事件 yield {"event": "message", "data": json.dumps({"jsonrpc": "2.0", "method": "notifications/message", "params": {"level": "info", "data": "connected"}})} return EventSourceResponse(event_generator())

如果你一开始不打算支持广播和订阅,GET 可以暂时返回 405 或者一个空流。不要为了“符合规范”硬上,结果把自己搞懵。

5. 客户端接入与实测中让人头秃的坑

协议讲完,代码写完,真正折磨人的往往是接入环节。不管是 Claude Code、Cursor、Trae 这类编辑器/IDE 客户端,还是自己写 SDK 做集成,你都会碰到几个高度相似的问题。

5.1 常见的连接报错:error posting to endpoint 是怎么来的

热词里那句到处见的报错,streamable http connect failed: streamable http error: error posting to endpoint,几乎成了 MCP 接入群里的问候语。它不是在说某个具体错误码,而是客户端的 HTTP POST 本身就没成功,底层原因五花八门,我按自己的排查习惯列出来。

第一梯队是端点 URL 本身有问题。你先确认这个 URL 能不能被 curl 正常访问:

curl -X POST 'http://localhost:8000/mcp' \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize"}'

如果 curl 都返回异常,那就不是客户端配置的问题,是你的 server 还没起来,或者端口/路径不对。如果 curl 返回connect failed,而你的服务在云端,先检查网络通路是否可达。

第二梯队是会话头缺失。某些 server 在 initialize 握手时返回了mcp-session-id,但客户端的实现压根没保存,后续发tools/list时又没有带这个头。服务器一识别不了就抛 400/404,客户端就会把网络请求的异常包装成那句笼统的 “error posting to endpoint”。

第三梯队才是真正隐蔽的:中间层拦截。如果你的 server 前面挂了 Nginx、网关、API 管理平台之类的,这些组件可能会把具有流式特性的请求截走。常见的是 SSE 响应被缓冲,导致客户端迟迟收不到第一个事件;或者 Content-Type 被改掉,客户端判定这不是合法的 SSE 流,直接放弃。

5.2 日志排查:服务端日志别只打一坨 exception

“MCP server 端的日志如何使用自定义日志管理”是我经常被问到的需求。我的建议很直白:把请求日志和响应日志全部结构化输出,别只打console.log一个字符串。

具体来说,每收到一条 JSON-RPC 消息,至少记下这几个字段:

  • 时间戳
  • session_id
  • 请求 id
  • method
  • 客户端来源 IP(只有在合规前提下才有必要)
  • 是否走上流式分支
  • 响应状态码
  • 响应耗时

当客户端报 “error posting to endpoint” 时,你先去看服务端有没有对应的 POST 日志。有日志,说明请求到达了服务端,那问题大概率在响应阶段;没日志,说明请求连服务端都没到,你得往上查网络和代理。

我个人有个习惯:在开发环境里把每个进入/mcp的请求 body 原样打印出来,但只保留最近几十条,避免刷屏。这样客户端行为和服务端行为就能一一对应上。

5.3 客户端配置范式和实测对比

不同客户端的配置入口五花八门,但底层都是给你一个 URL 输入框。以我用过的几类为例:

  • Claude Desktop / Claude Code 类:需要在配置 JSON 里指定url和可选 headers,比如{"mcpServers":{"demo":{"url":"http://127.0.0.1:8000/mcp"}}}。
  • Cursor 类:在 MCP 配置面板里填 URL,有的版本还让你选传输类型,但 Streamable HTTP 已经逐渐变成默认。
  • Trae / 各类 IDE 插件:同样填一个 URL,具体入口在扩展设置里的 MCP 配置项。

实测中最典型的差异是对Accept头的处理。有些客户端只发application/json,那你的服务器就别试图给它回 SSE 流,否则客户端甚至会直接把text/event-stream判断为非法响应。有些客户端会明确声明text/event-stream,那你就放心大胆开流。

我在一次接入里遇到过特别尴尬的情况:服务器逻辑没问题,客户端也支持流式,但 IDE 插件底层用了fetch,对 SSE 的读取方式不标准,它读不出event: message这种分段。最后排查下来,是那个插件根本没完全实现 Streamable HTTP 的流解析,只正确实现了 JSON 直返。所以你在给外部工具做 MCP server 时,不要默认所有客户端都优雅地支持流式,先用 JSON 直返保证能被识别,再逐步按需升级。

5.4 流式请求的超时、重试与幂等思考

最后聊一点不那么显眼但很要命的问题:幂等。HTTP GET 天然可重试,但 POST tools/call 不是。如果你的工具本身有副作用,客户端因为超时重试同一份请求,可能导致任务跑了两次。

Streamable HTTP 本身没有强制幂等机制,但你在服务端设计时可以加一个简单的过滤:给每个请求 id 生成一个执行状态,如果同一个 session 里收到了相同 id 的重复请求,直接返回上一次的结果,而不是重新执行。这在长耗时工具里非常有用,能省掉大量重复计算。

流式场景的超时策略,我上面已经提过一次空闲超时。再补一个实战动因:有一次我把工具请求发给一个外部大模型,对方偶尔会在 30 秒内没吐第一个 token,结果我的客户端因为“连接超时 15 秒”直接断了。后来我把超时改成“第一帧超时 60 秒 + 帧间空闲超时 60 秒”,问题瞬间消失。这个经验看起来简单,但在接入多个工具链时最容易被人忽略。

6. 踩过几次坑,绕回来看协议设计的一点心得

把 Streamable HTTP 从头撸到尾之后,我对它的感受是:它不是一个“炫技”的协议,而是被现实逼出来的务实设计。单一端点减少了配置心智,按需流式让长任务和短任务各得其所,会话 ID 把无状态和有状态糅合成了一个可选的层次。这三点单看都不惊人,合在一起就让 MCP 的远程接入顺滑了不少。

如果现在让我给后来者提建议,我会说三件事。第一,先跑通 JSON 直返的initialize、tools/list、tools/call三个方法,再碰流式;第二,流式不是越用越好,简单响应用 JSON 直返,长耗时响应才开流,这和数据大小、执行时长、通知需求都有关;第三,客户端和服务端的超时、会话头、Accept 头必须一起配,单独调任何一边都可能让排查陷入死循环。

最后说个实际体会:协议越简单,实现者越容易掉进“过度设计”的坑。我见过有人为了“完美符合规范”,硬把所有响应都做成流式,结果客户端在低带宽环境下体验极差。Streamable HTTP 的“按需”两个字,本质是提醒我们克制——该直返时直返,该流式时流式,只做客户端真正需要的传输形式就够了。这套思路放在 MCP 之外,也是个不错的工程准则。

返回列表