Skip to content
Published on

MCP 甩掉会话 — 读懂 2026-07-28 修订版的无状态核心

分享
Authors

引言 — 先说清楚这篇文章的保质期

今天是 2026 年 7 月 16 日。而 MCP 的当前(Current)规范仍然是 2025-11-25官方版本控制文档是这么说的。

接下来要谈的 2026-07-28 修订版仍处于草案阶段,已锁定为发布候选版(RC)。根据RC 公告,RC 于 2026 年 5 月 21 日锁定,最终规范计划于 2026 年 7 月 28 日发布 — 也就是写这篇文章的时候起算的 12 天后。

所以这篇文章不是「新规范解读」,而是「预读 RC」。细节还可能变,一旦真的变了,本文的部分内容就会出错。请带着这个前提来读。MCP 本身是什么、工具/资源/提示长什么样,MCP 参考手册一文已经整理过了,这里只聚焦发生了什么变化

问题出在哪里 — 会话在跟负载均衡器打架

Streamable HTTP 打开远程部署的大门之后,规模上的问题暴露了出来。2026 路线图把这一点写得很直白 — 有状态的会话会跟负载均衡器打架,水平扩展需要绕道而行,而注册表或爬虫没有一种标准方式,能在不连接服务器的情况下弄清楚这个服务器能做什么。

具体来说是这么回事。在现有设计里,客户端先做一次 initialize 握手,服务器签发一个 Mcp-Session-Id,此后所有请求都绑定在这个会话上。运维上就得跟着承受这些:

  • 粘性会话(sticky session)。 同一会话的请求必须落到同一实例,轮询式负载均衡就用不了。
  • 共享会话存储。 要跑多个实例,就得在某处共享会话状态。
  • 网关的报文检查。 想知道这个请求是 tools/call 还是 tools/list,就得把 JSON-RPC 报文体拆开看 — 在 HTTP 层它们全是一模一样的 POST。

RC 公告用一句话总结了这次变化的收益 — 以前需要粘性会话、共享会话存储、网关深度报文检查的远程 MCP 服务器,现在可以跑在一个普通的轮询式负载均衡器后面了。

路线图里也附了一句诚实的说明 — 这个周期不会新增官方传输方式(transport),只是演进现有的传输方式。这不是叠加一套新架构,而是对 HTTP 传输本身动手术。

无状态核心 — 握手和会话消失了

官方 changelog列出的主要变更(major changes),开头几条讲的都是这件事。

SEP-2575:移除 initializenotifications/initialized 握手。取而代之的是,每个请求都在 _meta 里携带自己的协议版本和客户端能力 — io.modelcontextprotocol/protocolVersionio.modelcontextprotocol/clientCapabilities。建议客户端在每次请求中都表明自己的身份(io.modelcontextprotocol/clientInfo),服务器也在每个结果的 _meta 中表明自己的身份。版本对不上就返回 UnsupportedProtocolVersionError

SEP-2567:移除 Mcp-Session-Id 头,以及协议层本身的会话概念。因此列表类端点(tools/listresources/listprompts/list)不再因连接不同而不同。那需要跨调用保持状态的服务器该怎么办呢 — 把服务器签发的显式句柄当作普通的工具参数来回传递。这就是这里的核心哲学。不是禁止状态,而是把状态从协议的隐式层,下放到应用层的显式数据里。

新增 server/discover:服务器必须实现这个 RPC(MUST)。用来广播自己支持哪些协议版本、能力和身份。客户端可以在发起其他请求之前先调用它来预先选定版本,在 stdio 上也可以把它当作向下兼容的探测手段。路线图所说的「不连接就能知道服务器能做什么」的答案就在这里。

被移除的东西pinglogging/setLevelnotifications/roots/list_changed 消失了。日志级别现在按请求通过 _meta 里的 io.modelcontextprotocol/logLevel 来指定,对于没有这个字段的请求,服务器不得发送 notifications/message(MUST NOT)。

