MCP Tasks拡張入門|長時間処理をポーリング・取得・キャンセルする

MCP Tasks。長時間処理の結果取得とキャンセルを学ぶ記事のアイキャッチ MCP
カテゴリー
MCP
公開日
2026.09.26

はじめに

動画変換や大規模解析を通常のツール呼び出しで待ち続けると、HTTPタイムアウトや再接続への対応が難しくなります。MCP Tasksは、要求をタスクとして受理し、後から状態と結果を取得するための拡張です。

この記事は、JavaScriptの非同期処理を学んだ方が、長時間処理の状態管理を理解するための入門です。Tasks拡張の2026-07-28版を参照し、Node.jsだけで動く模擬ジョブを作ります。MCPクライアントへ接続する完成サーバーは作りません。

コア仕様・Tasks拡張・SDKの実装は別々に確認します。手元の@modelcontextprotocol/client 2.0.0で行った接続試験では、汎用のrequest()からのtasks/getがMETHOD_NOT_SUPPORTED_BY_PROTOCOL_VERSIONで拒否されました。これはその構成の結果であり、すべてのSDKや将来版が未対応という意味ではありません。

参照する拡張ではtasks/getが最終結果も返し、追加の入力への応答にはtasks/update、取消要求にはtasks/cancelを使います。旧仕様のtasks/resultやexecution.taskSupportと混ぜないでください。実装時は、双方が同じ仕様版を扱うかを接続試験で確かめます。

TypeScript SDKの公式Roadmapでも、Tasksはコア仕様とは別の拡張として追跡され、v2が従来の実験的Tasksを提供しないことが説明されています。リポジトリのmainブランチに将来向けの説明やAPIが見えても、公開済みnpmパッケージで同じ機能を利用できるとは限りません。この記事のコードは独自のジョブ層を学ぶための例であり、SDK 2.0.0へTasksプロトコルを追加する完成コードではありません。

通常のツール呼び出しとの違い

通常の呼び出しは、処理が終わるまで同じ応答を待ちます。Tasksでは先に「受付番号」に相当するtask IDを返し、利用者は後から進捗や結果を確認します。宅配便で、荷物が届くまで窓口で待たず、追跡番号で状態を見るのに近い考え方です。

  • ポーリング:一定時間ごとに状態を問い合わせること
  • task ID:作成した処理を識別する番号
  • 状態遷移:workingからcompletedなどへ状態が変わること
  • 永続化:サーバーを再起動してもデータが残る場所へ保存すること

以下の模擬ジョブは外部通信・課金・実ファイルの変換を行いません。状態管理だけを切り出すので、MCPを導入する前でも試せます。

基本の流れ

  1. サーバーの能力と、要求ごとのクライアント能力でio.modelcontextprotocol/tasksの対応を確認する
  2. tools/callを受けたサーバーが、通常の結果を返すか、タスク化するかを決める
  3. タスク化した場合はresultType: "task"とtask IDなどを返す
  4. クライアントはtasks/getで状態を照会し、完了時には同じ応答のresultから結果を読む
  5. input_requiredの場合は、提示された入力要求にtasks/updateで応答する
  6. 不要になればtasks/cancelで取消の意図を伝える

クライアントが拡張対応を宣言しても、必ずタスクが作成されるわけではありません。通常結果とタスク受付の両方を扱う必要があります。

状態をアプリ内でも明示する

SDKへつなぐ前に、独自のジョブ層を作ります。以下のstateやgetJob()はアプリ内の名前で、MCPのメソッド・Task型ではありません。例えばこの例のfailedは単に模擬処理の失敗を表し、そのままプロトコルのエラー分類へ流用しません。

必要環境と完成コード

Node.js 24系を用意し、空の作業フォルダーへ次をjobs.mjsとして保存します。npmパッケージの追加は不要です。

import { setTimeout as delay } from "node:timers/promises";

const jobs = new Map();

export function getJob(id) {
  const job = jobs.get(id);
  if (!job) throw new Error("job not found");
  // 内部のAbortControllerや変更可能なジョブ本体は返さない。
  return { id: job.id, state: job.state, result: job.result, error: job.error };
}

export function createJob(id, { fail = false } = {}) {
  if (typeof id !== "string" || !id.trim()) throw new Error("invalid id");
  if (jobs.has(id)) throw new Error("job already exists");
  const job = { id, state: "working", abortController: new AbortController() };
  jobs.set(id, job);
  job.done = (async () => {
    try {
      await delay(3000, undefined, { signal: job.abortController.signal });
      job.abortController.signal.throwIfAborted();
      if (fail) throw new Error("simulated failure");
      job.result = 42;
      job.state = "completed";
    } catch (error) {
      if (error.name === "AbortError") job.state = "cancelled";
      else { job.error = error.message; job.state = "failed"; }
    }
  })();
  return getJob(id);
}

