MCP 无状态新规范迁移实操:codemod 跑完,你还差关键一步
你看完能拿走什么
- 判断自己的 MCP server 受不受这次规范变更影响,以及最晚什么时候必须改完
- 用官方 codemod 把 v1 SDK 迁到 v2,全过程约 5 分钟
- 知道 codemod 跑完之后还差哪一步——不补上,你的 server 一个 2026-07-28 的字节都不会上线
- stdio server 独有的一个坑,踩了会很难查
- 三条 curl 自查命令,外加三个高频报错的对照表
这次到底变了什么
2026 年 7 月 28 日,Model Context Protocol 发布了新版规范。核心是一句话:协议层的会话没了。
以前一个 MCP 连接是这样的:客户端先发 initialize,服务端回能力清单,客户端再发 notifications/initialized,握手完成,之后所有请求靠 Mcp-Session-Id 头认这条会话。
现在这三样东西全部退役。每个请求都是独立的,自带协议版本和客户端能力,放在 params._meta 里:
json{ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": { "_meta": { "io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientCapabilities": {}, "io.modelcontextprotocol/clientInfo": { "name": "curl", "version": "1.0" } } } }
跟着一起变的还有几项,按对你代码的杀伤力排序:
| 变更 | 影响 |
|---|---|
移除 initialize / notifications/initialized 握手 | 连接建立方式整个变了 |
移除 Mcp-Session-Id 头与协议层会话 | 跨调用状态得自己发句柄,当普通工具参数传回来 |
新增 server/discover,服务端必须实现 | 客户端靠它探测版本 |
POST 必须带 Mcp-Method、Mcp-Name 头 | 缺了直接 400 |
所有结果必须带 resultType 字段 | 手写响应的要补 |
tools/list 等必须返回 ttlMs、cacheScope | 手写响应的要补 |
| 移除 HTTP GET 端点与 SSE 断线续传 | 改用 subscriptions/listen |
| Roots、Sampling、Logging 三个特性进入弃用 | 有 12 个月窗口,不急 |
错误码也重新编了号:HeaderMismatch 从 -32001 改成 -32020,UnsupportedProtocolVersion 从 -32004 改成 -32022。日志里按老码匹配的告警规则记得跟着改。
官方给了最少 12 个月的弃用窗口,旧规范在这期间照常工作。所以这不是今晚必须加班的事,但也别拖到窗口末尾——SDK 的新特性只会长在 v2 上。
先判断你要不要动
只有一种情况可以完全不管:你只是 MCP 的使用者,装别人的 server 来用。那是 Claude Code、Cursor 这些客户端要操心的事。
只要你写过并部署了 MCP server,就得走这一趟。
第一步:跑官方 codemod
TypeScript SDK 在 7 月 27 日做了一次拆包重构。原来的单体包 @modelcontextprotocol/sdk 拆成了一组 2.0:
text@modelcontextprotocol/core 底层 schema @modelcontextprotocol/server 服务端 @modelcontextprotocol/client 客户端 @modelcontextprotocol/node Node 框架适配 @modelcontextprotocol/hono Hono 适配 @modelcontextprotocol/express Express 适配 @modelcontextprotocol/fastify Fastify 适配
手改 import 是没必要的体力活,官方出了 codemod。在你的 server 目录下跑:
bashnpx @modelcontextprotocol/codemod@latest v1-to-v2 .
真实输出长这样:
text@modelcontextprotocol/codemod — v1-to-v2 Scanning /path/to/your/mcp-server... Changes: 6 across 3 file(s) package.json updated: package.json Removed: @modelcontextprotocol/sdk Added: @modelcontextprotocol/server This codemod doesn't reformat its output. Run your formatter on the changed file(s): e.g. prettier --write http.mjs index.mjs server.mjs Migration complete. Review the changes and run your build/tests.
它改了三类东西:
diff-import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"; -server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools })); -server.setRequestHandler(CallToolRequestSchema, async (request) => handle(request)); +server.setRequestHandler('tools/list', async () => ({ tools })); +server.setRequestHandler('tools/call', async (request) => handle(request));
第三类是 package.json 的依赖替换。
跑完必须做三件事,少一件都可能带着问题往下走。
第一,搜一遍 codemod 自己标记的疑难点:
bashgrep -rn '@mcp-codemod-error' . --exclude-dir=node_modules
它认得出来但不敢自动改的地方会留这个标记,一条条手工处理。
第二,逐个文件读一遍 diff。 这一步不能用上面那个 grep 代替:codemod 会删掉紧贴首个 import 的文件头 docblock,而且不留任何标记。我在两个 server 上各丢了 9 行文件头注释,grep 一个标记都搜不到,只有 diff 能看见。
bashgit diff -- . ':!package-lock.json'
第三,装依赖跑测试:
bashnpm install && npm test
我拿一个真实的 server 试过:18 个测试,测试代码一行没动,全过。codemod 改代码本身的风险比想象中低——真正会咬你的是它顺手删掉的注释。
关键一步:codemod 跑完,你其实什么都没升级
这是整篇最容易踩空的地方。官方迁移指南写得很直白:
Nothing in v2 puts a 2026-07-28 byte on the wire by default.
翻译过来就是:v2 SDK 默认还在说 2025 年的老协议。你手工 new Server() 再 connect(transport),升级 SDK 这个动作本身不改变任何一个字节。说新规范必须显式开启。
所以到这一步你的进度是:包换新了,协议还是旧的。
服务端要说 2026-07-28,HTTP 的入口是 createMcpHandler。它一个端点同时服务新旧两代客户端。
改造前(v1 的典型写法,每个请求新建 transport 和 server):
jsapp.all("/mcp", async (c) => { // ...鉴权... const transport = new WebStandardStreamableHTTPServerTransport(); const server = createContentMCPServer({ allowedTools: resolved.token.allowedTools }); await server.connect(transport); return transport.handleRequest(c.req.raw); });
改造后:
jsimport { createMcpHandler } from "@modelcontextprotocol/server"; // handler 建一次即可,它自己按请求调 factory 造新实例 const mcpHandler = createMcpHandler((ctx) => { // authInfo 是严格 pass-through:handler 不读 header 也不验 token const { allowedTools, mcpToken } = ctx.authInfo.extra; return createContentMCPServer({ allowedTools, mcpToken }); }); app.all("/mcp", async (c) => { // ...鉴权逻辑原样保留... return mcpHandler.fetch(c.req.raw, { authInfo: { token: tokenString, clientId: "your-client", scopes: [], extra: { allowedTools: resolved.token.allowedTools, mcpToken: tokenString }, }, }); });
有三个点值得说清楚:
factory 拿得到请求上下文。 签名是 (ctx: McpRequestContext) => McpServer | Server,ctx 里有 era(这个请求是新协议还是老协议)、authInfo、以及原始的 requestInfo。多租户、按 token 分权限这些场景都接得住。
鉴权不要塞进 factory。 authInfo 是纯粹的 pass-through,handler 从不自己读 header、也不验 token。你原来的鉴权代码放在路由里原样保留,验完把结果通过 authInfo.extra 传进去。
老客户端不用改。 createMcpHandler 默认 legacy: 'stateless',同一个端点会自动兜住还在发 initialize 的旧客户端。
如果你原来是有会话的 v1 部署(sessionIdGenerator 传了值),不能直接套上面的写法。要用 isLegacyRequest(request) 在前面分流,把老流量导给你原有的 handler,新流量给 createMcpHandler(factory, { legacy: 'reject' })。
stdio server:一个会咬人的坑
上面说的是 HTTP。如果你的 server 走 stdio——就是 Claude Desktop、Cursor 这些客户端直接拉起一个本地进程的那种——入口是 serveStdio,而且有一个 HTTP 侧不存在的陷阱。
典型的 v1 stdio 写法是模块级建一个 server,挂上 handler:
jsconst server = new Server({ name: "my-server", version: "1.0.0" }, { capabilities: { tools: {} } }); server.setRequestHandler('tools/list', async () => ({ tools })); server.setRequestHandler('tools/call', async (req) => handle(req));
迁到 serveStdio 的时候,如果把这个模块级单例直接交给 factory,会出一个很难查的问题:
js// 错误示范 serveStdio(() => server); // 每次都返回同一个实例
原因是 serveStdio 每条连接调一次 factory,另外还会为 server/discover 探针单独造一个实例,探针用完就被 close() 掉。你要是返回单例,2025 era 的客户端回落握手时,那次 close 关掉的正是你还在用的那个 server。
正确写法是每次新建:
jsfunction createServer() { const server = new Server({ name: "my-server", version: "1.0.0" }, { capabilities: { tools: {} } }); server.setRequestHandler('tools/list', listTools); server.setRequestHandler('tools/call', handleToolCall); return server; } serveStdio(createServer);
顺手一个实操建议:把两个 handler 抽成模块级的具名函数(上面的 listTools / handleToolCall),而不是把几百行 handler 整体塞进 createServer 里缩进一层。改完的 diff 会干净得多,review 也好过。
另外 McpServerFactory 的类型是接受低阶 Server 的,不必为了迁移专门换成 McpServer。
别忘了 CORS
这一处不改,浏览器端的客户端会在预检就被拦掉,而且报错信息跟 MCP 毫无关系,很难往这上面想。
diffcors({ - allowMethods: ["GET", "POST", "DELETE", "OPTIONS"], - allowHeaders: ["Content-Type", "Authorization", "mcp-session-id", "Last-Event-ID", "mcp-protocol-version"], - exposeHeaders: ["mcp-session-id", "mcp-protocol-version"], + allowMethods: ["POST", "OPTIONS"], + allowHeaders: ["Content-Type", "Authorization", "mcp-protocol-version", "Mcp-Method", "Mcp-Name"], + exposeHeaders: ["mcp-protocol-version"], })
mcp-session-id 和 Last-Event-ID 对应的功能已经不存在了,GET 端点也被移除,留着是纯粹的历史包袱。
验证:三条 curl
第一条,新规范强制服务端实现的 server/discover:
bashcurl -s -X POST http://127.0.0.1:8081/mcp \ -H "Content-Type: application/json" \ -H "Mcp-Method: server/discover" \ -H "Mcp-Name: server/discover" \ -d '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}'
迁移成功的话,返回长这样:
json{ "result": { "supportedVersions": ["2026-07-28"], "capabilities": { "tools": {} }, "resultType": "complete", "ttlMs": 0, "cacheScope": "private", "_meta": { "io.modelcontextprotocol/serverInfo": { "name": "your-server", "version": "1.0.0" } } }, "jsonrpc": "2.0", "id": 1 }
resultType、ttlMs、cacheScope、_meta.serverInfo 这四个字段是新规范才有的。看到它们就说明真的切过去了。
第二条,正常调工具:
bashcurl -s -X POST http://127.0.0.1:8081/mcp \ -H "Content-Type: application/json" \ -H "Mcp-Method: tools/list" -H "Mcp-Name: tools/list" \ -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}'
第三条,确认老客户端没被你搞挂——注意这条不带任何 _meta,就是旧的握手:
bashcurl -s -X POST http://127.0.0.1:8081/mcp \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"old","version":"1.0"}}}'
应该照常回 "protocolVersion":"2025-06-18"。回不出来,说明你把 legacy 兼容关掉了。
stdio server 别用 printf '...' | node index.mjs 这种管道方式验证。shell 写完就关掉 stdin,新协议路径会因此静默无输出,看着就像迁坏了,其实是测法不对。用 spawn 驱动(Node 的 child_process.spawn、Python 的 subprocess),或者干脆挂到真实客户端上连一次。
三个高频报错
| 现象 | 错误码 | 原因 |
|---|---|---|
the required Mcp-Method header is absent | -32020 | 请求体带了 _meta 但没带 Mcp-Method 头 |
the body names method X but the Mcp-Method header names Y | -32020 | 头和 body 里的 method 对不上 |
Unsupported protocol version | -32022 | _meta 里的版本号服务端不支持,返回体会列出支持的版本 |
三个都是 HTTP 400。前两个几乎都是手写 curl 或者自研客户端时忘了带头——官方 SDK 客户端会自动处理。
为什么要用两个头重复表达同一件事
Mcp-Method 和 Mcp-Name 是 SEP-2243 引入的标准请求头。没了会话之后,网关和负载均衡需要在不解析 JSON body 的前提下知道这个请求要干什么,才能做路由、限流和鉴权。头里放一份,body 里放一份,服务端负责校验两者一致——对不上就是 -32020。
客户端那边
如果你还维护 MCP 客户端,默认行为同样没变:Client.connect() 仍然走 2025 的握手。要说新协议得显式开:
jsconst client = new Client( { name: 'my-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } } ); await client.connect(transport); client.getProtocolEra(); // 'modern' | 'legacy'
mode: 'auto' 会先用 server/discover 探一次,探不到就回落到老握手,代价是多一个来回。mode: { pin: '2026-07-28' } 是只认新协议,碰到老服务端直接拒绝。
有一类工具不该开 auto:一次性启动的 CLI 和调试工具。stdio 下有些老服务端对 initialize 之前的任何请求都不回应,探测会一直卡到超时才回落。
收尾清单
-
npx @modelcontextprotocol/codemod@latest v1-to-v2 .跑完 -
grep -rn '@mcp-codemod-error'无残留 - 逐文件读过 diff,确认文件头 docblock 没被静默删掉
-
npm install && npm test通过 - HTTP 入口换成
createMcpHandler - stdio 入口换成
serveStdio,且 factory 每次新建Server、不返回单例 - CORS 的
allowHeaders加了Mcp-Method/Mcp-Name -
server/discover能返回resultType和_meta.serverInfo - 老客户端的
initialize仍然能握手成功 - 监控告警里按
-32001/-32004匹配的规则改成-32020/-32022
常见问题
必须现在就改吗?
不必须。官方的弃用政策承诺最少 12 个月窗口,旧规范在窗口内照常工作。但 SDK 的新功能只会长在 v2 上,拖太久等于自己把自己锁在旧分支。
Python / Go / C# 的 SDK 有吗?
四个 Tier 1 SDK(TypeScript、Python、Go、C#)都已经跟进新规范,Rust 还在 beta。本文的 codemod 是 TypeScript SDK 专属,其它语言要照各自仓库的迁移文档走。
我的 server 需要跨请求保状态怎么办?
会话没了,但状态没被禁止。做法是服务端自己签发一个句柄(handle)返回给客户端,客户端在后续调用里当普通工具参数传回来。跟 Web 开发里用 token 换状态是一个路子。