DAY 60 · PROTOCOL

MCP 无状态化与迁移

2026-07-28 规范把握手和会话删掉了 — 你的 server 该改哪几行

2026-10-03 · BigCat

协议层删掉状态,不等于你的状态消失了——它只是从协议手里,搬到了你手里。

前置 → 本站 Day 18 MCP(三层原语 / transport / context 税,描述的是旧的有状态协议)

// WHY THIS MATTERS

2026-07-28 规范是 MCP 发布以来最大的一次破坏性改写:initialize 握手没了,Mcp-Session-Id 没了,协议层不再有"会话"这个概念。这不是一次语法糖更新——Day 18 里"连接后先握手协商能力"那套心智模型直接失效了。真正值钱的不是"它变无状态了"这句话,而是三件工程后果:远程 server 可以裸跑在轮询 LB 后面(不再需要粘性路由或共享 session store)、路由与限流信息被抬到 HTTP 头上(网关不拆 JSON-RPC body 就能按工具计量)、需要用户中途输入的流程改用"重试"而不是长连接。代价同样具体:无状态化把一批状态管理问题从协议推给了你,缓存作用域一旦标错就是跨用户泄漏。这一期按这三条讲清机制、给迁移清单,并点出每条新机制自带的失败模式。

// POINT 01

握手消失:身份与能力从连接级降到请求级

以前"连上问一次",现在"每次都带着"——于是任何请求可以落到任何实例。

// 背景与原理

旧协议的生命周期是:连接 → initialize 交换协议版本与 capability → initialized → 后续请求靠 Mcp-Session-Id 认回同一会话。这套设计让 server 有权假设"同一 session 的请求我见过前因",代价是横向扩容必须有粘性:要么 LB 按 session 粘住实例,要么把会话态放进 Redis 之类的共享存储。

新规范(SEP-2575 / SEP-2567)把这一层整个删掉。客户端身份改为每个请求都在 _meta 里自带,键名是固定的反向域名 io.modelcontextprotocol/clientInfo;能力发现变成一个可选的 server/discover 方法——客户端"需要提前知道"时才调,而不是作为任何调用的前置。配套的还有 W3C Trace Context(SEP-414)也走 _meta,键名固定为 traceparent / tracestate / baggage,这是第一次让 MCP 调用链能直接并进你现有的 OTel 链路。

// 实战示例

// 新协议:一个自足的 tools/call,没有前置握手
{
  "jsonrpc": "2.0", "id": 1,
  "method": "tools/call",
  "params": {
    "name": "search",
    "arguments": { "q": "mcp stateless" },
    "_meta": {
      "io.modelcontextprotocol/clientInfo": { "name": "my-app", "version": "1.0" }
    }
  }
}
// TypeScript SDK v2:工厂函数每请求建一个 server 实例
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';

const handler = createMcpHandler(() => {
  const server = new McpServer(
    { name: 'my-server', version: '1.0.0' },
    { capabilities: { tools: {} } }
  );
  // 在这里注册 tools / resources / prompts
  return server;
});
export default handler;  // Web 标准运行时直接导出
旧(2025 有状态) 新(2026-07-28 无状态) client ──initialize──┐ client ──tools/call──┐ └─Session-Id──┤ (_meta 自带身份)│ ▼ ▼ ┌─ sticky LB ─┐ ┌─ round-robin ─┐ ▼ ▼ ▼ ▼ [inst A] [inst B] [inst A] [inst B] └── shared session store (无共享状态) (Redis / 粘性路由)
失败模式:最常见的误解是把"协议无状态"当成"我不用管状态了"。协议只是不再替你认会话——业务状态该外置还得外置:server 在进程内存里攒 per-client 缓存或累积的授权 scope,单实例一切正常,扩到两个实例后行为随机漂移。第二个坑在 client 侧:server/discover 是可选的,省掉它再硬编码能力,遇到不支持某原语的 server 不会在握手时失败,而是延迟到首次真实调用才炸。
// POINT 02

Mcp-Method / Mcp-Name:把路由信息抬到 HTTP 头

网关不该为了做限流去解 JSON-RPC body——现在它不用了。

// 背景与原理

旧协议下所有 MCP 流量在 HTTP 层长得一模一样:同一个 POST /mcp,方法名和工具名埋在 body 里。想按工具做限流、按方法做鉴权、在 WAF 上拦某个危险操作,都得让网关去解 JSON body——这在基础设施层是个难看且易错的活。

