[控场AI]
· 15 分钟阅读· 7,950 字

MCP 无状态革命:2026-07-28 规范详解与实战迁移

MCP 无状态革命:2026-07-28 规范详解与实战迁移

MCP 2026-07-28 规范移除会话与握手,转向逐请求自描述的无状态架构。

2026-07-28 版 MCP 规范移除了 initialize 握手与 Mcp-Session-Id 会话头,把协议版本、客户端能力与身份信息塞进每个请求的 _meta 信封,使任意服务实例都能处理任意请求,普通轮询负载均衡即可横向扩展。应用状态改由数据库承载,通过工具返回的显式句柄(如 cart_id)寻址,且每个句柄都必须做用户归属校验。服务端主动发起的 elicit 与 sampling 改为 Multi Round-Trip Requests:返回 input_required,客户端携带 inputResponses 与经 HMAC-SHA256 签名的 requestState 重试。文章还给出了 TypeScript SDK v2 的完整迁移路径、双实例验证、错误码语义、兼容性边界与十二步迁移清单。

MCP 无状态革命:2026-07-28 规范详解与实战迁移

MCP 无状态规范示意图

Model Context Protocol(MCP)迎来了一次架构层面的重大转向。2026-07-28 版规范正式移除了 initialize 握手与 Mcp-Session-Id 会话头,将整个协议从有状态的会话模型改造为逐请求自描述的无状态模型。官方发布博客将这一定位为「开发者呼声最高的特性之一」,并直言「MCP 正在从双向有状态协议转变为请求/响应式的无状态协议」。

这意味着任何一个服务实例都可以处理任何一个请求,普通轮询负载均衡器即可横向扩展,无需粘性会话,也无需共享会话存储。对构建 Agent 工具链的团队而言,这既是一次显著的部署简化,也是一次必须认真对待的迁移工程。

这次规范到底改了什么

改动集中在「去会话化」与「请求自描述」两条主线上。旧版本中,客户端先发 initialize 完成握手,随后凭借 Mcp-Session-Id 维持上下文;新版规范删除了握手、HTTP GET 通知流、SSE 可恢复性以及服务端主动发起的请求,并把协议版本、客户端能力与客户端信息全部塞进每个请求的 _meta 信封。

变更点旧版(2025-11-25)新版(2026-07-28)
会话Mcp-Session-Id 头 + DELETE 结束已移除,改用显式句柄
握手initialize + notifications/initialized已移除,版本与能力随请求携带
服务端发请求推送 elicitation/create、sampling/createMessage返回 resultType: "input_required",客户端重试
通知流HTTP GET 流 + resources/subscribesubscriptions/listen 长连接 POST
可恢复性Last-Event-ID 重投已移除,断流即重发请求
结果类型无类型字段强制 resultType:complete 或 input_required
缓存无列表与读取结果强制携带 ttlMs 与 cacheScope

此外,server/discover 成为每个服务端必须实现的新 RPC,用于声明支持的版本、能力与身份。ping 与 logging/setLevel 被删除,日志级别改为通过请求的 _meta 传递。资源未找到的错误码从 -32002 调整为 -32602,并新增 -32020 至 -32022 三个错误码。

规范同时将 Roots、Sampling、Logging、旧的 HTTP+SSE 传输方式与动态客户端注册标记为废弃。按照官方特性生命周期政策,废弃特性拥有至少十二个月的移除窗口期,这给存量系统留出了缓冲空间。

_meta 信封是 MCP 各版本中用于承载附带元数据的保留字段,规范允许在请求/响应的 params 或 result 中放置 _meta 对象,工具与客户端可自由读写。在 2026-07-28 之前的版本里,它主要用于进度令牌、追踪 ID 等辅助信息;新版规范把它提升为协议协商的主要载体:协议版本、客户端能力声明、客户端名称与版本、日志级别等原先靠 initialize 握手一次性交换的信息,现在都逐请求写入 _meta。这一设计的代价是每个请求体积略增、头与体需要保持一致性校验,收益则是服务端不再依赖任何前置状态即可完整理解请求语义,从而支撑无状态部署。实现时需注意 _meta 的键名应遵循规范保留前缀,避免与工具自定义元数据冲突。