订阅方式被替换:HTTP GET 端点和 resources/subscribe / resources/unsubscribe 被单一的 subscriptions/listen 取代 — 这是一条用于服务器到客户端变更通知的、单一的长连接 POST 响应流。客户端选择自己想要的种类(toolsListChangedpromptsListChangedresourcesListChangedresourceSubscriptions)来选择加入。有一处区分值得注意 — 像 notifications/progressnotifications/message 这类请求作用域的通知,依然流经该请求自身的响应流,而不会流入 subscriptions/listen 流。

而这里才是真正的代价

同一个 SEP-2575,还从 Streamable HTTP 中移除了 SSE 流的断线重连(resumability)和消息重发Last-Event-ID 头和 SSE 事件 ID 都没有了。changelog 的措辞很平淡:响应流一旦断开,进行中的请求就丢了,客户端必须用新的请求 ID 重新发送一个新请求(MUST)。

这不是纯粹的收益,而是一笔交换。断线重连原本是让一次耗时较长的工具调用即便遭遇一次网络中断,也能续上继续接收的机制。它没了,就意味着长耗时工作的可靠性,如今得从别处 — 也就是后文会讲到的 Tasks 扩展 — 而不是协议核心本身去获取。

MRTR — 服务器反问的方式被彻底翻转

个人认为,这是本次修订里最深层的一处变化。

到目前为止,服务器可以在处理过程中反问客户端 — 用 roots/list 询问目录,用 sampling/createMessage 借用 LLM,或者用 elicitation/create 收集用户输入。这个前提是,服务器在挂起请求的同时,存在一条活着的双向通道。也就是说,本质上是有状态的。

MRTR(Multi Round-Trip Requests)模式文档取代了这一切。而且说得非常明确 — 服务器必须用 MRTR 模式来发送从服务器到客户端的请求(MUST),此前那种由服务器发起请求的模式不再受支持,这是一次破坏性变更(breaking change)。

新的流程是这样的:

1. 客户端 -> 服务器 :  tools/call (id: 1, 参数)
2. 服务器 -> 客户端 :  InputRequiredResult (id: 1)
                     resultType: "input_required"
                     inputRequests: { "github_login": ElicitRequest, ... }
                     requestState: "<只有服务器能解读的不透明数据块>"
   -> 到这里,最初的请求就结束了。服务器什么都不持有。

3. 客户端:向用户询问并收集输入

4. 客户端 -> 服务器 :  tools/call (id: 2, 原始参数
                                + inputResponses + requestState)
   -> 必须使用新的 JSON-RPC id,因为它是一个独立的请求。

5. 服务器 -> 客户端 :  Result (id: 2, resultType: "complete")

服务器反问时返回的 InputRequiredResult,大致是这样的形状:

{
  "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"
  }
}

文档着重强调的一句话,正是这套设计的要点 — 每一步的请求都是完全独立的,处理重试的服务器,除了那次重试请求本身直接携带的信息之外,不需要任何别的信息。这样一来,实例之间的共享存储、基于状态的负载均衡,统统都不再需要。

一些细节规则:

  • 每个结果里的 resultType 字段变成必填项 — 通常结果是 "complete",MRTR 的中间结果是 "input_required"。如果一个旧协议的服务器漏掉了这个字段,客户端必须把它当作 "complete" 来处理(MUST)。
  • 服务器只能对三种请求返回 InputRequiredResultprompts/getresources/readtools/call。除此之外一律不得发送(MUST NOT)。
  • 客户端没有以能力形式声明过的那种 inputRequests,服务器不得发送。客户端没说自己支持 elicitation,服务器就不能塞进一个 elicitation/create
  • 服务器不得假设客户端一定会重试(MUST NOT)。客户端完全可能就此不再回来。

状态没有消失,只是搬家了

这里得说实话。MRTR 并没有消灭服务器的状态,而是把它塞进一个叫 requestState 的不透明数据块里,让它穿过不可信的客户端往返一趟再拿回来。而规范也没有回避这个后果。

