MCP toolがCI、一括取り込み、調査ジョブを始めると、1回のtools/callを開いたまま待つ設計はすぐに限界を迎えます。通信が切れた後に結果を取り直せない、同じ処理を再送して二重実行する、キャンセルしたつもりでも外部ジョブが走り続ける。この問題をプロトコル上の永続的な状態機械として扱うのがMCP Tasks extensionです。
この記事は、長時間処理をMCP Tasksへ切り出す判断と運用設計に限定します。2026-07-28仕様全体への移行は MCP 2026-07-28 移行ガイド、同じリクエストを同期的に再送して追加入力を返す場合は MCP MRTR 設計ガイド を参照してください。業務run全体の再実行とcheckpointは AIエージェント再実行設計ガイド、MCPの外側も含むpause / resume / cancelは AIエージェント run control 設計ガイド が担当します。
先に結論: 長い処理ではなく、切断後も追跡する処理に使う
Tasksを選ぶ条件は処理時間だけではありません。次の3条件のうち2つ以上に当てはまるなら候補です。
- クライアントの切断や再起動後も同じ処理を追跡したい
- 外部job IDとMCP上のtaskを対応付けたい
- 実行中に追加入力やcancelを受け付けたい
数秒で終わるread-only callなら通常のtools/callで十分です。同じ論理リクエストの途中で1回だけ承認や入力が必要ならMRTRを使います。数分以上続き、クライアントがtask IDを保存して後から再開する場合にTasksを使います。
| 判断 | 通常のtool call | MRTR | Tasks |
|---|---|---|---|
| 主目的 | 即時結果 | 同じrequestの追加入力 | 非同期jobの継続追跡 |
| client再起動 | 再実行が必要 | 元requestからやり直す | taskIdで再開 |
| 状態の置き場所 | request内 | requestState | server側のdurable task store |
| input | 不要 | original requestをretry | tasks/update |
| cancel | request cancellation | retryしない | tasks/cancel |
導入前に対応確認gateを置く
Tasksはcore MCPではなく、io.modelcontextprotocol/tasks extensionで、仕様文書もDraftです。2026年8月15日時点ではhostの対応状況がクライアントごとに異なり、クライアントとサーバーの両方が明示的にopt-inする必要があります。
さらに、公式TypeScript SDK v2の標準handlerへtasks/*を登録するだけでは使えません。typed method mapからTasksが外れ、サーバー側のtasks/getとtasks/cancelはhandler到達前に-32601となる未解決issueがあります。tasks/updateはcustom handlerへ到達できますが、typed APIは未対応で、Tasks全体を標準SDKだけで構築できる状態ではありません。TypeScriptで受信側を作る場合は、SDK前段にextension dispatchを実装する、対応forkを使う、またはissue解消を待つ必要があります。本記事の通信例は、利用中のクライアント、host、SDKでTasks対応を実証できた場合の設計です。
本番設計では、次の4点を実機で確認します。
mcp_tasks_support_gate:
protocol_revision: "2026-07-28"
client_extension: "io.modelcontextprotocol/tasks"
server_extension: "io.modelcontextprotocol/tasks"
verified_methods:
- tools/call
- tasks/get
- tasks/update
- tasks/cancel
notification_path_verified: false
packageが新しいことは証拠になりません。wire logでリクエストごとのcapability、resultType、各methodのdispatch、切断後のpollを確認します。notificationが未対応でも、pollingが通るなら段階導入できます。
capability negotiationをリクエストごとに確認する
クライアントは各リクエストの_metaでextension対応を宣言します。
{
"jsonrpc": "2.0",
"id": "call-100",
"method": "tools/call",
"params": {
"name": "run_release_checks",
"arguments": { "ref": "feature/payment" },
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {
"name": "internal-agent-host",
"version": "4.2.0"
},
"io.modelcontextprotocol/clientCapabilities": {
"extensions": {
"io.modelcontextprotocol/tasks": {}
}
}
}
}
}
現在task化できる元methodはtools/callだけです。サーバーがTasksを選ぶ場合は、標準のCallToolResultではなくresultType: "task"を返します。クライアントがextensionを宣言していないリクエストへtaskを返してはいけません。また、クライアントが宣言したからといって、サーバーが必ずtaskを返すわけでもありません。
{
"jsonrpc": "2.0",
"id": "call-100",
"result": {
"resultType": "task",
"taskId": "task_01JX8K6FH2",
"status": "working",
"createdAt": "2026-08-15T03:00:00Z",
"lastUpdatedAt": "2026-08-15T03:00:00Z",
"ttlMs": 86400000,
"pollIntervalMs": 5000
}
}
このレスポンスを返す時点で、tasks/getが同じtaskIdを解決できる状態まで永続化しておきます。queueへの投入前にIDだけ返すと、直後のpollが404になり、クライアントはtask作成失敗と一時的な整合性遅延を区別できません。
各methodのresponseに付くresultTypeは次のとおりです。
| method | resultType |
|---|---|
taskを作らないtools/call | complete |
taskを作るtools/call | task |
tasks/get | complete |
tasks/update | complete |
tasks/cancel | complete |
task lifecycleを5状態で固定する
Tasksの状態は次の5つです。
| status | 意味 | 必須payload |
|---|---|---|
working | 処理中 | なし |
input_required | client入力待ち | inputRequests |
completed | JSON-RPC resultとして完了 | result |
failed | JSON-RPC errorで失敗 | error |
cancelled | cancel済み | なし |
completed、failed、cancelledはterminal stateです。ここで注意したいのは、toolがisError: trueの結果を返した場合もtask自体はcompletedになる点です。failedはJSON-RPC execution errorに限ります。
function classifyTaskOutcome(outcome: ToolOutcome): "completed" | "failed" {
if (outcome.kind === "json_rpc_error") return "failed";
return "completed"; // CallToolResult.isError may still be true.
}
業務上の失敗、protocol上の失敗、cancelを同じstatusへ潰すと、クライアントの再実行判断が壊れます。
durable task storeの最小設計
task IDだけを保存しても安全には運用できません。私は最低限、次のfieldを持たせます。
type StoredTask = {
taskId: string;
tenantId: string;
ownerSubject: string;
originalMethod: "tools/call";
toolName: string;
argumentsDigest: string;
idempotencyKey: string;
externalJobId: string | null;
status: "working" | "input_required" | "completed" | "failed" | "cancelled";
outstandingInputKeys: string[];
createdAt: string;
lastUpdatedAt: string;
expiresAt: string | null;
pollIntervalMs: number;
result: unknown | null;
error: unknown | null;
};
taskIdはopaqueで推測困難にします。ただし、推測困難なIDはauthorizationの代わりになりません。tasks/get、tasks/update、tasks/cancelのたびに認証主体とtenant ownershipを再検証します。
Streamable HTTPでは、これら3methodのMcp-NameにtaskIdを入れます。load balancerで同じworkerへ寄せるために使えますが、in-memory stateしか持たない設計の免罪符ではありません。worker lossと再配置に耐える永続storeを正とします。
pollingはサーバーの間隔を尊重する
クライアントはtasks/getで状態を取得し、pollIntervalMsを尊重します。
{
"jsonrpc": "2.0",
"id": "get-101",
"method": "tasks/get",
"params": {
"taskId": "task_01JX8K6FH2",
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {
"extensions": {
"io.modelcontextprotocol/tasks": {}
}
}
}
}
}
poll loopは次の条件で止めます。
- terminal stateへ到達した
- userが追跡をやめた
- client側の観測期限を超えた
tasks/cancelを送った
ttlMsはtask作成時点からの期限ですが、taskの途中で変更できます。クライアントは最新レスポンスの値で期限を再評価します。結果を何日保管するか、外部jobのartifactをいつ消すか、監査証跡を別storeへ残すかを分けて決めます。ttlMs: nullを安易に使うと、taskと結果が無期限に蓄積します。
Tasksにはtasks/listがありません。taskIdを失った後にprotocolから一覧復旧できないため、クライアントもtask IDを永続化します。
実行中の入力はtasks/updateへ返す
taskがinput_requiredになると、tasks/getのinputRequestsに未解決リクエストが入ります。クライアントは同じkeyを画面やmodelへ重複提示しないようdeduplicateし、回答をtasks/updateで返します。
{
"jsonrpc": "2.0",
"id": "update-102",
"method": "tasks/update",
"params": {
"taskId": "task_01JX8K6FH2",
"inputResponses": {
"deploy_approval": {
"action": "accept",
"content": { "environment": "staging" }
}
},
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {
"extensions": {
"io.modelcontextprotocol/tasks": {}
}
}
}
}
}
仕様上、サーバーは未知、回答済み、失効済みのkeyを無視することを推奨されています。本記事の実装方針でも無視し、同じkeyを別の質問へ再利用しません。クライアント入力はruntime schema validationを通し、認証主体が途中で変わった場合は再承認を要求します。
tasks/updateの成功レスポンスはresultType: "complete"を持つ空のackです。ackは入力の受領を示すだけで、task状態への反映はeventually consistentです。サーバーは未解決keyの一部だけを受け付けることもでき、その場合は残りの回答が届くまでinput_requiredを維持します。クライアントはack直後に完了扱いせず、tasks/getまたはnotificationで次の状態を確認します。
task作成前の追加入力はMRTR、task作成後の追加入力はtasks/get / tasks/updateです。この2つを同じretry loopへ混ぜない方が実装を追いやすくなります。
cancelは停止完了ではなく停止要求
tasks/cancelはcooperative cancellationです。ackを返した後もtaskが一時的にworkingのままか、先に処理が終わってcompletedになる場合があります。
server側では、次の順で扱います。
- ownershipとterminal stateを確認する
- cancel intentをdurable storeへ記録する
- queueまたはexternal jobへ停止要求を送る
- workerが安全なcheckpointで停止する
- 実際に停止できた場合だけ
cancelledへ遷移する
決済やdeploymentのように取り消せないside effectが始まった後は、cancelで巻き戻したように見せてはいけません。statusMessageで「停止要求受領」「external operationは完了済み」などを区別し、rollbackは別操作にします。
再実行と二重実行を分ける
クライアントがCreateTaskResultを受け取る前に切断すると、同じtools/callを再送する可能性があります。サーバーはtaskIdだけでなく、tenant、tool、arguments digest、idempotency keyで同一要求を判定します。
dedupe key = tenant_id + tool_name + idempotency_key + arguments_digest
既存taskがあれば新しいjobを作らず、同じtaskを返します。ここでいうidempotency keyはTasks仕様の標準fieldではなく、tool argumentまたはserver内部で持つアプリケーション側の契約です。idempotency windowと再実行を許す条件はtoolごとに定義します。read-only reportとpayment executionへ同じ規則は使えません。
notificationはpollingの代替として段階導入する
サーバーはnotifications/tasksをsubscriptions/listenでpushできます。ただし、クライアント / SDKの対応を実機で確認できない限り、pollingをbaselineにします。extension notificationを受けられないクライアントへ「対応済み」と広告すると、taskは進んでいるのにUIが更新されません。
また、Tasks仕様には標準のpartial output streamがありません。statusMessageを巨大なlog置き場にせず、進捗概要だけを返し、詳細logやartifactは権限制御したresourceまたは外部systemで扱います。
本番導入checklist
- clientとserverのextension supportをwireで確認した
- taskをdurable保存してから
CreateTaskResultを返す -
taskIdのentropyとtenant ownershipを検証する -
pollIntervalMs、rate limit、観測期限を決めた -
ttlMsとresult / artifactの削除期限を分けた - input keyのdeduplicateとruntime validationを実装した
- cancel ackと実停止を別eventで記録する
- retry時のidempotency keyとside effect境界を決めた
-
completed内のisErrorとfailedを区別した - notification未対応時もpollingで完了できる
まとめ
MCP Tasksは、長いtool callを単にbackgroundへ逃がす仕組みではありません。task作成、poll、入力、cancel、result retentionをクライアント再起動後も追跡できるprotocol contractです。
私なら、最初はread-onlyの外部jobを1つだけTasks化し、polling、再接続、tenant分離、TTLを検証します。その後にinputとcancelを足します。SDKやhostの対応確認gateを先に置けば、仕様上は正しくても実装が追いついていない機能を本番へ持ち込まずに済みます。