MCP 里跑长任务的正确姿势不是”客户端一直等”,而是三件事一起做对:用 progressToken 报进度、用 notifications/cancelled 支持取消、用超时兜底。如果任务真的会超过几十秒,那就别阻塞了——改用 Tasks 扩展,返回一个可轮询的 taskId 。
进度通知:客户端先要,服务端才发
进度不是服务端想发就发的。客户端发起请求时在 _meta 里带上 progressToken,服务端从中读出来,才能往回发 notifications/progress。
// 服务端:从请求上下文取 token,逐单元上报
server.registerTool('process-files', {
description: 'Process files with progress updates',
inputSchema: z.object({ files: z.array(z.string()) })
}, async ({ files }, ctx) => {
const progressToken = ctx.mcpReq._meta?.progressToken;
for (let i = 0; i < files.length; i++) {
await process(files[i]);
if (progressToken) {
await ctx.mcpReq.notify({
method: 'notifications/progress',
params: {
progressToken,
progress: i + 1,
total: files.length,
message: `Processed ${files[i]}`
}
});
}
}
return { content: [{ type: 'text', text: `Processed ${files.length} files` }] };
});
三条硬规则:
- progress 必须单调递增,
total和message可选。 - 客户端没给 token 就别发。省掉这次调用,同一个请求就不会带 token,服务端的空值判断自然跳过上报。
- progressToken 只对当前请求有效,不要跨请求复用,也不要自己编一个。
取消:三件事必须同时做对
很多”取消点了没反应”的问题,是因为只做了一半。
- 客户端侧:发
notifications/cancelled,带上要取消的requestId和可选的reason。 SDK 层面通常就是 abort 一个 signal 。 - 服务端侧:把
ctx.mcpReq.signal(AbortSignal)用到实处——在每个工作单元开始前检查它,被 abort 就立刻收尾退出。只注册回调不检查,等于没实现取消。 - 传输层差异:这一条最容易踩。
| 传输 | 取消信号 | 要点 |
|---|---|---|
| Streamable HTTP | 关闭 SSE 响应流 | 服务端必须把客户端断连当作取消,不需要也不应期待收到 notifications/cancelled |
| stdio | 显式发 notifications/cancelled | 没有可关闭的流,必须引用 requestId 发通知 |
{ "jsonrpc": "2.0", "method": "notifications/cancelled",
"params": { "requestId": "123", "reason": "User requested cancellation" } }
服务端的义务是”尽力”:应当停止处理、释放资源、并且不再为被取消的请求回响应;如果请求 ID 未知、已经处理完、或者本身无法取消,可以忽略这条通知。客户端同样要忽略迟到到达的响应——网络延迟下,取消通知和响应可能顺序颠倒,双方都得能优雅处理这个竞态。
超时:进度可以续命,但不能无限续
规范的建议是给所有发出的请求设超时,超时后发一个取消并停止等待。实现上可以在收到进度通知时重置计时器(毕竟说明活儿还在干),但必须同时保留一个最大超时上限——否则一个不停报进度却永不结束的对端,会把连接和连接资源一直占着。
什么时候该上 Tasks 扩展
如果你的任务已经不是”几秒”级别,阻塞式调用就开始碍事了:很多客户端和中间层都有超时,长连接本身也脆弱。 Tasks 扩展的做法是服务端返回一个持久化句柄,客户端拿它轮询。
| 状态 | 含义 |
|---|---|
| working | 执行中 |
| input_required | 需要客户端补充输入(如一次确认),通过 tasks/update 回应 |
| completed | 完成,result 字段就是原本同步返回的内容 |
| failed | 失败,error 字段是 JSON-RPC 错误 |
| cancelled | 已取消(不保证被真正执行) |
CreateTaskResult 里带上 taskId、初始状态、 TTL 和建议轮询间隔;客户端用 tasks/get 查状态,需要补充输入时用 tasks/update 回应。服务端如果支持,也可以通过 subscriptions/listen 推 notifications/tasks,省掉轮询。
两个提醒:取消在 Tasks 里是协作式的——服务端确认收到意图,但不保证真的停下;能力要协商,客户端在每请求能力里声明该扩展,服务端也要在自己能力里宣告。相关做法我们在MCP 能力协商里展开过。
五个常见错误
- 服务端不看 progressToken 就无条件上报进度。
- 注册了取消回调,但循环体里从不检查 signal 。
- 在 stdio 传输下期望”关流即取消”,结果对方根本收不到。
- 进度通知重置超时,却没有最大超时上限。
- 把长任务硬塞进同步调用,客户端一断连,已经干完的活儿全部丢失。










评论 (0)