
1. HTTP 传输在 MCP 里的旧账双端点为什么难用MCPModel Context Protocol走到今天传输层其实换过一次大思路。最早大家玩 stdio本地进程间通信一条 stdin、一条 stdout简单粗暴。后来要接远程服务官方搬出了 HTTPSSE 的老方案客户端向一个 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早期实现就是照搬 HTTPSSE。表面上看不大POST 路径/mcpSSE 路径/mcp/events配置里写两个地址。真正跑起来问题全冒出来了。第一个就是跨代理和网关的负担加倍。你部署在 Nginx 后面/mcp走普通的 HTTP 转发/mcp/events必须给 SSE 单独开 buffering off、proxy_read_timeout 拉长。两台机器负载均衡的话还得额外保证 POST 和 SSE 进了同一个后端不然会话状态就丢了。我那时为了这个把整个服务都按 session 粘到单机上扩容直接作废。第二个是客户端适配分裂。不同客户端对双端点的叫法不一样有的文档写sseEndpoint有的写eventsUrl有的那么默认。我记得当时为了接一个开源客户端憋了一个下午发现人家只支持新式的单一端点双端点的能力直接被砍掉。二选一不超向下兼容真的受罪。所以当我切换到 Streamable HTTP 之后第一个感觉就是“总算能把配置从两个 URL 变成一个 URL 了”。不是那种看得见的性能提升但你维护配置、写客户端、设代理时省下来的成本是肉眼可见的。1.2 从 HTTPSSE 到 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/jsonbody 里是 JSON-RPC 响应对象。如果响应升级为流Content-Type: text/event-streambody 里是由 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-starlettesse-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。这个方法的职责是握手它要返回协议版本、服务器能力和实现信息。第一次握手后服务器可以生成会话 IDapp.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 fsession-{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请求 idmethod客户端来源 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 之外也是个不错的工程准则。