MCP SDK 止步 1.30.0,2.0 版本引入无状态传输、移除初始化握手、将协议版本和能力放入请求 _meta 字段等重大变化;文章作者通过自己的 checker 工具发现了错误理解,详细梳理迁移陷阱。
如果你正在迁移一个 MCP server,那么在本文其他内容之前,先说一件足以耗掉你一下午的事:
@modelcontextprotocol/sdk 没有 2.x,以后也永远不会有。
它的版本停在 1.30.0。如果你去找 @modelcontextprotocol/sdk@^2,什么也找不到,于是会得出 v2 尚未发布的结论。其实它已经发布了——发布日期是 2026-07-27,只是换成了不同的包名:
@modelcontextprotocol/server 2.0.0
@modelcontextprotocol/client 2.0.0
@modelcontextprotocol/core 2.0.0
@modelcontextprotocol/node 2.0.0
@modelcontextprotocol/express 2.0.0 ┐
@modelcontextprotocol/fastify 2.0.0 ├ HTTP adapters
@modelcontextprotocol/hono 2.0.0 ┘
@modelcontextprotocol/server-legacy 2.0.0 compat shim
@modelcontextprotocol/codemod 2.0.0
我之所以知道,是因为我自己的工具曾经斩钉截铁地告诉人们完全相反的答案。
日期为 2026-07-28 的 MCP 修订版,是这个协议迄今为止规模最大的一次变更。简要来说:
传输层变成了无状态。initialize / notifications/initialized 握手已经取消。现在,每个请求都会在 _meta 中携带自己的协议版本和 client capabilities。
Streamable HTTP 中的 Mcp-Session-Id 已经取消。协议层面不再存在 session。
server/discover 是一项 MUST 要求。Server 必须通过它声明自己支持的协议版本和 capabilities。
由 server 发起的请求(roots/list、sampling/createMessage、elicitation/create)被 Multi Round-Trip Requests 取代:server 返回一个 InputRequiredResult,client 带着答案重试请求。
logging、sampling 和 roots 已被弃用,但没有删除——最短弃用窗口为十二个月。
第二点最让人头疼。如果你把任何内容保存在一个以 session id 为 key 的 Map 中,那么在笔记本电脑上以单进程运行时,它会表现得无比正常;可一旦两个实例部署到负载均衡器后面,它就会开始间歇性失败。而且这种失败悄无声息,这是最糟糕的失败方式。
所以我做了一个 checker:七条规则、一个确定性引擎,整个流程中没有 model 参与。把一个正在运行的 endpoint 或 repository 交给它,它就会告诉你哪些地方会出问题;每项发现还会附上对应的 specification 页面,方便你证明它错了。它以 Agent Skill 的形式发布,也能直接执行迁移。稍后我会再讲这部分——因为事实证明,scanner 本身反而只是整个工具中较小的那一半。
其中一条规则 MCP007 会在 @modelcontextprotocol/sdk 解析出的版本低于 2.0.0 时触发,并给出如下建议:
升级到 @modelcontextprotocol/sdk ^2。运行官方的 v1→v2 codemod 完成机械式重命名,然后重新检查。
这两句话都是错的,而且错法还不一样。
@modelcontextprotocol/sdk@^2 根本无法解析。这个包从来没有发布过 2.x。因此,这个工具曾无比自信地建议用户安装一个不存在的版本——然后用户会花上二十分钟琢磨自己到底做错了什么。
我检查了 npm,发现最新版本是 1.30.0,版本列表里完全没有 2.x;接着我阅读了 SDK 公告,也没有看到任何 major version 的名称,于是断定这条规则完全是凭空捏造的。因此我删除了它,写下一条注释,解释不存在 2.x 版本线、也不存在 codemod。最扎心的是,我还把这项“纠正”同步到了 README、Skill 和修复指南里。
我只是用另一种错误的说法,替换了原先那条错误的说法。
看看我实际检查了什么。我检查的是规则里提到的那个包,而它确实停在了 1.30.0。只从这一个包内部观察,下面两种情况根本无法区分:
there is no v2 yet
v2 exists under a different name
改名所产生的证据,与某个东西不存在时的证据完全一样。继续在同一个地方反复检查也无济于事——答案根本不在那里。直到我开始寻找 codemod,才发现它以独立 package 的形式存在,而且它的描述写着:“Codemod to migrate MCP TypeScript SDK code from v1 to v2”。如果 v2 根本不存在,专门发布一个面向 v2 的 codemod 就很奇怪了。
现在,这条规则会根据 v1 package 是否存在来判断,而不是根据版本阈值判断,因为真正的信号其实是 package name:
// The package name IS the v1 line. It stops at 1.30.0 and speaks the
// pre-2026-07-28 protocol. v2 shipped under different names entirely,
// so a version comparison here is meaningless.
if (!ctx.source?.sdkVersion) return null;
现在还有一项测试,会把最初那个错误永久钉死:
test("MCP007 names the real replacement packages and the real codemod", () => {
const f = evaluate({ source: withSdk("^1.17.0") })
.find((x) => x.ruleId === "MCP007");
assert.ok(f.fix.includes("@modelcontextprotocol/server"));
assert.ok(f.fix.includes("@modelcontextprotocol/client"));
assert.ok(f.fix.includes("@modelcontextprotocol/codemod@latest v1-to-v2"));
assert.ok(
!/@modelcontextprotocol\/sdk[@^ ]*\^?2/.test(f.fix),
"must not advise upgrading @modelcontextprotocol/sdk to 2.x — no such release",
);
});
正向断言和负向断言同样重要。如果测试只是禁止出现错误的 package name,那么一条什么 package name 都不写的规则也能通过——而我当时删除规则后产生的结果,大致就是这样。
每条规则都会引用 specification 中的对应章节,这样用户就能自行检查工具的结论,而不必盲目信任它。七个链接原本全都长这样:
https://modelcontextprotocol.io/specification/2026-07-28#lifecycle
https://modelcontextprotocol.io/specification/2026-07-28#transport
https://modelcontextprotocol.io/specification/2026-07-28#authorization
但 specification 实际上被拆分到了多个子页面中,根本不存在这些锚点。所有链接都会悄无声息地跳转到概览页面——没有 404,也没有链接失效警告,只是打开了一个与引用内容无关的页面。一个号称“可以对照 specification 验证”的功能,却悄无声息地让人无从验证,这甚至比没有这项功能更糟,因为它获得了一份自己并未赢得的可信度。
现在也有一项测试专门防止这种情况:
test("no specRef relies on a page anchor", () => {
for (const rule of rules) {
assert.ok(rule.specRef.startsWith("https://"));
assert.ok(
!rule.specRef.includes("#"),
`${rule.id} specRef relies on an anchor: ${rule.specRef}`,
);
}
});
第二项测试则会断言:任何指向 specification 网站的链接,都必须指向具体子页面,而不能只指向该修订版的根页面。这样,未来新增的规则就不能再悄悄引用首页了。
有两点经验,我会带到任何项目中,无论它是否与 MCP 有关。
确定性不等于正确。这个引擎最大的卖点,就是结果可复现:同样的输入一定得到同样的输出,不会有 model 到了星期二就换一种判断。这确实是一项重要特性,也值得拥有。但它同时也给了我一条稳定、可重复、可验证地出错的规则。确定性让你能够反驳输出,却不能为你买来真相。
“没有结果”和“你找错了地方”会产生完全相同的证据。这是我一直在反复思考的一点。你在某个地址没有找到某样东西,并不意味着它不存在。改名就是一个非常清晰的例子,但这种模式无处不在:一个被移动的配置项、一个经过版本化的 endpoint,或者一个被抽取到其他 module 中的 function。检查返回否定结果,而这个结果又出乎意料时,下一个问题应该先是“我找对地方了吗”,而不是“所以它不存在”。
现在我编写的 Agent 驱动代码比以前更多,失败模式也随之发生了变化。问题不再主要出在语法,而是那些有关整个生态的、自信且听起来合理、内部逻辑也完全自洽,却恰好不是真的陈述。防御方法不是减少信任,而是让每项声明都可以核查,然后真正去核查——这也是为什么每条规则都要引用具体页面,以及为什么链接失效的问题比最初看起来严重得多。
在搞错 SDK 之前,我就已经误判过一次自己的工具。
source scan 基于 regex。它报告的是信号,而不是证据。让 Agent 根据输出修复发现的问题,迟早会碰到下面这样的代码:
app.use(session({ secret: process.env.ADMIN_SESSION_SECRET }));
app.get("/admin/whoami", (req, res) => {
const sessionId = req.sessionID; // ← MCP002 fires here
res.json({ sessionId, user: req.session.user });
});
app.post("/mcp", async (req, res) => {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined, // ← already stateless. Nothing to do.
});
// …
});
这里的 session 是用于管理后台的 Express session,而这个 server 的 MCP transport 早已是无状态的。规则指出这个字符串确实存在,这一点没有错。但如果根据这项发现采取行动,就会重构一个从未出错的部分——而一次没有任何人需要的改动,其成本比这项发现本身的价值还要高。
所以,真正发布的是一套流程,而不是一个 scanner:
诊断——运行随附的确定性 checker;如果有 server 正在运行,就同时进行 source scan 和 live probe。两者看到的内容不同,任何一个都不是另一个的超集。
分类——修改任何内容之前,先针对每项发现,在对应的 file:line 位置说明其依据,并明确指出哪些发现只是噪声。这个步骤之所以存在,就是因为上面的那段代码。
修复——按照 dependency 顺序处理:先升级 SDK(这样后续重构所基于的就是最终要保留的 API),然后处理 session state、握手、弃用项,最后处理 OAuth posture。每完成一项都重新运行 checker,这样才能知道究竟是哪项改动解决了哪个问题。
验证——还要验证静态检查无法回答的问题:如果连续两个请求分别落在不同实例上,server 是否仍能正常工作?
rule id 是把整个流程串联起来的关键。checker 输出 MCP002,而修复指南中有一个以 MCP002 为 key 的章节,解释如何区分真正的 session dependency 和 Express session。诊断与修复不是两份会各自漂移的文档。
这一点值得说得准确些,因为“适用于所有 Agent”恰恰就是那种自信满满的说法,而整篇文章讨论的正是不要随便做这种断言。
.skill 格式——即一个带 frontmatter 的 SKILL.md,当相关话题出现时由 Agent 自动调用——是 Claude 的格式。在 Claude Code 或 Claude.ai 中安装这个文件,然后让它迁移一个 server,description 字段会负责其余工作。
但里面的内容并不专属于 Claude。.skill 文件其实就是一个 zip:
mcp-migration/
├── SKILL.md the procedure, plain markdown
├── references/remediation.md per-rule guidance, keyed by id
└── scripts/mcpcheck.mjs the engine, one file, zero dependencies
mcpcheck.mjs 是 rule engine 经过 esbuild 打包后的文件,除了 Node 之外不依赖任何东西。因此,对于 Codex、Cursor 或其他任何能够运行 shell command 的工具,可以这样使用:
unzip mcp-migration.skill
node mcp-migration/scripts/mcpcheck.mjs --source ./my-server --json
node mcp-migration/scripts/mcpcheck.mjs https://example.com/mcp
它的 exit code 对 CI 也很友好:0 表示没有 critical findings,1 表示至少存在一项,2 表示无法得出结论。references/remediation.md 单独阅读也完全没问题;你也可以把它放在 AGENTS.md 旁边,让你使用的任何 Agent 都能获得与 Claude 相同的分类规则。
之所以把它打包在 Skill 中,而不是将其发布为 dependency,原因正是如此:Skill 会出现在一些从未运行过 npm install 的机器上,如果依赖 import,到了那里只会直接失败。代价是 repository 中必须提交一个生成文件,而这个文件可能会过期——因此 CI 会在每次 push 时重新构建,并在构建结果与已提交文件不一致时失败。
codemod 确实存在,而且可以处理机械化的那一半工作:
npx @modelcontextprotocol/codemod@latest v1-to-v2 .
请在干净的工作树上运行它。它会重写 import path、symbol rename(McpError → ProtocolError、StreamableHTTPError → SdkHttpError)、setRequestHandler(Schema, …) → setRequestHandler('method/string', …)、.tool() → registerTool,以及 extra.* → ctx.mcpReq.*。如果无法安全地做出判断,它会留下一个 marker,而不是擅自猜测:
grep -rn '@mcp-codemod-error' .
需要注意的是,v2 要求使用 Zod 4,因此还在使用 Zod 3 的项目,必须先完成另一项升级。
接下来则是无法机械化处理的部分。引用 codemod 自己的原话:
codemod 只能处理 v1→v2 SDK surface upgrade。采用 2026-07-28 协议修订版(createMcpHandler、Multi Round-Trip Requests、versionNegotiation)属于架构层面的工作,无法通过 codemod 自动完成。
这句话是准确的,值得认真对待。移除 session state 是一项设计决策——你可以将其删除、把它作为显式 tool argument 传递,或者把它移到两个实例都能访问的 store 中。没有工具能够替你做出这个决定;而在你处理这些问题期间,那些 deprecated capabilities 还可以继续保留十二个月。
所有内容都已开源,采用 MIT 许可证:
托管版 checker——粘贴 endpoint,即可获得一份分级报告。无须安装,也不会存储任何内容。
下载 Skill——可以将它安装到 Claude,也可以解压后通过任何 Agent 运行 checker。
源代码——共有 86 项测试,其中大部分针对 SSRF guard,因为托管版本需要获取陌生用户输入的 URL。
README 中有一个名为“A rule that was wrong”的章节。它用更少的篇幅讲述了与本文大致相同的内容,而且这个章节会一直保留在那里。
如需采取进一步行动,你可以考虑屏蔽此人和/或举报滥用行为。