export async function cancelJob(id) {
  const job = jobs.get(id);
  if (!job) throw new Error("job not found");
  if (job.state !== "working") return getJob(id);
  job.abortController.abort();
  // この例では、待機処理が実際に終了してから取り消し結果を返す。
  await job.done;
  return getJob(id);
}

同じフォルダーにdemo.mjsを保存します。

import { setTimeout as delay } from "node:timers/promises";
import { createJob, getJob, cancelJob } from "./jobs.mjs";

console.log("開始:", createJob("success").state);
while (getJob("success").state === "working") await delay(250);
console.log("完了:", getJob("success").state, getJob("success").result);
console.log("結果再取得:", getJob("success").result);
console.log("完了後取消:", (await cancelJob("success")).state);

createJob("cancel");
console.log("処理中取消:", (await cancelJob("cancel")).state);
createJob("failure", { fail: true });
while (getJob("failure").state === "working") await delay(250);
console.log("失敗:", getJob("failure").state, getJob("failure").error);

try { getJob("missing"); } catch (error) { console.log(error.message); }
try { createJob("success"); } catch (error) { console.log(error.message); }

実行と動作確認

ターミナルで2ファイルのあるフォルダーを開き、次を実行します。

node demo.mjs

約6秒後までに次が表示され、プロセスが終了すれば正常です。処理時間は端末負荷により前後します。

開始: working
完了: completed 42
結果再取得: 42
完了後取消: completed
処理中取消: cancelled
失敗: failed simulated failure
job not found
job already exists

これは模擬ジョブの検証であり、MCPの通信試験ではありません。Mapは終了・再起動で消え、所有者の認可、保存期限、入力待ち、再送の冪等性を実装していません。同じIDの拒否だけでは、同じ依頼に別IDを付けた二重実行は防げません。公開サーバーへそのまま転用しないでください。

ポーリング設計

クライアントは間隔なしでtasks/getを連打してはいけません。サーバーが示すpollIntervalMsを尊重します。アプリ側の待機上限や再試行間隔も決めてください。上の250msはローカル模擬処理のための値で、サーバーへの推奨値ではありません。

結果には保存期限を設定します。期限切れ、存在しないID、別ユーザーのIDは区別しすぎると情報漏えいになる場合があるため、認可ポリシーを先に決めます。

キャンセルの意味

tasks/cancelの受理は、処理停止や最終状態cancelledを保証しません。完了と競合する場合もあります。上の独自cancelJob()は待機終了を待ちますが、これはサンプルの契約であり、MCPの取消応答と同じではありません。既に送ったメールなどの副作用は別途扱います。

処理を小さな単位に分け、区切りごとにAbortSignalを確認します。次は設計上の断片で、filesやconvertOneは実際のアプリで用意するものです。

for (const file of files) {
  if (signal.aborted) throw new DOMException("Cancelled", "AbortError");
  await convertOne(file, signal);
}

実装時の確認項目

  • workingからcompletedへ進み、結果を1回以上取得できる
  • 模擬ジョブは処理中の取消でcancelledになる。MCPへ接続する場合は取消受理と停止完了を区別する
  • 完了済みタスクのキャンセルを安全に扱う
  • 不明なIDと他ユーザーのIDを拒否する
  • サーバー再起動後も必要なタスクが復元される
  • 同じ作成要求の再送で二重実行しない

上の完成コードが確認するのは、完了・取消・失敗・不明ID・重複IDです。他ユーザーの認可や再起動復元は別の実装が必要です。Tasks対応ランタイムへ接続する際は、仕様上の能力宣言・応答形式・取消の意味も含めた通信試験を追加します。

よくあるトラブル

  • task IDを受け取ったのに結果がない:tasks/getの状態と、完了時のresultを確認する
  • 状態照会でサーバーが重くなる:待ち時間を入れずポーリングしていないか確認する
  • 再起動ですべて消える:サンプルのMapはメモリ上にしか保存されない
  • キャンセル後も外部処理が続く:処理側がAbortSignalを定期的に確認しているか調べる
  • 同じ処理が複数作られる:再送を識別するキーと冪等性の設計を追加する

向かない用途

数十ミリ秒で終わる計算をすべてTask化すると実装と通信が増えます。また、リアルタイムな連続データ配信をTasksだけで置き換えるものでもありません。短い処理は通常応答、進捗通知が必要ならsubscription等との組み合わせを検討します。

関連記事

まとめ

長時間処理では、受付と完了を分けるだけでなく、失敗・取消・再取得を設計します。模擬ジョブで状態管理を理解したら、参照するTasks仕様とSDKの対応を確認し、永続化・認可・冪等性を加えてから接続してください。

参考リンク

コメント

タイトルとURLをコピーしました