MCP 无状态新规范迁移实操:codemod 跑完,你还差关键一步

进阶进行中@modelcontextprotocol/server v27 月 31 日更新

你看完能拿走什么

  • 判断自己的 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-MethodMcp-Name缺了直接 400
所有结果必须带 resultType 字段手写响应的要补
tools/list 等必须返回 ttlMscacheScope手写响应的要补
移除 HTTP GET 端点与 SSE 断线续传改用 subscriptions/listen
Roots、Sampling、Logging 三个特性进入弃用有 12 个月窗口,不急

错误码也重新编了号:HeaderMismatch-32001 改成 -32020UnsupportedProtocolVersion-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 目录下跑:

bash
npx @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 自己标记的疑难点:

bash
grep -rn '@mcp-codemod-error' . --exclude-dir=node_modules

它认得出来但不敢自动改的地方会留这个标记,一条条手工处理。

第二,逐个文件读一遍 diff。 这一步不能用上面那个 grep 代替:codemod 会删掉紧贴首个 import 的文件头 docblock,而且不留任何标记。我在两个 server 上各丢了 9 行文件头注释,grep 一个标记都搜不到,只有 diff 能看见。

bash
git diff -- . ':!package-lock.json'

第三,装依赖跑测试:

bash
npm 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):

js
app.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);
});

改造后:

js
import { 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 | Serverctx 里有 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:

js
const 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。

正确写法是每次新建:

js
function 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 毫无关系,很难往这上面想。

diff
 cors({
-  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-idLast-Event-ID 对应的功能已经不存在了,GET 端点也被移除,留着是纯粹的历史包袱。

验证:三条 curl

第一条,新规范强制服务端实现的 server/discover

bash
curl -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
}

resultTypettlMscacheScope_meta.serverInfo 这四个字段是新规范才有的。看到它们就说明真的切过去了。

第二条,正常调工具:

bash
curl -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,就是旧的握手:

bash
curl -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-MethodMcp-Name 是 SEP-2243 引入的标准请求头。没了会话之后,网关和负载均衡需要在不解析 JSON body 的前提下知道这个请求要干什么,才能做路由、限流和鉴权。头里放一份,body 里放一份,服务端负责校验两者一致——对不上就是 -32020

客户端那边

如果你还维护 MCP 客户端,默认行为同样没变:Client.connect() 仍然走 2025 的握手。要说新协议得显式开:

js
const 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 换状态是一个路子。

MCP 无状态新规范迁移实操:codemod 跑完,你还差关键一步 | 资讯狗 | Zixungou