














Model Context Protocol 的 Tasks 扩展(
io.modelcontextprotocol/tasks,SEP-2663),让 server 用一个持久化的 task 句柄代替阻塞式的长时间等待。本文聚焦协议本身——消息流、状态机、数据结构、路由头——C# SDK 只在需要处一笔带过。
普通 tool 调用会阻塞到结果返回。对 CI 流水线、批处理、大计算、需要人工审批的流程,阻塞不可行:
Tasks 的核心是 "call-now, fetch-later":server 判断请求会长跑时,不返回最终结果,而返回一个 taskId;客户端凭它轮询进度、按需补输入、最终取回结果。taskId 是耐用句柄,断线重连后还能接着 poll。
io.modelcontextprotocol/taskstools/call 支持 task 增强;tasks/get / tasks/update / tasks/cancel 是配套方法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"。CreateTaskResult;若非它不可,返回 -32003(Missing Required Client Capability)。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 一旦到达不可再变。
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 // 建议轮询间隔,可选
}
tasks/get 的响应)GetTaskResult = Result & DetailedTask——一个扁平对象,task 字段直接摊在 result 上(不套 task 键),resultType 必须为 "complete"。
公共字段与上面一致,按状态追加:
result,必须是对象——即原始请求本该同步返回的结果(CallToolResult 形状):{
"resultType": "complete",
"taskId": "786512e2-...",
"status": "completed",
"createdAt": "...", "lastUpdatedAt": "...", "ttlMs": 3600000,
"result": {
"content": [ { "type": "text", "text": "Hello, Luca!" } ],
"isError": false
}
}
error(JSON-RPC error 对象),如 { "code": -32603, "message": "..." }。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)。
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 上要去重,避免重复向用户提示。input_required。inputRequests——task 不是更高信任的通道。注意区分:执行中途要输入 → 用这里的
inputRequests/tasks/update;在创建 task 之前就要输入 → 用原始请求的 MRTR 流程(resultType: "input_required"重试),两者是不同机制。
无状态化后 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 严格区分协议级错误和执行级错误:
tasks/get 拿到无效 taskId 返回 -32602)。failed,error 带该 JSON-RPC 错误;isError: true)→ task 进 completed,result 里带那个 isError:true 的 CallToolResult。一句话:tasks/get 返回的,就是底层请求本该返回的东西——协议错走 failed,其余(含工具级错误)走 completed。
ModelContextProtocol.Extensions.Tasks(2.0.0)。.WithTasks(new InMemoryMcpTaskStore())。.WithTasks(store, o => o.ExecutionModeSelector = ctx => ...),返回 Synchronous(不支持)/Optional/Required。IMcpTaskStore 接外部存储;无状态 HTTP 下 store 必须跨请求共享(singleton 或外部存储),否则 tasks/get 找不到 task。CompletedTaskResult / FailedTaskResult / … 或让内置 handler 生成,别手搓 JSON——completed 的 result 必须是 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 造成的。
Mcp-Name 是 taskId 而非工具名:tasks/get/update/cancel 的 Mcp-Name 头规范要求等于 params.taskId(基础规范里 Mcp-Name 只给 tools/call/prompts/get→name、resources/read→uri,tasks 是特例)。Mcp-Method 则每个 POST 都要带、且与 body 一致。Missing required Mcp-Name header(tasks/get):tasks/get 内置不要求 Mcp-Name(GetRoutingNameParameter 对它返回 null)。报这个错是因为自己注册的 McpServerRequestHandler 把 RoutingNameParameter 设成了 "taskId",SDK 于是强制校验该头,而基础规范客户端(MCP Inspector)不发它。改法:开发/单实例设 null;要网关路由才保留并让客户端发头。TaskSupport 属性:SDK 在 v2 把它从 [McpServerTool] 移除了;只在 v1.3/1.4 有,且 experimental(MCPEXP001)。包 < 1.3.0 也没有。v2 改用 .WithTasks(store, o => o.ExecutionModeSelector = ...)。ToolTaskSupport.Forbidden;v2 用 ExecutionModeSelector 返回 McpTaskExecutionMode.Synchronous。照 v1 写法在 v2 编不过。WithTasks 无泛型重载:只有 WithTasks(store) / WithTasks(store, configure),都要求传 IMcpTaskStore 实例,没有 WithTasks<TStore>() 从 DI 解析。DI 带依赖的 store 需在 Build() 后用"延迟壳"回填 provider。IMcpTaskStore 不注册 singleton / 不用外部存储的话,后续 tasks/get 落到空 store、永远找不到 task。CreateTaskAsync() 无参、SDK 自己 mint id;v1 那种 tool return McpTask 自带 id 的写法 v2 没了。要用外部 job id,得用 AsyncLocal 把 id 捅进 CreateTaskAsync,或维护 mcpTaskId ↔ jobId 映射。MCPEXP001(v1 Tasks)、MCPEXP002(McpServerRequestHandler/RoutingNameParameter)、MCPEXP003(Apps)——用到需 #pragma warning disable 或 <NoWarn>,否则编译报错。另注(非 bug,是设计):持久化 store ≠ 容错。store 只保状态;真正在跑的计算若在 MCP 进程内,进程挂了就没了。要跨重启续跑需独立执行层(Service Bus / Temporal / Durable Functions)。
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。