SEP-2567:通过显式状态句柄实现无会话 MCP

SEP-2567:通过显式状态句柄实现无会话 MCP

社区译文;来源: MCP 29affa6b

本 SEP 已达到 Final 状态,并作为已接受设计的历史记录保留。 定稿后对协议所做的更改不会反映在此处。权威要求请参阅当前规范 及其变更日志。
字段值
SEP2567
标题通过显式状态句柄实现无会话 MCP
状态Final
类型标准跟踪
创建日期2026-03-11
作者Peter Alexander (@pja-ant)
发起人Peter Alexander (@pja-ant)
PR#2567

摘要

本提案从 MCP 中移除协议层的 session 概念,用服务器签发的显式状态句柄取代隐式的 session 作用域状态,由模型携带这些句柄并在后续调用中持续传递。SEP-2575 移除 initialize 握手,改为在每个请求中携带协议版本和能力;本提案是与之互补的变更,用于移除 session 和 Mcp-Session-Id header。两者共同使 MCP 在协议层成为无状态协议。

Session 进入规范一年多以来,各客户端仍未就其含义达成一致:有些客户端为每次工具调用划定 session,有些为每次应用启动划定,有些为每次页面加载划定,而且几乎没有客户端会恢复 session。服务器作者无法预测自己的服务器连接到任意客户端后,session 会具有什么作用域或生命周期,这使 session 无法可靠承载应用状态。本提案主张应用状态可以通过显式标识符提供,而 session 抽象只增加了约束(固定基数、未定义的生命周期、跨 session 边界无法缓存的列表端点),却没有带来相应收益。

按照本提案,当前将购物篮等状态限定在 session 内的服务器应改为公开一个返回 basket_id 的 create_basket() 工具,并在后续工具调用中传递该 ID,例如 add_item(basket_id, ...)。模型决定哪些状态共享、哪些状态隔离;列表端点可以跨越原有 session 边界进行缓存;Agent 编排器也可以按需自由共享或隔离应用状态。显式状态句柄不是新的协议构造,不存在对应 schema 或 wire format。它是一种工具设计模式;协议变化是移除 session,而这使句柄成为表达跨调用状态的方式。

动机

当前由 Session 划定作用域的内容

当前规范没有精确定义哪些行为受 session 约束,但实践中有五类内容附着于 session 的生命周期:

  1. 协商得到的能力和协议版本。 initialize 的结果,包括使用哪个协议版本以及双方支持哪些可选能力,会在每个 session 建立一次,并假定在整个 session 期间有效。SEP-2575 通过移除 initialize 并在每个请求中携带版本和能力信息来解决此问题,因此本提案将其视为已处理。

  2. Elicitation 和 sampling 的中间状态。 当工具调用触发一次 elicitation/create 或 sampling/createMessage 往返时,服务器必须把最终响应与原始进行中的工具调用关联起来;这些状态目前隐式存在于 session 中。SEP-2322(Multi Round-Trip Requests)通过在请求/响应周期内显式携带关联状态来解决此问题,因此本提案将其视为已处理。

  3. 应用状态。 典型示例是购物篮:add_item()、add_item()、checkout(),购物篮隐式存在于每个 session 中。该问题可推广到任何有状态工作流,例如 Playwright 浏览器实例、数据库事务或打开的文件描述符。

  4. 可变列表端点。 tools/list(以及 resources/list、prompts/list)可以合法地在 session 生命周期内返回不同结果。例如,数据库服务器可以公开 connect_database 工具;调用后,query 和 list_tables 会出现在后续 tools/list 结果中。

  5. 资源订阅。 订阅生命周期与 session 生命周期绑定。(SEP-2575 引入 messages/listen 作为服务器向客户端发送通知的通道;该模型下的订阅生命周期不在本文中重新审视。)

由于 (1)、(2) 和 (5) 已由其他 SEP 处理,本提案处理 (3) 和 (4)。

Session 作用域的问题

无论 session 是强制要求(当前规范),还是改为可选,以下问题都存在。

Session 生命周期未定义,服务器无法据此设计