如果客户端的请求里带着 requestState,服务器必须把它当作攻击者可控的输入来对待(MUST)。如果这个值会影响授权、资源访问或业务逻辑,服务器就必须保护它的完整性(比如用 HMAC 或 AEAD),并拒绝任何验证失败的状态。只有当篡改除了导致该请求本身失败之外不会造成任何其他危害时,才可以省略完整性保护。

为了抵御重放攻击,建议服务器在受完整性保护的负载内部嵌入经过认证的主体(principal)、较短的有效期(TTL)、以及原始请求的标识符(比如方法名和关键参数的摘要),并且每次都做校验(SHOULD)。照搬文档自己的警告 — 这些措施能缩小重放窗口、阻止跨用户和跨请求的复用,但它们本身并不能保证「只使用一次」(single-use)。对于那些必须严格只被消费一次的场景 — 比如一次性兑换 — 服务器自己必须强制这条不变量(MUST)。

归纳一下,对服务器作者来说,这是一份新的作业。以前只要把会话对象放在内存里就够了,现在变成了要把密码学做对。一旦不带签名就把用户 ID 塞进 requestState,那就是一个授权绕过漏洞。无状态不是免费的,这份成本的很大一部分就在这里。

还有一点 — 重试从字面意义上说就是原始请求的重新执行。如果服务器在反问之前做过的工作代价高昂,那份工作要么在重试时再发生一遍(成本),要么就得搭载进 requestState 里(体积和复杂度)。

对运维者来说实际会变的东西 — 请求头、缓存、追踪

不像无状态核心那样引人注目,但对运维网关的人来说,这一部分可能感受更直接。

标准请求头(SEP-2243)。Streamable HTTP 的 POST 请求上,Mcp-MethodMcp-Name 请求头变成必填项。目的是让负载均衡器、网关、限流器不用拆开报文体,就能在 HTTP 层做路由和限流。同时一并加入的还有 x-mcp-header 支持,用来通过工具参数传递自定义请求头。

缓存(SEP-2549)。tools/listprompts/listresources/listresources/readresources/templates/list 的结果新增了 ttlMscacheScope,作为一个新的、必填CacheableResult 接口。ttlMs 是以毫秒为单位的新鲜度提示,让客户端可以缓存、减少轮询;cacheScope"public""private",控制共享中间层是否可以缓存它。这是照着 HTTP 的 Cache-Control 建模的,而且它不是替代既有的 listChanged 通知,而是对其做补充。

值得留意的是,会话消失、列表不再因连接不同而不同这件事,和这个缓存字段是配套的。列表足够稳定,缓存才有意义。同样的思路下,tools/list 被建议(SHOULD)以确定性的顺序返回工具,而理由挺有意思 — 不只是为了客户端缓存,也是为了提高 LLM 提示词缓存的命中率。这正是协议设计开始顾及 LLM 推理成本本身的地方。

追踪(SEP-414)。在 _meta 中传播 OpenTelemetry 追踪上下文的约定被正式写入文档 — traceparenttracestatebaggage 这些键名被固定下来,让分布式追踪能够跨 SDK 和网关关联起来。

错误码。 资源不存在的错误,从 MCP 自有的 -32002 改成了 JSON-RPC 标准的 -32602(Invalid Params)。如果你的客户端代码里有按字面值匹配 -32002 的逻辑,需要修一下。此外还建立了一套错误码分配策略 — -32000-32019 继续留给具体实现自行定义(既有 SDK 的用法予以保留),-32020-32099 由规范保留。这份草案里之前引入的错误码,也相应重新编号 — HeaderMismatch-32001 变成 -32020MissingRequiredClientCapability-32003 变成 -32021UnsupportedProtocolVersion-32004 变成 -32022

认证 — 进一步向 OAuth/OIDC 靠拢

