AIGC标识 MCP Tasks 扩展

MCP Tasks 扩展:协议详解

Model Context Protocol 的 Tasks 扩展(io.modelcontextprotocol/tasks,SEP-2663),让 server 用一个持久化的 task 句柄代替阻塞式的长时间等待。本文聚焦协议本身——消息流、状态机、数据结构、路由头——C# SDK 只在需要处一笔带过。


一、为什么需要 Tasks

普通 tool 调用会阻塞到结果返回。对 CI 流水线、批处理、大计算、需要人工审批的流程,阻塞不可行:

  • 连接被长时间占用,而客户端/网关普遍有超时;
  • 连接一断,进度全丢,不可恢复;
  • 看不到进度,也无法在执行中途与用户交互。

Tasks 的核心是 "call-now, fetch-later":server 判断请求会长跑时,不返回最终结果,而返回一个 taskId;客户端凭它轮询进度、按需补输入、最终取回结果。taskId 是耐用句柄,断线重连后还能接着 poll。

  • 扩展标识符:io.modelcontextprotocol/tasks
  • 当前仅 tools/call 支持 task 增强;tasks/get / tasks/update / tasks/cancel 是配套方法
  • 可选扩展,客户端与服务端双向 opt-in 才生效

二、整体消息流

sequenceDiagram participant C as Client participant S as Server (+ Task Store) Note over C,S: 1. 能力协商(每次请求携带) C->>S: tools/call { _meta: clientCapabilities.extensions["io.modelcontextprotocol/tasks"] } Note over S: server 自行决定是否把这次调用变成 task S-->>C: CreateTaskResult { resultType:"task", taskId, status:"working", ttlMs, pollIntervalMs } Note over C,S: 2. 轮询(按 pollIntervalMs) loop 直到终态 C->>S: tasks/get { taskId } S-->>C: GetTaskResult { resultType:"complete", status:"working", ... } end Note over C,S: 3. 中途需要输入(可选) S->>S: status -> input_required C->>S: tasks/get { taskId } S-->>C: { status:"input_required", inputRequests:{ key: elicitation/sampling } } C->>S: tasks/update { taskId, inputResponses:{ key: {...} } } S-->>C: {} (empty ack) Note over S: 收齐输入后 status 回到 working Note over C,S: 4. 完成 C->>S: tasks/get { taskId } S-->>C: { status:"completed", result: { content:[...], isError:false } }

关键规则:

  • 能力协商:客户端在每次请求的 _meta.io.modelcontextprotocol/clientCapabilities.extensions 里声明扩展;server 在 server/discover 里 advertise。server 是唯一决定方,按请求逐个决定是否创建 task;客户端不在请求上表达"我想要 task"。
  • 未声明扩展的客户端:server MUST NOT 返回 CreateTaskResult;若非它不可,返回 -32003(Missing Required Client Capability)。
  • 持久化先于响应:server MUST NOT 在 task 持久化之前返回 CreateTaskResult——即返回那一刻起,对该 taskId 的 tasks/get 必须能命中(哪怕打到别的实例)。

三、状态机

stateDiagram-v2 [*] --> working: 创建 task working --> input_required: 需要客户端输入 input_required --> working: tasks/update 收齐输入 working --> completed: 执行成功 working --> failed: JSON-RPC 协议错误 working --> cancelled: tasks/cancel(协作式) input_required --> cancelled: tasks/cancel completed --> [*] failed --> [*] cancelled --> [*]
状态 含义 终态
working 正在处理
input_required 等待客户端输入,见 inputRequests
completed 成功,result 含最终结果(含 isError:true 的工具结果)
failed 执行中发生 JSON-RPC 协议错误,error 含错误
cancelled 被取消(取消是协作式,不保证到达)

completed / failed / cancelled 一旦到达不可再变。


四、数据结构

CreateTaskResult(创建 task 时返回,取代标准结果)

resultType 必须"task",用来和标准结果区分:

{
  "resultType": "task",
  "taskId": "786512e2-...",
  "status": "working",
  "statusMessage": "The operation is now in progress.",  // 可选
  "createdAt": "2025-11-25T10:30:00Z",   // ISO 8601
  "lastUpdatedAt": "2025-11-25T10:40:00Z",
  "ttlMs": 60000,                        // number 或 null(无限)
  "pollIntervalMs": 5000                 // 建议轮询间隔,可选
}

GetTaskResult(tasks/get 的响应)