规范没有说明 session 何时开始或结束,因为这取决于宿主应用。实践中,已部署客户端的行为差异很大,而且很少有客户端按对话划定 session:ChatGPT 会为每一次工具调用创建新 session,Claude.ai 直到最近也是如此;1 大多数桌面和 IDE 客户端在应用启动时创建一个 session,并在进程生命周期内持续使用;Web 客户端通常在每次页面加载时创建一个 session。几乎没有客户端会在断开连接或重启后恢复先前的 session;在服务器端,参考 TypeScript SDK 也没有提供在不同节点上重建 session 的公共 API,因此即使客户端尝试恢复,多节点部署也无法实现。2 子 Agent 可能与父 Agent 共享 session,也可能拥有自己的 session,目前没有统一约定。

这一点很重要,因为由服务器作者决定哪些内容受 session 约束,而他们必须知道 session 对应什么,才能作出正确设计。将浏览器实例与 session 绑定的 Playwright 服务器需要知道 session 表示一个用户轮次、一个 Agent 进程,还是一次长期对话。规范没有定义这一点,不同宿主给出的答案也不同,因此服务器是在针对一个其语义不受自身控制的抽象进行设计。

实际后果是,受 session 约束的应用状态经常无法保留。面对每次工具调用都新建 session 的客户端,这些状态会在下一次调用前被销毁;面对每次应用启动创建 session 的客户端,这些状态会在窗口中的所有对话之间共享,并在重启时丢失;面对任何不恢复 session 的客户端,应用重启后状态都会消失。看似成功使用 session 状态的服务器通常是依赖进程生命周期的 stdio 服务器,而进程生命周期是传输的属性,不是协议的属性。

列表端点无法跨 Session 缓存

由于 tools/list 可能依赖 session,客户端不能假定在一个 session 中取得的结果在下一个 session 中仍然有效。即使服务器的工具集在构建时已经固定且永不变化(这是常见情况),每个新 session 也必须重新获取列表。

Python SDK 自己关于客户端列表缓存的设计 issue 将“缓存键是什么——按 session 还是按服务器 URL?”列为开放问题;3 网关实现者也专门实现了按 session 缓存,因为他们无法从规范中假定缓存跨 session 有效。4 每个列表端点都必须被视为可能受 session 约束,因此为保证安全,每个 session 都必须重新获取这些列表。

对于经常生成子 Agent 的宿主,这是热路径上的倍增器。服务器可能受 session 约束这一点,会强制执行 O(子 Agent 数量 × 服务器数量) 次 tools/list 调用:每个子 Agent、每台服务器、每一次都必须调用,即使底层工具集自编排器首次连接后从未变化。客户端无法跳过该调用,因为它事先不知道哪些服务器受 session 约束。对于生成许多短生命周期子 Agent 的编排器,这些开销可能超过实际工具调用产生的协议流量。按照本提案,同一工作负载变为 O(服务器数量):编排器获取每个列表一次,每个子 Agent 复用缓存结果。