SEP-2243 把这件事规范化:Streamable HTTP 请求必须带 MCP-Protocol-Version、Mcp-Method、Mcp-Name 三个头。Mcp-Method 是 JSON-RPC 方法名;Mcp-Name 镜像 params.name 或 params.uri,于是点名目标的调用(tools/call / resources/read / prompts/get)在头里就暴露了"要动哪个东西"。关键是这不是善意提示:server 必须校验头与 body 一致,不一致就拒——TS SDK 的实现是 HTTP 400 配错误码 -32020(HeaderMismatch)。工具还可在 inputSchema 里用 x-mcp-header 标记参数,SDK 会把它序列化成 Mcp-Param-{Name} 头(必要时 Base64)。

// 实战示例

# 手写请求:三个头缺一个就是协议错误
curl -X POST https://example.com/mcp \
  -H 'MCP-Protocol-Version: 2026-07-28' \
  -H 'Mcp-Method: tools/call' \
  -H 'Mcp-Name: search' \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
       "params":{"name":"search","arguments":{"q":"x"}}}'

# 现在才能在网关层这样写(Envoy 风格,按工具分桶限流)
# descriptor: request_headers -> Mcp-Name -> rate_limit
# 贵的工具(跑 SQL / 调外部 API)单独一个更紧的桶,
# 便宜的 tools/list 走宽桶 —— 不碰 body 一行。
失败模式:一是中间层剥头:不少反代、CDN、企业出口代理默认丢弃或重写不认识的自定义头,Mcp-Method 一被吃掉就是全部请求统一 400——因为是"全挂"而非"偶发",容易误判成 server 挂了。上线前务必在真实链路上 curl 验一遍头能否穿透。二是 x-mcp-header 把参数值抬进了基础设施日志:网关、WAF、CDN 默认记录请求头,别给含用户标识、查询内容、密钥的参数打这个标记。
进阶资源:MCP Blog · The 2026-07-28 Release Candidate(SEP-2243 等六个无状态化 SEP 的逐条说明,2026-05)
// POINT 03

MRTR:用重试代替长连接

工具执行中途要问用户,不再需要一条活着的服务端连接——把状态签个名交给客户端拿回来。

// 背景与原理

"工具跑一半需要用户确认/补参数"这个需求,旧协议靠 server 在 SSE 流上主动发请求实现。这是粘性会话的最后一个硬依赖:server 必须在那条连接上活着,连接断了流程就死了,也因此无法跨实例。

SEP-2322 引入 MRTR(Multi-Round-Trip Requests)把这件事翻过来:server 不主动推,而是直接返回一个"我还需要输入"的结果——resultType: 'input_required',带 inputRequests(要问什么)与 requestState(不透明字符串,装着继续执行所需的上下文);客户端收齐答案后重试原调用,答案放进 inputResponses、requestState 原样带回。流程状态于是在两跳之间寄存在客户端手里,server 不需要记住任何东西,下一跳落到别的实例也能续上。配套的 SEP-2260 收紧了残留的服务端发起请求:只允许在 server 正在处理某个客户端请求期间发出。

// 实战示例

// TS SDK v2:requestState 必须签名(HMAC-SHA256)
import { inputRequired, acceptedContent,
         createRequestStateCodec } from '@modelcontextprotocol/server';

const stateCodec = createRequestStateCodec<FlowState>({ key: SECRET });

async (args, ctx) => {
  const state = ctx.mcpReq.requestState<FlowState>();
  if (!state) {                      // 第一跳:问
    return inputRequired({
      inputRequests: { count: inputRequired.elicit({ /* schema */ }) },
      requestState: await stateCodec.mint({ phase: 'awaiting-count' })
    });
  }
  // 第二跳:取答案继续(可能落在另一个实例上)
  const response = acceptedContent(ctx.mcpReq.inputResponses, 'count', SCHEMA);
}
client server (任意实例) │── tools/call ──────────────────────▶ A │◀─ input_required + inputRequests ───┤ │ + requestState (已签名) │ │ │ │ (向用户取得输入;此间无需保持连接) │ │ │ │── 重试 tools/call ─────────────────▶ B ← 换实例也能续 │ + inputResponses + requestState │ │◀─ 最终结果 ─────────────────────────┤
失败模式:requestState 是在客户端手里过了一圈的数据,自己手写协议层时最容易漏掉签名,于是它退化成一个客户端可任意篡改的字段——把 {"userId":...,"approved":true} 塞进去就是一个提权漏洞。SDK 默认 HMAC 签名正是为此,别绕过。第二个坑是幂等:状态外置意味着同一个 requestState 可以被重放或并发提交多次,副作用型工具(下单、发信、写库)必须自己带幂等键,这一点与 Day 58 讲持久化执行时的 exactly-once 要求是同一个问题。第三,别把大 payload 塞进 requestState——它每跳都在网络上走一遍。
进阶资源:SEP-2322 · Multi-Round-Trip Requests · 本站 Day 58 Durable Agent Execution(幂等键与副作用 exactly-once)
// POINT 04

