上一篇结尾留了一句话:超过 grace 时间的任务一定会丢,只能靠幂等键和「提交-查询」两段式兜住。
我当时以为这是唯一解。后来翻 SDK 的类型定义,才发现协议在 2025-11-25 里已经有一整套任务原语:
bash
tasks/get 查状态
tasks/result 取结果
tasks/cancel 取消
tasks/list 列表
也就是说,上一篇文章里我手写的「先提交拿 id、再轮询查结果」两个工具,其实是在重新发明协议已经规定好的东西。
于是我把它接进那个笔记服务,跑了一晚上,踩了五个坑。最后一个坑是这样的:用户点了一次「取消」,我的服务进程退出了,exit code 1。
先把结论放在前面:任务原语把「长任务怎么被调用」标准化了。但它不负责 「长任务怎么活下来」,也不负责「取消的时候把正在跑的代码停下来」。
前者你得自己换 store,后者你得自己查状态。两件事它都不管。
任务工具长什么样
普通工具用 registerTool,任务工具用 registerToolTask,回调从「一个函数」变成「三个函数」:
typescript
server.experimental.tasks.registerToolTask(
"summarize_all_notes",
{
description: "读完全部笔记并生成一份摘要。耗时较长,建议以任务方式调用。",
inputSchema: {
durationMs: z.number().int().min(0).max(60000).optional(),
},
execution: { taskSupport: "optional" },
},
{
// 1) 建任务:必须立刻返回,不能在这里干活
createTask: async ({ durationMs }, extra) => {
const task = await extra.taskStore.createTask({ ttl: 300_000 });
void runBackground(extra.taskStore, task.taskId, durationMs ?? 6000);
return { task };
},
// 2) 查状态
getTask: async (_args, extra) => {
const task = await extra.taskStore.getTask(extra.taskId);
if (!task) throw new Error(`任务不存在:${extra.taskId}`);
return task;
},
// 3) 取结果
getTaskResult: async (_args, extra) => {
return (await extra.taskStore.getTaskResult(extra.taskId)) as never;
},
}
);
三个回调对应三个协议方法,一一对得上。createTask 只负责"登记一个任务然后返回",真正的活在后台跑------这一点和上一篇讲的是同一个道理,请求的生命周期不能绑着任务的执行周期。
还有一处容易漏:taskStore 要传在构造参数里。
css
const server = new McpServer(
{ name: "notes-tasks", version: "1.0.0" },
{
capabilities: { tools: {}, tasks: { requests: { tools: { call: {} } } } },
taskStore: new InMemoryTaskStore(),
}
);
不传 taskStore,任务相关的 handler 根本不会挂上去。
跑一次看看
客户端这边,用 callToolStream 而不是 callTool:
ini
const stream = client.experimental.tasks.callToolStream({
name: "summarize_all_notes",
arguments: { durationMs: 6000 },
});
for await (const msg of stream) {
if (msg.type === "taskCreated") console.log("任务号", msg.task.taskId);
if (msg.type === "taskStatus") console.log("状态", msg.task.status);
if (msg.type === "result") console.log("结果", msg.result);
}
一个 6 秒的任务,客户端看到的是这样:
scss
[ 2876ms] taskCreated id=9ef48382 status=working (+4ms)
[ 2880ms] taskStatus working "正在读取笔记" (+8ms)
[ 3887ms] taskStatus working "正在读取笔记" (+1015ms)
[ 4897ms] taskStatus working "正在生成摘要" (+2025ms)
[ 5909ms] taskStatus working "正在生成摘要" (+3037ms)
[ 7928ms] taskStatus working "正在生成摘要" (+5056ms)
[ 8939ms] taskStatus completed "正在生成摘要" (+6067ms)
[ 8947ms] result (+6075ms) 前 60 字:共 3 篇笔记,摘要如下...
taskCreated 4 毫秒就回来了。之后每一秒一次状态,第六秒出结果。
这解决了上一篇最难受的那个问题:以前客户端要傻等到 60 秒超时才知道出事了,现在每一秒都有一个心跳。服务要是死了,最迟一秒就能发现,而不是 58 秒后收到一句误导性的「超时」。
不用 SDK 也能跑:四个请求
任务原语最舒服的一点是它在协议层,不依赖 SDK。我用手写 HTTP 把整个流程走了一遍。
第一步,tools/call 多带一个 task 字段:
rust
curl -X POST http://localhost:3020/mcp \
-H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{
"name":"summarize_all_notes",
"arguments":{"durationMs":4000},
"task":{"ttl":300000}}}'
回来的不是结果,而是一个任务对象:
json
{"result":{"task":{
"taskId":"7afcfb05837a9e57058ba732e33c4300",
"status":"working",
"ttl":300000,
"pollInterval":1000,
"statusMessage":"正在读取笔记"
}}}
注意 pollInterval: 1000------服务端在告诉客户端「每隔多久来问我一次」。轮询节奏是服务端定的,不是客户端猜的。
第二步查状态,第三步再查一次:
json
{"result":{"taskId":"7afc...","status":"working","pollInterval":1000}}
{"result":{"taskId":"7afc...","status":"completed","pollInterval":1000}}
第四步取结果,tasks/result 直接返回原本该在 tools/call 里返回的那份 content。
四个请求,没有任何 SDK 特有的东西。这也是任务原语比我上一篇手写的两个工具更好的地方:它是一份约定,不是一个库。
坑一:不先列工具,任务模式会静默失效
我第一版探测脚本写完,跑出来是这样的:
ini
[ 20596ms] result (+6011ms) sawTaskCreated=false
没有 taskCreated,没有状态心跳,直接同步等了 6 秒。
我以为是服务端没生效,去翻日志,发现服务端确实建了任务:
ini
[ 6952ms] createTask id=973b4e7e status=working 预计 6000ms
[ 12958ms] 任务完成 id=973b4e7e 耗时=6006ms
服务端建了任务、跑完了、写了结果。但客户端从头到尾没拿到 taskId,它拿到的是一份"看起来正常"的同步结果。
查 callToolStream 的实现才明白:
css
task: options?.task ?? (clientInternal.isToolTask(params.name) ? {} : undefined)
isToolTask() 查的是一个缓存,而这个缓存只在 listTools() 之后才被填充:
ini
cacheToolMetadata(tools) {
for (const tool of tools) {
const taskSupport = tool.execution?.taskSupport;
if (taskSupport === 'required' || taskSupport === 'optional') {
this._cachedKnownTaskTools.add(tool.name);
}
}
}
所以顺序必须是:先 listTools(),再 callToolStream()。少这一步,它不报错,只是安静地退化成同步调用。
这个坑之所以阴,是因为它在小任务上完全看不出来------6 秒变 6 秒,结果还是对的。只有任务长到会超时的时候,你才发现任务机制压根没启用,而那时候你已经没有日志能证明它没启用了。
坑二:optional 还是 required
execution.taskSupport 有三个值:optional、required、forbidden。这里有个容易搞反的细节:
- 普通工具用
registerTool,execution默认是forbidden------它就是不给任务调用 - 任务工具用
registerToolTask,漏写execution会默认成required,不是退化成普通工具
我原本以为漏写会变回同步工具,结果拿 listTools 一看:
json
{"name":"no_exec","execution":{"taskSupport":"required"}}
也就是说漏写的后果是「对客户端的要求更硬」,而不是「悄悄降级」。这个默认值设计得挺合理,但和普通工具相反,第一次遇到会愣一下。
optional 和 required 的差别,我把六种组合都撞了一遍:
| 客户端状态 | optional |
required |
|---|---|---|
有工具缓存(调过 listTools)+ 声明 tasks |
走任务,拿到 taskId | 走任务,拿到 taskId |
| 有工具缓存 + 未声明 tasks | 同步等 6 秒 | 客户端本地抛 -32600 |
无缓存(漏了 listTools) |
同步等 6 秒 | 服务端返回 isError + -32601 |
required 下那句 -32600 是客户端自己拦的,请求根本没发出去:
css
同步调用失败 code=-32600
MCP error -32600: Tool "summarize_all_notes" requires task-based execution.
Use client.experimental.tasks.callToolStream() instead.
注意它只是"客户端尽力拦"。手写客户端不会拦,所以服务端也得拦。我用 curl 直接打那个 required 工具、不带 task 字段:
json
{"result":{"content":[{"type":"text","text":"MCP error -32601: Tool summarize_all_notes
requires task augmentation (taskSupport: 'required')"}],"isError":true}}
服务端确实拦了,方式是返回 isError: true 的工具结果,而不是 JSON-RPC 错误。错误码用了 -32601(Method not found),语义上有点怪------工具是存在的,只是调用方式不对------但至少拦住了。
optional 就没有这层保护。 一个不支持任务的客户端,会在 optional 工具上一直同步等待,等到它自己的超时。如果你的任务可能跑两分钟,那这个客户端会先超时,而服务端还在跑------正好是上一篇那个丢单场景。
坑三:无状态模式下,任务一建就找不到
接进真实服务的时候,我把 taskStore 建在了 createServer() 里面------也就是每个 server 实例一份。这是上一篇生产版的老写法,看起来最自然。
然后客户端拿到 taskCreated,3 毫秒后报错:
vbnet
code: -32602
message: MCP error -32602: Failed to retrieve task: Task not found
tasks/get 一次都没打到 handler 上。
原因和第六篇的 elicitation、第九篇的 inflight 表一模一样:无状态模式下每个 HTTP 请求都新建一个 server 实例。建任务的是实例 A,轮询状态的是实例 B,B 的 store 是空的。
改成进程级共享就通了:
arduino
// 建在模块顶层,所有实例共用
const globalStore = new InMemoryTaskStore();
坑四:重启之后任务就没了
InMemoryTaskStore 的注释写得很客气:
This is not suitable for production use as all data is lost on restart.
我去验证了一下这句话有多狠。建一个 20 秒的任务,3 秒后杀掉服务,重启,问它那个 taskId:
bash
tasks/get → {"error":{"code":-32602,"message":"Failed to retrieve task: Task not found"}}
tasks/result → {"error":{"code":-32602,"message":"Task not found: 7c97720b..."}}
任务凭空消失了,客户端手上只有一个再也问不出东西的 id。
这就是任务原语的边界 :它标准化了「怎么问」,但「问的东西还在不在」完全取决于你把 store 放在哪。内存 store 只适合演示,生产上要么落库、要么放 Redis,而且 ttl 得覆盖你的最长任务时间------否则任务还在跑,记录先过期了。
顺带说一句,-32602 是 InvalidParams。客户端拿到这个码,很难理解成「服务重启了一次,你的任务没了」,更像是「你参数传错了」。这个语义对排障不友好,得自己在日志里补上。
坑五:取消只是改状态,不会停代码
这是最后一晚踩到的,也是最狠的一个。
我用 curl 建了一个 9 秒的任务,1 秒后调 tasks/cancel,返回很漂亮:
json
{"result":{"taskId":"53e84950...","status":"cancelled"}}
然后服务挂了。exit code 1:
vbnet
McpError: MCP error -32602: Cannot update task "53e84950..."
from terminal status "cancelled" to "working".
Terminal states (completed, failed, cancelled) cannot transition to other states.
at Object.updateTaskStatus (shared/protocol.js:1064)
at async src/server-tasks.ts:70:11
Node.js v22.22.2
三步捋清楚就明白了:
- 我的后台任务在
sleep之后,要写一次updateTaskStatus(taskId, "working", "正在生成摘要") - 但任务已经被取消,状态是
cancelled------terminal,SDK 不允许再变回working,抛错 - 这个抛错发生在一个
void (async () => {...})()里,没有任何人 catch
未捕获的 promise rejection,在 Node 22 上默认直接终止进程。
也就是说:任何一个用户点一次「取消」,整个服务就会挂掉,所有人的任务一起断。 这不是一个任务失败,是一个实例下线。
更值得注意的是第一层原因:tasks/cancel 从头到尾没有给后台代码发过任何信号。它做的事就三件------查一次状态、把状态改成 cancelled、清掉消息队列:
javascript
const task = await this._taskStore.getTask(taskId, sessionId);
if (isTerminal(task.status)) {
throw new McpError(..., `Cannot cancel task in terminal status: ${task.status}`);
}
await this._taskStore.updateTaskStatus(
taskId, 'cancelled', 'Client cancelled task execution.'
);
this._clearTaskQueue(taskId);
没有一行碰到你正在跑的那段代码。你的 for 循环、你的 await fetch、你的文件写入,全都在继续跑。协议里 AbortSignal 那一套是给「请求」用的,任务不在其列。
所以「取消」在 MCP 的任务模型里,准确说法是「标记为取消」。真正的停止,得你自己实现。
修法:每写一次状态,先问一句还活着吗
修复要动三个地方。
第一,把终态判断抽出来:
javascript
const TERMINAL = new Set(["completed", "failed", "cancelled"]);
async function isAlive(store: InMemoryTaskStore, taskId: string) {
const t = await store.getTask(taskId);
return !!t && !TERMINAL.has(t.status);
}
第二,后台体每一步写状态之前先查:
javascript
async function runBackground(store, taskId, cost) {
const started = Date.now();
try {
if (!(await isAlive(store, taskId))) return log(`任务 ${short} 已取消,跳过阶段一`);
await store.updateTaskStatus(taskId, "working", "正在读取笔记");
await sleep(Math.floor(cost / 3));
if (!(await isAlive(store, taskId))) return log(`任务 ${short} 已取消,跳过阶段二`);
await store.updateTaskStatus(taskId, "working", "正在生成摘要");
await sleep(cost - Math.floor(cost / 3));
if (!(await isAlive(store, taskId))) return log(`任务 ${short} 已取消,不写入结果`);
await store.storeTaskResult(taskId, "completed", { content: [...] });
} catch (e: any) {
log(`任务 ${short} 后台异常(已吞掉):${e.message}`);
}
}
第三,void 后面那个 async 函数,必须能吞掉所有异常。这个 catch 不是防御性编程的客套话,它决定了「一个用户取消」是「一个任务结束」还是「一个实例下线」。
改完再跑同一条命令,服务活着:
ini
[ 19599ms] createTask id=ad0ad4e3 status=working 预计 9000ms
[ 22601ms] 任务 ad0ad4e3 已取消,跳过阶段二
healthz 照常返回 {"ok":true}。再去取结果:
css
{"error":{"code":-32603,"message":"Task ad0ad4e3... has no result stored"}}
-32603 是 internal error。语义上还是不够精确------「被取消所以没有结果」应该有个更明确的说法------但至少它诚实地表示「这里没有东西」,而不是假装任务还在跑。
顺带说一个更彻底的做法:如果你的任务可以分片,让每一片开工前查一次状态(像我上面那样)就够了;如果任务里有一个 30 秒的 await,查状态的粒度就得细到那个 await 里面去,否则取消后还要再跑满 30 秒。真要做得干净,就自己维护一个 Map<taskId, AbortController>,在 getTask 被调用时发现状态变了就 abort 掉。这件事 SDK 不会帮你说破,得自己想到。
两个小瑕疵
跑通之后还有两处细节让我不太舒服,写出来免得别人再踩。
一是 statusMessage 不会被清空。 任务 completed 之后,消息还是上一阶段的:
arduino
taskStatus completed "正在生成摘要"
对用户来说就是「已经完成了,但还在说正在生成」。要么在写结果时顺手改成「完成」,要么客户端别把它当进度条文案直接用。
二是取消和失败共用同一个出口。 不管是用户主动取消、任务抛异常、还是进程重启后找不到,客户端看到的都是 -32602 / -32603 这类通用错误码。要区分「我取消了」「它坏了」「它没了」,只能靠 status 字段------而 status 在进程重启后就查不到了。这大概就是它还在 experimental 目录下的原因。
什么时候该用任务原语
| 场景 | 建议 |
|---|---|
| 工具 P99 在 1 秒内 | 别用,registerTool 就够,加任务是纯负担 |
| 工具会跑几秒到几分钟,且客户端可能断线 | 用,任务原语 + 持久化 store |
| 需要给用户看进度 | 用,statusMessage 现成的 |
| 任务要跨发版存活 | 用,但 store 必须落库,内存 store 一定丢 |
| 只在本地 Claude Desktop 里自己用 | 别用,stdio 进程是你自己启停的,没有这个需求 |
| 客户端是老版本、不支持 tasks | 用 optional 会退化成同步,要么接受阻塞,要么升级客户端 |
还有一个判断标准更简单:如果你的工具名字里带「export」「analyze」「generate」这种一看就很慢的动词,它大概率该是任务工具。
总结
- 协议 2025-11-25 自带
tasks/get、tasks/result、tasks/cancel,不用自己发明「提交-查询」两个工具 - 任务工具用
registerToolTask注册,三个回调对应三个协议方法,taskStore必须传在构造参数里 callToolStream的taskCreated实测 4 毫秒返回,之后每秒一次心跳,比等 60 秒超时强太多- 不先
listTools()就调用callToolStream,任务模式会静默退化成同步调用,不报错,最难查 execution.taskSupport在registerToolTask里漏写会默认成required(普通工具才是forbidden),optional对不支持的客户端没有保护- 无状态模式下
taskStore建在实例里,任务建完就找不到,和 elicitation、幂等表是同一个根源 InMemoryTaskStore重启即丢,任务原语不解决存活,只解决「怎么问」tasks/cancel只改状态,不给后台代码发任何信号。后台继续跑,下次写状态就抛错,没人 catch 就是整个进程退出- 修法是三件事:终态判断、每步查状态、后台体全程 catch
适用边界 :如果你的工具都是毫秒级,这篇整篇都不用看,registerTool 更省事。任务原语真正值钱的地方是「客户端可能断线、任务可能超过 60 秒」这两个条件同时成立的时候------它把「等一个不知道还在不在的响应」换成了「每秒问一次还在不在」,这是质的变化。但代价是你要自己管一个 store,还得自己处理取消后的状态竞态。协议能标准化调用语义,标准化不了你的业务生命周期。
最后一句:那个 exit code 1 我盯着看了很久。它不是被取消的任务害的,是被我自己写的 void (async () => ...) 害的------一个没人接的 promise,能把一整个实例带走。这个教训跟 MCP 没关系,但值得记住。