MCP 2026-07-28 规范:从有状态连接走向无状态核心
学习目标:读懂 MCP 第 5 版规范的核心变化,判断自己的客户端/服务端需要改什么,并按清单完成迁移
预计时间:40 分钟
难度等级:⭐⭐⭐⭐☆
最后更新:2026 年 10 月
前置阅读:MCP 协议
一句话概括
2026-07-28 把 MCP 从一个「先握手、再对话」的有状态连接协议,改成了「每个请求自带全部上下文」的无状态 RPC 协议。
| 被删除的东西 | 曾经的作用 | 现在怎么做 |
|---|---|---|
initialize / notifications/initialized | 连接时协商版本与能力 | 每个请求在 _meta 里带版本与能力 |
Mcp-Session-Id 头 | 把请求绑到同一个服务端会话 | 服务端签发显式 handle,当普通工具参数传 |
| HTTP GET 端点 | 服务端主动推送变更 | subscriptions/listen 长连接 |
resources/subscribe / unsubscribe | 单资源订阅 | 同上,订阅类型写在请求里 |
ping | 保活探测 | 无(无状态协议不关心连接存活) |
logging/setLevel | 会话级日志级别 | 每请求 _meta 的 logLevel |
SSE Last-Event-ID | 断线续传、事件重放 | 断流即丢请求,用新 request ID 重发 |
服务端主动发 sampling/createMessage 等 | 服务端反向提问 | MRTR:返回 input_required,客户端带答案重试 |
| tasks(核心) | 长任务查询 | 官方扩展 io.modelcontextprotocol/tasks |
变更清单:2026-07-28 changelog | SEP 索引:modelcontextprotocol.io/seps
版本定位
这是 MCP 的第 5 个主要修订,上一版是 2025-11-25,由 Anthropic 于 2026-07-28 发布。
| 版本 | 关键特征 |
|---|---|
2024-11-05 | 初版,HTTP+SSE 传输 |
2025-03-26 | Streamable HTTP 取代 HTTP+SSE,加入 OAuth 授权框架 |
2025-06-18 | 授权细化,elicitation 等能力 |
2025-11-25 | 会话式 initialize 握手成熟的最后一版 |
2026-07-28 | 无状态核心、MRTR、订阅重构、扩展机制 |
两件与「规范怎么演进」有关的事同样重要:
- SEP 流程正式化:提案改为
seps/目录下的 Markdown 文件,编号由 PR 派生,明确 sponsor 职责,状态用 PR label 管理(SEP-1850)。 - 特性生命周期政策落地(SEP-2596):定义 Active / Deprecated / Removed 三态,最短 12 个月弃用窗口,并维护弃用特性注册表。这就是为什么 Roots / Sampling / Logging 这次只是「弃用」而非「删除」。
这是破坏性版本
initialize 移除、服务端主动请求移除、SSE 续传移除都是 breaking change。双轨兼容(dual-era)可行,但必须显式实现,见下文兼容矩阵。
状态模型:连接不再是会话
移除协议级会话
旧规范用 Mcp-Session-Id 头把一串请求绑成一个 session,服务端可以在 session 里存「客户端能力」「已授权范围」「上次列出的工具」。新规范把这条路封死:
- Streamable HTTP 不再有协议级 session,
Mcp-Session-Id头移除。 tools/list、resources/list、prompts/list的结果不再随连接变化。- 需要跨调用状态的服务端,必须签发服务端生成的显式 handle,由客户端每次作为普通工具参数传回(SEP-2567)。
代价换来的是可扩展性:列表结果稳定 = 可缓存 = 能放在网关/CDN 后面 = 可以水平扩容多个无状态实例,负载均衡不需要 sticky session。旧的 session 模型在 serverless 和容器编排环境里几乎无法实现。
handle 模式:让客户端携带状态
// 1) 建立上下文,服务端返回不透明 handle
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"workspace_open",
"arguments":{"path":"/repo"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28"}}}
{"jsonrpc":"2.0","id":1,"result":{"resultType":"complete","content":[{"type":"text","text":"workspace opened"}],
"structuredContent":{"workspaceHandle":"ws_7f3a91c2"}}}
// 2) 后续调用把 handle 当普通参数传回
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"workspace_grep",
"arguments":{"workspaceHandle":"ws_7f3a91c2","pattern":"deprecated"}}}实现要点:handle 必须对客户端不透明;必须能跨实例校验(签名或共享存储),否则扩容后请求落到别的实例就认不出;必须带过期时间并校验归属,防止 A 用户拿 B 用户的 handle。
无状态核心:每个请求自带上下文
_meta 里的字段
请求没有前导握手(SEP-2575),所以每个请求都要说清「我是什么版本」「我有什么能力」「我是谁」:
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{
"name":"search_files","arguments":{"query":"mcp"},
"_meta":{
"io.modelcontextprotocol/protocolVersion":"2026-07-28",
"io.modelcontextprotocol/clientCapabilities":{
"elicitation":{},
"extensions":{"io.modelcontextprotocol/tasks":{}}},
"io.modelcontextprotocol/clientInfo":{"name":"ExampleClient","version":"1.0.0"}}}}| 键 | 必需性 | 说明 |
|---|---|---|
io.modelcontextprotocol/protocolVersion | 必需 | 请求使用的协议版本 |
io.modelcontextprotocol/clientCapabilities | 必需 | 客户端能力,含 extensions |
io.modelcontextprotocol/clientInfo | 客户端 SHOULD | 名称与版本 |
io.modelcontextprotocol/serverInfo | 服务端 SHOULD(结果 _meta) | 仅供展示/日志,不用于安全决策 |
io.modelcontextprotocol/logLevel | 可选 | 该请求的日志级别 |
版本协商因此从「握手」变成「逐请求判断」:服务端支持请求里的版本就正常处理;不支持就返回 UnsupportedProtocolVersionError,客户端从 supported 列表里选一个共同版本重试。
{"jsonrpc":"2.0","id":1,"error":{"code":-32022,"message":"Unsupported protocol version",
"data":{"supported":["2026-07-28","2025-11-25"],"requested":"1900-01-01"}}}HTTP 上版本还会镜像到 MCP-Protocol-Version 头,两处必须一致,不一致返回 400 + HeaderMismatch。
server/discover:服务端 MUST 实现
无状态不等于不可发现。服务端必须实现这个 RPC,一次性宣告支持的版本、能力与身份。
// 请求:除了标准 _meta 没有参数
{"jsonrpc":"2.0","id":"discover-1","method":"server/discover","params":{"_meta":{
"io.modelcontextprotocol/protocolVersion":"2026-07-28",
"io.modelcontextprotocol/clientCapabilities":{},
"io.modelcontextprotocol/clientInfo":{"name":"ExampleClient","version":"1.0.0"}}}}
// 响应
{"jsonrpc":"2.0","id":"discover-1","result":{"resultType":"complete",
"supportedVersions":["2026-07-28"],"capabilities":{"tools":{},"resources":{}},
"instructions":"This server provides weather and resource utilities.",
"ttlMs":3600000,"cacheScope":"public",
"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"ExampleServer","version":"1.0.0"}}}}客户端调用它是可选的,但两个场景下强烈建议:
- 界面展示:一次请求拿到身份与能力,不用分别试探
tools/list、prompts/list、resources/list。 - STDIO 兼容探测:STDIO 没有 HTTP 状态码可依赖。先发
server/discover,返回DiscoverResult说明是现代服务端;返回不认识的其他错误或超时,说明是旧服务端,回退到initialize。
兼容矩阵
| 客户端 | 服务端 | 结果 |
|---|---|---|
| 现代 | 现代 | 正常。server/discover 可选,版本不匹配时按错误码重试 |
| 现代 | 旧版 | 失败。STDIO 上应先探测,以便确定性地失败 |
| 双轨 | 现代 | 正常,探测返回新式结果 |
| 双轨 | 旧版 | 正常,探测失败后回退 initialize |
| 旧版 | 现代 | 失败。STDIO 上 initialize 被当未知方法拒绝 |
| 旧版 | 双轨 | 正常,服务端按 initialize 走旧语义 |
| 旧版 | 旧版 | 按旧版本规范工作 |
「双轨服务端」的选择逻辑很简单:请求带现代 _meta → 无状态处理;收到 initialize → 进入旧语义。两套语义可以并存于同一进程、同一端点。另外,「服务端是新是旧」是服务端属性,不是请求属性:判断一次后应缓存(STDIO 缓存到进程结束,HTTP 缓存到 origin),直到假设失败再重新探测。
订阅与通知:subscriptions/listen
旧模型有两条通知通道:HTTP GET 端点(服务端主动推)与 resources/subscribe(按资源订阅)。新规范合并成一个请求。
{"jsonrpc":"2.0","id":1,"method":"subscriptions/listen","params":{
"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28"},
"notifications":{"toolsListChanged":true,"promptsListChanged":false,
"resourcesListChanged":true,"resourceSubscriptions":["file:///project/config.json"]}}}| 字段 | 类型 | 订阅内容 |
|---|---|---|
toolsListChanged | boolean | notifications/tools/list_changed |
promptsListChanged | boolean | notifications/prompts/list_changed |
resourcesListChanged | boolean | notifications/resources/list_changed |
resourceSubscriptions | string[] | 指定 URI 的 notifications/resources/updated |
规则链条:
- 服务端 MUST NOT 发送客户端没显式订阅的类型。
- 服务端必须先发
notifications/subscriptions/acknowledged,其 filter 是服务端实际同意的子集(不支持的类型被省略),客户端应比对并优雅处理差异。 - 该流每条通知都在
_meta里带io.modelcontextprotocol/subscriptionId,值即subscriptions/listen请求的 JSON-RPC id。STDIO 上所有订阅共用一个通道,客户端必须靠它分流。 - 一个客户端可以同时开多个订阅。
- 服务端主动收尾时应先回一个
resultType: "complete"的响应再关流,响应同样带subscriptionId——收到是「优雅结束」,没收到是异常断开。
注意:notifications/progress、notifications/message 属于「某次调用的进度/日志」,不走这条 listen 流,而是走该请求自己的响应流。塞进 listen 流,客户端就分不清是哪次调用产生的。
移除 ping / logging/setLevel / roots/list_changed
这三个东西都依赖「连接有持续状态」这个前提:
| 被删 | 替代 |
|---|---|
ping | 无。请求能否成功本身就是存活信号 |
logging/setLevel | 每请求 _meta 的 io.modelcontextprotocol/logLevel |
notifications/roots/list_changed | Roots 整体弃用(见迁移建议) |
日志语义还收紧了一条:服务端 MUST NOT 对没有携带 logLevel 的请求发 notifications/message。日志变成纯 opt-in——客户端不声张,服务端就不该说话(当然仍可写自己的 stderr 或 OTel)。
MRTR:服务端不再主动发请求
对 SDK 结构影响最大的一条(SEP-2322)。旧模型允许服务端在处理 tools/call 中途反向发 JSON-RPC 请求(roots/list、sampling/createMessage、elicitation/create),客户端答完再继续——这要求连接上存在双向的、有状态的请求关联,与无状态核心直接冲突。
// 服务端认为信息不足
{"jsonrpc":"2.0","id":1,"result":{
"resultType":"input_required",
"inputRequests":{
"github_login":{"method":"elicitation/create","params":{
"mode":"form","message":"Please provide your GitHub username",
"requestedSchema":{"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}}}},
"requestState":"AEAD-protected blob"}}
// 客户端收集完,用新的 request id 重发原请求
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{
"name":"create_issue","arguments":{"title":"bug"},
"inputResponses":{"github_login":{"action":"accept","content":{"name":"octocat"}}},
"requestState":"AEAD-protected blob"}}要点:
- 只有
prompts/get、resources/read、tools/call三个请求可以返回InputRequiredResult,其他请求返回它属于违规。 inputRequests的 key 由服务端分配、一次请求内唯一;value 必须是ElicitRequest/CreateMessageRequest/ListRootsRequest三者之一。- 服务端不得请求客户端没在
clientCapabilities里声明支持的能力。 requestState对客户端不透明,客户端不得解析或修改;首次结果里有就必须原样回传,没有就不得添加。requestState会经过客户端,属于攻击者可控输入。服务端若用它做授权或访问控制,必须做完整性保护(HMAC/AEAD)并拒绝校验失败的值;建议把「主体身份 + 短 TTL + 原请求标识摘要」打包进去防重放。
所有结果新增 resultType
resultType 现在是必填:普通结果 "complete",MRTR 中间结果 "input_required"。扩展可定义新取值,但必须在能力里宣告。对旧服务端的兼容要求是:客户端必须把缺少该字段的结果当作 "complete",认不出的取值视为非法。
这对 SDK 的影响是结构性的:以前「处理服务端请求」是独立 handler(onSampling、onElicit、onListRoots),现在必须变成「工具调用的重试循环」。运行时若把工具调用包在 for 循环里,改动大概 20 行;若它假设了长连接双向通信,那要重新设计。
tasks 移出核心,成为官方扩展
旧核心的 tasks/* 是试验性能力:tasks/result 阻塞等待,tasks/list 枚举。新规范把它整体搬进扩展 io.modelcontextprotocol/tasks(SEP-2663):阻塞的 tasks/result 改为 tasks/get 轮询;新增 tasks/update 让客户端把补充输入写回任务;移除 tasks/list;服务端可不经客户端预先 opt-in 直接返回任务句柄。
{"capabilities":{"tools":{},"extensions":{"io.modelcontextprotocol/tasks":{}}}}扩展协商规则:一方支持、另一方不支持时,支持方必须回退到核心行为或明确拒绝请求;扩展应文档化自己的 fallback 行为。
传输层:Streamable HTTP 的新约束
断线不可续传
Last-Event-ID 头和 SSE 事件 ID 一起被删:
- 响应流断了,正在进行的那次请求就丢了。
- 客户端 MUST 用新的 request ID 重发,不能假装「接着上次的流」。
- 好处是不用实现事件缓冲与去重;坏处是幂等性要客户端自己兜——重放一个已执行的非幂等工具,可能下两次单。
因此 notifications/progress 只告诉你「跑到哪了」,不再暗示「可以续」。凡是可能被重试的写操作,服务端应支持幂等键(放在工具参数里),而不是指望传输层去重。
强制请求头
| 头 | 来源 | 何时必需 |
|---|---|---|
MCP-Protocol-Version | _meta.protocolVersion | 所有 POST |
Mcp-Method | JSON-RPC method | 所有请求 |
Mcp-Name | params.name 或 params.uri | tools/call、resources/read、prompts/get |
设计意图是让网关、负载均衡、可观测系统不解析 body 就能路由与打点,例如按 Mcp-Name 做工具级限流。代价是「头与 body 不一致」成了新的错误来源,所以规范定义了 HeaderMismatch(-32020)与 400 的对应关系。工具参数还可以映射成自定义 HTTP 头(x-mcp-header),用于网关级路由(例如按 tenant_id 参数做租户隔离)。
状态码对应关系:未知方法 → 404 + -32601;版本不支持 → 400 + -32022;头与 body 不一致 → 400 + -32020;通知被接受 → 202 无 body。HTTP 上取消 = 关掉响应流,服务端必须当作该请求的取消;本版核心没有定义客户端到服务端的通知(notifications/cancelled 只在 STDIO 使用)。
缓存:ttlMs 与 cacheScope
新的 CacheableResult 给这些结果加了两个必填字段:tools/list、prompts/list、resources/list、resources/read、resources/templates/list,以及 server/discover。其中 ttlMs 是新鲜度提示(毫秒),允许客户端缓存、减少轮询;cacheScope 取 "public" 或 "private",决定共享中间层(CDN/网关)能否缓存。
配套一条 SHOULD:tools/list 应当返回确定性顺序。理由很实际——工具列表通常直接拼进系统提示,顺序稳定,上游 LLM 的 prompt cache 才能命中;顺序随机会让缓存前缀每次都变。
ttlMs 不要随手写一个巨大值:工具列表变了但客户端还用旧缓存,会出现「调用了不存在的工具」这类难查问题。listChanged 通知与 ttlMs 是互补关系,前者主动失效,后者兜底过期。
授权相关变化
| 变化 | 动作 |
|---|---|
授权响应带 iss(RFC 9207,SEP-2468) | 授权服务器 SHOULD 返回;客户端 MUST 在兑换 code 前校验 iss 与记录的 issuer 一致 |
DCR 必须指定 application_type(SEP-837) | 客户端注册时显式声明,避免 OIDC redirect URI 冲突 |
| 凭证按 issuer 绑定(SEP-2352) | 客户端 MUST 以 issuer 标识为 key 持久化凭证,MUST NOT 跨授权服务器复用,issuer 变更后必须重新注册 |
| DCR(RFC 7591)被弃用 | 转向 Client ID Metadata Documents。DCR 仍保留,用于兼容不支持 CIMD 的授权服务器 |
这几条指向同一个威胁模型:混流攻击(把 A 授权服务器的 code 拿去 B 兑换)。凭证不按 issuer 隔离,这个防护就不成立。
Schema 放宽
inputSchema/outputSchema允许使用 JSON Schema 2020-12 的任意关键字(SEP-2106),不再限制在原来的子集。structuredContent可以是任意 JSON 值,不再限定为 object。- 新增
$ref解析要求,以及组合关键字(allOf/anyOf/oneOf)的资源上限,防止构造解析爆炸的 schema。 - 未声明
$schema时默认按 2020-12 处理;实现必须至少支持 2020-12。
自己实现 schema 校验的客户端要注意:$ref 与组合关键字会带来递归与展开成本。规范要求支持它们,但同时给了资源上限——实现时要有深度/节点数保护。
错误码重编号
JSON-RPC 的 -32000 ~ -32099 过去被各实现随意占用,新规范把它切开:
| 区间 | 归属 |
|---|---|
-32000 ~ -32019 | 遗留(legacy)。新代码不得分配;除 -32002 外不得假设含义 |
-32020 ~ -32099 | 规范所有。实现只能发出规范定义的码 |
| 错误 | 旧码 | 新码 |
|---|---|---|
HeaderMismatch | -32001 | -32020 |
MissingRequiredClientCapability | -32003 | -32021 |
UnsupportedProtocolVersion | -32004 | -32022 |
另外两处:资源未找到从 -32002 改为 -32602(Invalid Params),与 JSON-RPC 对齐,客户端 SHOULD 仍接受旧服务端返回的 -32002;-32042(URL elicitation required,仅 2025-11-25 存在)不再发出。新业务错误码应分配在 JSON-RPC 保留区间之外,避免和规范抢位。
弃用清单与迁移建议
弃用 ≠ 删除。这些特性在弃用窗口内仍然可用,但新实现不该再支持(SEP-2577 / SEP-2596):
| 弃用项 | 状态 | 迁移方向 |
|---|---|---|
| Roots | 12 个月窗口 | 目录/文件通过工具参数、资源 URI 或服务端配置传入 |
| Sampling | 12 个月窗口 | 服务端直接集成模型厂商 API,不要借客户端当代理 |
| Logging | 12 个月窗口 | STDIO 写 stderr;HTTP 用 OpenTelemetry |
| HTTP+SSE 传输 | 自 2025-03-26 起弃用,现正式归类 | 迁移到 Streamable HTTP |
includeContext 的 thisServer / allServers | 自 2025-11-25 软弃用 | 省略该字段或使用 "none" |
| OAuth 2.0 DCR(RFC 7591) | 弃用 | Client ID Metadata Documents |
顺带删除的两个细节:notifications/elicitation/complete 通知,以及 URL 模式 elicitation 的 elicitationId 字段。MRTR 下客户端靠「重试原请求」获知带外交互的结果,服务端主动发的完成信号不再有位置;需要跨重试关联的,把标识编码进自己的 requestState。
Sampling 为什么被放弃:它的初衷是「让服务端借用客户端的模型额度」,但它要求双向长连接、要求工具调用中途停下来问用户,正好踩中无状态化的两条红线。给它 12 个月弃用窗口是给已有实现的缓冲,不是给新实现的选项。
迁移检查清单
客户端侧
| # | 动作 | 判据 |
|---|---|---|
| 1 | 去掉 initialize,改为每请求写 _meta(版本/能力/客户端信息) | 抓包看不到 initialize |
| 2 | 收到 -32022 时从 supported 里选共同版本重试 | 能用旧版本服务端跑通 |
| 3 | STDIO 上先发 server/discover 判定服务端年代,并缓存结论 | 新旧服务端都不会卡死 |
| 4 | 去掉 Mcp-Session-Id 存取;handle 当普通参数传递 | 请求头里没有 session id |
| 5 | onSampling/onElicit/onListRoots 改成 input_required 重试循环 | 收到 inputRequests 能正确回填 |
| 6 | resultType 解析:缺省视为 "complete",未知值报错 | 新旧服务端混跑无异常 |
| 7 | SSE 断流后以新 ID 重发;非幂等工具加幂等键 | 断流测试不产生重复副作用 |
| 8 | 按 ttlMs/cacheScope 缓存;订阅改用 subscriptions/listen 并校验 ack | 不再使用 GET 端点 |
服务端侧
| # | 动作 | 判据 |
|---|---|---|
| 1 | 实现 server/discover(MUST),返回版本、能力与 serverInfo | 新客户端首请求即可发现 |
| 2 | 校验每请求 _meta,缺失或版本不支持时返回 -32022 | 非法请求被确定性拒绝 |
| 3 | 移除连接级状态:列表与连接无关,跨调用状态改用签名 handle | 多实例轮询负载均衡下功能正常 |
| 4 | 错误码按新编号调整(-32020/-32021/-32022) | 错误码表与规范一致 |
| 5 | 删除 ping/logging/setLevel/roots/list_changed,日志读请求 _meta.logLevel | 无 logLevel 的请求不再收到 message |
| 6 | 实现 subscriptions/listen:先 ack、filter 取交集、通知带 subscriptionId | 客户端能正确分流多订阅 |
| 7 | 主动请求改为 InputRequiredResult;requestState 做完整性保护 + TTL + 归属校验 | 篡改 requestState 被拒 |
| 8 | tasks 迁到扩展(tasks/get 轮询 + tasks/update) | 不再依赖 tasks/result/tasks/list |
顺序建议就是上表:先做服务端双轨(同时支持现代 _meta 与 initialize),再做客户端切现代,最后删旧路径。反过来做,你会在旧客户端还没升级的那段时间里同时维护两套半成品。
本节小结
✅ 2026-07-28 是 MCP 第 5 个主要修订,核心是无状态:删除 initialize 握手与 Mcp-Session-Id,每个请求自带版本、能力、身份,结果带 serverInfo
✅ 版本协商变成逐请求判断,不匹配返回 UnsupportedProtocolVersionError(-32022);server/discover 是服务端 MUST,兼作 STDIO 年代探测
✅ 跨调用状态改为服务端签发的显式 handle,当普通工具参数传递(SEP-2567);列表接口结果与连接无关,因此可缓存
✅ subscriptions/listen 取代 GET 端点与 resources/subscribe,客户端按类型 opt-in,通知带 subscriptionId;progress/message 仍走请求自身响应流
✅ MRTR(SEP-2322)用 resultType: "input_required" + inputRequests/inputResponses + requestState 取代服务端主动请求,所有结果新增必填 resultType
✅ tasks 移出核心成为官方扩展;SSE 断线不可续传;强制 Mcp-Method/Mcp-Name 头;CacheableResult 引入 ttlMs/cacheScope
✅ Roots / Sampling / Logging 进入 12 个月弃用窗口,HTTP+SSE 与 DCR 也被弃用——按迁移检查清单逐项推进,别按旧流程实现
延伸阅读:MCP 协议基础 | 官方 2026-07-28 changelog
