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

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

* **状态（Status）**: Final
* **类型（Type）**: Standards Track
* **创建（Created）**: 2026-03-11
* **作者（Author(s)）**: Peter Alexander (@pja-ant)
* **担保人（Sponsor）**: Peter Alexander (@pja-ant)
* **PR**: [https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2567](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2567)
* **相关**: [SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575)（使 MCP 无状态）、[SEP-2322](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322)（多轮往返请求）、[SEP-2549](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549)（列表结果的 TTL）

## 摘要

本提案从 MCP 中移除协议层的会话概念，用显式的、由服务器铸造的状态句柄替代隐式的会话作用域状态，模型携带这些句柄并将其贯穿于后续调用。[SEP-2575] 移除了 `initialize` 握手并逐请求携带协议版本和能力；本提案是与之互补的变更，移除会话和 `Mcp-Session-Id` 头部。二者共同使 MCP 在协议层无状态。

在规范中存在一年多之后，会话并未在各客户端间收敛出一致的含义：有些将其作用域限定为每次工具调用，有些为每次应用启动，有些为每次页面加载，而几乎没有一个恢复它们。当服务器连接到任意客户端时，服务器作者无法预测会话将具有什么作用域或生命周期，这使得会话作为应用状态的容器变得不可靠。本提案主张应用状态可以由显式标识符服务，而会话抽象增加了约束（固定的基数、未定义的生命周期、跨会话边界不可缓存的列表端点），却没有相应的收益。

在本提案下，一个当前将购物车（例如）作用域限定为会话的服务器，改为暴露一个工具 `create_basket()`，它返回一个 `basket_id`，并将该 ID 贯穿于后续工具调用，例如 `add_item(basket_id, ...)`。模型决定什么被共享、什么被隔离；列表端点跨越曾经的会话边界变得可缓存；而代理编排器可以按需自由地共享或不共享应用状态。显式状态句柄不是一个新的协议构件——它们没有 schema 或线路格式。它们是一种工具设计模式；协议变更是移除会话，这使句柄成为表达跨调用状态的方式。

