我手上有几个自己写的 MCP 小工具,一直是 stdio 模式跑的——Agent 起个子进程,stdin 喂 JSON-RPC,stdout 收回来,同一个屋檐下没事。前两天我想把它们搬到另一台机器上常驻,让好几边的 Agent 都能连,stdio 就没法用了,得挂 HTTP。

我以为这是个体力活:把读 stdin 换成读 request body,把写 stdout 换成 res.end(),完事。结果打开规范文档一看,版本号写的是 2026-07-28,不是我脑子里那个 2025-06-18。翻下去更懵:Streamable HTTP 里我最熟悉的那两样——Mcp-Session-Id 会话、GET 开一条独立 SSE 流——在新版本里都没了,握手也没了,改成彻底无状态。

我第一反应是担心:那我之前那批按老协议写的客户端怎么办?后来发现分流可以放在服务端,一个 endpoint 同时伺候两个时代。下面是把我这事儿跑通的过程,输出都是本地真跑出来的。

先说清楚两个时代长什么样

我核对的是 2026 年 10 月 1 日的官方文档。当前协议版本是 2026-07-28,握手时代的最后一版是 2025-11-25。

老时代(2025-03-26 到 2025-11-25)的流程我熟:

  1. POST 一个 initialize 请求
  2. 服务端在响应头里回 Mcp-Session-Id
  3. 客户端把这个 id 贴在后续每一个请求上
  4. notifications/initialized 是个通知,服务端回 202 Accepted,不带 body
  5. 想收服务端主动推的消息,就 GET 这个 endpoint 开一条 SSE 流

新时代(2026-07-28)把这些全推翻了:

  • 不做 initialize 握手,没有协议级 session,Mcp-Session-Id 见到就忽略
  • GET / DELETE 到 endpoint 直接 405,独立 SSE 流没了
  • Last-Event-ID 断点续传没了,流断了就得重发请求
  • 多出一个 server/discover,服务端必须实现,用来一次性问出版本、能力和身份
  • 每个请求都得自带信封:params._meta 里塞 io.modelcontextprotocol/protocolVersion、clientInfo、clientCapabilities

一句话概括:老时代是"先建会话再办事",新时代是"每个请求自己把话说全"。

我的分流方案:只看一个头

关键点是,分流依据非常朴素——就是 MCP-Protocol-Version 这个请求头。它是"每个请求的属性",不是"服务器全局的模式"。所以我可以在同一个 POST handler 里这么切:

const vHead = req.headers['mcp-protocol-version'];

if (vHead === MODERN) return handleModern(req, res, msg);   // 2026-07-28
// 没有这个头 = 老客户端,规范允许按 2025-03-26 处理
return handleLegacy(req, res, msg);

老的那条路没什么好说的,就是 initialize 发 session id、校验 session、通知回 202:

if (method === 'initialize') {
  const sid = crypto.randomUUID();
  sessions.set(sid, { createdAt: Date.now() });
  return sendJson(res, 200, { jsonrpc: '2.0', id, result: { ... } },
                  { 'Mcp-Session-Id': sid });
}
// 通知:202 Accepted,无 body
if (method === 'notifications/initialized') {
  return send(res, 202, {}, null);
}

新时代那条路才是让我栽跟头的地方。它要求HTTP 头和 JSON-RPC body 严格对齐,对不上就是 400 + 错误码 -32020(HeaderMismatch)。我一开始只带了 MCP-Protocol-Version,请求全被自己写的校验挡回来了:

// 1) 版本头必须等于 body 里的 _meta 值
if (req.headers['mcp-protocol-version'] !== meta[PK + 'protocolVersion'])
  return headerMismatch(res, id, 'MCP-Protocol-Version mismatch');

// 2) Mcp-Method 是【所有】请求都必带,不只是 tools/call
if (req.headers['mcp-method'] !== method)
  return headerMismatch(res, id, 'Mcp-Method mismatch');

// 3) tools/call / resources/read / prompts/get 额外要 Mcp-Name
if (method === 'tools/call' && req.headers['mcp-name'] !== params.name)
  return headerMismatch(res, id, 'Mcp-Name mismatch');

Mcp-Method 这条我漏了两次。它在老协议里根本不存在,我按惯性只给 tools/call 带了 Mcp-Name,结果就是一直吃 -32020。

跑出来的真实结果

我把服务起在 127.0.0.1:8398/mcp,然后拿一个裸的 Node HTTP 客户端去打。先是老客户端,不带 MCP-Protocol-Version 头:

