MCP 取消 Session 之后,状态去了哪里
MCP 2026-07-28 移除了协议级 Session,但没有让 Agent 应用变成“没有状态”的系统。它真正做的是拆开过去混在长连接里的状态,并把它们交给更明确的所有者:
- 单次调用所需的上下文,随每个请求传递;
- 跨调用的业务状态,通过显式 Domain Handle 引用;
- 中途确认和补充输入,由 MRTR 组织;
- 长任务状态,由 Tasks Extension 承载;
- 身份、审批、轨迹和任务编排,继续由 Agent Runtime 管理。
这次变更的核心不是“删除状态”,而是让每一种状态都能被看见、命名、授权、恢复和审计。
原始资料:Model Context Protocol,The 2026-07-28 Specification,2026-07-28。
正式规范:MCP Specification 2026-07-28。
实现参考:TypeScript SDK 2026-07-28 Migration Guide、SEP-2663: Tasks Extension。
MCP 究竟取消了什么
旧版 MCP 使用 initialize/initialized 完成能力协商,并通过 Mcp-Session-Id 把之后的请求绑定到同一个协议会话。Server 还可以借助保持打开的连接向 Client 发起 Sampling、Elicitation 或 Roots 请求。
这意味着协议状态与应用状态容易混在一起:
连接属于哪个 Client
Client 支持什么能力
前一次调用创建了什么对象
当前流程等待什么输入
任务执行到哪里
这些信息可能都依赖同一条连接或同一个 Session ID。Server 扩容时,要么保持 Session Affinity,要么把会话状态放入共享存储;实例失效时,客户端还要判断连接与业务状态分别丢失了什么。
2026-07-28 规范取消 initialize/initialized 和 Mcp-Session-Id。每个请求携带协议版本,并通过 _meta 提供客户端身份和能力信息。需要提前了解 Server 能力的客户端可以调用可选的 server/discover。
旧模式
Client ── initialize ──> Server A
Client <== protocol session ==> Server A
新模式
Self-contained Request ──> Server A
Self-contained Request ──> Server B
任意请求可以落到任意健康实例。无状态首先是一项协议属性:Server 不再依赖前一条连接,才能理解当前 MCP 请求。
状态没有消失,而是重新分配
新版协议可以用一张状态所有权表概括:
| 状态类型 | 过去常见位置 | 2026-07-28 的主要归属 |
|---|---|---|
| 请求上下文 | initialize 结果与 Session | 每个自描述请求 |
| 业务对象状态 | Server 隐式 Session | 显式 Domain Handle |
| 交互状态 | 保持打开的双向流 | MRTR 与 Agent Runtime |
| 长任务状态 | 长连接或实验性任务机制 | Tasks Extension 与任务存储 |
| 治理状态 | 分散在 Client、Gateway、Server | Runtime / Gateway 的身份、策略与轨迹 |
这张表也给出了迁移时最重要的判断方法:任何原来放在 Session 中的字段,都应该先判断它属于哪一种状态,再选择新的承载方式。
请求上下文:让每次调用独立成立
无状态请求必须携带完成当前调用所需的协议信息。规范将协议版本放在 MCP-Protocol-Version Header 中,并把 Client Info 与 Client Capabilities 放入请求 _meta。
它解决的是“这个请求该怎样解释”,而不是“整个任务进行到哪里”。
适合放入请求上下文的内容包括:
- 协议版本;
- 客户端产品与版本;
- 当前请求需要声明的能力;
- Trace、Idempotency 或 Correlation 标识;
- 与本次调用直接相关的 Locale、格式或 Feature Flag。
不适合放入请求上下文的内容包括大型工作区状态、完整任务历史、长期凭证和可变业务对象。把所有状态复制到每个请求会形成另一种耦合:Payload 持续膨胀,敏感信息反复传播,状态并发更新也更难控制。
自描述请求的原则是:携带解释当前调用所需的最小上下文,把可变状态留在拥有它的系统中。
代码对比一:握手加 Session,变成单次自描述请求
旧版 2025-11-25 要先初始化:
POST /mcp HTTP/1.1
Content-Type: application/json
{"jsonrpc":"2.0","id":1,"method":"initialize",
"params":{"protocolVersion":"2025-11-25","capabilities":{},
"clientInfo":{"name":"my-app","version":"1.0"}}}
Server 签发 Mcp-Session-Id 后,后续调用必须带回它:
POST /mcp HTTP/1.1
Mcp-Session-Id: 1868a90c-3a3f-4f5b
Content-Type: application/json
{"jsonrpc":"2.0","id":2,"method":"tools/call",
"params":{"name":"search","arguments":{"q":"otters"}}}
新版 2026-07-28 直接发送完整调用:
POST /mcp HTTP/1.1
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: search
Content-Type: application/json
{"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"search","arguments":{"q":"otters"},
"_meta":{"io.modelcontextprotocol/clientInfo":
{"name":"my-app","version":"1.0"}}}}
对负载均衡器而言,差别非常具体:旧请求的正确处理依赖 Mcp-Session-Id 对应的历史;新请求只依赖当前 Header、Body 与外部授权上下文。Mcp-Method 和 Mcp-Name 让 Gateway 无需解析完整 JSON 即可路由,但它们必须与 Body 一致,否则应拒绝请求。
clientInfo 是客户端自报信息,只适合日志、显示与调试,不能代替 Access Token、Principal 或租户校验。把它用于授权,会把“自描述”误实现成“自证明”。
业务状态:用显式 Domain Handle 取代隐藏 Session
官方说明明确指出,取消协议 Session 不要求应用保持无状态。Server 需要跨调用保存业务状态时,可以由 Tool 创建一个显式 Handle,再让模型或 Runtime 在下一次调用中把它作为参数传回。
例如,一次分析任务先创建工作对象:
{
"name": "analysis/create",
"arguments": {
"dataset": "sales-2026-q2"
}
}
Server 返回:
{
"analysis_handle": "anl_7f92",
"expires_at": "2026-08-17T18:00:00Z"
}
下一次 Tool Call 显式引用该对象:
{
"name": "analysis/run",
"arguments": {
"analysis_handle": "anl_7f92",
"metric": "retention"
}
}
关键变化在于:过去由传输层暗中关联的状态,现在成为 Tool Schema 中可见的领域对象。
代码对比二:进程内 Session Map,变成显式领域对象
下面是旧实现中常见的写法。业务对象被挂在协议 Session 下,并保存在单实例内存中:
type Basket = { items: Array<{ sku: string; quantity: number }> };
const sessions = new Map<
string,
{ tenantId: string; basket: Basket }
>();
async function addItem(req: Request, args: { sku: string; quantity: number }) {
const sessionId = req.headers.get("Mcp-Session-Id");
const state = sessionId ? sessions.get(sessionId) : undefined;
if (!state) throw new Error("SESSION_NOT_FOUND");
state.basket.items.push({ sku: args.sku, quantity: args.quantity });
return { item_count: state.basket.items.length };
}
这个函数看似简单,实际隐含了三个前提:请求必须回到能访问该 Map 的实例;Session 同时承担连接关联和业务对象定位;并发写入没有版本冲突语义。
新版应把 Basket 设计成 Tool 明确创建、持久化并返回的领域对象。以下是与 MCP SDK 无关的应用层 TypeScript:
type Principal = { tenantId: string; subjectId: string };
type BasketRecord = {
basketId: string;
tenantId: string;
ownerId: string;
revision: number;
expiresAt: string;
items: Array<{ sku: string; quantity: number }>;
};
async function createBasket(principal: Principal) {
const basket: BasketRecord = {
basketId: `bsk_${crypto.randomUUID()}`,
tenantId: principal.tenantId,
ownerId: principal.subjectId,
revision: 1,
expiresAt: new Date(Date.now() + 30 * 60_000).toISOString(),
items: []
};
await basketStore.insert(basket);
return {
basket_id: basket.basketId,
revision: basket.revision,
expires_at: basket.expiresAt
};
}
async function addItem(
principal: Principal,
args: {
basket_id: string;
expected_revision: number;
idempotency_key: string;
sku: string;
quantity: number;
}
) {
const replay = await idempotencyStore.get(
principal.tenantId,
args.idempotency_key
);
if (replay) return replay;
const current = await basketStore.get(args.basket_id);
if (!current) throw new Error("BASKET_NOT_FOUND");
if (
current.tenantId !== principal.tenantId ||
current.ownerId !== principal.subjectId
) {
throw new Error("BASKET_FORBIDDEN");
}
if (Date.parse(current.expiresAt) <= Date.now()) {
throw new Error("BASKET_EXPIRED");
}
if (current.revision !== args.expected_revision) {
throw new Error("REVISION_CONFLICT");
}
const next: BasketRecord = {
...current,
revision: current.revision + 1,
items: [...current.items, { sku: args.sku, quantity: args.quantity }]
};
await basketStore.compareAndSwap(
current.basketId,
current.revision,
next
);
const result = {
basket_id: next.basketId,
revision: next.revision,
item_count: next.items.length
};
await idempotencyStore.put(
principal.tenantId,
args.idempotency_key,
result
);
return result;
}
这里的 basket_id 只负责定位对象;当前 Principal 决定能否访问,expected_revision 处理并发,idempotency_key 处理网络重试。四项责任彼此独立,任一 Server 实例都可以执行同一调用。
一个合格的 Handle 需要哪些约束
Handle 不能只是一段能够猜测或无限复用的字符串。生产实现至少需要定义:
| 约束 | 需要回答的问题 |
|---|---|
| 类型与签发方 | Handle 指向什么对象,由哪个 Server 签发 |
| Subject Binding | 它属于哪个用户、租户、Agent 或授权主体 |
| Scope | 允许读取、修改、执行还是结束对象 |
| 生命周期 | 何时过期,能否续期、撤销和回收 |
| Revision | 并发调用时基于哪个对象版本执行 |
| 可转移性 | 能否跨 Session、设备、Agent 或 Client 使用 |
| 审计关系 | 哪次 Tool Call 创建、读取或修改了它 |
Handle 最适合作为不透明引用。模型负责在调用间传递它,Server 负责解释和验证它。Handle 本身不应自动等同于完整授权;Server 仍需校验当前身份、租户和操作 Scope,防止获得 Handle 的其他主体直接继承权限。
并发和重试不能依赖运气
无状态请求可以同时落到多个实例,因此 Domain Handle 指向的对象必须具备明确的一致性语义:
- 只读调用可以并发;
- 写调用使用 Revision、ETag 或 Expected Version 检测冲突;
- 重复请求通过 Idempotency Key 返回同一结果;
- 已过期或撤销的 Handle 返回可区分错误;
- Server 故障不能让 Handle 指向无法恢复的单实例内存。
无状态协议降低了连接层复杂度,却会把隐藏的一致性问题暴露出来。这是架构收益:状态越显式,越容易定义恢复和冲突规则。
交互状态:MRTR 把反向调用改成可重试流程
旧版 Elicitation、Sampling 和 Roots 可以通过保持打开的流,由 Server 向 Client 发起请求。新版 Multi Round-Trip Requests 将它改成普通请求/响应序列:
Server 返回 resultType: input_required 以及所需输入;Client 收集答案后,把 inputResponses 附加到原调用并重新请求。协议不再要求 Server 为等待用户保持一条双向连接。
代码对比三:等待反向调用,变成显式返回与重入
旧版 Handler 可以在一次调用内部暂停,主动向 Client 请求确认:
const answer = await ctx.mcpReq.elicitInput({
mode: "form",
message: "确认删除 3 个文件?",
requestedSchema: {
type: "object",
properties: { approved: { type: "boolean" } },
required: ["approved"]
}
});
if (answer.action !== "accept" || !answer.content?.approved) {
return textResult("已取消");
}
return deleteFiles(args.files);
await 背后需要可用的 Server→Client 通道,以及仍然存活的原始请求。新版在线路上拆成三步。
第一步,Client 发起普通 Tool Call:
{
"jsonrpc": "2.0",
"id": 41,
"method": "tools/call",
"params": {
"name": "files/delete",
"arguments": {
"operation_id": "op_817",
"files": ["a.csv", "b.csv", "c.csv"]
}
}
}
第二步,Server 不删除文件,只返回待确认输入和一个不透明的 requestState:
{
"jsonrpc": "2.0",
"id": 41,
"result": {
"resultType": "input_required",
"inputRequests": {
"confirm_delete": {
"type": "elicitation",
"message": "确认删除 3 个文件?",
"schema": {
"type": "object",
"properties": { "approved": { "type": "boolean" } },
"required": ["approved"]
}
}
},
"requestState": "signed-opaque-state"
}
}
第三步,Client 使用新的 JSON-RPC ID 重试原方法和原参数,并附上本轮答案:
{
"jsonrpc": "2.0",
"id": 42,
"method": "tools/call",
"params": {
"name": "files/delete",
"arguments": {
"operation_id": "op_817",
"files": ["a.csv", "b.csv", "c.csv"]
},
"inputResponses": {
"confirm_delete": {
"action": "accept",
"content": { "approved": true }
}
},
"requestState": "signed-opaque-state"
}
}
用 TypeScript SDK v2 实现时,Handler 变成一个显式状态机:
import {
acceptedContent,
createRequestStateCodec,
inputRequired
} from "@modelcontextprotocol/server";
import * as z from "zod/v4";
const CONFIRM_SCHEMA = z.object({ approved: z.boolean() });
const CONFIRM_REQUEST = {
type: "object" as const,
properties: { approved: { type: "boolean" as const } },
required: ["approved"]
};
type DeleteState = {
step: "awaiting-confirmation";
operationId: string;
argsHash: string;
};
const REQUEST_STATE_KEY = Buffer.from(
process.env.MCP_REQUEST_STATE_SECRET!,
"base64url"
);
if (REQUEST_STATE_KEY.length < 32) {
throw new Error("MCP_REQUEST_STATE_SECRET_TOO_SHORT");
}
const stateCodec = createRequestStateCodec<DeleteState>({
key: REQUEST_STATE_KEY,
ttlSeconds: 300
});
// 创建 Server 时配置:
// { requestState: { verify: stateCodec.verify } }
async function deleteFilesTool(args: DeleteArgs, ctx: ToolContext) {
const state = ctx.mcpReq.requestState<DeleteState>();
const argsHash = await stableHash(args.files);
if (!state) {
return inputRequired({
inputRequests: {
confirm_delete: inputRequired.elicit({
message: `确认删除 ${args.files.length} 个文件?`,
requestedSchema: CONFIRM_REQUEST
})
},
requestState: await stateCodec.mint({
step: "awaiting-confirmation",
operationId: args.operation_id,
argsHash
})
});
}
if (
state.operationId !== args.operation_id ||
state.argsHash !== argsHash
) {
throw new Error("APPROVAL_CONTEXT_CHANGED");
}
const answer = acceptedContent(
ctx.mcpReq.inputResponses,
"confirm_delete",
CONFIRM_SCHEMA
);
if (!answer?.approved) return textResult("已取消");
return idempotencyStore.runOnce(
args.operation_id,
() => deleteFiles(args.files)
);
}
这段代码表达了四个边界:input_required 前不产生删除副作用;requestState 只保存续跑所需的最小状态;确认绑定原参数摘要;真正执行仍以 operation_id 保证幂等。
requestState 会经过 Client,因此必须视为不可信输入。SDK 的 Codec 提供 HMAC 完整性保护,但不加密 Payload;敏感内容应存入服务端状态库,只在 requestState 中放短期 Operation Handle。多轮交互中,inputResponses 只包含当前一轮答案,前几轮已收集的信息需要进入签名后的 requestState 或服务端持久化对象。
MRTR 的关键不是多一次请求,而是重试语义
实现 MRTR 时需要明确四件事:
输入返回前是否允许产生副作用。
最安全的设计是在 input_required 前只完成检查和报价,不创建资源、不发送消息、不删除数据。第二次请求怎样识别为同一次操作。
Client 应保持稳定的 Operation ID 或 Idempotency Key,Server 应避免把重试解释成新的业务动作。用户批准的对象是什么。
Approval 应绑定具体 Tool、参数、目标、费用、作用域和有效期。参数发生变化时重新确认。拒绝、取消、超时怎样结束流程。
三者需要可区分状态,方便 Runtime 决定终止、重规划或再次询问。
如果首次请求必须产生准备性状态,Server 应返回显式 Continuation Handle,并为其定义过期与清理规则。这样,中间状态仍然是领域对象,而不是重新藏回连接。
长任务状态:Tasks Extension 取代连接生命周期
长时间运行的数据处理、代码构建或外部工作流不能依赖 HTTP 连接一直存活。新版把 Tasks 从 2025-11-25 的实验性核心能力移入 io.modelcontextprotocol/tasks 扩展。
旧方案要求 Client 先通过 tools/list 了解方法与具体 Tool 是否支持 Task,再决定是否给请求增加 Task 参数。遇到 input_required 时,Client 还要提前调用会阻塞的 tasks/result,让 Server 获得可发送 Elicitation 或 Sampling 的 SSE 通道。能力发现、执行和连接生命周期因此相互耦合。
新版只保留三项任务操作:
tasks/get:读取任务当前状态、待处理输入或最终结果;tasks/update:Client 回传任务等待的inputResponses;tasks/cancel:表达取消意图。
tasks/list 被移除,因为无 Session 架构下很难为“列出当前调用者的全部任务”定义天然且安全的作用域。Task ID 应作为不可猜测的 Handle,并继续绑定当前授权主体。
代码对比四:阻塞等待,变成可恢复轮询
Client 在当前 tools/call 中声明 Tasks Extension。Server 可以自行决定立即返回结果,还是创建 Task:
{
"jsonrpc": "2.0",
"id": 101,
"method": "tools/call",
"params": {
"name": "dataset/import",
"arguments": { "source_uri": "s3://tenant-a/orders.parquet" },
"_meta": {
"io.modelcontextprotocol/clientCapabilities": {
"extensions": { "io.modelcontextprotocol/tasks": {} }
}
}
}
}
Task 必须在 Server 返回 Handle 前持久化完成,保证 Client 随即调用 tasks/get 时已经能够读到:
{
"jsonrpc": "2.0",
"id": 101,
"result": {
"resultType": "task",
"taskId": "task_42",
"status": "working",
"statusMessage": "正在导入数据",
"createdAt": "2026-08-17T10:00:00Z",
"lastUpdatedAt": "2026-08-17T10:00:00Z",
"ttlMs": 86400000,
"pollIntervalMs": 5000
}
}
Client 按 Server 建议的间隔轮询,而不是保持原始 Tool Call:
{
"jsonrpc": "2.0",
"id": 102,
"method": "tasks/get",
"params": { "taskId": "task_42" }
}
任务完成时,tasks/get 在同一对象中返回终态和原始 tools/call 对应的结果:
{
"jsonrpc": "2.0",
"id": 102,
"result": {
"resultType": "complete",
"taskId": "task_42",
"status": "completed",
"statusMessage": "导入完成",
"createdAt": "2026-08-17T10:00:00Z",
"lastUpdatedAt": "2026-08-17T10:04:12Z",
"ttlMs": 86400000,
"result": {
"content": [
{ "type": "text", "text": "已导入 12,480,331 行" }
]
}
}
}
如果任务进入 input_required,tasks/get 会带回 inputRequests;Client 收集答案后调用 tasks/update:
{
"jsonrpc": "2.0",
"id": 103,
"method": "tasks/update",
"params": {
"taskId": "task_42",
"inputResponses": {
"resolve_schema_conflict": {
"action": "accept",
"content": { "strategy": "create_nullable_column" }
}
}
}
}
tasks/update 只确认接收输入,不直接宣告任务已经推进到某个状态。Worker 更新状态库,Client 再通过 tasks/get 读取新的事实。这个区分避免 Client 越权修改服务端任务状态,也允许执行系统采用最终一致的 Worker 与队列。
协议只定义交互边界,任务状态仍需要可靠存储。一个可运营的任务对象至少应具备:
task:
task_id: task_42
owner: tenant-a
operation: dataset.import
status: running
progress_ref: progress_18
created_at: 2026-08-17T10:00:00Z
expires_at: 2026-08-18T10:00:00Z
result_ref: null
具体字段由实现决定,但责任不能模糊:
- Task ID 绑定用户和租户,不能仅凭 ID 越权查询;
- 状态迁移保持单调或具备明确回退原因;
- Worker 重启后任务可以继续、重试或进入确定失败;
- 任务取消与业务回滚分别建模;
- 最终结果引用对应 Artifact、外部状态或完成证据;
- 轮询和订阅看到同一个事实源。
Tasks 的意义不是为长连接换一个名称,而是让长任务的生命周期独立于网络连接和单个 Server 实例。
其他规范变化只承担辅助作用
Header、缓存和 OAuth 变化很重要,但它们服务于无状态核心,无需再次展开成完整 Gateway 架构。
| 变化 | 直接作用 |
|---|---|
| Mcp-Method、Mcp-Name Header | Gateway 可直接路由、限流和计量 |
| ttlMs、cacheScope、确定性排序 | Tool、Prompt 与 Resource 结果可以稳定缓存 |
| RFC 9207 iss 校验 | 防止 Authorization Server Mix-up |
| Credential 与 Issuer 绑定 | 限制凭证跨授权服务器复用 |
| CIMD 替代 DCR | 让 Client Metadata 管理更可控 |
| 正式 Extensions Framework | 让 Tasks、Apps、企业授权独立演进 |
Header 与 JSON-RPC Body 必须保持一致;Cache Key 必须包含身份与 Scope;OAuth Credential 必须绑定正确 Issuer。这些是无状态部署的配套约束。
Tool Catalog、Top-K Discovery、Registry 和 Gateway 适用边界已在《Agent 工具规模化之后,为什么需要 MCP Gateway》中展开。本文只讨论协议状态迁移。
迁移时不要直接删除 Session Store
从旧协议迁移时,最危险的做法是先删除 Session,再逐个修复出现的错误。更可靠的顺序是先完成状态盘点。
第一步:列出 Session 中的全部字段
记录字段的写入者、读取者、更新频率、敏感度、过期规则和故障恢复方式。
第二步:为每个字段重新分类
| 字段性质 | 新位置 |
|---|---|
| 解释单次请求所需 | Request Header 或 _meta |
| 指向业务对象 | Domain Handle |
| 等待用户补充输入 | MRTR |
| 描述长任务进度 | Tasks Store |
| 身份、审批、策略与轨迹 | Agent Runtime / Gateway |
| 只为连接维护 | 删除 |
第三步:定义失败语义
重点明确:
- 请求超时后是否可以安全重试;
- Handle 过期后能否恢复;
- 两个请求同时修改同一对象时如何裁决;
- input_required 前是否已经发生副作用;
- Task Worker 失效后由谁接管;
- Client 重连后从哪里恢复任务状态。
第四步:用故障场景验收
迁移测试至少覆盖:
- 连续请求被分配到不同 Server 实例;
- 请求在得到响应前超时并重试;
- 同一 Idempotency Key 被重复提交;
- Handle 过期、撤销、跨租户和版本冲突;
- MRTR 的批准、拒绝、修改参数和超时;
- 长任务期间 Worker 与 Gateway 分别重启;
- Polling 与 Subscription 返回一致状态;
- 旧 Client 与新 Server 的版本协商和明确失败。
只有这些场景通过,无状态化才真正带来可靠性,而不是把原来的 Session Bug 转移成分布式状态 Bug。
新规范没有解决什么
2026-07-28 规范明确了通信方式,但仍有几项责任留给 Agent 平台:
- 哪些工具应该对当前 Agent 可见;
- 多个工具和 Skill 如何编排;
- 高风险动作由谁批准;
- Domain Handle 应采用怎样的一致性模型;
- Task 完成需要哪些证据;
- 执行轨迹怎样关联用户、规则和外部副作用。
因此,无状态 MCP Server 不能替代 Agent Runtime。协议负责让请求独立、状态显式;Runtime 负责把这些能力组织成可恢复、可授权、可验证的任务。
结语
MCP 取消 Session 之后,状态去了五个地方:
请求上下文 → 自描述 Request
业务状态 → Domain Handle
交互状态 → MRTR
长任务状态 → Tasks Extension
治理状态 → Agent Runtime / Gateway
这比“有状态还是无状态”的二选一更准确。新版 MCP 移除的是协议对隐式连接状态的依赖,并没有消除业务状态。它要求系统为每一种状态明确所有者、标识、权限、生命周期、并发和恢复语义。
当这些责任被正确分配,MCP Server 才能真正实现任意实例处理、普通负载均衡和故障恢复;Agent Runtime 也获得更清晰的状态边界。