为什么无状态是更好的选择

除了部署层面的收益,官方给出的理由还有一个常被忽视的维度:模型可见性。发布博客指出,如果服务端需要跨调用保持状态,「从一个工具铸造一个显式句柄,让模型把它作为参数传回来。我们发现这比隐藏在传输层的会话状态效果更好——模型能看到句柄,并在工具之间传递它。」

这个观点值得展开。会话状态对模型是不可见的黑盒,模型无法推理、无法调试、也无法在多个工具之间显式编排。而 cart_id、job_id 这类句柄一旦作为工具返回值暴露出来,模型就获得了对状态的一等公民访问权,能够做出更合理的决策。

AWS 架构团队在其平台实践中得出了相同结论:基于会话的服务端所需要的粘性会话与共享会话存储,「已经不再必要」。他们从基础设施视角验证了这次规范调整的实际价值,这与 MCP 官方博客的表述形成了交叉印证。

维度有状态 MCP(会话时代)无状态 MCP(2026-07-28)
首个请求initialize 后再 notifications/initialized直接是真实调用 tools/call
负载均衡粘性会话或共享会话存储普通轮询即可
Serverless 适配会话需跨冷启动存活,较别扭天然契合
状态存放位置隐藏在传输层会话中自建数据库,以模型可见的句柄寻址
向用户提问服务端在调用中途推送 elicitation/create返回 input_required,客户端重试

Multi Round-Trip Requests(MRTR)是本次规范用来替代服务端主动推送的核心机制。旧模型中,服务端在工具执行中途通过 SSE 连接向客户端推送 elicitation/create 或 sampling/createMessage,客户端应答后服务端继续执行;这要求连接在整段交互期间保持存活,且服务端能定位到那个特定的会话。MRTR 把一次交互拆成多个独立的 HTTP 请求:服务端发现缺少必要输入时,返回 resultType: "input_required"、一份 inputRequests 清单以及可选的 requestState,随即结束本次请求;客户端收集用户答案后,携带 inputResponses 与原样回传的 requestState 重新发起同一个调用。服务端凭借签名验证 requestState 的来源与完整性,恢复上下文继续处理。这样每个请求都自成闭环,任意实例都能接手,代价是客户端需要实现重试循环——SDK 客户端已内置该逻辑。

用 TypeScript 构建无状态 MCP 服务端

原作者的实战基于官方 TypeScript SDK v2(@modelcontextprotocol/server 2.3.1),在 Node.js 24.22.0 上完成验证。核心思路是:为每个请求构造一个全新的 McpServer 实例,交给 createMcpHandler 处理;在 Node 环境用 @modelcontextprotocol/node 的 toNodeHandler 包装,在 Web 运行时则直接调用 handler.fetch(request)。

状态放进存储,而不是会话

示例是一个极简购物车服务,购物车数据存放在共享存储中,以一个服务端铸造的句柄作为键。原文用 JSON 文件模拟以支持两个进程共享,生产环境应替换为 Postgres 或 Redis。

这里有一个关键的安全细节:句柄会流经模型,因此必须被视为标识符而非访问凭证。原文的 carts.get() 方法中显式校验 cart.owner === owner,注释写得很直白——「句柄不是凭证」。每一次工具调用都要拿句柄与已认证用户做比对。

用 requestState 承载交互中间状态

只在一次交互的多个轮次之间需要的数据,交给 requestState,并用 createRequestStateCodec 做 HMAC-SHA256 签名。这个编解码器在实例之间共享密钥,因此 A 实例铸造的状态 B 实例也能验证。

SDK 的迁移文档明确指出,requestState「经由客户端往返,因此是不可信输入」,必须做完整性保护;同时它「是签名而非加密」,所以绝不能把秘密放进去。这两句话构成了使用 requestState 的完整边界。

双实例验证:状态确实不在连接里

原文启动了两个实例(端口 3101 与 3102),在 A 上创建购物车,然后依次通过 B 和 A 添加商品:

