高级 📋 7 个步骤 第 438 / 441 篇

把 MCP Server 迁到无状态规范:2026-07-28 版的破坏性变更与逐项改法

MCP 2026-07-28 规范取消了 initialize 握手与 Mcp-Session-Id 会话,改用显式状态句柄与 Multi Round-Trip Requests。本教程按破坏性变更清单,把已有 MCP Server 逐项迁到无状态版本,并指出两处会静默出错的改动与双向验证方法。

2026.09.17· 20 分钟阅读· 约 2484 字· 🔌 MCP / 🧩 协议迁移

你有一个跑得挺稳的 MCP Server:stdio 或 HTTP 传输、工具齐全、客户端也接上了。但 MCP 在 2026-07-28 这一版把协议底子换了——initialize 握手没了,Mcp-Session-Id 会话也没了,每个请求必须自带版本与能力信息。如果你的 Server 还按老路子缓存会话、按字面匹配 -32002 错误码,升级后会遇到「不报错但行为不对」的静默故障。这篇教程按破坏性变更逐项改,改完用 Inspector 和老客户端双向验一遍。

🔌 本教程适合:已经用 Python/TypeScript 官方 SDK 写过 MCP Server、并准备升级到 2026-07-28 规范的后端与平台同学。如果你还没写过 Server,先看「从零写一个 MCP Server」那篇,再回来做迁移。

先搞懂:无状态到底改了什么

旧版(2025-03-26 到 2025-11-25)要求客户端先发 initialize,服务端回一个 Mcp-Session-Id,之后每个请求都要带这个头、并且必须回到同一个实例——也就是粘性会话。官方给出的理由很直接:会话从未收敛出统一含义,不同客户端有的按工具调用划分、有的按页面加载划分,服务端作者根本没法预测「会话」是什么。

2026-07-28 版把会话整层删掉,协议变成「每个请求自包含」。四个关键差异:

维度旧版(有状态)2026-07-28(无状态)
握手必须先 initialize取消握手;可选 server/discover 预探
会话标识依赖 Mcp-Session-Id 请求头移除;协议层无会话概念
版本与能力握手时协商一次每个请求用 _meta 字段随请求送出
水平扩展必须粘性路由 + 共享会话存储任意实例皆可响应,普通轮询即可

Step 1:先自查,把要改的地方扫出来

1 在仓库里跑一遍体检

这五类代码是高风险区,先用一条命令看它们在哪些文件里:

# 在 MCP Server 仓库根目录执行
grep -rn "Mcp-Session-Id" --include=*.py --include=*.ts --include=*.js .
grep -rn "initialize" --include=*.py --include=*.ts --include=*.js .
grep -rn "sampling\|roots\|logging" --include=*.py --include=*.ts .
grep -rn "32002" .
grep -rn "inputSchema" --include=*.py --include=*.ts .
💡 命中不等于都要改:initialize 可能只是你自己的业务函数名。判断标准是「它是否在读写协议层的会话状态」——是,就必须动。

Step 2:去掉握手与会话,让每个请求自包含

2 服务端不再建会话

官方 SDK 升级到 2.x 之后,服务端的处理函数不再依赖会话对象。你要做的是把「从会话里取用户/租户」改成「从请求自带的元数据里取」。协议层版本、客户端身份与能力都走 _meta 字段:

// 请求自带的保留键(前缀使用反向 DNS 记号)
// io.modelcontextprotocol/protocolVersion
// io.modelcontextprotocol/clientInfo
// io.modelcontextprotocol/clientCapabilities
// 每个请求都要带:MCP-Protocol-Version 与 Mcp-Method 两个头

// 服务端:从请求上下文读取,而不是从会话缓存读取
function handleToolCall(req, res) {
  const meta = req.meta || {};
  const client = meta['io.modelcontextprotocol/clientInfo'];
  const version = meta['io.modelcontextprotocol/protocolVersion'];
  if (!version) return res.error(-32600, 'missing MCP-Protocol-Version');
  // 后续一律不使用「上次那个会话」,全部按本次请求参数决策
  return runTool(req.params, { client, version });
}

兼容性坑:规范要求服务端在收到旧客户端发来的 Mcp-Session-Id忽略它,既不要报错、也不要自己生成或回显会话 ID。很多团队图省事直接对未预期的头报 400,结果把还没升级的老客户端全打挂了。

Step 3:把会话级状态改成显式句柄

3 状态不停在协议层,下沉到应用层

无状态不等于「服务端不能有状态」。官方推荐的做法与普通 HTTP API 一致:在工具调用里生成一个显式句柄返回给模型,模型在后续调用中当成普通参数传回来。

// 调用开始时:服务端生成句柄,状态存在自己这边(数据库/缓存)
// tools/call  create_report  ->  { handle: "rpt_9f2c7a", status: "draft" }

// 后续调用:模型把句柄当普通参数传回
// tools/call  update_section  ->  { handle: "rpt_9f2c7a", section: "risk" }

// 服务端每次都必须用本次请求的授权上下文校验句柄归属
def resolve(handle, auth_ctx):
    row = db.get(handle)
    if not row or row.tenant != auth_ctx.tenant:
        raise PermissionError("handle not owned by caller")
    return row

