RSS

第 10 章 / 共 12 章

从本地进程到远程服务:Streamable HTTP

约 8 分钟 更新于

10.1 什么时候才真的需要远程

先泼一盆冷水:大多数 MCP server 不需要变成远程服务。

stdio 有几个很难被超越的优点:宿主直接把它当子进程拉起来,没有网络、没有端口、没有证书、没有鉴权;它天然只服务本机的这一个用户;出了问题就是本地进程的问题。规范也说,客户端应该尽可能支持 stdio。

真正需要远程的信号只有三个:server 要访问的资源不在用户机器上(内网数据库、公司 API)、你要给一群人用而不是一个人、server 依赖重到不适合每个人本地装一份。

三条都不占,就留在 stdio,省下这一章所有的麻烦。

第10章:从本地进程到远程服务:Streamable HTTP
图 10.1:从本地进程走到公网服务,中间有四道必须补齐的关口。跳过任何一道,你的 server 都会成为别人扫描器里的一条记录。

10.2 一行代码切换传输

代码层面,切换传输几乎不需要改业务逻辑:

if __name__ == "__main__":
    mcp.run(transport="streamable-http", port=3001)

默认监听 127.0.0.1:8000,路径 /mcp。想挂进已有的 Web 应用,就取出 ASGI app:

app = mcp.streamable_http_app()

挂进 Starlette 时有一个必须做、否则不工作的动作:你得自己在应用的 lifespan 里跑 session manager。

from collections.abc import AsyncIterator
from contextlib import asynccontextmanager

from starlette.applications import Starlette
from starlette.routing import Mount

@asynccontextmanager
async def lifespan(app: Starlette) -> AsyncIterator[None]:
    async with mcp.session_manager.run():
        yield

app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())], lifespan=lifespan)

注意第 2 章提到的另一处版本差异:host、port、stateless_http 这些参数在 2.x 里不再接受于构造函数,全部移到了 run() 或 streamable_http_app() 上。看到 MCPServer("x", port=8000) 形态的代码,那是旧写法。

10.3 线上长什么样:三条必须知道的规则

2026-07-28 的 Streamable HTTP 比旧版简单了很多,但有三条规则你必须知道,否则读日志会一头雾水。

一、只有 POST。 服务器暴露单个 HTTP 端点,只接受 POST。客户端把每个 JSON-RPC 请求或通知作为一次独立的 POST 发出。服务器对每个请求回一个 JSON 对象,或者一条只属于这个请求的 SSE 流。

旧版本那个「GET 打开一条常驻 SSE 流」的设计被移除了。对旧客户端的处理方式规范也写死了:收到 GET 或 DELETE 就回 405;收到 Mcp-Session-Id 头就忽略它,不要签发也不要回显;收到 Last-Event-ID 也忽略,流不可恢复。

二、有两个必填的请求头。 除了 MCP-Protocol-Version,新版还要求:

头取自用于哪些请求
Mcp-Method请求的 method所有请求
Mcp-Nameparams.name 或 params.uritools/call、resources/read、prompts/get

规范说这两个头是合规必需的。而且头和请求体必须一致——MCP-Protocol-Version 头的值如果和 body 里 _meta 中的版本对不上,服务器必须返回 400 和一个 HeaderMismatch 错误。

这个设计本身就是一条安全考虑:避免出现「负载均衡器按 header 路由、服务器按 body 执行」的双重真相。

三、没有会话了。 跨调用状态用第 7.5 节讲的 handle 模式。另外,反向代理后面开 SSE 流时,规范建议服务器在响应里带上 X-Accel-Buffering: no,否则 Nginx 这类代理会把流缓冲住,表现出来就是「调用没反应,过很久才一次性返回」。

10.4 上线前必须补的四件事

这一节的每一条都对应着一个真实发生过的事故。

第一,改掉默认的 host 绑定,但要知道 SDK 已经替你做了一半。 SDK 的 HTTP 传输默认开启 DNS rebinding 防护,只接受 Host 头是 localhost 的请求。这是个好默认,但它意味着你把服务部署到真实域名后,请求会被拒绝,而报错信息未必指向这里。常见坑:部署到真实域名后所有请求被拒,报错却不指向这里。SDK 默认只接受 Host 头为 localhost 的请求,上线必须显式配置允许列表。

正确做法是显式配置:

from mcp.server.transport_security import TransportSecuritySettings

security = TransportSecuritySettings(
    allowed_hosts=["mcp.example.com", "mcp.example.com:*"],
    allowed_origins=["https://app.example.com"],
)
app = mcp.streamable_http_app(transport_security=security)

第二,校验 Origin 头。 规范的要求是硬性的:服务器必须校验所有入站连接的 Origin 头以防止 DNS rebinding 攻击;Origin 存在且非法时必须返回 403。规范还补了一句解释:没有这些保护,攻击者可以用 DNS rebinding 从一个远程网站来操作你本地的 MCP server。

本地运行时,规范建议只绑 127.0.0.1 而不是 0.0.0.0。这条很容易在「本机测试连不上」的时候被随手改掉,改完就忘了。

第三,所有连接都要认证。 规范的措辞是服务器应当为所有连接实现适当的认证。这不是可选项——数据说明了一切:2025 年 7 月一项公开扫描发现了 1,862 个暴露在公网的 MCP server,人工验证的 119 个样本全部在没有认证的情况下交出了内部工具清单。2026 年 5 月的一篇学术测量论文覆盖了 7,973 个远程 MCP server,其中 40.55% 没有认证就暴露工具。

第四,如果你要做 OAuth,先读这几条硬性要求。 完整的授权规范超出本教程范围,但下面几条是最容易做错、后果也最严重的:

  • MCP server 是 OAuth 2.1 的资源服务器,必须实现受保护资源元数据(RFC 9728)。
  • 客户端必须实现资源指示器(RFC 8707):resource 参数必须同时出现在授权请求和 token 请求里,必须用 MCP server 的规范 URI(带 scheme、不带 fragment)。
  • Token 必须放在 Authorization: Bearer 头里,不能放在 URL 查询串里。
  • 最重要的一条:MCP server 只能接受签发给自己的 token,不得接受或转发其他 token。这条就是「禁止 token 透传」的规范原文。
  • 一个版本变化:动态客户端注册(RFC 7591)已被弃用,规范现在推荐客户端 ID 元数据文档(CIMD)。你读到的很多 2025 年的教程还在把 DCR 当默认路径讲。

10.5 一份最小上线清单

[ ] 明确回答:为什么这个 server 必须是远程的
[ ] 显式配置 allowed_hosts / allowed_origins,不依赖默认值
[ ] 校验 Origin 头,非法返回 403
[ ] 所有连接都要认证;不认证的服务不要放到公网
[ ] 只接受签发给本 server 的 token,不透传
[ ] 反向代理后面记得关掉缓冲(X-Accel-Buffering: no)
[ ] 给工具调用加超时与限流
[ ] 服务端日志记录工具调用,但不记录密钥与用户数据原文
[ ] 部署后用 Inspector 连一次远程地址,确认走的是 Streamable HTTP 而不是旧的 SSE 传输

10.6 练习与检查点