メインコンテンツまでスキップ

MCP Tasks:セッションなしで長時間実行する仕事

上級

ステートレスなMCP 2026-07-28仕様はセッションを廃止することで水平スケーリングを解決したが、同時に「ツールの実行に20分かかる場合はどうする?」という問いの簡単な答えも廃止した。その答えが Tasks拡張(io.modelcontextprotocol/tasks、SEP-2663)である。サーバーはブロックせず、代わりに永続的な タスクハンドル を返し、クライアントが tasks/gettasks/updatetasks/cancel で作業を駆動する。2026年後半、まともな長時間実行MCPサーバーはすべてこのパターンで実装される。

What you'll learn
  • サーバーがロードバランサーやサーバーレスランタイムの背後に置かれた瞬間、リクエストのブロックが機能しなくなる理由
  • 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) をポーリングする。ステータスが completedfailedcancelled に変わると、ポーリング応答は同期呼び出しが返したはずと同じペイロードを運ぶ。実行中、サーバーは input_required に移行して質問できる — クライアントは tasks/update で答え、ポーリングが再開される。これがモデル全体だ。

なぜブロックしないのか?

作業が終わるまで接続を保持することはできる。MCPワーキンググループはこれを検討し、却下した — サーバーレス開発者なら誰もが知っている理由で:

Watch out
  • タイムアウト。AWS API Gatewayは29秒でキャップ。Cloudflare Workersは30秒CPU + 6分ウォール。Vercel Functionsは5分。バッチインポートをそれらのいずれかを通してロングポーリングすると、途中で504を食らう。
  • クラッシュ耐性。クライアントタブがリロードされたりネットワークが切断されたりすると、ブロックされた呼び出しは結果を失う。taskIdは永続的である — 同じクライアントが数分後にポーリングを再開できる。
  • ロードバランサーの粘着性。ブロッキングはリクエストを1つのサーバーインスタンスに固定する。操作中のスケールインイベントごとに呼び出しが殺される。
  • 進行状況の可視性。ブロックされた呼び出しは終わるまで何も返さない。タスクはプログレスバーとしてレンダリングできるステータスメッセージを運ぶ。
  • 実行中の入力。ツールがユーザー確認を必要とする場合、ブロックされた呼び出しはサーバー→クライアントの一方的なメッセージなしでは尋ねる方法がない — これはステートレス仕様が禁じている。

5状態ライフサイクル

すべてのタスクはこれらの状態のちょうど1つに存在する。completedfailedcancelled終端 — 到達すると状態は変わらない:

Status意味設定される値
working操作進行中。サーバーは進行に応じてオプションのステータスメッセージを更新する。statusMessage
input_requiredサーバーはクライアント入力を待ってブロックされている。要求を提示し、tasks/update で送信する。inputRequests
completed操作は成功裏に終了。result は同期呼び出しが返したであろう値を保持する。result
failed実行中にJSON-RPCエラーが発生した。error
cancelledクライアントがキャンセルを要求し、サーバーがそれを尊重した。すべてのリクエストで保証されるわけではない。

合法な遷移:working ↔ input_requiredworking → completed | failed | cancelledinput_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を使うタイミング(と使わないタイミング)

Guided walkthrough1 of 6
  1. CIパイプライン、バッチインポート、モデル訓練、ビデオエンコード、大規模リファクタリング、デプロイ。p99が約10秒を超えるならすでにTasksが欲しい。p99が30秒を超えるなら、Tasksなしですでに壊れている。

2025-11-25の実験版Tasks APIからの移行

旧来の tasks/create / tasks/status の形状(ステートレス前の仕様)はSEP-2663と 互換性がない 。バージョンアップではなく書き直しとして扱え:

Watch out
  • 旧:クライアントは明示的に 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に対して書き直し、両エンドポイントを並行して実行し、自分のスケジュールで切り替えよ。

サーバー実装チェックリスト

Guided walkthrough1 of 6
  1. CreateTaskResultはクライアントがポーリングできるという約束だ。DB書き込みがHTTP応答の後に発生すると、その間のクラッシュがその約束を破る。ライトスルー、それから応答。

クライアント実装チェックリスト

Guided walkthrough1 of 5
  1. Tasksにオプトインした瞬間、あらゆるツールコールがタスクとして返る可能性がある。単一の無視された resultType: task 分岐は、静かに落とされた結果を意味する。

実例: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 }) };
}

ほとんどのチームが最初の週に遭遇する落とし穴

Watch out
  • 「tasks/listがない。」はい — 意図的に。スコープするセッションがない。自分の製品データベースでtaskIdを追跡せよ。
  • 「私のキャンセルボタンは嘘をつく。」常にそうだ。「キャンセル要求」に名前を変えるか、サーバーの終端ackに状態変更をゲートせよ。
  • 「ポーリングしたときだけ結果が来る。」その通り — notifications/tasks + subscriptions/listen も実装するまでは。両方のパス、常に。
  • 「Python SDKにこのためのヘルパーがまだない。」一部のTier 1 SDKヘルパーはまだ安定化中だ。生のJSON-RPCを手で常に実装できる — ワイヤーフォーマットは完全に指定されている。
  • 「ユーザーがIDを推測して別のユーザーのタスクを引っ張った。」なぜならtasks/getでテナントによるスコープを忘れたからだ。すべてのハンドラーは認証されたプリンシパルでフィルターしなければならない。

クイズ

Check yourself

0/4
  1. あなたのクライアントは io.modelcontextprotocol/tasks をリクエストの _meta に含めなかった。呼び出したツールは20分かかる。サーバーは何をすべきか?
  2. ユーザーがタスクが完了する100 ms前に「キャンセル」を押す。サーバーはキャンセルと完了を同時に処理する。タスクは合法的にどの状態で終わることができるか?
  3. 2025-11-25の実験版Tasks APIから移行している。古いコードはキューを表示するために tasks/list を呼び出す。正しい修正は何か?
  4. TasksではなくMRTR(SEP-2322)に手を伸ばす正しい理由はどれか?

フラッシュカード

Enter キーまたはスペースキーでカードを裏返します。左右の矢印キーでカードを移動できます。用語を表示しました。
1 / 8

ソースと参考文献