一个导出任务卡住之后
假设你在一个企业知识库客户端里点下“导出全部文档”。这个操作由远程 MCP Server 上的 export_documents 工具执行,需要遍历几万条记录、写一个临时 CSV、再上传到对象存储。整个流程可能跑几分钟。用户等不下去,点了界面上的“停止”。
如果客户端只是把按钮置灰、不再等待结果,服务端仍然在遍历、仍然在写临时文件、仍然占着数据库连接。用户以为任务停了,账单和磁盘占用还在涨。MCP 用两个通知消息处理这类问题:notifications/progress 回传进度,notifications/cancelled 请求中止。它们都是可选能力,规范只规定消息格式和最低行为要求,真正的资源回收要靠服务端自己实现。
下面用这个导出场景贯穿全文,说明进度令牌怎么传、取消怎么发、服务端在哪些位置必须检查中止信号,以及为什么“收到取消”不等于“资源已释放”。
进度令牌如何把通知和请求对上
MCP 的进度跟踪靠一个叫进度令牌(progress token)的值。客户端希望收到进度时,在请求的 _meta 里放一个 progressToken。规范要求这个值必须是字符串或整数,并且在所有活跃请求中唯一。服务端收到请求后,可以按自己的节奏发 notifications/progress,每条通知带上同一个令牌、当前进度值、可选的 total 和可选的 message。
导出场景里,客户端可以这样发起调用:
{
"jsonrpc": "2.0",
"id": 42,
"method": "tools/call",
"params": {
"name": "export_documents",
"arguments": { "collection": "kb-main" },
"_meta": { "progressToken": "export-42" }
}
}
服务端每处理完一批文档就发一条:
{
"jsonrpc": "2.0",
"method": "notifications/progress",
"params": {
"progressToken": "export-42",
"progress": 12000,
"total": 48000,
"message": "已写入 12000 条"
}
}
规范对进度值有两条硬要求。第一,同一个令牌的 progress 必须逐条递增,即使 total 未知。第二,进度通知只能引用活跃请求里的令牌,请求完成后必须停止发送。total 和 message 都是可选的,服务端不知道总数时可以只发 progress。
令牌的唯一性由发送方负责。如果客户端用同一个令牌同时发起两个导出请求,服务端无法区分这两条进度属于谁。工程上通常用请求 ID 或请求 ID 加随机后缀来生成令牌,而不是用工具名这种会重复的字符串。
取消通知发出后服务端做什么
客户端决定中止时,发一条 notifications/cancelled,带上要取消的请求 ID 和可选的原因字符串:
{
"jsonrpc": "2.0",
"method": "notifications/cancelled",
"params": {
"requestId": 42,
"reason": "用户点击停止"
}
}
规范对接收方的要求是“应该”而不是“必须”:停止处理被取消的请求、释放相关资源、不再为这个请求发响应。接收方可以忽略取消通知,条件是请求 ID 未知、处理已经完成、或者这个请求本身不可取消。发送方则应该忽略取消之后才到达的响应。
这里的关键在于取消是通知,不是请求。它没有响应,也没有确认。客户端发出取消后,无法从协议层面知道服务端是否真的停了。规范把它描述为“发后即忘”,并明确要求双方处理竞态。
导出场景里,服务端在三个位置需要检查中止信号:遍历文档的循环里、写临时文件的分块之间、上传对象存储的分片之间。只在循环开头检查一次不够,因为一次数据库查询或一次上传可能阻塞几十秒。
// 伪代码:服务端导出工具的中止检查点
function exportDocuments(collection, signal, progressToken):
tmpFile = createTempFile()
try:
total = countDocuments(collection)
written = 0
for batch in iterateBatches(collection):
if signal.aborted:
throw new CancelledError()
writeRows(tmpFile, batch)
written += batch.size
notifyProgress(progressToken, written, total)
if signal.aborted:
throw new CancelledError()
upload(tmpFile, signal)
return result
finally:
closeAndDelete(tmpFile)
finally 块是资源回收的落点。无论正常结束还是抛出取消异常,临时文件都要关闭并删除。如果服务端在取消路径上直接 return 而不走 finally,临时文件就会留在磁盘上。
从请求到资源释放的完整路径
下面这张图展示导出任务从发起到资源回收的状态流转。注意取消通知和正常响应是两条竞争路径,服务端只走其中一条。
flowchart TD
A[客户端发起 tools/call] --> B[服务端登记请求与令牌]
B --> C[遍历文档并写临时文件]
C --> D{收到取消通知?}
D -- 否 --> E[继续处理下一批]
E --> F{全部完成?}
F -- 否 --> D
F -- 是 --> G[上传对象存储]
G --> H[删除临时文件]
H --> I[返回结果]
D -- 是 --> J[置中止标志]
J --> K[循环内检查点抛出取消]
K --> L[finally 关闭并删除临时文件]
L --> M[不发送响应]
I --> N[客户端收到结果]
M --> O[客户端忽略迟到响应]
图中的转折点在 D 和 K。取消通知到达时,服务端不一定正卡在检查点上。它可能正在执行一次长查询,也可能已经进入上传阶段。把中止标志设成原子变量,让每个检查点读取它,比在通知处理函数里直接终止线程更可靠。
另一个转折点是 M。规范要求接收方不再为被取消的请求发响应。如果服务端在取消后仍然返回结果,客户端应该忽略它。但客户端忽略不代表服务端可以继续跑,服务端自己要先停。
三类失败模式
进度令牌缺失导致前端空转
客户端没有在 _meta 里放 progressToken,服务端就按规范不发任何进度通知。这在协议上完全合法,但用户界面会一直停在“导出中”,没有任何百分比或条数反馈。用户不知道任务是在跑还是已经卡死,只能靠猜。
更隐蔽的情况是客户端放了令牌,但服务端没有读取 ctx.mcpReq._meta.progressToken,或者只在任务开始时读一次、后续通知里漏传。规范要求进度通知必须引用活跃请求的令牌,漏传令牌的通知会被接收方丢弃。
排查这类问题要看两处日志:客户端发出的请求里有没有 _meta.progressToken,服务端有没有对应的 notifications/progress 出站记录。两者缺一,前端就不会更新。
取消竞态导致响应和取消交叉
网络延迟会让取消通知晚于请求完成到达。规范明确提到这种可能:取消可能在处理完成后才到,甚至可能在响应发出后才到。
导出场景里,服务端刚把结果写进响应、正要发送,取消通知到了。服务端此时已经完成处理,按规范可以忽略这条取消。客户端那边,它已经发出取消,应该忽略随后到达的响应。双方各自忽略,任务实际上已经成功,但用户看到的是“已取消”。
另一种竞态是取消先到、服务端正在上传。中止标志被置位,但上传函数没有检查它,整个上传仍然跑完。用户点了停止,对象存储里还是多了一个文件。要避免这种情况,上传分片之间也要读中止标志。
服务端未清理临时资源
这是代价最高的一类失败。取消路径和正常路径分开写,取消分支只抛异常、不做清理,临时文件、数据库游标、对象存储的分片上传会话都会残留。
规范只说接收方“应该”释放相关资源,没有规定怎么释放。工程上通常把资源获取和释放放在同一个作用域里,用 try/finally 或语言的等价结构保证两条路径都走清理。如果服务端用了连接池,还要确认取消时连接是归还而不是泄漏。
超时轮询与幂等重试的对比
不是所有长任务都需要进度和取消。有些客户端选择超时轮询,有些选择幂等重试。三种方案解决的是不同问题。
| 维度 | 进度通知加取消 | 超时轮询 | 幂等重试 |
|---|---|---|---|
| 解决的核心问题 | 用户可见进度,可主动中止 | 客户端不无限等待 | 网络抖动后安全重发 |
| 协议依赖 | 需要 progressToken 和取消通知 | 只需请求超时设置 | 需要业务侧幂等键 |
| 服务端改动 | 需埋检查点、发通知、清理资源 | 无需改动 | 需去重存储和状态查询 |
| 取消后资源回收 | 由服务端检查点保证 | 不涉及,任务继续跑 | 不涉及,任务继续跑 |
| 用户感知延迟 | 实时 | 超时前无反馈 | 重试期间无反馈 |
| 主要失败模式 | 令牌缺失、竞态、清理遗漏 | 任务在服务端空转 | 重复执行副作用 |
| 适用场景 | 分钟级、用户可等待、资源敏感 | 秒级、结果可丢弃 | 写操作、必须最终成功 |
超时轮询的代价是任务在服务端继续执行。客户端超时返回后,导出仍在跑,临时文件仍在写。如果这类任务多,磁盘和连接会被慢慢占满。
幂等重试解决的是另一件事:网络断开后客户端不知道请求是否被处理,重发可能造成重复导出。它需要一个幂等键,让服务端识别“这个请求我已经处理过”,直接返回上次结果。重试不解决资源回收,也不给用户进度。
三种方案可以叠加。导出工具可以用进度通知给用户反馈,用取消处理主动中止,同时用幂等键防止重试造成重复文件。叠加会增加服务端复杂度,需要权衡。
可观测指标与验证方法
服务端要能回答“有多少取消请求真正停了任务”。可以观察这些信号:
- 活跃请求数与活跃进度令牌数是否一致。两者长期偏离说明令牌没有随请求结束释放。
- 取消通知收到数与被取消请求的实际中止数。差值大说明检查点太少或位置不对。
- 临时文件目录的文件数与活跃导出任务数。文件数持续高于任务数说明清理遗漏。
- 取消后到任务真正停止的时间。这个值应该接近一个检查点的间隔,而不是整个任务时长。
- 进度通知的发送频率。规范建议双方做限流,频率过高会挤占正常消息通道。
验证取消是否生效,可以在测试环境发起一个长导出,中途发取消,然后检查三件事:临时文件是否被删除、数据库连接是否归还、对象存储是否没有新增对象。只检查客户端界面显示“已取消”不够,那只能说明客户端不再等待。
适用边界与未解决的问题
进度通知和取消适合分钟级、用户可等待、资源占用明显的任务。秒级任务加这套机制,检查点和通知的开销可能超过任务本身。完全后台化、不需要用户等待的任务,取消也没有意义。
规范留下的空白主要在资源回收的验证上。取消是通知,没有确认,客户端无法从协议层面确认服务端已经释放资源。要弥补这一点,只能在业务层加一个查询接口,或者依赖服务端的可观测指标。
另一个未定问题是取消的传播范围。如果一个导出任务内部又调用了其他 MCP 工具,取消是否应该级联到子请求,规范没有规定。工程上通常由服务端自己决定,并在工具文档里写明。
最后,进度值的语义由服务端定义。progress: 12000 可能表示文档条数,也可能表示字节数。客户端不应该假设单位,只应该按 progress / total 的比例展示。total 缺失时,客户端只能显示“进行中”,不能编造百分比。