[A1 initialize] HTTP 200
  headers: {"content-type":"application/json","mcp-session-id":"94c5b3f6-f6c4-4338-9b27-2d1ba71d3da0"}
  body:    {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25",...}}
[A2 notifications/initialized] HTTP 202
  body:    (空)
[A4 tools/call sha256] HTTP 200
  body:    {"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"c71e5f70...fa58"}]}}

session 拿到了,通知是干净的 202 空 body,工具照常调用。老路径没坏。

再换新客户端,头里带 MCP-Protocol-Version: 2026-07-28,不握手,直接 server/discover:

[C1 server/discover] HTTP 200
  body:    {"jsonrpc":"2.0","id":"discover-1","result":{
             "resultType":"complete",
             "supportedVersions":["2026-07-28"],
             "capabilities":{"tools":{}},
             "serverInfo":{"name":"two-era-demo","version":"0.1.0"},
             "instructions":"两个小工具,用来验证传输层。",
             "ttlMs":3600000,"cacheScope":"private"}}

一次往返就把版本、能力、身份全问出来了,全程没有 session。以前为了知道"这个 server 能干啥",得连着发 tools/list、prompts/list、resources/list 三次,现在一次搞定,ttlMs + cacheScope 还直接告诉你能不能缓存。

然后是那些被挡回来的请求,我把错误消息原样贴出来:

[D2 Mcp-Name 写错] HTTP 400
  {"error":{"code":-32020,"message":"Header mismatch: Mcp-Name header 'echo_tagged' does not match body value 'sha256'"}}
[D3 漏掉 Mcp-Name] HTTP 400
  {"error":{"code":-32020,"message":"Header mismatch: Mcp-Name header 'undefined' does not match body value 'sha256'"}}
[D4 头里的版本和 body 不一致] HTTP 400
  {"error":{"code":-32020,"message":"Header mismatch: MCP-Protocol-Version header '2026-07-28' does not match body value '2025-11-25'"}}

两个我觉得值得单独说的点

x-mcp-header:工具参数可以强制走 HTTP 头。

这个设计我一开始觉得多余,后来想明白了:像 API key、租户 ID 这类东西,你本来就不希望它混在 JSON body 里被日志整段打出来、被模型看见。现在可以在 inputSchema 里给参数加一个 x-mcp-header 标注,客户端必须把它同时放进 Mcp-Param-{Name} 头,服务端校验头体一致:

[E1 带对 Mcp-Param-Tag] HTTP 200
  body: {"result":{"content":[{"type":"text","text":"[ops] hello"}]}}
[E2 漏掉 Mcp-Param-Tag] HTTP 400
  {"error":{"code":-32020,"message":"Header mismatch: Mcp-Param-Tag header 'undefined' does not match body value 'ops'"}}

代价是多一层要对齐的东西。我的取舍是只在真的敏感的参数上加,别图省事全标上。

Origin 校验从"建议"变成了"必须返回 403"。

老规范只说 MUST validate the Origin header,新规范补了一句硬的:Origin 存在且不合法时,必须回 403 Forbidden。这是防 DNS rebinding 的——你本地跑的 MCP server 要是被恶意网页用重绑定的域名摸到,等于把本地工具开放给了外网。我实测:

[G1 陌生 Origin] HTTP 403   {"error":{"code":-32000,"message":"Forbidden origin"}}
[G2 白名单 Origin] HTTP 200  {"result":{"content":[{"type":"text","text":"c71e5f70...fa58"}]}}

顺带提醒一句:本地跑的服务只绑 127.0.0.1,别绑 0.0.0.0。我这次特意这么写的,这比后面补任何校验都管用。

我的判断

先说结论:这次改动我认。

砍掉 session 是对的。我自己的 server 是纯函数式的工具(算个哈希、查个东西),为它维护一张 session 表纯属负担——进程一重启 session 全丢,客户端还得处理 404 重连。无状态之后,我可以直接把它丢到任意多个副本后面,不用管粘性会话,这对自部署的人来说是实打实省事。

但我也得说清楚代价:状态被从协议层赶走了,就得在业务层自己捡回来。 新规范给的答案很明确——工具返回一个 handle、task id 或状态标识,客户端下一次调用时当普通参数带回来。也就是说,"多轮任务"这件事从协议帮你管,变成了你自己设计。这不是退步,是把复杂度挪到了该挪的地方,但你得意识到自己现在有这份责任了。

另外一个明显的断层:2026-07-28 和之前那批客户端是两套完全不同的行为,不是"加个字段"的兼容。如果你的 server 只实现新时代,老客户端发 initialize 过来会撞上 -32020 而不是握手成功。所以你要么像我这样在服务端做分流(官方 Python / Go / Ruby SDK 现在都是这么干的,一个 app 同时服务两个时代),要么明确只支持一边并在文档里写死。我选前者,代码量其实就几十行。

最后一点我没在这篇文章里验:MRTR(Multi Round-Trip Requests)。新规范里服务端想反过来问客户端要东西(sampling、elicitation)不再走 SSE 上发请求,而是返回一个 InputRequiredResult,客户端带上 inputResponses 重发原请求。我这次的工具都是一问一答,用不上,等哪天真碰到需要中途问用户一句的场景再写。

代码在我本地跑通了,两个时代的客户端打同一个 endpoint 都拿到了结果。你要照着做,记得三件事:Mcp-Method 所有请求都要带、Origin 非法必须 403、本地只绑 127.0.0.1。


规范细节核对日期:2026-10-01,来源:

文中所有 HTTP 状态码、响应头、错误码与 body 均来自我在本机 127.0.0.1:8398/mcp 上跑出来的真实输出,不是抄文档的示例。

添加新评论

文章目录