一次代码分析需要三分钟,第二分钟连接断了。若任务身份只藏在这条连接里,重连后就无法判断原任务仍在运行、已经完成,还是已经失败;盲目重发 tools/call,还可能再启动一次分析。此时,三分钟的计算可能仍在继续,任务与连接之间的关联却已无法恢复。
MCP 2026-07-28 消灭的不是状态,而是藏在连接里的状态。
状态从连接迁移到任务句柄
MCP 新核心移除了协议级 session,请求改为逐次自描述。长任务由官方可选扩展 io.modelcontextprotocol/tasks 承接;该扩展有 2026-07-28 Stable 快照,却不是核心协议的内建能力,客户端与服务端都要声明支持。当前稳定版只有 tools/call 可以任务化。
服务端先持久化任务,再返回 taskId;客户端保存它,随后用普通 tasks/get 查状态和最终结果。可以把它理解为一个取件号。但 taskId 只是定位真实任务记录的键,不是状态副本。句柄持久保存后,客户端进程重启、传输通道更换,都只是换了查询的起点,不会换掉后台的那份工作。

tasks/get 一次成功返回的 resultType: "complete" 只说明这次查询 RPC 结束,后台任务是否结束,必须继续读 status。completed 携带最终结果;failed 携带 JSON-RPC error;cancelled 是取消终态;working 继续等待,input_required 则需要通过 tasks/update 回填请求的输入。
因此,轮询“够用”只指它已经形成正确性、断线恢复和结果获取的完整路径,不代表它延迟最低或流量最少。对低延迟和高频状态更新,notifications/tasks 推送仍可减少空轮询;断线后仍可回到 tasks/get,重新对齐服务端所持有的真实状态。
轮询构成完整恢复路径
一组本地确定性状态机模拟运行了 15 项断言,结果为 15/15 通过。连接侧是为隔离差异而人为构造的非 MCP 负控,证据分成三层:
| 层级 | 观测 | 能推出的结论 |
|---|---|---|
| MCP 事实 | tasks/get 按 taskId 查询 |
轮询是完整的结果获取路径 |
| 模拟结果 | 断线后以新连接查询;共享状态下请求依次落到 B、A、B,执行计数仍为 1 | 可持久句柄可以恢复原任务查询 |
| 负控边界 | 连接绑定路径盲目重发后执行计数变为 2;本地状态落错副本返回 -32602 |
句柄既不阻止原调用重发,也不会复制状态 |
这不是完整 MCP Server 的一致性测试,也没有真实网络、代理或性能数据。它只验证恢复机制,不比较轮询与推送的性能。
taskId 仍需定位真实状态
模拟中的负例更重要:任务只存在副本 A 的内存里时,请求落到 B,即使带有正确的 Mcp-Name: task-local,仍返回 -32602 Task not found。若任务状态放在共享存储或外部作业系统,任意副本都可查询;若保留副本本地状态,网关必须按 Mcp-Name = taskId 定向到状态所有者。
显式句柄也不会自动解决创建响应丢失后的重复提交,更不能替代租户授权。它解决的是“已拿到任务号后如何继续找回工作”,创建端幂等与访问控制仍是另外的设计责任。

实现路径由任务状态的位置决定:
| 状态位置 | 服务端与网关 | 客户端 |
|---|---|---|
| 共享存储或外部作业系统 | 普通分发,任意副本读取同一记录 | 保存 taskId,遵守 pollIntervalMs |
| 副本本地内存 | 按 Mcp-Name = taskId 定向所有者 |
同样持久保存句柄,并处理路由失败 |
两条路径都要先协商 Tasks 能力,在返回句柄前持久化任务,并处理 ttlMs、input_required、failed.error 与取消后的继续查询。tasks/cancel 成功只表示服务端接受取消意图,任务可能仍为 working,最终状态也不保证是 cancelled。分钟级、低频长任务可先用轮询;需要高频进度展示时,再叠加 notifications/tasks。
协议依据:MCP 2026-07-28 变更说明与 Tasks 2026-07-28 Stable 规范。最小复现使用虚拟时间,不联网、不读取墙上时钟、不调用随机数;固定任务时长为 2000ms、TTL 为 3000ms,多副本顺序为 B → A → B。脚本与结果位于随稿证据包的 research/ 目录:
python3 research/mcp_long_task_experiment.py
