MCP Tasks:セッションなしで長時間実行する仕事
ステートレスなMCP 2026-07-28仕様はセッションを廃止することで水平スケーリングを解決したが、同時に「ツールの実行に20分かかる場合はどうする?」という問いの簡単な答えも廃止した。その答えが Tasks拡張(io.modelcontextprotocol/tasks、SEP-2663)である。サーバーはブロックせず、代わりに永続的な タスクハンドル を返し、クライアントが tasks/get、tasks/update、tasks/cancel で作業を駆動する。2026年後半、まともな長時間実行MCPサーバーはすべてこのパターンで実装される。
- サーバーがロードバランサーやサーバーレスランタイムの背後に置かれた瞬間、リクエストのブロックが機能しなくなる理由
- 5つのタスク状態 — working、input_required、completed、failed、cancelled — と、どの遷移が合法か
- ワイヤープロトコル:能力ネゴシエーション、CreateTaskResult、tasks/getポーリング、notifications/tasksプッシュ
- input_requiredが持続的な接続なしで旧来のelicitationを置き換える方法
- 2025-11-25の実験版Tasks APIからの移行 — なぜアップグレードではなく書き直しなのか
- 落とし穴:協調的キャンセル、tasks/listが意図的に削除された理由、TTL失効、テナント間漏洩
一段落版
ステートレスなMCPサーバーは長時間保持される接続に依存できない:HTTPの中継が切断し、ロードバランサーがクライアントを新しいインスタンスに再割り当てし、モバイルネットワークが瞬断する。Tasksは長時間ツールコールを 永続的なリソース に変える — サーバーは最初のリクエストに応答する前に taskId を永続化する。クライアントはサーバーが提案した間隔で tasks/get(taskId) をポーリングする。ステータスが completed、failed、cancelled に変わると、ポーリング応答は同期呼び出しが返したはずと同じペイロードを運ぶ。実行中、サーバーは input_required に移行して質問できる — クライアントは tasks/update で答え、ポーリングが再開される。これがモデル全体だ。
なぜブロックしないのか?
作業が終わるまで接続を保持することはできる。MCPワーキンググループはこれを検討し、却下した — サーバーレス開発者なら誰もが知っている理由で:
- タイムアウト。AWS API Gatewayは29秒でキャップ。Cloudflare Workersは30秒CPU + 6分ウォール。Vercel Functionsは5分。バッチインポートをそれらのいずれかを通してロングポーリングすると、途中で504を食らう。
- クラッシュ耐性。クライアントタブがリロードされたりネットワークが切断されたりすると、ブロックされた呼び出しは結果を失う。taskIdは永続的である — 同じクライアントが数分後にポーリングを再開できる。
- ロードバランサーの粘着性。ブロッキングはリクエストを1つのサーバーインスタンスに固定する。操作中のスケールインイベントごとに呼び出しが殺される。
- 進行状況の可視性。ブロックされた呼び出しは終わるまで何も返さない。タスクはプログレスバーとしてレンダリングできるステータスメッセージを運ぶ。
- 実行中の入力。ツールがユーザー確認を必要とする場合、ブロックされた呼び出しはサーバー→クライアントの一方的なメッセージなしでは尋ねる方法がない — これはステートレス仕様が禁じている。
5状態ライフサイクル
すべてのタスクはこれらの状態のちょうど1つに存在する。completed、failed、cancelled は 終端 — 到達すると状態は変わらない:
| Status | 意味 | 設定される値 |
|---|---|---|
working | 操作進行中。サーバーは進行に応じてオプションのステータスメッセージを更新する。 | statusMessage |
input_required | サーバーはクライアント入力を待ってブロックされている。要求を提示し、tasks/update で送信する。 | inputRequests |
completed | 操作は成功裏に終了。result は同期呼び出しが返したであろう値を保持する。 | result |
failed | 実行中にJSON-RPCエラーが発生した。 | error |
cancelled | クライアントがキャンセルを要求し、サーバーがそれを尊重した。すべてのリクエストで保証されるわけではない。 | — |
合法な遷移:working ↔ input_required、working → completed | failed | cancelled、input_required → working | failed | cancelled。それ以外はサーバーのバグである。
ワイヤープロトコル
1. 双方がオプトイン
Tasksはコアではなく 拡張 である — 双方がそれを広告しなければならない。クライアントはすべてのリクエストの _meta に入れ、サーバーは server/discover から返す:
// クライアント → サーバー、タスクとして返される可能性のあるあらゆるリクエストで:
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "run_ci_pipeline",
"arguments": { "commit": "abc123" },
"_meta": {
"io.modelcontextprotocol/clientCapabilities": {
"extensions": {
"io.modelcontextprotocol/tasks": {}
}
}
}
}
}
クライアントがサポートを宣言していない場合、サーバーは タスクを返してはならない — ブロックするか、エラーを返すか、操作を拒否するしかない。オプトインしていないクライアントに CreateTaskResult を送ってはならない。
2. サーバーはタスクハンドルを返す
通常の CallToolResult の代わりに、サーバーは resultType: "task" で応答する:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"resultType": "task",
"task": {
"taskId": "tsk_01HZY7...",
"status": "working",
"statusMessage": "Cloning repo",
"ttlMs": 3600000,
"pollIntervalMs": 2000
}
}
}
タスクはサーバーがこの応答を送る 前 に 永続的に保存 されなければならない(Postgres、AOF付きRedis、DynamoDB — Pod再起動を生き延びるあらゆるもの)。サーバーがリクエストを受け入れてからタスクを永続化するまでの間にクラッシュした場合、クライアントは通常のエラーを受け取り、再試行できる。永続化後にクラッシュした場合、taskIdは任意のレプリカから解決可能である。
3. クライアントは tasks/get をポーリング
// クライアント → サーバー、pollIntervalMsごと:
{ "jsonrpc": "2.0", "id": 2, "method": "tasks/get", "params": { "taskId": "tsk_01HZY7..." } }
// サーバー → クライアント、まだ実行中:
{ "jsonrpc": "2.0", "id": 2, "result": { "taskId": "tsk_01HZY7...", "status": "working", "statusMessage": "Running tests (128/342)" } }
// サーバー → クライアント、終端:
{ "jsonrpc": "2.0", "id": 2, "result": { "taskId": "tsk_01HZY7...", "status": "completed", "result": { "content": [{ "type": "text", "text": "All 342 tests passed in 4m12s" }] } } }
pollIntervalMs は 提案 である — クライアントはそれを下限として尊重し、繰り返される working 応答ではバックオフし、サーバーが要求したよりも速くポーリングしてはならない。
4. 実行中の入力
ツールがユーザー確認を必要とする場合(「47個のファイルを削除します。続行しますか?」)、サーバーは input_required に切り替わり、inputRequests マップを添付する — これはステートレス以前の世界でelicitationが取っていたのと同じ形状である:
// tasks/get 応答:
{
"taskId": "tsk_01HZY7...",
"status": "input_required",
"inputRequests": {
"confirm_delete": {
"type": "elicitation",
"message": "Delete 47 files matching *.tmp?",
"schema": { "type": "object", "properties": { "confirm": { "type": "boolean" } } }
}
}
}
クライアントはプロンプトを表示し、tasks/update で答える:
{
"jsonrpc": "2.0",
"id": 5,
"method": "tasks/update",
"params": {
"taskId": "tsk_01HZY7...",
"inputResponses": { "confirm_delete": { "confirm": true } }
}
}
サーバーは空の結果でackし、状態は working に戻る。未知の、または既に満たされたキーへの応答は無視されなければならない — これにより再試行が安全になる。
5. 協調的キャンセル
{ "jsonrpc": "2.0", "id": 9, "method": "tasks/cancel", "params": { "taskId": "tsk_01HZY7..." } }
サーバーは空の結果でackする。仕様書の文言に注意:キャンセルは 協調的 である — サーバーは意図を承認するが、作業を停止する義務はない。completed に到達寸前のタスクに対する tasks/cancel は、それでも completed として着地することがある。クライアントUIは「キャンセルされた」ではなく「キャンセル要求済み、確認待ち」の周りに設計せよ。これは移行中にユーザーに見えるバグの最も一般的な原因である。
ポーリングの代わりに通知
ポーリングはデフォルトであり、常に機能する。サーバーが通知をサポートする 場合 、クライアントは一度サブスクライブしてポーリングループを完全にスキップできる:
// クライアントはタスク変更イベントにサブスクライブする:
{ "jsonrpc": "2.0", "id": 3, "method": "subscriptions/listen", "params": { "notifications": ["notifications/tasks"] } }
// サーバーはすべての状態変更で完全なタスクスナップショットをプッシュする:
{ "jsonrpc": "2.0", "method": "notifications/tasks", "params": { "task": { "taskId": "tsk_01HZY7...", "status": "completed", "result": { "..." : "..." } } } }
各プッシュは 完全な タスク状態を運ぶ — クライアントは後続の tasks/get を必要としない。subscriptions/listen が「サポートされていない」を返すか、ストリームが切断された場合はポーリングにフォールバックする。
Tasksを使うタイミング(と使わないタイミング)
- CIパイプライン、バッチインポート、モデル訓練、ビデオエンコード、大規模リファクタリング、デプロイ。p99が約10秒を超えるならすでにTasksが欲しい。p99が30秒を超えるなら、Tasksなしですでに壊れている。
- AWS Batch、GitHub Actions、Kubernetes Jobs、Temporalワークフロー。ジョブが作成されたときにタスクを返し、ジョブが完了したときに解決する。taskIdは文字通り上流のジョブIDを埋め込める。
- 承認ゲート、レビューステップ、確認のために一時停止するあらゆるもの。「承認/拒否」ボタン付きのSlack通知が、タスクをinput_requiredまたは終端状態に切り替える形で自然に機能する。
- モバイル、タブレット、飛行機の中のラップトップ。クラッシュしたクライアントは永続的なtaskIdからポーリングを再開できる — クラッシュした同期呼び出しはすべてを失う。
- すべてのタスクはポーリングのラウンドトリップを伴う。天気予報や通貨変換は依然としてブロックして返すべきだ。Tasksは追加のレイテンシーを実際に稼ぐ呼び出しのために取っておけ。
- MRTR(SEP-2322)は現在の呼び出しを継続するために必要な入力をカバーする — 1つのラウンドトリップ、永続性なし。Tasksはリクエストより長生きする永続的な作業をカバーする。MRTRの2つのラウンドトリップ間の飛行機事故が部分的に入力されたフォームだけを失うなら、MRTRを使え。2時間のデプロイを失うなら、Tasksを使え。
2025-11-25の実験版Tasks APIからの移行
旧来の tasks/create / tasks/status の形状(ステートレス前の仕様)はSEP-2663と 互換性がない 。バージョンアップではなく書き直しとして扱え:
- 旧:クライアントは明示的に tasks/create を呼び出した。新:あらゆる tools/call がタスクとして返される可能性がある — クライアントはすべてのリクエストで多相な結果を処理しなければならない。
- 旧:tasks/list はセッションのタスクを列挙した。新:tasks/list は意図的に削除された — ステートレスサーバーにはスコープするセッションがなく、テナントをまたいだ列挙はデータ漏洩である。自分の taskId をクライアント側や製品DBで追跡せよ。
- 旧:elicitation は独立したサーバー→クライアントプッシュだった。新:elicitation は input_required としてタスクに折り畳まれる — 一方的なプッシュは不要。
- 旧:ステータスは {pending, running, done, error} のいずれかだった。新:{working, input_required, completed, failed, cancelled}。error → failed にマッピングし、新しい input_required 分岐を追加せよ。
- 廃止クロック:実験版APIは少なくとも2027年7月28日まで動作し続ける。SEP-2663に対して書き直し、両エンドポイントを並行して実行し、自分のスケジュールで切り替えよ。
サーバー実装チェックリスト
- CreateTaskResultはクライアントがポーリングできるという約束だ。DB書き込みがHTTP応答の後に発生すると、その間のクラッシュがその約束を破る。ライトスルー、それから応答。
- 100 msではない — レート制限される。60秒でもない — ユーザーはUIがフリーズしたと思う。ジョブの中央進捗ケイデンスに合わせよ:CIジョブ? 2-5秒。バッチインポート? 10-30秒。夜間訓練? 60秒。
- 仕様は完了したタスクをどれくらい保持しなければならないかについて何も言っていない。ポリシーを選び(24時間が一般的)、ttlMsで広告し、期限切れIDへのtasks/getを -32602 で拒否せよ。さもないと永遠にストレージが漏れる。
- クライアントは再試行する。同じinputResponseを2回受け入れ、既に満たされたキーを無視し、状態機械を二重に進めるな。
- taskIdは秘密ではない。すべてのtasks/get / tasks/update / tasks/cancelを呼び出し元の認証されたIDでスコープせよ — 別のユーザーに属するタスクを取得することは -32602 を返さなければならない(タスクでもなく、それが存在することを確認する認証エラーでもなく)。
- 協調的とは、終わっても良いということであり、終わるべきということではない。キャンセルフラグの約1秒ごとのチェックインは、UXを劇的に良くする。
クライアント実装チェックリスト
- Tasksにオプトインした瞬間、あらゆるツールコールがタスクとして返る可能性がある。単一の無視された resultType: task 分岐は、静かに落とされた結果を意味する。
- ブラウザではLocalStorage、CLIではsqlite、バックエンドでは製品DB。taskIdを失ったクラッシュしたクライアントは再開できない。
- 10-20%のランダムジッターを追加せよ。さもないと、同じ間隔で同じタスクをポーリングする1000のクライアントがサーバーを叩き潰す。
- 終端状態を待つ間は「キャンセル中…」をレンダリングせよ、「キャンセルされた」ではない。それでも completed として着地したら説明せよ。
- notifications/tasksは最適化である。すべてのクライアントは依然としてポーリングパスを処理しなければならない — さもないと、サブスクリプションのしゃっくりで結果を失う。
実例:run_migration ツール
サーバー疑似コード — 5〜30分のDBマイグレーションを実行するツール
// tools/call handler
async function handleToolCall(req) {
const supportsTasks = req.params._meta
?.["io.modelcontextprotocol/clientCapabilities"]
?.extensions?.["io.modelcontextprotocol/tasks"];
if (req.params.name === "run_migration") {
if (!supportsTasks) {
return jsonRpcError(req.id, -32603, "run_migration requires Tasks extension");
}
const taskId = "tsk_" + ulid();
await db.tasks.insert({
id: taskId, tenant: req.auth.tenant, status: "working",
createdAt: Date.now(), ttlMs: 24 * 3600 * 1000,
});
// Kick off the actual work OUT OF BAND — do not await it here.
queue.enqueue({ taskId, migration: req.params.arguments.name });
return {
resultType: "task",
task: { taskId, status: "working", ttlMs: 24 * 3600 * 1000, pollIntervalMs: 5000 },
};
}
}
// tasks/get handler — scoped by authenticated tenant
async function handleTasksGet(req) {
const t = await db.tasks.findOne({ id: req.params.taskId, tenant: req.auth.tenant });
if (!t) return jsonRpcError(req.id, -32602, "unknown taskId");
if (Date.now() > t.createdAt + t.ttlMs) return jsonRpcError(req.id, -32602, "task expired");
return { taskId: t.id, status: t.status, statusMessage: t.statusMessage,
...(t.status === "completed" && { result: t.result }),
...(t.status === "failed" && { error: t.error }) };
}ほとんどのチームが最初の週に遭遇する落とし穴
- 「tasks/listがない。」はい — 意図的に。スコープするセッションがない。自分の製品データベースでtaskIdを追跡せよ。
- 「私のキャンセルボタンは嘘をつく。」常にそうだ。「キャンセル要求」に名前を変えるか、サーバーの終端ackに状態変更をゲートせよ。
- 「ポーリングしたときだけ結果が来る。」その通り — notifications/tasks + subscriptions/listen も実装するまでは。両方のパス、常に。
- 「Python SDKにこのためのヘルパーがまだない。」一部のTier 1 SDKヘルパーはまだ安定化中だ。生のJSON-RPCを手で常に実装できる — ワイヤーフォーマットは完全に指定されている。
- 「ユーザーがIDを推測して別のユーザーのタスクを引っ張った。」なぜならtasks/getでテナントによるスコープを忘れたからだ。すべてのハンドラーは認証されたプリンシパルでフィルターしなければならない。
クイズ
Check yourself
0/4フラッシュカード
ソースと参考文献
- MCP Tasks拡張概要 — modelcontextprotocol.io — 正典の仕様ページ、完全なライフサイクル図と各側の実装ガイド付き。
- ext-tasksリポジトリ(SEP-2663) — スキーマ、生成された型、動作する仕様テキスト。
- MCP 2026-07-28仕様発表 — TasksをAWS貢献のファーストパーティ拡張として名指ししたリリースブログ。
- Anthropic: MCP 2026-07-28をClaudeに導入 — Claudeホスト展開ノート。
- Composio: 2026-07-28アップデート、平易な言葉で — TasksとMRTRのどちらに手を伸ばすかの実践的なフレーミング。
- AILmanac関連:MCP 2026-07-28: ステートレス仕様、MCP Apps: インタラクティブUI、Managed Agents、長時間実行エージェントハーネス。