你的 MCP Server 该升 2.0 了——无状态到底解决了什么?

把一个 MCP 服务部署到 K8s 集群上,挂了三个节点。然后发现:客户端全断了。不是某个请求失败,是所有请求都失败了。因为每个客户端的「会话」都卡在某一台机器上,那台机器挂了,会话状态跟着一起消失。

2026 年 7 月 28 日,MCP 规范发布了一个大版本,把这个问题从协议层干掉了。

1.x 的问题

MCP 1.x 要求客户端先和服务器握手(initialize / initialized),拿到 Mcp-Session-Id 后,所有请求带着这个 ID,服务器在内存里维护会话上下文。本地跑 stdio 没问题,部署到云端就崩了:节点挂了会话丢失、负载均衡不认识会话只能开 Sticky Sessions、Serverless 根本跑不了。

2.0 怎么解决的

思路很干脆:默认无状态,需要的时候应用层自己管。SEP-2575 把这叫「Pay as you go」

MCP 2.0 无状态请求与 MRTR 流程

请求自省

2.0 删掉了 initialize / initializedMcp-Session-Id。每个请求在 _meta 里自带协议版本、客户端身份和能力声明,落到集群任意节点都能独立处理。需要跨请求状态?让模型自己传状态句柄,状态从传输层搬到应用层。版本协商也是内联的:不支持就返回 UNSUPPORTED_PROTOCOL_VERSION(-32022)附带支持列表,客户端挑一个重发。

HTTP 请求头限制

Mcp-MethodMcp-Name 提到 HTTP 标头,网关不解包 JSON 就能限流路由。MCP-Protocol-Version 必须跟 _meta 一致,否则直接 400。

请求轮询

1.x 靠 SSE 长连接让服务端反向问客户端,网络一抖就断。2.0 用 MRTR(多轮次请求) 替代:服务端需要用户输入时结束当前请求,返回 requestState 凭证;客户端收集输入后重发,附带 inputResponses 和原样 requestState。任意节点反序列化就能恢复上下文,每轮都是独立 HTTP 请求。

代码快速上手

用 MCP SDK V2 重写一下内部的一个 release 发布的 MCP Server,验证两件事:

  1. 2.0 的协议协商能不能正常工作
  2. 是不是真的「每个请求一个实例」

场景是我日常遇到的:发布前需要检查服务错误率、未关闭故障和变更窗口,决定能不能上线。工具名就叫 check_pig_release

服务端

核心是用 createMcpHandler。它是 2.0 的 HTTP 入口,已经内置在 @modelcontextprotocol/server v2 里。接收一个 factory 函数,每个 HTTP 请求进来时调用一次,创建新的 McpServer 实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
const handler = createMcpHandler(
({ era }) => {
const instanceId = randomUUID();
console.error(`[factory] era=${era} instance=${instanceId}`);

const server = new McpServer({
name: 'pig-release-gate',
version: '1.0.0'
});

server.registerTool(
'check_pig_release',
{
description: '根据错误率、未关闭故障和变更窗口判断 PIG 服务是否应该发布',
inputSchema: deployGateInput,
outputSchema: deployGateOutput
},
async (input) => {
const reasons = evaluateDeployment(input);
const decision = reasons.length === 0 ? 'allow' : 'block';
return {
content: [{ type: 'text', text: summary }],
structuredContent: { ...result, instanceId }
};
}
);

return server;
},
{ legacy: 'reject' }
);

注意 { legacy: 'reject' }。这表示只接受 2026-07-28 协议,拒绝 1.x 的初始化握手请求。如果你需要同时兼容两个版本,改成 legacy: 'stateless' 就行,同一个 factory 会服务两边。

部署方面,createMcpHandler 返回一个 Web 标准的 { fetch } 对象,可以直接 export default 到 Cloudflare Workers、Deno、Bun。Node.js 环境下用 toNodeHandler 包一层就能挂到 Express、Fastify 或原生 node:http 上。

客户端

客户端这边也很直接,协议版本锁定 2026-07-28

1
2
3
4
5
6
7
8
const client = new Client(
{ name: 'lengleng-pig-release-client', version: '1.0.0' },
{ versionNegotiation: { mode: { pin: '2026-07-28' } } }
);

await client.connect(new StreamableHTTPClientTransport(endpoint));
// 断言:协商结果是 modern
assert.equal(client.getProtocolEra(), 'modern');

我分两次调用了同一个工具。一次是安全场景(低错误率、无故障、变更窗口已批准),一次是风险场景(错误率 1.7%、两个未关闭故障)。

跑出来的结果

安全场景:

1
2
3
4
5
{
"decision": "allow",
"reasons": [],
"instanceId": "a1b2c3d4-..."
}

风险场景:

1
2
3
4
5
6
7
8
{
"decision": "block",
"reasons": [
"仍有 2 个故障未关闭",
"生产环境错误率 1.7% 已达到 1% 门槛"
],
"instanceId": "e5f6g7h8-..."
}

两个 instanceId 不同。这说明什么?两次工具调用走了两个不同的 McpServer 实例。factory 被调用了两次,每次创建新实例。没有任何跨请求的会话状态被复用。

总结

我试了 MCP 官方 Java SDK,它目前没有适配 2.0 的无状态特性,跑起来直接 500。langchain4j 的相关集成 SDK 也没跟上。相比之下,TypeScript SDK 是四个 Tier 1 SDK 里最先适配 2026-07-28 规范的,createMcpHandler 一行搞定。