GetTaskResult = Result & DetailedTask——一个扁平对象,task 字段直接摊在 result 上(不套 task),resultType 必须"complete"

公共字段与上面一致,按状态追加:

  • working / cancelled:无额外字段。
  • completed:加 result,必须是对象——即原始请求本该同步返回的结果(CallToolResult 形状):
{
  "resultType": "complete",
  "taskId": "786512e2-...",
  "status": "completed",
  "createdAt": "...", "lastUpdatedAt": "...", "ttlMs": 3600000,
  "result": {
    "content": [ { "type": "text", "text": "Hello, Luca!" } ],
    "isError": false
  }
}
  • failed:加 error(JSON-RPC error 对象),如 { "code": -32603, "message": "..." }
  • input_required:加 inputRequests(map),见下。

三个配套方法

方法 请求 params 响应
tasks/get { taskId } GetTaskResult(上面)
tasks/update { taskId, inputResponses:{ key:{...} } } 空 ack { resultType:"complete" }
tasks/cancel { taskId } 空 ack;取消是协作式

无效/不存在的 taskId:tasks/get MUST 返回 -32602(Invalid params)。没有 tasks/list——一个调用方看不到另一个的 task(安全:taskId 需高熵、可当 bearer token)。


五、中途输入(MRTR)

task 执行到一半需要用户确认/补数据时,进入 input_required,tasks/get 响应里带 inputRequests——每个条目就是一个完整的 server→client 请求(elicitation 或 sampling):

{
  "status": "input_required",
  "inputRequests": {
    "name": {
      "method": "elicitation/create",
      "params": {
        "mode": "form",
        "message": "Please enter your name.",
        "requestedSchema": {
          "type": "object",
          "properties": { "name": { "type": "string" } },
          "required": ["name"]
        }
      }
    }
  }
}

客户端通过 tasks/update 回填,key 与 inputRequests 对应:

{
  "method": "tasks/update",
  "params": {
    "taskId": "786512e2-...",
    "inputResponses": { "name": { "action": "accept", "content": { "input": "Luca" } } }
  }
}

规则要点:

  • inputRequests某一时刻所有未决请求的快照;客户端在同一 key 上要去重,避免重复向用户提示。
  • 每个 key 在一个 task 生命周期内唯一,不复用。
  • server 可接受部分响应,未收齐前保持 input_required
  • 客户端必须用对待独立 elicitation/sampling 请求的同等信任模型来处理 inputRequests——task 不是更高信任的通道。

注意区分:执行中途要输入 → 用这里的 inputRequests/tasks/update;在创建 task 之前就要输入 → 用原始请求的 MRTR 流程(resultType: "input_required" 重试),两者是不同机制。


六、Streamable HTTP 路由头

无状态化后 server 要活在负载均衡/网关后面。规范要求 POST 带标准头,让中间层不解析 body 就能路由:

  • Mcp-Method:每个 POST 都带,值 = JSON-RPC 方法名。
  • Mcp-Name:只对携带 primitive 名字的请求——tools/call/prompts/get(→ body 的 name)、resources/read(→ uri)。

Tasks 额外规定:tasks/get / tasks/update / tasks/cancel 时,客户端必须Mcp-Name = params.taskId,让中间层按 taskId 把请求路由到持有该 task 的实例。server 校验头与 body 一致,不一致直接拒。

实践坑:C# SDK 里 tasks/get 默认不要求 Mcp-Name;若你手动注册请求 handler 并设了 RoutingNameParameter = "taskId",就会强制要求它,导致只按基础规范发头的客户端(如 MCP Inspector)报 Missing required Mcp-Name header。开发/单实例场景把它设为 null 即可;需要网关按 taskId 路由时才保留,并让客户端发 Mcp-Name: <taskId>


七、错误处理:两条通道

Tasks 严格区分协议级错误执行级错误:

  1. 协议错误 → 标准 JSON-RPC error(如 tasks/get 拿到无效 taskId 返回 -32602)。
  2. 执行错误:
    • 底层请求发生 JSON-RPC 协议错误 → task 进 failed,error 带该 JSON-RPC 错误;
    • 请求正常返回、但工具业务失败(isError: true)→ task 进 completed,result 里带那个 isError:true 的 CallToolResult。

一句话:tasks/get 返回的,就是底层请求本该返回的东西——协议错走 failed,其余(含工具级错误)走 completed