如果列表端点仅取决于服务器部署和已认证主体,客户端就能缓存它们,并在收到显式信号时失效缓存。SEP-2549 规定了这种信号(服务器声明的 TTL 加 notifications/*/list_changed),但只有列表不会再因 session 而变化时,该缓存模型才可靠。移除 session 可使该模型安全;子 Agent 随后能够零成本继承父 Agent 缓存的列表。

每个 Session 的基数固定为一

Session 状态在每个 session 内的基数恰好为一。模型只能获得一个购物篮、一个浏览器,以及服务器限定在 session 内的任何其他对象各一个;不能有两个,也不能没有。

当不同状态需要不同作用域时,这会产生问题。假设一个编排器生成多个子 Agent,分别研究要购买的产品。子 Agent 应向同一个购物篮添加商品(它们正在协作处理一份订单),但每个子 Agent 都需要独立的浏览器状态(它们并行浏览不同网站)。

没有任何 session 边界能够同时满足这两项要求:

Session 模型购物篮(要求:共享)浏览器(要求:隔离)
子 Agent 共享父 Agent 的 session✓ 共享✗ 共享(互相覆盖)
子 Agent 各自使用独立 session✗ 隔离✓ 隔离

使用显式 ID 时,编排器只需调用一次 create_basket(),将返回的 basket_id 传给每个子 Agent;每个子 Agent 再分别调用 create_browser(),获得自己的 browser_id。模型可以针对每份状态决定共享或隔离,而不是让所有状态都受到同一个作用域的约束。

缺少标识符还意味着,无法从创建 session 以外的位置寻址 session 状态。在一个聊天中创建的购物篮对另一个聊天不可见;如果用户想在新对话中继续工作、把某项内容移交给另一个 Agent,或与同事共享状态,session 模型没有可供引用的对象。显式 basket_id 可以传递给其中任何一方。

规范

变更摘要

  1. 从协议中移除 session 概念。 移除 Mcp-Session-Id header,并删除描述 session 生命周期和 session 作用域行为的规范文本。协议在每一层都不再使用 session。(SEP-2575 移除 initialize 握手,但明确将 session 移除工作留给本提案。)

  2. 列表端点与 session 无关。 不再存在 session 后,tools/list、resources/list 和 prompts/list 的结果不再依赖每个 session 或每个连接的作用域。列表仍可能由于其他原因变化(服务器部署、认证变化);这些情况的缓存和失效机制由 SEP-2549 单独规定。

  3. 有状态工作流使用显式句柄。 移除 session 后,需要跨工具调用维护状态的服务器应通过创建工具返回标识符,并在后续调用中将其作为参数接收。

第三点不是协议变化。不存在 handles/* 方法、schema 中不存在 handle 类型、wire 层也完全没有 handle 概念。从协议角度看,handle 是工具结果中的字符串,也是工具参数中的字符串,与其他工具数据没有区别。“显式状态句柄”是规范记录并推荐的一种工具设计模式,如同规范可能记录分页或错误消息约定,而不是协议实现的功能。本 SEP 的规范性内容是 (1) 中的移除操作;(2) 是其必然结果;(3) 是填补空缺的指导。

显式状态句柄

模式

服务器原先依赖隐式 session 状态时,例如让 add_item 调用操作每个 session 的购物篮,现在应改为公开显式创建工具并返回句柄:

// → tools/call
{ "name": "create_basket", "arguments": {} }

// ← result
{ "content": [{ "type": "text", "text": "Created basket bsk_a1b2c3" }],
  "structuredContent": { "basket_id": "bsk_a1b2c3" } }

随后,模型将该句柄作为普通参数传入后续调用:

// → tools/call
{ "name": "add_item",
  "arguments": { "basket_id": "bsk_a1b2c3", "sku": "shoes" } }

// ← result
{ "content": [{ "type": "text", "text": "Added shoes to bsk_a1b2c3 (1 item)" }] }

// → tools/call
{ "name": "checkout",
  "arguments": { "basket_id": "bsk_a1b2c3" } }

这里没有任何协议扩展:basket_id 是 structuredContent 中的普通字符串字段,也是后续工具的普通字符串参数。该模式已成为广泛部署的远程 MCP 服务器管理持久资源时的常见做法:

服务器(官方、远程)创建工具 → 返回的 ID使用该 ID 的操作工具
Linearcreate_issue → issue idget_issue、update_issue、create_comment
Notionnotion-create-pages → page idnotion-update-page、notion-move-pages
GitHubcreate_pull_request → PR numberpull_request_read、update_pull_request、merge_pull_request
Stripecreate_customer → customer idcreate_invoice、list_subscriptions

该方式也可用于持久性较低的对象(浏览器上下文、尚未完成的购物篮),方法是为创建的对象设置有限生命周期,以及/或者将其可发现范围限制为创建它的主体。服务器持有状态,客户端持有其名称,并且每次调用都会检查授权。

服务器指导

以下内容均不属于规范性要求。Handle 是工具设计模式,不是协议功能;服务器可以按最适合其领域的方式设计。满足以下条件时,该模式效果最佳:

  • Handle 应不透明。 编码内部结构的 handle(cart_user42_2026-03-11)会诱使客户端解析它或模型猜测它;bsk_a1b2c3 这样的不透明 handle 不会。
  • 持有 handle 不代表获得授权(存在认证时)。 对于经过认证的服务器,每次调用都要验证 (handle, auth_context);handle 会出现在聊天日志、复制粘贴缓冲区和子 Agent 提示中。对于未经认证的服务器,handle 必然是 bearer token,应使用至少 128 位加密安全熵生成,并限制其生命周期。参见安全影响。
  • 在工具描述中记录持久性。 Handle 按设计会比连接存活更久,因此不再适用“状态一直保留到连接关闭”的说法。应将策略写入 create_* 工具描述,例如“返回 basket_id;购物篮空闲 24 小时后过期”,让模型决定创建状态时能够看到。只存在于服务器文档中的策略对模型不可见。
  • Handle 过期时返回有用的错误。 工具收到指向已过期或已销毁状态的 handle 时,错误应明确说明,例如“basket bsk_a1b2c3 has expired”,而不是“invalid argument”。清晰的过期错误可让模型通过再次调用 create_* 恢复;不透明错误通常只会导致重试或失败。
  • 创建操作应接收参数。 create_context(cluster="staging") 优于先调用 create_context() 再调用 set_cluster(ctx, "staging"):前者只需一次往返,并且状态不会处于配置了一半的状态。
  • 应提供清理能力。 destroy_*(handle) 工具允许模型释放资源。list_*() 工具允许模型在忘记自己创建过哪些对象后恢复。这两者都不是必需项。

客户端指导

从客户端角度看,handle 是工具结果中的普通字符串。客户端的主要责任是确保该字符串能在上下文压缩后保留;如果对话经过摘要,而 handle 位于被丢弃的部分,状态就会成为孤儿。跨压缩边界跟踪工具调用结果的客户端已经能够处理这一问题。

与 Session 无关的列表端点

移除 session 后,列表端点不再具有可供变化的 session 维度。这是本 SEP 对 tools/list、resources/list 和 prompts/list 施加的唯一约束:结果不再具有可依赖的每 session 或每连接作用域。这并不禁止列表随请求中携带的授权而变化:凭据随每个请求携带,因此服务器向不同主体或 scope 返回不同工具集,依赖的是每个请求的输入,而不是连接状态。列表仍可能随时间因其他原因变化,例如服务器部署新版本、用户方案或已授予 scope 发生变化;本 SEP 不枚举或限制这些原因。

客户端如何得知缓存列表已经过期属于 SEP-2549 的主题;该 SEP 定义了列表响应中由服务器声明的 TTL,以及它与 notifications/*/list_changed 的交互。这两个 SEP 相互补充:本提案消除 session 这一变化来源,从而提供稳定的缓存对象;SEP-2549 规定缓存时长和失效时机。

上述约束的一个结果是,服务器不能再把其他请求的副作用用于改变列表结果;动机中的模式,即调用 connect_database() 后让 query 和 list_tables 出现在后续 tools/list 结果中,将不再允许。要实现相同效果,服务器应在列出工具时无条件公开 query 和 list_tables,并让它们接收 connect_database() 返回的 connection_id 参数。未提供有效 connection_id 的 query 调用应失败,并在错误中引导模型先调用 connect_database();这种依赖关系通过工具的 input schema 和描述表达,而不是通过列表结果表达。

后续规范编辑

除了移除 §Session Management 一节本身,当前规范中还有若干位置根据 session 作用域定义行为,需要重新划定作用域:

  • JSON-RPC 请求 ID 唯一性。 当前规范要求请求 id“MUST NOT have been previously used by the requestor within the same session”。id 的用途是让发送方把收到的响应与产生该响应的请求相关联;接收方只会回显它。移除 session 后,该约束相应调整为:发送方 MUST NOT 发出与另一条已发送但尚未收到响应的请求具有相同 id 的请求。该规则与传输无关,足以在所有传输下进行关联,也正是 TypeScript 和 Python SDK 已经通过每个客户端对象内单调递增计数器实现的行为。(JSON-RPC 2.0 §4 本身不施加唯一性要求,只要求接收方回显 id,因此这仍是 MCP 层约束。)
  • SSE event ID 唯一性。 当前规范将 SSE event ID 的作用域定义为“在该 session 的所有 stream 中全局唯一”。移除 session 后,该约束简化为 ID 在服务器管理的所有 stream 中全局唯一,使 Last-Event-ID 能够解析到唯一 stream。现有关于 event ID 编码来源 stream 的指导已经隐含这一点。
  • 分页 cursor 有效性。 当前规范建议客户端不要“跨 session 持久化 cursor”。移除 session 后,该建议消失。Cursor 稳定性和快照一致性不属于本提案范围。
  • 列表端点可变性。 tools/list、resources/list 和 prompts/list 页面都写明结果“MAY change over the lifetime of the connection”。这些表述将根据§与 Session 无关的列表端点重新划定作用域:结果 MAY 随时间变化,但 MUST NOT 因连接而异,也不能因同一连接上的其他请求产生副作用而变化。
  • 措辞。 架构概览中的“stateful session protocol”、能力协商中的“available during the session”、授权中的“same logical session”、elicitation 中禁止将状态“with session IDs alone”关联起来的表述,以及少量类似措辞,将被重写或移除。除了移除 session 本身之外,这些调整不带来语义变化。

理由

为什么移除 Session,而不是只让它默认关闭?

SEP-2575 已经通过移除 initialize 握手,让 MCP 可以在负载均衡器之后运行且无需粘性路由。仍需移除 session 而不是将其保留为可选能力,原因如下:

  • 可选 session 仍会妨碍列表缓存。 客户端只有在知道服务器不会选择启用 session 作用域变更时,才能跨 session 边界缓存 tools/list,但客户端无法事先得知这一点。因此,即使很少有服务器选择启用,客户端仍会按每个 session、每台服务器重新获取列表。动机一节中的 O(子 Agent 数量 × 服务器数量) 成本是由 session 存在的可能性导致的,而不是由 session 实际使用导致的,所以将 session 改为可选并不能消除此成本。
  • 该 primitive 会影响服务器设计。 规范提供 session 作用域状态,会促使服务器作者将其用于更适合显式 ID 的工作流。
  • 减少 primitive 可缩小实现范围。 每个协议概念都必须由 SDK 作者实现、写入文档,并由新用户学习。

表达能力

Session 为每个连接提供恰好一个作用域。显式 ID 可以提供模型所创建的任意多个作用域,并且每个作用域都可独立共享或隔离。任何能用 session 表达的内容,都能用模型在对话开始时创建的单个 ID 表达;反之则不成立。

恢复

由于 handle 出现在工具结果中,它们会成为聊天记录的一部分。因此,任何持久化聊天的客户端(大多数客户端都会这样做)也会自动持久化 handle。在应用重启、页面重新加载后,或在另一台设备上重新打开对话,都会让模型重新看到 handle,无需额外恢复机制,而且该行为在所有客户端之间保持一致。相比之下,基于 session 的状态要求客户端在带外持久化并重新发送 Mcp-Session-Id;如动机所述,几乎没有客户端这样做。

预期异议

垃圾回收

Session 提供了生命周期信号:session 结束时释放状态。没有它,模型可能忘记调用 destroy_basket(),导致状态泄漏。

然而,实践中的 session 并不能可靠提供该信号。如动机所述,真实客户端要么永不结束 session(每次应用启动),要么不断结束 session(每次工具调用),要么在与对话无关的时刻结束 session(页面重新加载、网络抖动)。负载均衡器后的无状态 HTTP 服务器永远看不到连接关闭。服务器目前就依赖基于 TTL 的过期机制;执行清理的并不是 session 边界。

使用显式 ID 并记录持久性策略(“购物篮空闲 24 小时后过期”)仍是同一种机制,只是把它显式化。

模型必须持续携带 ID

使用隐式 session 状态时,服务器跟踪标识符;使用显式 ID 时,模型负责将 basket_abc123 传入每次相关调用。失败模式包括产生略有偏差的幻觉 ID,或在对话压缩时 ID 脱离上下文。

模型已经经常在对话中携带不透明标识符,例如文件路径、URL、commit hash、PR 编号和先前工具调用返回的 UUID,当前模型能够可靠完成此操作。压缩是更困难的情况,但它会影响任何长期状态:如果压缩器丢弃仍有效的工具调用结果,模型同样会忘记 session 作用域购物篮中有什么,而不仅仅是忘记购物篮的 ID。

聊天记录中的 ID

可以粘贴到任何位置的 basket_id 可能成为用户聊天日志中未经认证的能力。

对于经过认证的服务器,ID 应是名称,服务器应在每次调用时检查 (id, auth_context)。Google Doc ID 会出现在 URL 和浏览器历史中;访问由 ACL 控制,而不是由 ID 保密性控制。这里同样如此。

对于没有认证的服务器,ID 必然是 bearer token,因为持有它是服务器唯一能检查的条件。在这种情况下,handle 应遵循不可猜测能力 token 的标准实践:由加密安全随机源生成,至少具有 128 位熵(例如 UUIDv4,或 22 个以上 URL-safe Base64 字符),绝不能从可预测输入派生,并设置有限生命周期。这与其他常见临时公共 ID 的安全姿态相同,例如“任何知道链接的人”共享 URL、密码重置 token、Stripe Checkout session ID;也具有相同权衡:使用方便,但任何取得 token 的人在其生命周期内都拥有访问权限。

破坏性变更

Session 当前已在规范中;移除它会破坏所有依赖它的实现。

对 1000 个随机开源 MCP 服务器仓库样本进行自动调查(由 LLM 逐仓分类)后得到以下结果:

类别占比迁移方式
应用层未引用 MCP session ID90.0%无需迁移
Map<sessionId, Transport> 路由(TS SDK 样板代码)3.5%由无 session 的 SDK transport 移除
仅用于 transport 设置(sessionIdGenerator,从未读取)2.8%删除一个构造器选项
以 Session 为键的应用状态2.5%迁移到显式 handle 或认证主体
代理/网关粘性路由0.7%需要设计替代方案
认证绑定(以 session 为键的 JWT claim、PKCE verifier)0.5%改用服务器生成的 nonce 或 token subject

加粗行是将 session ID 用于应用语义的仓库。受影响最严重的类别是每个 session 生成一个上游实例的网关,需要经过设计的替代方案,而不是机械修改;参见向后兼容性。

向后兼容性

对于依赖协议层 session 状态的服务器,这是一项破坏性变更。迁移路径取决于服务器类别:

使用进程生命周期状态的 Stdio 服务器。 这是目前最常见的有状态服务器。在默认部署中,从机制上看,本提案不会破坏它们:进程生命周期仍然存在;如果 stdio 客户端生成一个进程,服务器为每个进程保留单个内存浏览器实例,仍会继续工作。但这类服务器 SHOULD NOT 依赖进程生命周期状态,并且 SHOULD 迁移到显式 handle。进程生命周期具有与本 SEP 为 HTTP 消除的 session 相同的作用域未定义问题(进程究竟对应一段对话、一次应用启动还是其他范围由宿主决定),依赖它的服务器无法在 HTTP 上提供等效行为,因为 HTTP 不会为每个客户端分配一个进程。Stdio 服务器从未使用 Mcp-Session-Id,因此移除该 header 本身不会影响它们。

使用 Mcp-Session-Id 的 HTTP 服务器。 这类服务器相对少见,并且必须迁移到显式 handle。迁移方式是机械性的:将以 session 为键的状态 map 替换为以 handle 为键的状态 map,添加 create_* 工具,并将 handle 添加为有状态工具的参数。

将 session ID 用作遥测键的服务器。 一些服务器用 session ID 标记 trace、日志或限流 bucket,以关联一个 session 内的活动。这种做法在不同客户端上本来就不一致:面对每次工具调用都新建 session 的客户端,每个事件都会落入独立 bucket;面对不恢复 session 的客户端,每次重启都会中断关联。这些用例需要改用其他作用域机制,通常是已认证主体(bearer token subject、API key)或请求级关联 ID。

使用 session ID 进行粘性路由的代理和网关。 按 Mcp-Session-Id 路由的网关会失去路由键,但它们过去只因上游有状态才需要该键。如果上游无状态(或者迁移到显式 handle,状态键位于工具 arguments 中,任何副本都能从共享存储提供服务),网关完全不再需要粘性路由。剩余情况是将 HTTP 桥接到 stdio、并为每个 session 生成一个子进程的网关;这些网关需要不同的关联键。这是传输层问题(按已认证主体路由,或使用 cookie/网关签发的 header),不由本 SEP 定义。

将认证制品绑定到 session ID 的服务器。 少量服务器存储以 session ID 为键的 OAuth PKCE verifier、session→user 固定映射或 JWT claim。对于 PKCE,服务器已经通过 OAuth state 参数传递关联值(浏览器 callback 不是 MCP 请求,从未携带 Mcp-Session-Id),因此只需在 state 中放入服务器生成的 nonce,而不是 session ID。Session→user 固定是一项针对安全影响所述 session 路由与认证解耦问题的防御;每个请求独立认证后不再需要。迁移基本是机械性的,但由于涉及认证代码,仍值得审查。

客户端。 客户端会变得更简单:不再跟踪或重新发送 session 标识符,也无需判断某台服务器是否有状态。列表端点缓存变得安全。

发布采用明确的断点:下一个规范版本直接移除 session,不设弃用窗口。当前依赖 session 作用域状态的服务器在迁移到显式 handle 前,继续使用当前协议版本。协议版本协商已经能够处理混合版本部署:同时支持两个版本的客户端会对尚未迁移的服务器使用旧协议,对其他服务器使用新协议。这样可以避免发布一个要求客户端同时支持两种模式的版本,因为那会妨碍缓存收益(只要任何已连接服务器可能受 session 约束,客户端就不能缓存列表端点)。

安全影响

Handle 暴露

本 SEP 引入的主要安全考虑是,handle 会出现在 session ID 不会出现的位置,例如聊天日志、子 Agent 提示、复制粘贴缓冲区,甚至其他用户的屏幕上。

这是暴露面的变化,不是新类别的漏洞。实践中 session ID 已经具有能力属性:例如 Python SDK 的有状态 session manager 只依据 Mcp-Session-Id 路由,不会验证请求中的已认证身份是否与创建该 session 的身份匹配,因此泄露的 session ID 会使任何其他已认证主体都能劫持 session。5 下文“每次调用都验证 (id, auth_context)”的指导同样适用于今天的 session ID 和显式 handle;本 SEP 让该要求更醒目,因为 handle 本身更加显眼。

对于经过认证的服务器,建议采取与 Google Doc ID 和 GitHub PR 编号相同的策略:ID 标识资源,请求上的 auth context 决定访问权限。每次调用都验证 (handle, auth_context) 的服务器不会受到 handle 暴露的影响。

对于未经认证的服务器,不存在可检查的 auth context,因此 handle 就是 capability token。此类 handle 应使用至少 128 位加密安全熵生成,绝不能从可预测输入派生,并设置有限生命周期;这与“任何知道链接的人”共享 URL 或密码重置 token 的做法相同。Handle 一旦暴露,在其生命周期内即授予访问权限;服务器应据此设置生命周期长度。

这些内容是指导,不是协议要求,因为协议中不存在可供强制执行的 handle 概念。

参考实现

除 PHP 外,所有官方 SDK 都已经提供无状态模式,其实现方式是不生成 session ID,例如 TypeScript SDK 中的 sessionIdGenerator: undefined、Python SDK 中的 stateless_http=True。本 SEP 使该模式成为使用新协议版本的服务器的唯一选项。支持多个协议版本的 SDK 会保留为旧版本生成 session ID 的代码路径;变化在于,协商得到引入本 SEP 的协议版本时,不再能够进入该路径。

未来工作

本 SEP 有意不引入协议层 handle 概念:从 wire 角度看,basket_id 是普通字符串。其结果是,客户端或模型无法通过标记得知 basket_id 是状态 handle;create_basket 输出与 add_item 输入之间的关系,需要从命名和工具描述中推断,而不是通过声明确定。

后续提案可以显式表达该关系,例如通过服务器工具输入和输出 schema 共同引用的 JSON Schema $defs,或者通过将结果字段标记为 handle 的工具注解。这样,编排器无需解析工具描述即可识别哪些值代表有效状态,用于上下文压缩、移交或清理。该内容留在本 SEP 范围之外,以使本 SEP 保持在移除 session 所需的最小范围内。


  1. microsoft/playwright-mcp#1045,2025 年 9 月——服务器作者报告 ChatGPT 和 Claude.ai 都会在每次工具调用后关闭 session,导致浏览器状态丢失;“Connector tool calls generating fresh MCP session each invocation”,OpenAI Developer Community,2025 年 11 月。 ↩︎

  2. modelcontextprotocol/typescript-sdk#1658,2026 年 3 月——StreamableHTTPServerTransport 将 session 状态保存在私有实例字段中,没有从外部存储恢复状态的 API。 ↩︎

  3. modelcontextprotocol/python-sdk#2108,2026 年 2 月。 ↩︎

  4. agentgateway/agentgateway#1510,2026 年 4 月。 ↩︎

  5. modelcontextprotocol/python-sdk#2100。 ↩︎