缓存作用域、CIMD 与 12 个月窗口:迁移清单

这一版给了你两个新的脚枪:标错的缓存作用域,和一个会替你发 HTTP 请求的授权服务器。

// 背景与原理

无状态化之后,客户端重复拉 tools/list 的成本浮上来了,于是 SEP-2549 给 list 与 resource 结果加了仿 HTTP Cache-Control 的 ttlMs 与 cacheScope。TS SDK 的默认值是最保守的 ttlMs: 0 / cacheScope: 'private'——这个默认是对的,把它改成 'shared' 之前要能证明该结果与调用者身份无关。

授权侧两条变化更需要主动动手。其一,Dynamic Client Registration(DCR)被正式弃用,转向 CIMD(Client ID Metadata Documents):客户端的 client_id 变成一个 URL,授权服务器首次见到时去 fetch 该 URL 拿元数据文档。动机是 DCR 的开放注册被用来注册钓鱼客户端(2025 年有公开的一键账号接管案例),CIMD 把客户端身份绑到一个它必须真正控制的域名上。其二,SEP-2468 要求按 RFC 9207 校验授权响应里的 iss 参数。

弃用节奏由 SEP-2577 定规矩:Active / Deprecated / Removed 三段,弃用到最早可能移除之间至少十二个月。本版被标弃用的是 Roots、Sampling、Logging 与旧的 HTTP+SSE transport——方法、类型与 capability 标志在本版及此后一年内发布的每个版本中都继续可用。SDK 侧:TypeScript、Python、Go、C# 四个 Tier 1 SDK 均已支持 2026-07-28,Rust 为 beta。

// 实战示例

