你有一个跑得挺稳的 MCP Server:stdio 或 HTTP 传输、工具齐全、客户端也接上了。但 MCP 在 2026-07-28 这一版把协议底子换了——initialize 握手没了,Mcp-Session-Id 会话也没了,每个请求必须自带版本与能力信息。如果你的 Server 还按老路子缓存会话、按字面匹配 -32002 错误码,升级后会遇到「不报错但行为不对」的静默故障。这篇教程按破坏性变更逐项改,改完用 Inspector 和老客户端双向验一遍。
先搞懂:无状态到底改了什么
旧版(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:先自查,把要改的地方扫出来
这五类代码是高风险区,先用一条命令看它们在哪些文件里:
# 在 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:去掉握手与会话,让每个请求自包含
官方 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:把会话级状态改成显式句柄
无状态不等于「服务端不能有状态」。官方推荐的做法与普通 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 替代旧的流式反问
旧版里 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 GET 流(用于服务端主动推送消息)已被移除,SSE 流的可恢复语义(Last-Event-ID)也一并取消。需要订阅变更的,改用 subscriptions/listen。
Step 5:三处弃用功能的迁移映射
规范给了至少 12 个月的移除窗口,所以现有部署还有时间,但新功能不要再建在这三者之上。迁移映射如下:
| 被弃用 | 替代方案 | 迁移动作 |
|---|---|---|
| roots | 工具参数 | 把「工作目录/范围」做成显式入参,由调用方传入 |
| sampling | 直接调用 LLM API | 服务端自己持有模型凭证,不再借客户端的模型 |
| logging | stderr 或 OpenTelemetry | 日志改走标准输出/OTel,不再用协议通道回传 |
把 sampling 换成「服务端直连模型」不只是改接口:凭证、配额、审计责任都从客户端转移到了服务端。上线前先确认自己的模型调用预算与合规口径,别把一次协议升级做成一次成本事故。
Step 6:两处会静默出错的破坏性变更
这两处最危险,因为它们不会让服务起不来,只会让逻辑悄悄跑偏:
# 变更一:资源不存在时的错误码
- 旧:MCP 自定义的 -32002
+ 新:JSON-RPC 标准 -32602
# 如果你的代码按字面匹配 -32002,升级后会“漏掉”所有 missing resource 错误
# 变更二:inputSchema 支持完整 JSON Schema 2020-12
# 现在合法:oneOf / anyOf / $ref
# 假设 schema 是扁平结构的解析器需要同步升级
自查清单:①把所有 -32002 字面量换成对错误码范围的判断,而不是精确等于;②确认 schema 解析依赖支持 oneOf、anyOf、$ref;③如果你在 2025-11-25 版上试过实验性的 Tasks API,必须迁到 SEP-2663 定义的新生命周期。
Step 7:双向验证——新客户端要通,老客户端不能挂
# 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。全部完成后再用老客户端做一次回归,确认兼容窗口内旧版仍可用。