create_cart  on A  → Created cart_d892bc05-… on A
add_item     on B  → 1 line(s) in cart_d892bc05-…, served by B
add_item     on A  → 2 line(s) in cart_d892bc05-…, served by A

每次调用落在不同进程上,却没有任何中断,因为状态存在存储里,而不是连接里。这是对无状态设计最直观的验证。

无状态请求长什么样

一个无状态 MCP 请求就是单次 HTTP POST,携带三个头:MCP-Protocol-Version、Mcp-Method,工具调用场景再加 Mcp-Name。请求体的 params._meta 重复协议版本并声明客户端能力。由于没有前置握手,客户端发送的第一个请求就可以是工具调用本身。

server/discover 由 SDK 自动实现,返回支持的版本列表、能力与 serverInfo。列表类结果因为设置了 cacheHints,会告诉客户端可以缓存 60 秒。若不设置,SDK 会采用最保守的策略:ttlMs: 0 配合 cacheScope: "private"。规范强调 "public" 允许共享中间缓存,因此当工具列表因用户而异时,必须保持在 private。

cacheHints 中的 ttlMs 与 cacheScope 是本次规范新增的缓存语义。ttlMs 表示该结果可被缓存多少毫秒,设为 0 表示不可缓存;cacheScope 取 "public" 时允许共享中间缓存(如 CDN 或反向代理)存储并复用该响应,取 "private" 时只允许针对单一用户或客户端的私有缓存。对于 tools/list、resources/list 与资源读取结果,规范强制要求携带这两个字段,SDK 在未显式设置时采用最保守的 ttlMs: 0 加 private。判断标准很简单:内容对所有用户一致才可用 public,一旦列表或资源内容因认证主体而异,就必须保持 private,否则共享缓存可能把一个用户的数据返回给另一个用户。

确认交互改由多轮请求承担

失去会话后,服务端无法在调用中途推送 elicitation/create。取而代之的是 Multi Round-Trip Requests(MRTR):服务端返回 resultType: "input_required",附带问题清单 inputRequests 与可选的 requestState;客户端向用户询问后,带着 inputResponses 和原样回传的 requestState 重试同一个调用。

原文用 clear_cart 演示了这个流程:第一轮发往 B 实例,返回 input_required;第二轮发往 A 实例,因为两个实例共享 MCP_STATE_KEY,A 成功验证了 B 铸造的状态并返回 complete。当把 requestState 改动一个字符后,服务端返回 -32602 与 "Invalid or expired requestState"。

使用 SDK 客户端时无需手写重试逻辑,它会通过你注册的 elicitation/create 处理器自动应答并重试。不过原文提醒了一个容易被忽略的点:v2 的 Client 默认仍然使用 2025 握手,必须通过 versionNegotiation: { mode: 'auto' } 或 { pin: '2026-07-28' } 显式启用新版协商。

错误响应与兼容性边界

无状态服务端的错误语义相当明确:头与体不一致或缺头返回 HTTP 400 与 -32020;不支持的协议版本返回 -32022 并附带 data.supported;工具需要请求未声明的客户端能力时返回 -32021;ping 这类已移除方法返回 404 与 -32601;对 GET 请求返回 405。

头校验的存在是因为安全。Streamable HTTP 规范要求服务端必须拒绝不一致,否则「负载均衡器按头值路由、而 MCP 服务端按体值执行」的差异可能被利用。

兼容性方面,createMcpHandler 默认 legacy: 'stateless',会逐请求应答 2025 时代的 initialize 与后续请求,且不签发会话 ID。普通工具可用,但需要用户输入的工具对老客户端会失败,因为逐请求的旧版连接无法接收服务端到客户端的请求。原文实测中,clear_cart 在老客户端下返回了明确的错误提示,指出该连接无法声明所需能力。

若老客户端确实需要确认交互,可行方案是保留原有的有状态处理器,用 isLegacyRequest(request) 做前置判断,将旧流量路由到旧处理器。规范版本页也明确,旧版客户端连接仅支持新版的服务端会失败,因为「旧版客户端没有向前回退机制」。