// 缓存提示:改 'shared' 前先问"这个 list 会按用户过滤吗"
new McpServer({ name: 'my-server', version: '1.0.0' }, {
  capabilities: { tools: {} },
  cacheHints: { tools: { ttlMs: 3600000, cacheScope: 'shared' } }
  // ↑ 仅当工具清单对所有调用者完全相同时才成立
});
# 自建 server 迁移清单
1. 升到支持 2026-07-28 的 SDK(TS/Python/Go/C# 已 GA,Rust beta)
2. 入口换成无状态 handler(TS: createMcpHandler);删掉 initialize 分支
3. 审一遍进程内状态:per-session 缓存 / 累积 scope → 外置或去掉
4. 验证 Mcp-Method / Mcp-Name 能穿透你的反代与 CDN
5. 服务端发起请求 → 改写成 MRTR;requestState 必须签名
6. 给每个 list 显式标 ttlMs / cacheScope,默认留 private
7. 授权:加 RFC 9207 iss 校验;规划 DCR → CIMD
8. 盘点对 Roots / Sampling / Logging 的依赖(约一年窗口)
9. 旧客户端:SDK 的 legacy 'stateless' 模式可同端点兼容 2025 流量
失败模式:最危险的是cacheScope: 'shared' 用在按用户过滤的清单上——多租户 server 很容易这么干("工具清单不就是静态的吗"),结果 A 用户缓存到的是 B 用户可见的工具集。凡 list 结果里出现过"因人而异"的内容,就只能 private。其次,CIMD 的机制是让授权服务器去 fetch 一个客户端提供的 URL,这天然是 SSRF 面:自建 AS 必须对该 fetch 做出网限制(禁内网与元数据端点、限超时与大小、不跟跳转)。最后是节奏误判:把"还有十二个月"读成"这次不用管"——Roots / Sampling 的替代路径尚未定型,现在深绑,一年后的迁移是被动的。
进阶资源:MCP Spec 2026-07-28 · Client Registration(CIMD 与 DCR 弃用的规范原文)· MCP Blog · Release Candidate(授权侧六个 SEP)

// 接下来会怎么走

官方 2026-08-22 的路线图把方向说得很直:transport 继续向 HTTP 收敛——连本地 server 也要统一成"在 stdio 上跑 Streamable HTTP",而不是维护两套;授权向 workload identity 走,落地 DPoP、以 Enterprise-Managed Authorization 背后的 ID-JAG grant 与标准 token exchange 取代长期 API key;补回服务端主动事件(webhook 与 channel,让客户端不必轮询结果——这与本版收紧服务端发起请求不矛盾,是把它从协议内连接挪到协议外通道);以及渐进式工具发现:server 先给一个小入口,随对话收窄再展开目录,这正是 Day 18 讲的 tool-definition context 税的协议级解法。对自建 server 的人,前两条意味着现在别把设计绑死在 stdio 专属路径和静态 API key 上。

// 术语表

stateless protocol core(无状态协议核心)
2026-07-28 的主题变更:删除 initialize 握手与 Mcp-Session-Id,任何请求可落任何实例(SEP-2575 / SEP-2567)。
server/discover
取代握手的可选能力发现方法;客户端需要提前知道 server 能力时调用,而非任何请求的前置。
Mcp-Method / Mcp-Name
Streamable HTTP 必填头(SEP-2243):前者是 JSON-RPC 方法名,后者镜像 params.name / params.uri,供网关不拆 body 路由与计量。
HeaderMismatch(-32020)
头与 body 不一致时的拒绝:HTTP 400 + 该 JSON-RPC 错误码。头不是提示,是必须一致的声明。
MRTR(Multi-Round-Trip Requests)
SEP-2322:server 返回 input_required + requestState,客户端带 inputResponses 重试原调用,取代长连接上的服务端发起请求。
requestState
MRTR 中寄存在客户端手里的不透明状态串;必须签名(SDK 用 HMAC-SHA256),否则等于可篡改输入。
ttlMs / cacheScope
SEP-2549 给 list 与 resource 结果的缓存提示,仿 HTTP Cache-Control;默认 0 / private,shared 需结果与调用者无关。
CIMD(Client ID Metadata Documents)
取代被弃用的 DCR:以一个客户端控制的 URL 作 client_id,授权服务器 fetch 该文档取元数据。
RFC 9207
OAuth 授权响应的 issuer 标识;SEP-2468 要求校验 iss 参数,防混淆攻击。
deprecation window(弃用窗口)
SEP-2577 的 Active / Deprecated / Removed 三段生命周期,弃用到最早移除至少十二个月。

// 深入思考

"协议无状态"是把复杂度消灭了,还是搬了个地方?
搬了地方。原来"会话"替双方承担三件事:身份记忆、能力契约、流程中途状态。删掉之后,身份变成每请求重复传输的开销,能力契约变成客户端要么多调一次 server/discover、要么承担延迟失败的风险,流程状态变成签名后交给客户端往返的 requestState。净收益是真的——横向扩容不再需要粘性路由,运维上值一大笔;代价是协议表面积变大,新增的正确性责任落在实现者身上。所以收益分布不均:对运行大规模远程 server 的人是净赚,对只跑一个本地 stdio server 的人几乎只有迁移成本。
把方法名和工具名暴露在 HTTP 头上,有没有安全上的新代价?
有,是权衡而非纯收益。代价有三条:可观测性即泄漏——头会进网关、CDN、WAF 的日志,"谁在何时调了哪个工具"沿链路留痕,x-mcp-header 更会把参数值也带进去;元数据侧信道——就算 body 脱敏,调用模式本身可能暴露业务意图;新的不一致面——头体一致校验本身是一处要正确实现的逻辑,宽松实现会给出"按头放行、按体执行"的绕过。结论不是别用,而是把头当公开数据设计。
MRTR 把流程状态交给客户端往返,这和分布式系统里哪个老问题同形?
同形于"会话态下沉到 cookie / JWT"。三个老教训直接适用:客户端持有的状态必须签名,否则是提权入口;可往返即可重放,带副作用的续跑必须幂等,requestState 应绑定幂等键而非只装"下一步做什么";没有服务端记账就没有过期,签发时间要自己放进载荷并校验。更深一层的差异:持久化执行框架的 journal 在服务端,MRTR 的状态在客户端——所以它适合短、少跳、可重放的交互流程,不适合拿来实现长跑工作流。
缓存作用域这类"默认安全但容易被改坏"的设计,工程上该怎么防?
事故不发生在默认值上,而发生在有人为省延迟手改成 shared 的那一刻——而且这类错误不会在测试里暴露,单用户环境下两者行为完全一致。所以要靠结构性约束而非默认值:把"结果是否按调用者变化"做成类型层面的事实,或在 CI 里加一条断言——同一个 tools/list 用两个不同身份请求,结果有差异则 shared 即错误配置。这是通用模式:凡"错了也能跑、只在多租户下才错"的配置项,都该有一个双身份对比测试。
Day 18 到 Day 60 这种破坏性改写,对"该不该早接新协议"给了什么启示?
给了一条分界:把协议当依赖隔离,而不是当架构内化。这次被打掉的恰恰是那些把协议语义吃进架构的实现——业务逻辑里真的依赖了"同一 session 我见过前因"的人迁移最痛;当初只把 MCP 当一层薄适配的人改几行就过了。所以要点不是早接晚接,而是接的时候留一层:工具实现不感知 transport 与会话语义,协议细节集中在适配层。附带一条:规范这次写进了至少十二个月的弃用窗口,未来的破坏性变更更可预测,"等它稳定"的性价比反而变低了。

// 延伸阅读