多个 SEP 一点一点收紧了认证这一块。

  • SEP-2468:建议(SHOULD)授权服务器按照 RFC 9207 在授权响应中包含 iss 参数,如果存在 iss,MCP 客户端必须在兑换授权码之前将它与记录下来的发行方进行比对校验(MUST)。这是为了缓解授权服务器混淆攻击。
  • SEP-837:客户端在做动态客户端注册(DCR)时必须指定合适的 application_type — 是为了避免 OpenID Connect 重定向 URI 冲突。
  • SEP-2352:客户端凭据被绑定到签发它的那个授权服务器上。客户端必须按发行方标识符对存储的凭据进行键控(MUST),不得在另一个授权服务器上复用它(MUST NOT),授权服务器变了就必须重新注册(MUST)。
  • 而且DCR(RFC 7591)本身也被标记为弃用 — Client ID Metadata Documents 成为首选的注册机制。不过为了兼容那些尚不支持这套机制的授权服务器,DCR 仍然可以继续使用。

正在消失的东西 — Roots、Sampling、Logging

SEP-2577 把 Roots、Sampling、Logging 这三个功能标记为弃用。提议的迁移路径是这样的:

功能替代方案
Roots通过工具参数、资源 URI 或服务器配置传递目录/文件
Sampling直接与 LLM 提供商的 API 集成
Loggingstdio 场景下用 stderr,结构化可观测性用 OpenTelemetry

重要的是,现在什么都不会坏。按 RC 公告的措辞,相关的方法、类型和能力标志,在本次发布中会继续可用,并且在本次发布之后一年内发布的每一个规范版本中也都会继续可用。这一次只是先打上一个注释。

而这之所以能够实现,源于一处安静、但或许比无状态核心本身更重要的变化 — SEP-2596 引入了功能生命周期与弃用政策。一个功能会处于 Active、Deprecated、Removed 三种状态之一,弃用和移除之间保证有至少 12 个月的窗口期。这个窗口期不是从 SEP 变成 Final 那天起算,而是从那个把该功能标记为 Deprecated 的规范修订版发布之日起算。例外只有一种 — 只有当存在已发布的安全公告,或者有文档记录的真实被利用情况、且没有替代缓解措施的活跃安全风险时,才可以缩短这个窗口,即便如此也至少要给 90 天

配套的还有一份把所有已弃用功能汇总在一处的登记表,让人不必翻遍散落在各个修订版 changelog 里的条目,去拼凑出「什么东西什么时候没的」。Tier 1 的 SDK 有义务用各自语言习惯的方式(@Deprecated[Obsolete]@deprecated JSDoc 等)标记出相应的 API 表面。

在同一套政策下,HTTP+SSE 传输(自 2025-03-26 起就处于弃用状态)以及 includeContext"thisServer" / "allServers" 取值,也被正式重新归类为 Deprecated。

说实话,我认为这次治理层面的变化,会比无状态核心本身在这个生态里留存得更久。「能不能在这个协议之上构建产品」这个问题的答案,第一次有了一个明确的日期。

扩展 — Tasks 离开核心规范

SEP-2133 把扩展(extension)变成了一等概念。每个扩展都有一个反向 DNS 标识符,通过能力里的 extensions 映射进行协商,独立于规范本身进行版本管理,并拥有自己的仓库和被委托的维护者。

在这套框架之上,出现了两个官方扩展。

  • MCP Apps(SEP-1865):在沙箱化的 iframe 里,由服务器渲染 HTML 界面。工具声明 UI 模板,UI 里发生的操作会走和直接调用相同的 JSON-RPC 路径。
  • Tasks(SEP-2663):面向耗时较长的工作。这原本是 2025-11-25 里一个实验性的核心功能,照搬 RC 公告的原话 — 生产环境的使用暴露出足够多需要重新设计的地方,规范之外的扩展,才是它真正该待的地方。新设计把阻塞式的 tasks/result 换成了 tasks/get 轮询,新增了用于客户端到服务器输入的 tasks/update,移除了 tasks/list,并且让服务器可以在不需要逐请求选择加入的情况下,预先返回一个任务句柄。

前面说到的 SSE 断线重连消失,在这里又接上了。长耗时工作的可靠性,如今该用任务句柄加轮询这条正路来解决,而不是靠流的断线重连。还有一点提醒 — 已经按照实验性的 2025-11-25 Tasks API 上线的人,需要迁移到新的生命周期上去。