isLegacyRequest(request) 是 SDK 提供的请求判别辅助函数,用于在同一个处理器入口区分 2025 时代与 2026-07-28 时代的流量。典型用法是在路由层先调用它:返回 true 的请求交给保留的有状态处理器,返回 false 的交给逐请求的无状态处理器。这样老客户端仍能完成握手、维持会话并使用普通工具,而新客户端享受无状态部署。需要注意这条双轨路径并非完全等价:走旧处理器的连接是逐请求应答的,无法接收服务端到客户端的请求,因此依赖 elicitation 或 sampling 的工具在老客户端下会失败并返回明确错误。规范版本页也指出,旧版客户端连接仅支持新版的服务端会直接失败,因为旧客户端没有向前回退机制,所以双轨支持是迁移期内的过渡手段,而非长期方案。

迁移清单

迁移的核心动作可以归纳为:升级 SDK、切换为逐请求处理器、将会话数据分流到句柄 / requestState / 认证信息、把推送式 elicit 改写为 input_required,最后移除会话基础设施并设置缓存提示。

  1. 升级 SDK:TypeScript 从 @modelcontextprotocol/sdk 1.x 迁移到 @modelcontextprotocol/server 2.x;Python、Go、C# 的 Tier 1 SDK 也已同步更新。
  2. 排查所有会话 ID 使用点(extra.sessionId、ctx.sessionId、以 Mcp-Session-Id 为键的映射),并将数据迁移至句柄、requestState 或认证主体。
  3. 从工具返回句柄(cart_id、job_id、doc_id),并在工具描述中点名,引导模型回传。
  4. 对每个句柄都做用户归属校验。
  5. 把 elicitInput()、requestSampling() 改写为 return inputRequired({...}),并在重入时读取 ctx.mcpReq.inputResponses。
  6. 用 createRequestStateCodec 封装 requestState,并在实例间共享密钥。
  7. 确保工具可安全重试——流不再可恢复,连接中断意味着客户端会重新发起请求。
  8. 为 tools/list 与资源设置 cacheHints,结果因用户而异时用 private。
  9. 删除 ping 处理器与会话级日志级别代码,改用 stderr 或 OpenTelemetry。
  10. 将资源未找到错误从 -32002 改为 -32602。
  11. 同时测试新旧两个时代:mode: 'auto' 的现代客户端与旧版客户端,包括需要用户输入的工具。
  12. 完成以上步骤后,才移除粘性会话、会话存储与 GET 路由。

常见误区

原文列出的高频错误大多会在测试中以可见的 400 暴露,但其中两类会静默泄露数据,值得单独强调:把 cart_id 当作访问凭证,以及把按用户区分的列表标记为 cacheScope: 'public'。前者造成越权访问,后者可能导致共享缓存把一个用户的列表返回给另一个用户。

其他值得警惕的点包括:把秘密写入 requestState(客户端可 base64 解码)、各实例使用不同的 MCP_STATE_KEY 导致跨实例重试失败、自定义客户端漏发 Mcp-Method / Mcp-Name 头、v2 客户端停留在默认握手设置,以及把需要 elicit 的工具挂在 legacy: 'stateless' 路径下。

关键要点

  • 2026-07-28 规范让 MCP 协议层彻底无状态:无 initialize、无 Mcp-Session-Id,每个请求自描述。
  • 服务端必须实现 server/discover,并对头体不一致返回 -32020。
  • 应用状态存入数据库,由工具返回的句柄寻址,每个句柄都要做归属授权。
  • 确认交互改为多轮请求:返回 input_required,客户端带 inputResponses 与 requestState 重试。
  • 用 createRequestStateCodec 签名 requestState 并共享密钥,它是签名而非加密,不能放秘密。
  • createMcpHandler 可逐请求服务旧版客户端,但该路径无法处理 elicit 交互。
  • v2 TypeScript 客户端默认仍是 2025 握手,需用 versionNegotiation: { mode: 'auto' } 主动启用新版。
#MCP 无状态#Model Context Protocol#MCP 2026-07-28 规范#MCP 服务端迁移#createMcpHandler#requestState#Multi Round-Trip Requests#TypeScript MCP SDK
分享:

相关推荐