AI 技术
#MCP#Cancellation#Progress Token#Long-running Task#Resource Cleanup

MCP 进度通知与取消:长任务执行中客户端如何中断并回收服务端资源

远程 MCP Server 执行耗时数据导出时,客户端如何用进度令牌跟踪任务、用取消通知请求中止,以及服务端如何回收临时文件与数据库连接。文章分析令牌缺失、取消竞态和资源未清理三类失败模式,并对比超时轮询与幂等重试的适用边界。

一个导出任务卡住之后

假设你在一个企业知识库客户端里点下“导出全部文档”。这个操作由远程 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 缺失时,客户端只能显示“进行中”,不能编造百分比。

资料来源

  1. MCP Specification - Progress
  2. MCP Specification - Cancellation
  3. MCP TypeScript SDK - Server
  4. Logging, progress, and cancellation | MCP TypeScript SDK