[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` 工具，一经调用便使 `query` 和 `list_tables` 出现在后续 `tools/list` 结果中。

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 自己关于客户端列表缓存的设计 issue 将"缓存键是什么——每会话还是每服务器 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 月。

对于经常派生子代理的宿主，这在热路径上是一个乘数。服务器可能是会话作用域这一可能性，迫使 `O(子代理 × 服务器)` 次对 `tools/list` 的调用：每个子代理、对每个服务器、每一次，即便自编排器首次连接以来底层工具集未曾改变。客户端无法跳过该调用，因为它无法预先知道哪些服务器是会话作用域的。对于派生许多短期子代理的编排器，这一开销可能超过实际工具调用的协议流量。在本提案下，同一工作负载是 `O(服务器)`：编排器每个列表获取一次，每个子代理复用缓存结果。

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

#### 基数固定为每会话一个

会话状态的基数恰好是每会话一个。模型得到一个购物车、一个浏览器、一个服务器作用域到会话的任何东西；它不能有两个，也不能有零个。

当不同的状态片段需要不同的作用域时，这是个问题。设想一个编排器派生若干子代理来独立研究要购买的产品。子代理应添加到同一个购物车（它们协作完成一个订单），但每个需要自己的浏览器状态（它们并行浏览不同的站点）。

没有会话边界能同时满足两者：

| 会话模型     | 购物车（想要：共享） | 浏览器（想要：隔离） |
| -------- | :--------: | :--------: |
| 子代理共享父级的 |    ✓ 共享    | ✗ 共享（互相覆盖） |
| 子代理各得其一  |    ✗ 隔离    |    ✓ 隔离    |

有了显式 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/*` 方法，schema 中没有句柄类型，根本没有线路层的句柄概念。从协议的角度看，句柄是工具结果中的一个字符串和工具参数中的一个字符串，与任何其他工具数据无异。"显式状态句柄"是规范记录并推荐的一种工具设计模式——就像它可能记录分页或错误消息约定一样——而非它所实现的东西。本 SEP 的规范性内容是 (1) 中的移除；(2) 由其推出，而 (3) 是填补空白的指导。

### 显式状态句柄

#### 模式

在服务器此前会依赖隐式会话作用域状态之处——`add_item` 调用作用于每会话购物车——它改为暴露一个返回句柄的显式创建工具：

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

// ← result
{ "content": [{ "type": "text", "text": "Created basket 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": "Added shoes to bsk_a1b2c3 (1 item)" }] }

// → 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)`；句柄最终会出现在聊天日志、复制粘贴缓冲区和子代理提示中。对于未认证的服务器，句柄必然是 bearer 令牌，应以至少 128 位密码学安全熵生成它并限定其生命周期。见[安全影响](#security-implications)。
* **持久性记录在工具描述中。** 句柄在设计上比连接活得更久，因此"状态持续到连接关闭"不再适用。将策略放在 `create_*` 工具的描述中——"返回一个 basket\_id；购物车在闲置 24 小时后过期"——以便在模型决定创建状态时可见。仅在服务器文档中的策略对模型不可见。
* **过期句柄返回有用的错误。** 当工具收到一个指向已过期或已销毁状态的句柄时，错误应说明这一点——"购物车 `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 移除作为变化来源的会话，从而有一个稳定的可缓存对象；[SEP-2549] 规定缓存多久以及何时失效。

上述约束的一个后果是，服务器不能再将变异列表结果作为其他请求的副作用；动机中的模式——调用 `connect_database()` 使 `query` 和 `list_tables` 出现在后续 `tools/list` 结果中——不再被允许。为达到相同效果，服务器在列表时无条件地暴露 `query` 和 `list_tables`，并让它们接受一个由 `connect_database()` 返回的 `connection_id` 参数。没有有效 `connection_id` 的 `query` 调用以错误失败，指引模型先调用 `connect_database()`；依赖关系表达在工具的输入 schema 和描述中，而非列表结果中。

### 连带的规范编辑

除了移除 §会话管理 一节本身，当前规范中还有若干处以会话作用域定义行为，需要重新界定作用域：

* **JSON-RPC 请求 ID 唯一性。** 规范当前要求请求 `id`"不得（MUST NOT）在同一会话内被请求方先前使用过"。`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）在连接生命周期内变化"。这些依据[§与会话无关的列表端点](#session-independent-list-endpoints)重新界定作用域：结果可以（MAY）随时间变化，但不得（MUST NOT）逐连接或作为连接上其他请求的副作用而变化。
* **措辞。** 一些描述性地使用"会话"的短语——架构概览中的"有状态会话协议"、能力协商中的"在会话期间可用"、授权中的"同一逻辑会话"、征询中关于"仅凭会话 ID"关联状态的禁止，以及类似之处——被重新措辞或移除。这些除会话移除本身外不带语义变化。

## 理由

### 为何移除会话而非仅将其默认关闭？

[SEP-2575] 已经通过移除 `initialize` 握手来解决使 MCP 在负载均衡器后、无粘性路由地工作。同时移除会话、而非将其保留为选择加入能力的理由是：

* **选择加入的会话仍妨碍列表缓存。** 客户端无法跨会话边界缓存 `tools/list`，除非它知道服务器不选择加入会话作用域的变异，而它无法预先知道这一点。因此客户端逐会话、逐服务器重新获取，尽管很少有服务器选择加入。动机一节中的 `O(子代理 × 服务器)` 成本是由会话之可能、而非会话之使用引起的，因此将它们设为可选并不消除它。
* **原语影响服务器设计。** 在规范中提供会话作用域状态，导致服务器作者将其用于本可由显式 ID 更好服务的工作流。
* **更少的原语减少实现表面。** 每个协议概念都必须由 SDK 作者实现、记录，并由新用户学习。

### 表达力

会话提供恰好每连接一个作用域。显式 ID 提供模型创建的任意数量的作用域，且每个可以独立地共享或隔离。任何能用会话表达的东西，都能用模型在对话开始时创建的单个 ID 表达；反之不成立。

### 恢复

由于句柄出现在工具结果中，它们是聊天记录的一部分。任何持久化其聊天的客户端——大多数如此——因此自动持久化句柄。在应用重启、页面重载或不同设备上重新打开对话，会将句柄放回模型面前，无需额外的恢复机制，且此行为在各客户端间一致。相比之下，基于会话的状态要求客户端带外持久化并重发 `Mcp-Session-Id`，而（如动机所述）几乎没有客户端这样做。

### 预期的异议

#### 垃圾回收

会话提供一个生命周期信号——会话结束时，状态被释放。没有它，模型可能忘记调用 `destroy_basket()`，状态就泄露。

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

带有成文持久性策略的显式 ID（"购物车在闲置 24 小时后过期"）是同样的机制，只是显式化了。

#### 模型必须将 ID 向前携带

有隐式会话状态时，服务器跟踪标识符；有显式 ID 时，模型负责将 `basket_abc123` 贯穿于每个相关调用。失败模式是产生一个略有偏差的 ID，或对话被压缩时 ID 脱离上下文。

模型已经例行地在对话中携带不透明标识符——文件路径、URL、提交哈希、PR 编号、先前工具调用返回的 UUID——而当前模型可靠地做到这一点。压缩是更难的情况，但它影响任何长时程状态：如果压缩器丢弃了活的工具调用结果，模型也会跟丢会话作用域购物车里有什么，而不仅是购物车的 ID。

#### 聊天历史中的 ID

一个可以粘贴到任何地方的 `basket_id` 可能成为用户聊天日志中的一个未认证能力。

对于已认证的服务器，该 ID 应是一个名称，服务器在每次调用时检查 `(id, auth_context)`。Google Doc ID 存在于 URL 和浏览器历史中；访问由 ACL 控制，而非 ID 保密。此处同理。

对于没有认证的服务器，该 ID 必然是 bearer 令牌——持有是服务器唯一能检查的东西。在那种情况下，句柄应遵循不可猜测能力令牌的标准实践：从密码学安全随机源以至少 128 位熵生成（例如 UUIDv4，或 22+ 字符的 URL 安全 base64），绝不从可预测输入派生，并给定有界的生命周期。这与常用的其他临时公开 ID 姿态相同——"任何有链接的人"的分享 URL、密码重置令牌、Stripe Checkout 会话 ID——并带有相同的取舍：便利，但任何获得该令牌的人在其生命周期内都有访问权。

#### 破坏性变更

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

对开源 MCP 服务器 1000 仓库随机样本的自动化调查（通过逐仓库 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 或令牌 subject 替代 |

加粗行是将会话 ID 用于应用语义的仓库。受影响最重的类别——每会话派生一个上游的网关——需要设计好的替代而非机械编辑；见[向后兼容性](#backward-compatibility)。

## 向后兼容性

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

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

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

**将会话 ID 用作遥测键的服务器。** 一些服务器用会话 ID 标记追踪、日志或限速桶，以关联会话内的活动。这在各客户端间已经工作得不一致——针对每工具调用客户端，每个事件落入其自己的桶，而针对不恢复的客户端，关联在每次重启时中断。这些用例需要迁移到不同的作用域机制，通常是已认证主体（bearer 令牌 subject、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 保持在移除会话所需的最小范围内。
