小白 / Xiaobai
开发者 · 产品构建者
持续构建 AI 工程系统、开发者工具与长期数字资产。
关于作者与 XBSTACK →
MCP Streamable HTTP 实战:从本地 stdio Server 到远程 MCP 服务部署
MCP Streamable HTTP 怎么部署?本文按 MCP 2026-07-28 与官方 Python SDK 重写远程部署流程,覆盖 streamable-http、stateless 请求、反向代理、认证、Origin、超时、业务状态和 legacy Session 兼容。
如果你现在新部署远程 MCP Server,不要再从 transport="sse"、/sse + /messages 或“每个 Client 一个 Mcp-Session-Id”开始。当前 MCP 2026-07-28 已把核心协议改成 stateless;官方 Python SDK 对新服务推荐 streamable-http,同一个 /mcp 端点处理远程请求,现代请求自身携带协议版本、Client 信息与能力元数据。SSE transport 仍可能存在于 SDK 中,但它是旧客户端兼容路径,不应该成为 2026 新架构的默认教程。
这篇只解决一件事:如何把本地 stdio MCP Server 迁移成可部署、可认证、可扩展、可排障的 Streamable HTTP 服务。OAuth 的完整授权链路另见 MCP OAuth 认证实战,协议 Session 迁移另见 MCP 2026-07-28 stateless 迁移指南。
先分清:stdio、legacy SSE、Streamable HTTP
stdio 仍然适合本机工具:宿主应用启动一个子进程,通过 stdin/stdout 发送 MCP 消息,进程权限、文件权限和本机用户边界天然成为一部分安全边界。
SSE transport 是旧 HTTP 传输。它使用独立的 SSE 与消息端点,官方 Python SDK 当前文档明确提示:**SSE 已被 Streamable HTTP 取代,新项目不要再基于它构建。**所以旧教程中的:
mcp.run(transport="sse")
以及 /sse、/messages 两个端点,不应继续当成 Streamable HTTP 示例。
新的远程入口是:
https://mcp.example.com/mcp
客户端把远程 MCP 请求发到这个端点。响应可以是普通 JSON,也可以根据协议/SDK 能力使用流式响应。部署层不需要自己再发明一套“消息 POST 端点 + SSE 订阅端点”。
2026-07-28 最大变化:远程请求不再依赖协议 Session
legacy 2025-era Streamable HTTP 常见流程是:
initialize
→ notifications/initialized
→ server 分配/接受 Mcp-Session-Id
→ 后续请求携带 Session ID
MCP 2026-07-28 已移除这套核心握手和协议级 Mcp-Session-Id。现代 Client 可以先调用 server/discover,也可以直接发送第一条业务请求;每个请求携带必要的协议、Client 与能力元数据,因此同一请求可以落到负载均衡后的任意兼容实例。
这件事对部署最重要的影响不是“少一个 Header”,而是:
- 不再要求 sticky session 才能维持 MCP 协议状态;
- Server 不能默认从几分钟前的 Session 内存读取 Client capability;
- 多副本部署更容易做成无共享协议 Session;
- 业务状态必须和协议生命周期分开设计。
**注意:协议 stateless 不等于你的应用必须 stateless。**审批草稿、购物篮、上传任务、分页游标、长任务等仍然需要状态,只是这些状态应该是明确的业务对象或受保护的 state handle,而不是偷偷塞进协议 Session 内存。
最小可运行 Python Server
官方 Python SDK 当前最直接的远程写法是 streamable-http:
from mcp.server.fastmcp import FastMCP
mcp = FastMCP(
"RemoteToolHub",
stateless_http=True,
json_response=True,
)
@mcp.tool()
def get_remote_status() -> dict:
return {"status": "ok"}
if __name__ == "__main__":
mcp.run(
transport="streamable-http",
host="127.0.0.1",
port=8000,
)
本地先验证:
http://127.0.0.1:8000/mcp
再用当前 MCP Inspector 或兼容 2026-07-28 的 Client 连接。不要一上来就挂 Nginx/Cloudflare;先确认不经过代理时协议能跑通,再引入网络层变量。
如果需要接进现有 FastAPI/Starlette,可以使用 SDK 提供的 streamable_http_app() 作为 ASGI 应用挂载。挂载时要特别检查 host app 的 lifespan;如果跳过 SDK 要求的 lifespan/session manager 初始化,第一条请求就可能失败。这里的 session_manager 是 SDK 运行时对象名称,不意味着 2026-07-28 又恢复了协议级 Mcp-Session-Id。
生产部署拓扑:先把认证和网络边界放在 MCP 前面
一个更现实的最小拓扑是:
MCP Client
↓ HTTPS
Reverse Proxy / Gateway
↓ authenticated request
MCP Streamable HTTP app
↓ least-privilege credentials
Tools / Database / Filesystem / SaaS API
远程 MCP Server 不应该因为“只有 AI Client 会调用”就裸露在公网。至少要明确:
- TLS 在哪里终止;
- 谁负责认证;
- Token / OAuth scope 如何映射到具体 Tool;
- Origin / Host 如何校验;
- 是否允许浏览器类 Client;
- 哪些
Mcp-*Header 必须穿过代理; - 请求体、并发、超时与速率限制由谁控制;
- Tool 使用什么下游凭据;
- 审计日志如何关联 principal、tool、resource 与 trace。
认证和 Tool 授权也不能混成一个概念。Client 拿到合法 access token,只说明“它是谁/允许进入什么资源服务器”,不代表它对所有 Tool 和所有资源都有执行权限。高风险写操作仍应在 Tool Gateway 做资源级授权、Schema 校验、幂等和必要的人工审批。
反向代理到底要配置什么
旧文章最容易把所有 MCP HTTP 问题归咎于 proxy_buffering。实际应该按症状分层:
1. 连接直接失败:先查 Host / Origin / Auth
当前 SDK 和规范都强调远程 Transport 的 Host/Origin 安全边界。出现 401、403、421 一类错误时,先检查:
- 外部域名是否在允许列表;
- 代理有没有改写 Host;
Origin是否符合 Server policy;Authorization是否被代理丢失;MCP-Protocol-Version和必要的Mcp-*Header 是否保留。
2. 普通 JSON 正常,流式结果卡住:再查 buffering / timeout
只有当 Server 确实返回 text/event-stream 或保持响应流时,buffering 才成为关键变量。此时要验证:
- 代理是否缓存/聚合流式响应;
- idle/read timeout 是否短于正常工具执行时间;
- CDN 是否支持该响应模式;
- 客户端断线是否能取消后端任务;
- 长时间运行是否应该改成 MCP task/应用自己的异步任务,而不是无限拉长一个 HTTP 请求。
不要写“80% MCP 故障都来自 buffering”——没有站内实验或公开数据能支持这个比例。
3. Serverless 只在 subscriptions/listen 上跑满超时:先查 capability 与断连传播
MCP 2026-07-28 把长期变更通知放进 subscriptions/listen:Client 发起这个请求后,Server 返回 SSE acknowledgment,随后按协议保持流打开,直到 Client 或 Server 主动关闭。这个行为本身不是 bug;问题出在部署边界——如果 Server 根本不会发布工具/资源变化,却仍宣告 tools/prompts/resources.listChanged=true、resources.subscribe=true,兼容 Client 仍可能自动打开 listen;而某些 Serverless Adapter 又不能把客户端断线可靠传播成 ASGI 的 http.disconnect,于是一个“没有业务工作”的订阅流可能把一次 invocation 占到平台 timeout。
XBSTACK 在 2026-09-20 用官方 Python SDK 做了不依赖 Lambda 的最小夹具:故意模拟“请求已到达,但后续永远收不到 http.disconnect”。结果如下:
| Python SDK | server/discover | subscriptions/listen |
|---|---|---|
mcp 2.1.1 | 正常完成,并宣告 listChanged/subscribe=true | 1 秒后仍保持打开 |
mcp 2.2.0 | 正常完成,并宣告 listChanged/subscribe=true | 1 秒后仍保持打开 |
mcp 2.2.0 + 移除 listen handler 的诊断对照 | listChanged/subscribe=false | 立即返回 -32601 Method not found |
这与上游 Python SDK #3493 报告的生命周期一致,也不是单一仓库信号:更早的 Python SDK #3357 已请求提供关闭 subscriptions/listen 的公共配置,TypeScript SDK 的 #2650 也报告了无有效订阅内容时长流占用 Vercel invocation 的问题。
因此生产排障不要把它误判成“Tool 调用很慢”:
- 先按 method 统计耗时,确认是否只有
subscriptions/listen长时间不结束; - 查看
server/discover是否宣告了应用实际不会使用的listChanged/subscribe; - 验证客户端断开后,网关/Serverless Adapter 是否真的把 disconnect 传播到 ASGI;
- 不要仅通过把 Function timeout 从 60 秒提高到 900 秒来“修复”,那只会延长占用时间;
- 上游当前 Issue 给出的 handler 移除方案依赖 SDK 私有属性,只适合作为诊断/临时 containment,不能当稳定公共 API 承诺。
协议规范本来就要求 subscriptions/listen 是长连接,所以真正的工程判断是:**你的服务是否真的需要发布变更通知,以及部署平台是否适合承载这种长期响应流。**如果不需要,应优先等待/采用 SDK 正式的 capability/serve-subscriptions 控制,而不是让不使用订阅的服务无条件宣告订阅能力。
4. Legacy Streamable HTTP 生命周期回归:incomplete body、GET Accept 与 stale Session 404
2026-09-24,XBSTACK 又对 Python SDK #3494 与 #3503 做了本地复现。这两条都发生在 legacy 2025-era initialize / standalone GET 兼容路径,所以不能把它们写成 MCP 2026-07-28 stateless 核心协议本身失效。
第一组对应 Python SDK #3494。我在 macOS 26.6.2 arm64、Python 3.13.15、FastMCP 4.0.3、mcp 2.2.0、uvicorn 0.52.4、httpx 0.28.1、anyio 4.14.2 上连续启动 20 个 fresh uvicorn server,每次只做一次 legacy initialize POST。从第 4 次开始稳定出现:
ASGI callable returned without completing response.
RemoteProtocolError: peer closed connection without sending complete message body
最终 17/20 次失败。这与上游报告的错误形状和“重复 server start/stop 后失败率上升”一致,而且这次没有 Linux 1 CPU 容器压力也能复现。它不证明所有 Streamable HTTP 请求都会断流;当前证据只支持:在这组 legacy initialize + repeated fresh-server 生命周期里,mcp 2.2.0/对应 FastMCP 栈存在可重复 incomplete ASGI body 风险。
第二组对应 Python SDK #3503。在 Python 3.14.7、mcp 2.2.0、uvicorn 0.52.4 上,legacy Client 打开的 standalone GET 实际发送:
Accept: application/json, text/event-stream
Content-Type: application/json
但这条 GET 后续由 SSE reader 消费。测试 wrapper 故意“相信客户端声明”,返回 application/json,GET stream 随即断开并重试;与此同时普通 tools/call 仍成功返回 hi。这说明最危险的不是“整个 MCP Session 立即报错”,而是业务 Tool Call 看起来仍正常,但通知/反向通道可能已经丢失。需要同时强调:规范兼容 Server 对 standalone GET 应返回 SSE 或 405;测试中的 JSON 响应是为了验证客户端自己宣告但无法消费的 representation,不代表生产 Server 应该返回 JSON。
第三组对应 Python SDK #3556。2026-09-25,XBSTACK 用一个刻意隔离 404 recovery 分支的 transport-level fixture 分别测试了 mcp 1.28.1 / Python 3.11.15 和 mcp 2.2.0 / Python 3.14.7。测试先写入一个确定的旧 Session ID stale-session-xbstack,再让连续两次 tools/list POST 都收到 HTTP 404。两个版本结果一致:
request 1 -> Session terminated
request 2 -> Session terminated
session id after both 404s -> stale-session-xbstack
outbound methods -> tools/list, tools/list
automatic initialize -> none
这说明在本次验证的 legacy Streamable HTTP client 路径里,404 后旧 Mcp-Session-Id 没有被丢弃,第二次请求继续带着同一个 stale ID,也没有自动产生新的 InitializeRequest。这里的边界同样要写清:这个夹具是在 transport 层直接验证 SDK 的 404 recovery contract,不等价于复现所有代理、部署重启、idle timeout 或网络时序;它证明的是客户端当前这条 404 分支本身不会完成自动恢复。
生产排障可以据此再加三条:
- legacy 兼容压测不要只复用一个长寿命 Server;要加入“同一 event loop 反复 start/stop fresh server + initialize”的回归;
- 抓 standalone GET / DELETE 的真实请求头,确保 Client 不宣告自己无法消费的表示形式,也不要给无 body 请求机械带上
Content-Type: application/json; - 如果请求带旧
Mcp-Session-Id收到 404 /Session terminated,在上游修复前应把它视为协议 Session 已失效:丢弃旧 Client/Session,重新 initialize;只有确认可安全重试的幂等请求才能自动重放,外部写入、副作用和 Tool 执行必须单独做幂等保护。
完整可复跑脚本、原始日志与版本矩阵已公开在 xbstack/mcp-streamable-http-lifecycle-regressions。本文继续把它作为版本化回归资产维护,而不是再拆一个站内 Bug URL。
5. 大工具结果失败:查 body/response limit,而不是只拉长 timeout
大 JSON、图片、文档和日志可能触发:
- gateway request/response size limit;
- upstream memory pressure;
- Client 自身 result limit;
- 模型上下文浪费。
优先把 Tool 设计成分页、过滤、搜索、摘要和显式附件/资源引用,不要默认把完整数据一次塞回模型。
2026 架构里“会话隔离”应该怎么做
不要把用户隔离继续写成:
/sse 握手 → 生成 session_id → 全局 dict[session_id]
现代远程 Server 应该先从可信身份建立 principal,再让业务状态显式归属到 principal/tenant/resource:
access token
→ principal / tenant
→ tool authorization
→ business handle / record id
→ external durable store
例如审批流:
approval_id = apr_123
owner = tenant_a:user_42
state = pending
expires_at = ...
下一次请求带 approval_id 时,Server 必须重新检查 owner、状态和权限,而不是只因为“这个 ID 曾经存在”就信任它。若使用 2026 SDK 提供的 requestState/state handle,也应按官方要求把它视作不可信输入,做完整性保护、绑定 principal/方法并设置过期时间。
多副本部署:什么时候真的可以 round-robin
MCP 2026-07-28 的协议请求不依赖协议级 Session,因此协议层可以由任意兼容实例处理。但你的 Tool 实现仍可能破坏这一点,例如:
- 把上传文件只放在当前 Pod
/tmp; - 把审批状态保存在进程全局 dict;
- 把 OAuth token refresh 状态只放内存;
- Tool A 创建临时资源后,Tool B 默认去当前实例查;
- 后台任务与结果没有 durable task store。
所以“stateless MCP”只解决协议层的一部分横向扩展问题。真正多副本还要审计应用状态、缓存、任务、凭据和外部副作用。
legacy Client 怎么办
官方 Python SDK v2 的设计之一就是同时服务 2025-era 和 2026-era Client:现代 Client 走新流程,旧 Client 仍可以使用 initialize/session 流程。你在生产迁移时应明确选择:
- 只支持 2026-07-28:架构最简单,但老客户端可能无法连接;
- SDK 双代兼容:同一部署服务新旧 Client,但监控和测试要区分两种生命周期;
- Gateway 分流:大型平台可按协议版本路由到不同兼容层。
不要让旧客户端的 Session 约束反向决定整个新架构。最关键的回归测试是:同一 Tool 在 modern path 和 legacy path 都能得到正确授权、参数校验和业务结果。
上线前验证清单
远程部署完成后至少验证:
- 不经过代理时,当前 MCP Inspector / Client 能连接
/mcp; - 经过代理后,Host、Origin、Authorization、协议 Header 没被错误改写;
- 未认证请求被拒绝;
- 合法身份只能访问被授权 Tool / Resource;
- 高风险 Tool 不因为模型选中了函数就自动获权;
- 429、5xx、timeout 的重试不会造成重复写;
- 流式响应经过代理时不会被错误缓存或过早断开;
- 大结果有分页/上限,不靠无限增加 body limit;
- 多副本下不存在只保存在某个 Pod 内存的关键业务状态;
- modern 2026-07-28 和需要支持的 legacy Client 分别跑自动测试。
最终判断
如果工具只服务本机 IDE,stdio 仍然可能是最简单、最小暴露面的方案;远程并不天然比本地高级。当你需要跨设备、团队共享、统一鉴权、网关审计或水平扩展时,再使用 Streamable HTTP。
真正的 2026 迁移重点不是“把 stdio 改成 HTTP”,而是同时完成三次解耦:
- 传输:本地进程 → 远程
/mcp; - 协议状态:legacy Session → 2026-07-28 每请求自描述;
- 业务状态:进程内临时变量 → 有身份、有权限、有过期策略的外部状态。
做到这三点,Streamable HTTP 才真正具备生产部署价值。
继续阅读
- MCP 2026-07-28:initialize 与 Mcp-Session-Id 移除后的迁移
- MCP OAuth 认证实战
- MCP 安全最佳实践
- MCP JSON-RPC Parse Error 排查
- MCP Protocol Deep Dive
继续按 MCP 生产部署路径读,而不是堆 guide / tutorial
MCP 内容统一按协议理解、本地 Server、远程部署、OAuth、安全治理、stdio/JSON-RPC 排障和工具对比来承接,避免站内关键词互相抢。
继续阅读
返回专题 →AI 工程周报
只发真正改变工程判断的变化、故障、实验和新资产。
参与讨论
问题、验证与勘误
登录后可发表评论。所有新评论先进入审核;审核期间仅评论者本人和管理员可见,通过后才公开。