用户点了「取消」,我的服务进程退出了

上一篇结尾留了一句话:超过 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

三步捋清楚就明白了:

  1. 我的后台任务在 sleep 之后,要写一次 updateTaskStatus(taskId, "working", "正在生成摘要")
  2. 但任务已经被取消,状态是 cancelled------terminal,SDK 不允许再变回 working,抛错
  3. 这个抛错发生在一个 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 没关系,但值得记住。

相关推荐
网易云信9 分钟前
别急着让 AI 出方案:学会追问三步走,医美咨询全跑通
人工智能
panyz29 分钟前
不想再开 7 个标签页查AI额度,我用 Node 写了个本地看板
人工智能·开源·github
炫酷的大高个35 分钟前
3.2TB 权重开源了,我算了 4bit 之后还要几张卡
人工智能·架构·开源
字节跳动开源38 分钟前
AI 原型几分钟,设计返工一整天?OpenViking 让 AI 用上你的 Figma 设计稿
人工智能·开源
陈工大模型2 小时前
解压工具的三个技术分水岭:编码、分卷、容器格式
人工智能·windows·安全·解压工具·免费国内办公软件
hpoenixf2 小时前
让 Agent 持续研究半小时:从普通研究到可靠交付的设计
人工智能
MysTic_Zhong3 小时前
【2】整体架构蓝图:六层依赖与技术选型
人工智能·agent
硅谷秋水3 小时前
EgoHumanoid‑V2 方法分析
人工智能·机器学习·计算机视觉·语言模型·机器人
hpoenixf3 小时前
Spec 驱动的 Agent 研发全景:从需求澄清到可信交付
人工智能