> ## Documentation Index
> Fetch the complete documentation index at: https://mcp.zhcndoc.com/llms.txt
> Use this file to discover all available pages before exploring further.

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

> 通过显式状态句柄实现无会话 MCP

<div className="flex items-center gap-2 mb-4">
  <Badge color="green" shape="pill">
    最终版
  </Badge>

  <Badge color="gray" shape="pill">
    标准轨道
  </Badge>
</div>

| Field         | Value                                                                           |
| ------------- | ------------------------------------------------------------------------------- |
| **SEP**       | 2567                                                                            |
| **Title**     | 通过显式状态句柄实现无会话 MCP                                                               |
| **Status**    | 最终版                                                                             |
| **Type**      | 标准轨道                                                                            |
| **Created**   | 2026-03-11                                                                      |
| **Author(s)** | Peter Alexander ([@pja-ant](https://github.com/pja-ant))                        |
| **Sponsor**   | Peter Alexander ([@pja-ant](https://github.com/pja-ant))                        |
| **PR**        | [#2567](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2567) |

***

## 摘要

本提案将 MCP 中协议层的会话概念移除，改为使用由服务器生成的显式状态句柄；模型携带这些句柄，并在后续调用中继续传递。[SEP-2575] 移除了 `initialize` 握手，并将协议版本和能力按请求携带；本提案是与之互补的变更，用于移除会话和 `Mcp-Session-Id` 头。两者结合，使 MCP 在协议层面上成为无状态。

在规范中存在一年多之后，会话在不同客户端之间始终没有形成一致的含义：有些按单次工具调用划分，有些按应用启动划分，有些按页面加载划分，几乎没有任何客户端会恢复会话。服务器作者无法预测当其服务器连接到任意客户端时，会话的范围或生命周期是什么，这使得会话作为应用状态容器变得不可靠。本提案认为，应用状态可以通过显式标识符来承载，而会话抽象增加了约束（固定基数、未定义生命周期、跨会话边界的列表端点不可缓存），却没有带来相应收益。

在本提案下，当前将某个购物车之类的状态限定到会话中的服务器，会改为暴露一个工具 `create_basket()`，该工具返回一个 `basket_id`，并在后续工具调用中传递该 ID，例如 `add_item(basket_id, ...)`。模型决定哪些状态共享、哪些状态隔离；列表端点可以跨越原本的会话边界被缓存；代理编排器则可按需自由共享或不共享应用状态。显式状态句柄并不是一种新的协议构造——它们没有对应的 schema 或 wire format。它们是一种工具设计模式；协议层面的变化是移除会话，从而让句柄成为表达跨调用状态的方式。

[SEP-2575]: https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575

[SEP-2322]: https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322

[SEP-2549]: https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549

## 动机

### 当前会话所涵盖的内容

现行规范并未明确哪些行为属于会话绑定，但在实践中，以下五类内容会附着在会话生命周期上：

1. **协商得到的能力和协议版本。** `initialize` 的结果——即正在使用哪种协议版本，以及双方支持哪些可选能力——是在每个会话中建立一次，并在其持续期间默认保持不变。[SEP-2575] 通过移除 `initialize` 并按请求携带版本/能力信息来解决这一点，因此本提案将其视为已处理。

2. **提问与采样的中间状态。** 当某次工具调用触发一次 `elicitation/create` 或 `sampling/createMessage` 往返时，服务器必须将最终响应与最初处于进行中的工具调用关联起来——而这类状态如今是隐式地存在于会话中的。[SEP-2322]（多轮往返请求）通过在请求/响应周期中显式携带关联状态来解决这一点，因此本提案将其视为已处理。

3. **应用状态。** 典型示例是购物车：`add_item()`、`add_item()`、`checkout()`，其中购物车以每会话隐式存在。此模式可推广到任何有状态工作流——Playwright 浏览器实例、数据库事务、打开的文件描述符等。

4. **可变列表端点。** `tools/list`（以及 `resources/list`、`prompts/list`）在一个会话的生命周期内可以合法返回不同结果。例如，某个数据库服务器可以暴露 `connect_database` 工具，该工具一旦被调用，后续 `tools/list` 结果中就会出现 `query` 和 `list_tables`。

5. **资源订阅。** 订阅生命周期与会话生命周期绑定。（[SEP-2575] 引入了 `messages/listen` 作为服务器到客户端通知的传递通道；在该模型下订阅生命周期不在此重新审视。）

在 (1)、(2) 和 (5) 已由其他 SEP 处理的前提下，本提案处理 (3) 和 (4)。

### 会话范围划分的问题

以下问题无论会话是强制性的（当前规范）还是可选的，都会存在。

#### 会话生命周期未定义，服务器无法据此设计

规范并未说明会话何时开始或结束，因为这取决于宿主应用。实践中，已部署客户端差异很大，而且很少有客户端会把会话限定为一次对话：ChatGPT 为每次单独的工具调用创建一个新的会话，Claude.ai 直到最近也是如此；[^per-call] 大多数桌面端和 IDE 客户端会在应用启动时创建一个会话，并在进程生命周期内保持它；Web 客户端通常会在每次页面加载时创建一个会话。几乎没有客户端会在断开连接或重启后恢复先前的会话，而在服务器端，参考 TypeScript SDK 也没有公开 API 可在另一节点上重建会话，因此多节点部署即使客户端尝试恢复也无法支持。\[^^ts-sdk-resume] 子代理可能共享其父级会话，也可能拥有自己的会话——没有统一约定。

[^per-call]: [microsoft/playwright-mcp#1045](https://github.com/microsoft/playwright-mcp/issues/1045)，2025 年 9 月——服务器作者报告称 ChatGPT 和 Claude.ai 都在每次工具调用后关闭会话，导致浏览器状态丢失；["Connector tool calls generating fresh MCP session each invocation"](https://community.openai.com/t/connector-tool-calls-generating-fresh-mcp-session-each-invocation/1364975)，OpenAI 开发者社区，2025 年 11 月。

[^ts-sdk-resume]: [modelcontextprotocol/typescript-sdk#1658](https://github.com/modelcontextprotocol/typescript-sdk/issues/1658)，2026 年 3 月——`StreamableHTTPServerTransport` 将会话状态存储在私有实例字段中，没有从外部存储重建状态的 API。

这很重要，因为决定把什么内容限定到会话中的，是服务器作者；而他们需要知道会话究竟对应什么，才能正确地这样设计。一个将浏览器实例绑定到会话的 Playwright 服务器，需要知道这意味着一次用户交互、一段代理进程，还是一个长生命周期聊天。规范并未对此做出说明，不同宿主给出的答案也不同，因此服务器实际上是在围绕一个其语义并不受自己控制的抽象进行设计。

实际后果是，会话范围的应用状态往往无法保留。对于按工具调用创建会话的客户端，它会在下一次调用之前就被销毁；对于按应用启动创建会话的客户端，它会在窗口中的每次对话间共享，然后在重启后丢失；对于任何不恢复会话的客户端，应用重启时它都会消失。那些看起来成功使用了会话状态的服务器，通常只是依赖进程生命周期的 stdio 服务器，而这属于传输层属性，而非协议属性。

#### 列表端点无法跨会话缓存

由于 `tools/list` 可能依赖会话，客户端不能假设在一个会话中获取的结果在下一个会话中仍然有效。每个新会话都必须重新获取，即使服务器的工具集合在构建时就已固定且从不变化——这其实是常见情况。

Python SDK 关于客户端侧列表缓存的设计问题中，明确将“缓存键是什么——按会话还是按服务器 URL？”列为开放问题，[^py-sdk-cache] 而网关实现者也专门实现了按会话缓存，因为他们无法从规范中假定跨会话有效性。\[^^agentgateway-cache] 每个列表端点都必须被视为可能是会话范围的，因此为了安全起见，每个会话都必须重新获取。

[^py-sdk-cache]: [modelcontextprotocol/python-sdk#2108](https://github.com/modelcontextprotocol/python-sdk/issues/2108)，2026 年 2 月。

[^agentgateway-cache]: [agentgateway/agentgateway#1510](https://github.com/agentgateway/agentgateway/issues/1510)，2026 年 4 月。

对于经常生成子代理的宿主来说，这会放大热路径上的开销。如果某个服务器可能是会话范围的，那么客户端就被迫对 `tools/list` 发起 `O(subagents × servers)` 次调用：每个子代理、每个服务器、每次都要调用，即使底层工具集合自编排器首次连接以来根本没有变化。客户端无法跳过该调用，因为它无法提前知道哪些服务器是会话范围的。对于一个生成大量短生命周期子代理的编排器来说，这一开销甚至可能超过实际工具调用的协议流量。在本提案下，同样的工作负载变为 `O(servers)`：编排器只获取一次每个列表，然后所有子代理复用缓存结果。

如果列表端点仅是服务器部署和已认证主体的函数，客户端就可以缓存它们，并在收到显式信号时失效。[SEP-2549] 规定了这样的信号（服务器宣告的 TTL 加上 `notifications/*/list_changed`），但它的缓存模型只有在列表不会同时按会话变化时才成立。移除会话后，该模型才是安全的；此时子代理可以零成本继承父级缓存列表。

#### 基数在每个会话中都固定为 1

会话状态的基数在每个会话中都恰好为 1。模型只能得到一个购物车、一个浏览器、以及服务器限定到会话中的任意一个状态；它不能有两个，也不能没有。

当不同状态片段需要不同范围时，这就成了问题。设想一个编排器派出多个子代理，分别独立研究要购买的产品。子代理应当添加到同一个购物车中（它们在协作完成一笔订单），但每个子代理都需要各自独立的浏览器状态（它们并行浏览不同网站）。

没有任何会话边界能够同时满足这两点：

| 会话模型       | 购物车（期望：共享） | 浏览器（期望：隔离） |
| ---------- | :--------: | :--------: |
| 子代理共享父级    |    ✓ 共享    | ✗ 共享（互相覆盖） |
| 子代理拥有自己的会话 |    ✗ 隔离    |    ✓ 隔离    |

使用显式 ID 时，编排器只需调用一次 `create_basket()`，将返回的 `basket_id` 传给每个子代理，而每个子代理则分别调用 `create_browser()` 获取自己的 `browser_id`。模型可以对每一块状态单独决定哪些是共享、哪些是隔离，而不必让所有东西都被强加同一种作用域。

缺少标识符还意味着，会话状态无法从创建它的会话之外被寻址。一个聊天中创建的购物车对另一个聊天是不可见的；如果用户希望在新对话中继续工作、将某些内容交接给另一个代理，或与同事共享状态，会话模型都没有可供引用的对象。显式的 `basket_id` 可以在这些场景中被传递。

## 规范

### 变更摘要

1. **从协议中移除会话概念。** `Mcp-Session-Id` 头被移除，描述会话生命周期和会话范围行为的规范语言也被删除。该协议在所有层面上都是无会话的。([SEP-2575] 移除了 `initialize` 握手，但明确将会话移除推迟到本提案。)

2. **列表端点与会话无关。** 在没有会话的情况下，`tools/list`、`resources/list` 和 `prompts/list` 的结果不再依赖任何按会话或按连接划分的范围。列表仍然可能因其他原因而变化（服务器部署、认证变更）；这些情况的缓存和失效机制在 [SEP-2549] 中单独规定。

3. **有状态工作流使用显式句柄。** 在没有会话后，需要在工具调用之间维护状态的服务器，会通过在创建工具中返回一个标识符，并在后续调用中将其作为参数接收，来实现这一点。

第三点**不是**协议变更。这里没有 `handles/*` 方法，模式中也没有句柄类型，协议线上更没有句柄这一概念。就协议而言，句柄只是工具结果中的一个字符串，以及工具参数中的一个字符串，与其他任何工具数据没有区别。“显式状态句柄”是规范所文档化并推荐的一种工具设计模式——就像它可能会文档化分页或错误消息约定一样——而不是协议所实现的内容。本 SEP 的规范性内容是第 (1) 点中的移除；(2) 由此推出，而 (3) 则是填补这一空白的指导。

### 显式状态句柄

#### 模式

在服务器原本会依赖隐式、按会话范围保存的状态——例如 `add_item` 调用作用于每会话购物车——的地方，它改为暴露一个显式的创建工具，该工具返回一个句柄：

```jsonc theme={null}
// → tools/call
{ "name": "create_basket", "arguments": {} }

// ← result
{ "content": [{ "type": "text", "text": "已创建购物篮 bsk_a1b2c3" }],
  "structuredContent": { "basket_id": "bsk_a1b2c3" } }
```

随后，模型会将该句柄作为普通参数传递给后续调用：

```jsonc theme={null}
// → tools/call
{ "name": "add_item",
  "arguments": { "basket_id": "bsk_a1b2c3", "sku": "shoes" } }

// ← result
{ "content": [{ "type": "text", "text": "已将 shoes 添加到 bsk_a1b2c3（1 项）" }] }

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

这里没有任何协议扩展：`basket_id` 只是 `structuredContent` 中的一个普通字符串字段，也是后续工具的一个普通字符串参数。这个模式已经是广泛部署的远程 MCP 服务器管理持久资源时的惯例：

| 服务器（官方，远程）                                                  | 创建工具 → 返回的 ID                     | 接收该 ID 的操作工具                                                     |
| ----------------------------------------------------------- | --------------------------------- | ---------------------------------------------------------------- |
| [Linear](https://linear.app/docs/mcp)                       | `create_issue` → issue id         | `get_issue`, `update_issue`, `create_comment`                    |
| [Notion](https://developers.notion.com/docs/mcp)            | `notion-create-pages` → page id   | `notion-update-page`, `notion-move-pages`                        |
| [GitHub](https://github.com/github/github-mcp-server#tools) | `create_pull_request` → PR number | `pull_request_read`, `update_pull_request`, `merge_pull_request` |
| [Stripe](https://docs.stripe.com/mcp)                       | `create_customer` → customer id   | `create_invoice`, `list_subscriptions`                           |

对于不那么持久的对象（浏览器上下文、进行中的购物车），也可以采用这种方法：为创建出来的对象设置有限生命周期，和/或将其可发现性限制在创建它的主体内。服务器拥有状态，客户端持有其名称，并且每次调用都会检查授权。

#### 面向服务器的指导

以下内容都不是规范性要求。句柄是一种工具设计模式，而不是协议特性，服务器可以自由地按其领域需要来塑造它们。该模式在以下情况下效果最佳：

* **句柄应是不可解析的。** 一个编码了内部结构的句柄（`cart_user42_2026-03-11`）会诱使客户端去解析它，或让模型去猜测它；而像 `bsk_a1b2c3` 这样的不可解析句柄则不会。
* **持有句柄不等于授权（在存在认证的情况下）。** 对于已认证的服务器，应在每次调用时验证 `(handle, auth_context)`；句柄最终会出现在聊天记录、复制粘贴缓冲区以及子代理提示中。对于未认证的服务器，由于句柄本质上就是持有者令牌，应使用至少 128 位的密码学安全熵生成，并限制其生命周期。参见 [Security Implications](#security-implications)。
* **持久性应在工具描述中说明。** 句柄按设计会跨连接存在，因此“状态持续到连接关闭”为前提的说法不再适用。把策略写在 `create_*` 工具的描述里——“返回一个 basket\_id；购物篮在空闲 24 小时后过期”——这样模型在决定是否创建状态时就能看到。仅写在服务器文档中的策略模型是看不到的。
* **过期句柄应返回有用的错误。** 当工具接收到一个已过期或已销毁状态的句柄时，错误应明确说明这一点——例如“basket `bsk_a1b2c3` 已过期”，而不是“无效参数”。清晰的过期错误可以让模型通过再次调用 `create_*` 来恢复；模糊的错误通常只会导致重试或失败。
* **创建时应接收参数。** `create_context(cluster="staging")` 优于先 `create_context()` 再 `set_cluster(ctx, "staging")`：这样往返一次而不是两次，而且状态不会在半配置状态下存在。
* **应提供清理能力。** `destroy_*(handle)` 工具可以让模型释放资源。`list_*()` 工具可以让模型在丢失所创建对象的追踪后恢复。两者都不是必需的。

#### 面向客户端的指导

从客户端的角度看，句柄只是工具结果中的一个普通字符串。客户端的主要职责是确保该字符串在上下文压缩后仍然保留；如果对话被总结，而句柄位于被丢弃的部分，那么状态就会成为孤儿。已经跟踪上下文压缩边界之外工具调用结果的客户端，已经在处理这件事了。

### 与会话无关的列表端点

移除会话后，列表端点不再有会话可供变化对照。这是本 SEP 对 `tools/list`、`resources/list` 和 `prompts/list` 施加的唯一约束：它们的结果不再依赖任何按会话或按连接划分的范围。这并不排除根据请求中提供的授权来变化列表：凭据是随每个请求携带的，因此服务器向不同主体或不同范围返回不同工具集，依赖的是按请求输入，而不是连接状态。列表也仍然可以因其他原因随时间变化——例如服务器部署了新版本，用户的计划或已授予范围发生变化——而本 SEP 不枚举也不限制这些情况。

客户端如何得知缓存的列表已过期，是 [SEP-2549] 的内容；该提案定义了列表响应上的服务器声明 TTL，以及与 `notifications/*/list_changed` 的交互。这两个 SEP 是互补的：本提案移除了作为变化来源的会话，因此缓存对象变得稳定；[SEP-2549] 规定了缓存多久以及何时失效。

上述约束的一个结果是，服务器不能再把列表结果作为其他请求的副作用来修改；在动机部分中的模式——调用 `connect_database()` 会让 `query` 和 `list_tables` 出现在后续 `tools/list` 结果中——不再允许。为了达到同样效果，服务器在列表时无条件暴露 `query` 和 `list_tables`，并让它们接收由 `connect_database()` 返回的 `connection_id` 参数。若 `query` 调用时没有有效的 `connection_id`，则会返回错误并指示模型先调用 `connect_database()`；这种依赖关系通过工具的输入模式和描述来表达，而不是通过列表结果来表达。

### 由此带来的规范编辑

除了删除 §Session Management 小节本身之外，当前规范中还有若干其他地方使用会话范围来定义行为，因此需要重新定界：

* **JSON-RPC 请求 ID 的唯一性。** 当前规范要求请求 `id`“MUST NOT have been previously used by the requestor within the same session.” `id` 的用途是让发送方把传入响应与产生该响应的请求关联起来；接收方只会回显它。随着会话被移除，这一约束也相应重新定界：发送方 MUST NOT 发出一个其 `id` 与另一条已发送但尚未收到响应的请求相同的请求。这与传输无关，足以在任何传输下进行关联，并且 TypeScript 和 Python SDK 已经通过每个客户端对象上的单调递增计数器实现了这一点。([JSON-RPC 2.0 §4](https://www.jsonrpc.org/specification#request_object) 本身并不施加唯一性要求——它只要求接收方回显 `id`——因此这仍然是 MCP 层面的约束。)
* **SSE 事件 ID 的唯一性。** 当前规范将 SSE 事件 ID 规定为“在该会话内所有流之间全局唯一”。随着会话被移除，这一约束变为：ID 必须在服务器管理的所有流之间全局唯一，这样 `Last-Event-ID` 才能解析到唯一的一条流。现有关于事件 ID 编码来源流的指导已经隐含了这一点。
* **分页游标有效性。** 当前规范建议客户端不要“跨会话持久化游标”。随着会话被移除，这一建议也不再存在。游标稳定性和快照一致性超出了本提案的范围。
* **列表端点的可变性。** `tools/list`、`resources/list` 和 `prompts/list` 这些页面分别写道，其结果“在连接生命周期内 MAY change”。这些内容根据 [§与会话无关的列表端点](#session-independent-list-endpoints) 重新定界：结果 MAY 随时间变化，但 MUST NOT 按连接变化，也不得作为连接上其他请求的副作用而变化。
* **措辞。** 少数使用“session”作描述性表述的短语——架构概述中的“stateful session protocol”、能力协商中的“available during the session”、授权中的“same logical session”、关于不得仅将状态与“session IDs alone”关联的 elicitation 禁止语，以及类似表述——都会被改写或删除。除去会话移除本身，这些不带来任何语义变化。

## 原因

### 为什么要移除会话，而不是 պարզապես默认关闭它们？

[SEP-2575] 已经通过移除 `initialize` 握手，解决了在负载均衡器后面以及没有粘性路由的情况下让 MCP 正常工作的问题。之所以还要移除会话，而不是将其保留为一种可选能力，原因如下：

* **即使是可选会话，也仍然会阻止列表缓存。** 除非客户端知道服务器没有选择进入会话作用域的变更，否则它不能在会话边界之间缓存 `tools/list`；而它事先并不知道这一点。因此，客户端会对每个服务器、每个会话重新获取，尽管真正选择启用的服务器很少。动机部分中的 `O(subagents × servers)` 成本是由“会话可能存在”导致的，而不是由“会话被使用”导致的，所以把它们设为可选并不能消除这个成本。
* **这一原语会影响服务器设计。** 在规范中提供会话作用域状态，会引导服务器作者把它用于那些更适合显式 ID 的工作流。
* **更少的原语意味着更小的实现面。** 每一个协议概念都必须由 SDK 作者实现、记录文档，并被新用户学习。

### 表达能力

一个会话为每个连接提供且只提供一个作用域。显式 ID 则提供与模型创建的数量一样多的作用域，而且每个作用域都可以独立共享或隔离。任何能用会话表达的内容，都可以用模型在对话开始时创建的一个 ID 来表达；反过来则不成立。

### 恢复

因为句柄会出现在工具结果中，所以它们是聊天记录的一部分。任何会持久化聊天内容的客户端——绝大多数客户端都是如此——因此会自动持久化这些句柄。无论是在应用重启后、页面刷新后，还是在不同设备上重新打开对话，句柄都会以额外恢复机制为零的方式重新出现在模型面前，而且这种行为在各类客户端之间是一致的。相比之下，基于会话的状态要求客户端在带外持久化并重新发送 `Mcp-Session-Id`，而正如在动机部分所述，几乎没有客户端会这么做。

### 预期的反对意见

#### 垃圾回收

会话提供了一种生命周期信号——会话结束时，状态被释放。没有这个信号，模型可能会忘记调用 `destroy_basket()`，从而导致状态泄漏。

然而，在实践中，会话并不能可靠地提供这一点。正如动机部分所述，真实客户端要么从不结束会话（每次应用启动一个会话），要么不断结束会话（每次工具调用一个会话），要么在与对话无关的时刻结束会话（页面刷新、网络抖动）。运行在负载均衡器后面的无状态 HTTP 服务器根本看不到连接关闭。服务器今天已经依赖基于 TTL 的过期机制；真正执行清理的并不是会话边界。

带有文档化持久性策略的显式 ID（“basket 在空闲 24 小时后过期”）本质上是同样的机制，只是变得明确了。

#### 模型必须把 ID 一路带下去

在隐式会话状态下，服务器负责跟踪标识符；而使用显式 ID 时，模型需要在每一次相关调用中持续传递 `basket_abc123`。失败模式要么是编造出一个稍有错误的 ID，要么是在对话被压缩后，ID 脱离了上下文。

模型在对话中持续携带不透明标识符本来就很常见——文件路径、URL、提交哈希、PR 编号、先前工具调用返回的 UUID——而当前模型对此做得相当可靠。压缩是更难的情况，但它会影响任何长程状态：如果压缩器丢掉了仍然有效的工具调用结果，模型同样会失去对会话作用域购物篮中内容的跟踪，而不仅仅是它的 ID。

#### 聊天历史中的 ID

一个可以随处粘贴的 `basket_id` 可能会变成用户聊天记录中的未认证能力令牌。

对于已认证服务器，ID 应该只是一个名称，服务器在每次调用时检查 `(id, auth_context)`。Google 文档 ID 会出现在 URL 和浏览器历史中；访问控制由 ACL 决定，而不是由 ID 的保密性决定。这里同样如此。

对于没有认证的服务器，ID 本质上就是持有者令牌——服务器能检查的只有“是否持有”。在这种情况下，句柄应遵循不可猜测能力令牌的标准做法：使用至少 128 位熵的密码学安全随机源生成（例如 UUIDv4，或 22 个及以上字符的 URL 安全 base64），绝不从可预测输入派生，并且赋予有限生命周期。这与常见用途中的其他短期公开 ID 的做法一致——“任何有链接的人”共享 URL、密码重置令牌、Stripe Checkout 会话 ID——并且带来相同的权衡：方便，但任何获得该令牌的人在其生命周期内都能访问。

#### 破坏性变更

今天规范中包含会话；移除它们会破坏依赖它们的任何人。

对 1000 个开源 MCP 服务器仓库随机样本进行的自动化调查（按每个仓库的 LLM 分析分类）发现：

| 类别                                          |    占比 | 迁移方式                  |
| ------------------------------------------- | ----: | --------------------- |
| MCP 会话 ID 上没有应用层引用                          | 90.0% | 无                     |
| `Map<sessionId, Transport>` 路由（TS SDK 样板代码） |  3.5% | 通过无会话 SDK 传输层移除       |
| 仅传输层设置（`sessionIdGenerator`，从不读取）           |  2.8% | 删除一个构造器选项             |
| **按会话键控的应用状态**                              |  2.5% | 迁移到显式句柄或认证主体          |
| **代理 / 网关粘性路由**                             |  0.7% | 需要设计替代方案              |
| **认证绑定**（JWT 声明、按会话键控的 PKCE verifier）       |  0.5% | 替换为服务器生成的 nonce 或令牌主体 |

加粗行是将会话 ID 用于应用语义的仓库。受影响最严重的类别——每个会话启动一个上游实例的网关——需要设计替代方案，而不是机械式修改；参见 [向后兼容性](#backward-compatibility)。

## 向后兼容性

对于依赖协议级会话状态的服务器来说，这是一个**破坏性变更**。迁移路径取决于服务器类别：

**使用进程生命周期状态的 Stdio 服务器。** 这是今天最常见的有状态服务器。就默认部署而言，它们在机制上不会被该提案破坏——进程生命周期仍然存在，并且一个在每个进程中保留单个内存中浏览器实例的服务器，继续与启动一个进程的 stdio 客户端配合工作。然而，这类服务器不应依赖进程生命周期状态，而应迁移到显式句柄。进程生命周期具有与本 SEP 为 HTTP 所移除的相同的作用域未定义问题（一个进程对应一次对话、一次应用启动，还是别的什么，取决于宿主），而依赖它的服务器无法在 HTTP 上提供等价行为，因为 HTTP 中没有每个客户端对应的进程。Stdio 服务器从未拥有 `Mcp-Session-Id`，因此头部移除本身不会影响它们。

**使用 `Mcp-Session-Id` 的 HTTP 服务器。** 这类服务器较少见，必须迁移到显式句柄。迁移是机械性的：将会话作用域状态映射替换为按句柄键控的状态映射，增加一个 `create_*` 工具，并将句柄作为参数添加到有状态工具中。

**将会话 ID 用作遥测键的服务器。** 一些服务器会用会话 ID 为 trace、日志或限流桶打标签，以关联会话内的活动。这已经在不同客户端之间表现不一致——对于按工具调用的客户端，每个事件都会落入自己的桶中；对于不恢复的客户端，关联会在每次重启时断开。这些用例需要迁移到其他作用域机制，通常是经过认证的主体（持有者令牌主体、API 密钥）或请求级关联 ID。

**使用会话 ID 进行粘性路由的代理和网关。** 依赖 `Mcp-Session-Id` 路由的网关会失去它们的路由键——但它们之所以需要这个键，只是因为上游是有状态的。如果上游是无状态的（或者迁移到显式句柄，其中状态键位于工具参数中，任何副本都可以从共享存储中提供服务），网关就完全不需要粘性路由。剩余情况是那些通过每个会话启动一个子进程来桥接 HTTP 到 stdio 的网关；它们需要不同的关联键，这属于传输层问题（按已认证主体路由，或使用 cookie / 网关签发的头部），而不是本 SEP 定义的内容。

**将认证工件绑定到会话 ID 的服务器。** 少数服务器会将 OAuth PKCE verifier、会话→用户绑定映射，或按会话 ID 键控的 JWT 声明进行存储。对于 PKCE 情况，服务器已经通过 OAuth `state` 参数传递了关联值（浏览器回调不是 MCP 请求，也从未携带过 `Mcp-Session-Id`），因此变更是把服务器生成的 nonce 放入 `state`，而不是会话 ID。会话→用户绑定是为了解决 [安全影响](#security-implications) 中描述的会话路由 / 认证解耦问题，而一旦每个请求都独立认证，这种绑定就不再需要了。迁移大多是机械性的，不过由于涉及认证代码，值得进行审查。

**客户端。** 客户端会更简单：它们不再跟踪或重新发送会话标识符，也不需要判断给定服务器是否有状态。列表端点缓存将变得安全。

发布方式是一次干净的切换：会话将在下一个规范版本中移除，不设弃用窗口。目前依赖会话作用域状态的服务器会继续停留在当前协议版本，直到它们迁移到显式句柄。协议版本协商已经可以处理混合版本部署——支持两个版本的客户端会向尚未迁移的服务器使用旧协议，向其他所有对象使用新协议。这样可以避免发布一个让客户端同时支持两种模式的版本，因为那样会阻止缓存收益（如果任何连接的服务器都可能是会话作用域的，客户端就不能缓存列表端点）。

## 安全影响

### 句柄暴露

本 SEP 引入的主要安全考虑是，句柄最终会出现在会话 ID 不会出现的地方——聊天日志、子代理提示、复制粘贴缓冲区，甚至其他用户的屏幕上。

这改变的是暴露面，而不是引入一种新的漏洞类别。实际上，会话 ID 本身已经具备能力凭证属性：例如，Python SDK 的有状态会话管理器仅通过 `Mcp-Session-Id` 路由，而不会验证请求中的已认证身份是否与创建该会话的身份匹配，因此，泄露的会话 ID 允许任何其他已认证主体劫持会话。\[ ^py-sdk-hijack ] “在每次调用时验证 `(id, auth_context)`” 的建议同样适用于今天的会话 ID 和显式句柄；之所以这个 SEP 让这一要求更显眼，是因为句柄本身更容易被看见。

[^py-sdk-hijack]: [modelcontextprotocol/python-sdk#2100](https://github.com/modelcontextprotocol/python-sdk/issues/2100)。

对于已认证服务器，推荐的做法与 Google Doc ID 和 GitHub PR 编号相同：ID 标识资源，而请求中的认证上下文决定访问权限。那些在每次调用时验证 `(handle, auth_context)` 的服务器不受句柄暴露影响。

对于未认证服务器，没有可供检查的认证上下文，因此句柄就是一个能力令牌。这类句柄应至少使用 128 位的密码学安全熵生成，绝不能从可预测输入推导，并且应设置有限的生命周期——这与“任何知道链接的人都能访问”的共享 URL 或密码重置令牌的做法相同。此类句柄的暴露会在其生命周期内授予访问权限；服务器应据此合理设置该生命周期。

这只是建议，而非协议要求，因为协议中没有可强制约束的句柄概念。

## 参考实现

除 PHP 之外，所有官方 SDK 都已经提供无状态模式，其实现方式是不生成会话 ID（例如，TypeScript SDK 中的 `sessionIdGenerator: undefined`，Python SDK 中的 `stateless_http=True`）。本 SEP 将该模式变为新协议版本服务器的唯一选项。支持多个协议版本的 SDK 会保留为旧版本生成会话 ID 的代码路径；变化在于，当协商出的协议版本是本 SEP 引入的版本时，该路径不再可达。

## 未来工作

本 SEP 有意不在协议层引入句柄这一概念：从网络传输的角度看，`basket_id` 只是一个普通字符串。其结果是，`basket_id` 对客户端或模型都不会被标记为状态句柄——`create_basket` 的输出与 `add_item` 的输入之间的关系是通过命名和工具描述推断出来的，而不是显式声明的。

后续提案可以将这种关系显式化，例如通过在服务器的工具输入和输出 schema 之间引用共享的 JSON Schema `$defs`，或者通过将结果字段标记为句柄的工具注解。这样编排器就可以识别哪些值是活动状态（用于压缩、交接或清理），而无需解析工具描述。这里之所以不包含这些内容，是为了将本 SEP 限定在移除会话所需的最小范围内。
