协议层删掉状态,不等于你的状态消失了——它只是从协议手里,搬到了你手里。
2026-07-28 规范是 MCP 发布以来最大的一次破坏性改写:initialize 握手没了,Mcp-Session-Id 没了,协议层不再有"会话"这个概念。这不是一次语法糖更新——Day 18 里"连接后先握手协商能力"那套心智模型直接失效了。真正值钱的不是"它变无状态了"这句话,而是三件工程后果:远程 server 可以裸跑在轮询 LB 后面(不再需要粘性路由或共享 session store)、路由与限流信息被抬到 HTTP 头上(网关不拆 JSON-RPC body 就能按工具计量)、需要用户中途输入的流程改用"重试"而不是长连接。代价同样具体:无状态化把一批状态管理问题从协议推给了你,缓存作用域一旦标错就是跨用户泄漏。这一期按这三条讲清机制、给迁移清单,并点出每条新机制自带的失败模式。
以前"连上问一次",现在"每次都带着"——于是任何请求可以落到任何实例。
旧协议的生命周期是:连接 → 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 标准运行时直接导出
server/discover 是可选的,省掉它再硬编码能力,遇到不支持某原语的 server 不会在握手时失败,而是延迟到首次真实调用才炸。网关不该为了做限流去解 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 一行。
Mcp-Method 一被吃掉就是全部请求统一 400——因为是"全挂"而非"偶发",容易误判成 server 挂了。上线前务必在真实链路上 curl 验一遍头能否穿透。二是 x-mcp-header 把参数值抬进了基础设施日志:网关、WAF、CDN 默认记录请求头,别给含用户标识、查询内容、密钥的参数打这个标记。工具执行中途要问用户,不再需要一条活着的服务端连接——把状态签个名交给客户端拿回来。
"工具跑一半需要用户确认/补参数"这个需求,旧协议靠 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);
}
requestState 是在客户端手里过了一圈的数据,自己手写协议层时最容易漏掉签名,于是它退化成一个客户端可任意篡改的字段——把 {"userId":...,"approved":true} 塞进去就是一个提权漏洞。SDK 默认 HMAC 签名正是为此,别绕过。第二个坑是幂等:状态外置意味着同一个 requestState 可以被重放或并发提交多次,副作用型工具(下单、发信、写库)必须自己带幂等键,这一点与 Day 58 讲持久化执行时的 exactly-once 要求是同一个问题。第三,别把大 payload 塞进 requestState——它每跳都在网络上走一遍。这一版给了你两个新的脚枪:标错的缓存作用域,和一个会替你发 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 的替代路径尚未定型,现在深绑,一年后的迁移是被动的。官方 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 上。
initialize 握手与 Mcp-Session-Id,任何请求可落任何实例(SEP-2575 / SEP-2567)。params.name / params.uri,供网关不拆 body 路由与计量。input_required + requestState,客户端带 inputResponses 重试原调用,取代长连接上的服务端发起请求。Cache-Control;默认 0 / private,shared 需结果与调用者无关。client_id,授权服务器 fetch 该文档取元数据。iss 参数,防混淆攻击。server/discover、要么承担延迟失败的风险,流程状态变成签名后交给客户端往返的 requestState。净收益是真的——横向扩容不再需要粘性路由,运维上值一大笔;代价是协议表面积变大,新增的正确性责任落在实现者身上。所以收益分布不均:对运行大规模远程 server 的人是净赚,对只跑一个本地 stdio server 的人几乎只有迁移成本。x-mcp-header 更会把参数值也带进去;元数据侧信道——就算 body 脱敏,调用模式本身可能暴露业务意图;新的不一致面——头体一致校验本身是一处要正确实现的逻辑,宽松实现会给出"按头放行、按体执行"的绕过。结论不是别用,而是把头当公开数据设计。requestState 应绑定幂等键而非只装"下一步做什么";没有服务端记账就没有过期,签发时间要自己放进载荷并校验。更深一层的差异:持久化执行框架的 journal 在服务端,MRTR 的状态在客户端——所以它适合短、少跳、可重放的交互流程,不适合拿来实现长跑工作流。shared 的那一刻——而且这类错误不会在测试里暴露,单用户环境下两者行为完全一致。所以要靠结构性约束而非默认值:把"结果是否按调用者变化"做成类型层面的事实,或在 CI 里加一条断言——同一个 tools/list 用两个不同身份请求,结果有差异则 shared 即错误配置。这是通用模式:凡"错了也能跑、只在多租户下才错"的配置项,都该有一个双身份对比测试。_meta 身份、server/discover、必填头、MRTR、缓存与弃用一览createMcpHandler、versionNegotiation、cacheHints、requestState 签名的实际 API