八、C# SDK 落地(要点即可)

  • :v2 独立扩展包 ModelContextProtocol.Extensions.Tasks(2.0.0)。
  • 服务端开启:.WithTasks(new InMemoryMcpTaskStore())
  • 哪些 tool 支持 task:.WithTasks(store, o => o.ExecutionModeSelector = ctx => ...),返回 Synchronous(不支持)/Optional/Required
  • 持久化:实现 IMcpTaskStore 接外部存储;无状态 HTTP 下 store 必须跨请求共享(singleton 或外部存储),否则 tasks/get 找不到 task。
  • 构造响应:优先用 SDK 的 CompletedTaskResult / FailedTaskResult / … 或让内置 handler 生成,别手搓 JSON——completedresult 必须是 CallToolResult 对象,塞裸字符串会被客户端 schema 拒(expected: record, path: ["result"])。

v1(1.3/1.4)是内建 Core 的 experimental 版,用 [McpServerTool(TaskSupport=...)] + McpServerOptions.TaskStore;v2 与 v1 wire 不兼容


九、踩坑速查

区分两类:协议层的坑源自规范本身的规则(换任何 SDK 都存在);SDK 层的坑是 C# SDK 的实现/API 造成的。

9.1 协议层(规范规则,与实现无关)

  1. Mcp-Name 是 taskId 而非工具名:tasks/get/update/cancelMcp-Name 头规范要求等于 params.taskId(基础规范里 Mcp-Name 只给 tools/call/prompts/getnameresources/readuri,tasks 是特例)。Mcp-Method 则每个 POST 都要带、且与 body 一致。
  2. v1/v2 wire 不兼容:2026-07-28 的 Tasks(SEP-2663)与 2025-11-25 的 experimental Tasks 在 API 和协议层都不兼容——v2 client 打 v1 task-server 只拿到普通结果,反之亦然。这是规范级 breaking change。

9.2 SDK 层(C# SDK 实现/API 造成)

  1. Missing required Mcp-Name header(tasks/get):tasks/get 内置不要求 Mcp-Name(GetRoutingNameParameter 对它返回 null)。报这个错是因为自己注册的 McpServerRequestHandlerRoutingNameParameter 设成了 "taskId",SDK 于是强制校验该头,而基础规范客户端(MCP Inspector)不发它。改法:开发/单实例设 null;要网关路由才保留并让客户端发头。
  2. v2 找不到 TaskSupport 属性:SDK 在 v2 把它从 [McpServerTool] 移除了;只在 v1.3/1.4 有,且 experimental(MCPEXP001)。包 < 1.3.0 也没有。v2 改用 .WithTasks(store, o => o.ExecutionModeSelector = ...)
  3. "让某 tool 不支持 task"的 API 变了:v1 用 attribute 的 ToolTaskSupport.Forbidden;v2 用 ExecutionModeSelector 返回 McpTaskExecutionMode.Synchronous。照 v1 写法在 v2 编不过。
  4. WithTasks 无泛型重载:只有 WithTasks(store) / WithTasks(store, configure),都要求传 IMcpTaskStore 实例,没有 WithTasks<TStore>() 从 DI 解析。DI 带依赖的 store 需在 Build() 后用"延迟壳"回填 provider。
  5. 无状态 HTTP 下 store 必须共享:SDK 每请求 new 一个 server 实例,IMcpTaskStore 不注册 singleton / 不用外部存储的话,后续 tasks/get 落到空 store、永远找不到 task。
  6. taskId 由 store 生成,tool 不能自带:v2 CreateTaskAsync() 无参、SDK 自己 mint id;v1 那种 tool return McpTask 自带 id 的写法 v2 没了。要用外部 job id,得用 AsyncLocal 把 id 捅进 CreateTaskAsync,或维护 mcpTaskId ↔ jobId 映射。
  7. experimental 诊断码要抑制:MCPEXP001(v1 Tasks)、MCPEXP002(McpServerRequestHandler/RoutingNameParameter)、MCPEXP003(Apps)——用到需 #pragma warning disable<NoWarn>,否则编译报错。

另注(非 bug,是设计):持久化 store ≠ 容错。store 只保状态;真正在跑的计算若在 MCP 进程内,进程挂了就没了。要跨重启续跑需独立执行层(Service Bus / Temporal / Durable Functions)。


参考

posted @ 2026-08-08 10:39  victor.x.qu  阅读(0)  评论(0)    收藏  举报