工具 schema 也变得更宽松了。SEP-2106 允许 inputSchemaoutputSchema 使用完整的 JSON Schema 2020-12 关键字集合(oneOfanyOfallOf、条件语句、$ref$defs),并且让 structuredContent 可以接受任意 JSON 值。不过实现方不得自动解引用外部的 $ref URI,并且必须对深度和校验时间设上限 — schema 的表达力越强,随之打开的攻击面也就越大。

现在该做什么 — 以及不必着急做什么

2026 年 6 月 29 日,测试版(beta)SDK 发布了。四个 Tier 1 SDK 全部到齐。

Python      mcp==2.0.0b1                       (v2, 需要精确锁定版本号 — 不锁就会停在 1.x)
TypeScript  @modelcontextprotocol/server@beta  (v2, 服务器/客户端包已拆分
            @modelcontextprotocol/client@beta   仅支持 ESM, Node.js 20+ / Bun / Deno)
Go          v1.7.0-pre.1                       (模块路径与 stable 版本相同)
C#          2.0.0-preview.1                    (dotnet add package ... --prerelease)

照搬公告自己附上的提醒 — 对于重要的工作负载,仍然推荐使用 stable 版 SDK,公开 API 在 beta 和 stable 之间仍然可能发生变化。意思是测试期间要锁定精确的版本号。

关于向下兼容,说得很明确 — 现有服务器会继续正常工作,用公告自己的话说,今天不会有任何东西坏掉,7 月 28 日那天也不会有任何东西坏掉。遇到旧版服务器时,新客户端会自动回退到旧版握手方式。

现在值得做的事

  • 在代码库里搜一下有没有按字面值匹配 -32002 的地方。这是少数几个可能悄悄弄坏东西的点之一。
  • 如果你曾经接入过实验性的 2025-11-25 Tasks API,制定一下迁移计划。这是已经确定要做的作业。
  • 如果你在运维远程服务器,算一算粘性会话、共享会话存储现在到底让你付出了多大成本。这就是衡量这次修订对你到底值不值的那把尺子。
  • 用 beta SDK 在测试环境里戳一戳。不要在生产环境。

不必急着做的事

这是最重要的一部分。如果你只在 stdio 上跑本地服务器,这次修订的大部分内容都跟你没什么关系。 无状态核心从头到尾讲的都是远程、水平扩展这件事。对于一个进程只对接一个客户端的 stdio 服务器来说,粘性会话从一开始就不是问题,也不存在什么轮询式负载均衡器。对这种服务器来说,换来的不是收益,而是纯粹的迁移成本 — 已经弃用的 Roots、Sampling、Logging 总有一天得清理掉,反问逻辑得按 MRTR 重写一遍,而换来的回报并不多。

12 个月的弃用窗口期,正是为这样的人准备的。没有理由着急的话,就不必着急。

结语

归纳一下就是这样。2026-07-28 是 MCP 在协议层放弃状态的一次修订。握手和 Mcp-Session-Id 消失了,每个请求都通过 _meta 自我说明,server/discover 打开了不用连接就能发现服务器信息的通道,标准请求头和缓存提示给了网关一套 HTTP 风格的把手。目标只有一个 — 让任意实例都能处理任意请求。

代价同样清楚。服务器反问的方式被 MRTR 翻转成了一次破坏性变更;状态没有消失,而是被塞进 requestState 数据块里,往返穿过不可信的客户端,让这个数据块安全的密码学工作,如今成了服务器作者自己的责任。SSE 断线重连消失后留下的空缺,得靠 Tasks 扩展来填补。Roots、Sampling、Logging 都进入了倒计时。

而截至今天,这一切都仍然只是 RC。当前规范是 2025-11-25,最终版本将在 12 天后发布。等规范定稿之后,该重新去读的文档不是这篇博客,而是官方 changelog — 本文的目的,仅仅是告诉你去读那份文档时该留意些什么。

参考资料