安全点:句柄一旦成为可传递的普通参数,它同时也就成了可被伪造的入参。每次解析都必须绑定当前调用者的授权上下文校验归属,否则等于把越权入口做成了普通参数。

Step 4:用 Multi Round-Trip Requests 替代旧的流式反问

4 服务端向客户端要信息,现在走重试式往返

旧版里 sampling、elicitation、roots 这些「服务端向客户端发起请求」的动作是骑在一条常开的流上完成的。无状态之后没有那条流了,改用新的 Multi Round-Trip Requests(MRTR) 模式:服务端返回一个需要补充信息的中间结果,客户端补齐后重新发起该次调用。

# 服务端:需要用户确认时,不挂起连接,而是返回可继续的状态
{
  "status": "input_required",
  "request_id": "mrtr_31ab",
  "prompt": "导出范围请二选一:全量 / 仅本月",
  "options": ["all", "this_month"]
}

# 客户端:拿到 input_required 后补齐参数,再发一次同一个工具调用
{ "name": "export_report", "arguments": { "range": "this_month", "mrtr": "mrtr_31ab" } }
🔁 这个改动对运维是好事:过去一条长任务要一直占着 HTTP 连接,现在服务端可以随时重启,客户端重试即可续上。代价是你必须把「中间状态」显式设计出来,不能再靠连接本身当状态容器。

注意:旧的独立 HTTP GET 流(用于服务端主动推送消息)已被移除,SSE 流的可恢复语义(Last-Event-ID)也一并取消。需要订阅变更的,改用 subscriptions/listen

Step 5:三处弃用功能的迁移映射

5 roots / sampling / logging 都下线了

规范给了至少 12 个月的移除窗口,所以现有部署还有时间,但新功能不要再建在这三者之上。迁移映射如下:

被弃用替代方案迁移动作
roots工具参数把「工作目录/范围」做成显式入参,由调用方传入
sampling直接调用 LLM API服务端自己持有模型凭证,不再借客户端的模型
loggingstderr 或 OpenTelemetry日志改走标准输出/OTel,不再用协议通道回传

把 sampling 换成「服务端直连模型」不只是改接口:凭证、配额、审计责任都从客户端转移到了服务端。上线前先确认自己的模型调用预算与合规口径,别把一次协议升级做成一次成本事故。

Step 6:两处会静默出错的破坏性变更

6 错误码变了,schema 也变宽了

这两处最危险,因为它们不会让服务起不来,只会让逻辑悄悄跑偏:

# 变更一:资源不存在时的错误码
- 旧:MCP 自定义的 -32002
+ 新:JSON-RPC 标准 -32602
# 如果你的代码按字面匹配 -32002,升级后会“漏掉”所有 missing resource 错误

# 变更二:inputSchema 支持完整 JSON Schema 2020-12
# 现在合法:oneOf / anyOf / $ref
# 假设 schema 是扁平结构的解析器需要同步升级

自查清单:①把所有 -32002 字面量换成对错误码范围的判断,而不是精确等于;②确认 schema 解析依赖支持 oneOfanyOf$ref;③如果你在 2025-11-25 版上试过实验性的 Tasks API,必须迁到 SEP-2663 定义的新生命周期。

Step 7:双向验证——新客户端要通,老客户端不能挂

7 用 Inspector 加旧客户端各跑一遍
# 1) 先用 Inspector 验证新规范行为
npx @modelcontextprotocol/inspector --url http://localhost:8000/mcp
#    重点关注:tools/list 是否可缓存(看 ttlMs)
#              工具调用是否无需 initialize 即可完成

# 2) 再用未升级的老客户端连一次,确认不会 400
#    老客户端会带 Mcp-Session-Id,服务端必须忽略而非拒绝

# 3) 压测一把水平扩展:把服务放两副本,前面挂普通轮询负载均衡
#    连续调用 20 次,确认没有请求因“换了实例”而失败
✅ 通过标准:老客户端不报错、新客户端不需要握手、轮询负载均衡下不出现实例相关的失败。三条都满足,才算真的迁完了。

常见问题速查

现象常见原因处理
升级后老客户端全报 400对未知的 Mcp-Session-Id 直接拒绝改为忽略该头,不生成也不回显
missing resource 错误抓不到仍按字面匹配 -32002改为按标准错误码 -32602 判断
工具调用换了实例就失败仍依赖实例内存里的会话状态状态改存外部存储,用显式句柄回传
长任务连接被网关掐断仍在用常开连接承载中间状态改用 MRTR 的 input_required 往返
schema 校验器报解析失败解析器不支持 JSON Schema 2020-12升级解析依赖,先覆盖 oneOf/$ref 用例

结语

这次改版的方向很清楚:把状态从协议层下沉到应用层,让 MCP Server 从「难运维的有状态服务」变成「普通的 HTTP 函数」。对团队来说,运维模型确实简单了——粘性路由、共享会话存储、网关深度包检测都可以撤掉;但对代码的要求更高了,因为过去协议替你兜的那部分「上下文」,现在必须自己显式设计。迁移本身不难,难的是别漏掉那两处静默变更。

建议按本文顺序走:先体检、再改握手与会话、然后换句柄、最后动错误码与 schema,每一步都跑一次 Inspector。全部完成后再用老客户端做一次回归,确认兼容窗口内旧版仍可用。

← 返回教程中心