小白 / Xiaobai

小白 / Xiaobai

开发者 · 产品构建者

持续构建 AI 工程系统、开发者工具与长期数字资产。

关于作者与 XBSTACK →
MCP Streamable HTTP 实战:从本地 stdio Server 到远程 MCP 服务部署:MCP 协议文章封面

MCP Streamable HTTP 实战:从本地 stdio Server 到远程 MCP 服务部署

MCP Streamable HTTP 怎么部署?本文按 MCP 2026-07-28 与官方 Python SDK 重写远程部署流程,覆盖 streamable-http、stateless 请求、反向代理、认证、Origin、超时、业务状态和 legacy Session 兼容。

发布 · 2026-06-0612 分钟阅读XBSTACK 原创
#MCP#Streamable HTTP#Remote MCP#API Gateway#Security

如果你现在新部署远程 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”,而是:

  1. 不再要求 sticky session 才能维持 MCP 协议状态;
  2. Server 不能默认从几分钟前的 Session 内存读取 Client capability;
  3. 多副本部署更容易做成无共享协议 Session;
  4. 业务状态必须和协议生命周期分开设计。

**注意:协议 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 SDKserver/discoversubscriptions/listen
mcp 2.1.1正常完成,并宣告 listChanged/subscribe=true1 秒后仍保持打开
mcp 2.2.0正常完成,并宣告 listChanged/subscribe=true1 秒后仍保持打开
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 调用很慢”:

  1. 先按 method 统计耗时,确认是否只有 subscriptions/listen 长时间不结束;
  2. 查看 server/discover 是否宣告了应用实际不会使用的 listChanged/subscribe;
  3. 验证客户端断开后,网关/Serverless Adapter 是否真的把 disconnect 传播到 ASGI;
  4. 不要仅通过把 Function timeout 从 60 秒提高到 900 秒来“修复”,那只会延长占用时间;
  5. 上游当前 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 分支本身不会完成自动恢复。

生产排障可以据此再加三条:

  1. legacy 兼容压测不要只复用一个长寿命 Server;要加入“同一 event loop 反复 start/stop fresh server + initialize”的回归;
  2. 抓 standalone GET / DELETE 的真实请求头,确保 Client 不宣告自己无法消费的表示形式,也不要给无 body 请求机械带上 Content-Type: application/json;
  3. 如果请求带旧 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 都能得到正确授权、参数校验和业务结果。

上线前验证清单

远程部署完成后至少验证:

  1. 不经过代理时,当前 MCP Inspector / Client 能连接 /mcp;
  2. 经过代理后,Host、Origin、Authorization、协议 Header 没被错误改写;
  3. 未认证请求被拒绝;
  4. 合法身份只能访问被授权 Tool / Resource;
  5. 高风险 Tool 不因为模型选中了函数就自动获权;
  6. 429、5xx、timeout 的重试不会造成重复写;
  7. 流式响应经过代理时不会被错误缓存或过早断开;
  8. 大结果有分页/上限,不靠无限增加 body limit;
  9. 多副本下不存在只保存在某个 Pod 内存的关键业务状态;
  10. modern 2026-07-28 和需要支持的 legacy Client 分别跑自动测试。

最终判断

如果工具只服务本机 IDE,stdio 仍然可能是最简单、最小暴露面的方案;远程并不天然比本地高级。当你需要跨设备、团队共享、统一鉴权、网关审计或水平扩展时,再使用 Streamable HTTP。

真正的 2026 迁移重点不是“把 stdio 改成 HTTP”,而是同时完成三次解耦:

  • 传输:本地进程 → 远程 /mcp;
  • 协议状态:legacy Session → 2026-07-28 每请求自描述;
  • 业务状态:进程内临时变量 → 有身份、有权限、有过期策略的外部状态。

做到这三点,Streamable HTTP 才真正具备生产部署价值。

继续阅读

专题入口 / MCP Hub

继续按 MCP 生产部署路径读,而不是堆 guide / tutorial

MCP 内容统一按协议理解、本地 Server、远程部署、OAuth、安全治理、stdio/JSON-RPC 排障和工具对比来承接,避免站内关键词互相抢。

继续阅读

返回专题 →
MCP StreamableHTTPClientTransport 请求一直 Pending:SSE 断开后为什么只等 Timeout?MCP StreamableHTTPClientTransport 的 POST 收到 SSE 后,如果流在 JSON-RPC 响应前 EOF/报错,请求可能一直 pending 到 timeout。XBSTACK 在 SDK 1.29.0/1.30.0 复现 Issue #2739,并给出规避与版本边界。MCP Filesystem Server 实战:让 Claude / Cursor 安全读取本地文件MCP Filesystem Server 实战:实战讲解如何构建 MCP Filesystem Server,让 Claude / Cursor 安全读取本地文件,并通过路径白名单、Roots、Tool Scope、只读权限和 Prompt Injection 防护控制风险。MCP 2026-07-28 安全治理:Tool Scope、allowedRoots、OAuth 与审计MCP 2026-07-28 安全治理实战:按 stateless core 设计每请求身份与 Tool 授权,区分 MCP Roots 与 Server allowedRoots,并覆盖 Tool Poisoning、Rug Pull、Tool Result Injection、Prompt Injection、OAuth/CIMD、审计与凭据脱敏。MCP Server 上线前怎么检查?用只读 Inspector 验证协议、Tools、Resources 和 PromptsMCP Server 上线前不一定要先调用真实 Tool。本文按 MCP 2026-07-28 的 server/discover 与 list 方法,做无副作用只读预检:协议版本、Tools/Resources/Prompts、鉴权挑战、缓存提示和 modern/legacy 兼容。

AI 工程周报

只发真正改变工程判断的变化、故障、实验和新资产。

评论与补充证据

参与讨论

问题、验证与勘误

登录后可发表评论。所有新评论先进入审核;审核期间仅评论者本人和管理员可见,通过后才公开。

登录评论 审核后公开
正在加载评论区…