MCPツールがタイムアウトする原因|再試行・キャンセル・冪等性の設計

MCPツールがタイムアウトする原因|再試行・キャンセル・冪等性の設計 AI開発

MCPサーバーへ接続できていてtools/listも成功するのに、特定のMCPツールだけ「Request timed out」になることがあります。

原因はMCPサーバーそのものが停止しているとは限りません。

外部APIの応答待ち、巨大ファイルの処理、データベースのロック、長時間のシェルコマンド、クライアント側のタイムアウト設定などによって、tools/callだけが時間切れになるケースがあります。

さらに注意したいのが、タイムアウト後の再試行です。

検索ツールなら同じ処理をもう一度実行しても大きな問題は起こりにくいでしょう。

しかし、

副作用を持つツール
send_email
create_order
charge_payment
delete_file
publish_post

のようなツールをタイムアウト後にそのまま再試行すると、1回目が実際には成功していた場合にメールの二重送信、注文の二重作成、二重決済などが発生する可能性があります。

MCPのタイムアウトは、

タイムアウトが意味すること
クライアントが結果を受け取れなかった

ことを意味しても、

タイムアウトが保証しないこと
サーバー側で処理されなかった

ことまでは保証しません。

そのためMCPツールを安定運用するには、タイムアウト、キャンセル、再試行、冪等性を別々に設計する必要があります。

MCP 2026-07-28仕様でも、実装は送信するすべてのリクエストにタイムアウトを設け、時間内に成功またはエラー応答が得られない場合はリクエストをキャンセルして待機を終了することが推奨されています。さらに、Progress通知によってタイムアウトを延長する場合でも、無制限にはせず最大時間を設けることが推奨されています。

この記事では、MCPツールがタイムアウトする原因と、TypeScriptで再試行・キャンセル・冪等性を実装する方法を解説します。

スポンサーリンク
  1. MCPツールのタイムアウトとは
  2. タイムアウトとTool Errorは違う
  3. MCPツールがタイムアウトする主な原因
  4. クライアントとサーバーでタイムアウト値が違う場合もある
  5. MCP Client側でタイムアウトを明示する
  6. 長時間ToolにはProgressを返す
  7. Progressが来たらタイムアウトをリセットできる
  8. ProgressをHeartbeat代わりに乱用しない
  9. MCP ServerからProgressを送る
  10. タイムアウトしたらサーバー側の処理も止める
  11. TypeScript SDK v2ではAbortSignalを使う
  12. Server側でもAbortSignalを確認する
  13. 外部fetchにもSignalを渡す
  14. AbortSignalに対応していない処理は自分で確認する
  15. キャンセルは「ロールバック」ではない
  16. タイムアウトしたToolをすぐ再試行してはいけない
  17. 冪等性とは
  18. JSON-RPCのRequest IDを冪等性キーにしない
  19. create_orderを冪等化する
  20. 確認してから作成するだけではRace Conditionが残る
  21. 外部APIにもIdempotency Keyを渡す
  22. Timeout後にunknown状態を持たせる
  23. 再試行してよいToolを分類する
  24. Protocol Errorをそのまま再試行しない
  25. Tool Execution Errorは内容を見て判断する
  26. Timeoutだけでは再試行可否を判断できない
  27. 再試行には指数バックオフを使う
  28. MCP Tool用の再試行Wrapperを作る
  29. Timeoutエラーを判定する
  30. ユーザーのキャンセルをRetryしない
  31. Timeout時にもCancellationを伝える
  32. Serverはキャンセル後にResponseを返さない
  33. stdioでClientを終了するだけでは粒度が粗い
  34. stdioサーバーが落ちた場合はRetryできるが副作用に注意する
  35. 非常に長い処理は通常のtools/callで待たない
  36. Tasksなら接続Timeoutから処理を分離できる
  37. Tasksのキャンセルも強制停止とは限らない
  38. MCP 2026-07-28ではProtocol Sessionに依存しない
  39. タイムアウト値はToolごとに分ける
  40. 外側のAgent Timeoutも確認する
  41. Tool実行時間をログへ保存する
  42. p95とTimeout率を見る
  43. Retry回数も監視する
  44. Tool Timeoutをモデルにそのまま再試行させない
  45. 副作用ToolはHuman in the Loopとも組み合わせる
  46. Retry前に「処理済み確認Tool」を呼ぶ設計もある
  47. 長時間Toolを細かく分割する方法もある
  48. MCPツールのタイムアウトに関するよくある質問
  49. まとめ

MCPツールのタイムアウトとは

MCPのTool Callは、クライアントからサーバーへtools/callリクエストを送信し、結果が返ってくるまで待つ処理です。

通常の流れ
MCP Client
↓
tools/call
↓
MCP Server
↓
Tool実行
↓
CallToolResult
↓
MCP Client

通常ならToolが終了すると結果が返ります。

しかし、Tool内部で時間の掛かる処理を実行すると、クライアント側の待機上限へ先に到達する場合があります。

タイムアウトの例
Client
↓
tools/call
↓
Server
↓
外部APIを実行
↓
まだ処理中
↓
Client側のtimeout

この状態がMCPツールのタイムアウトです。

MCPの現行仕様では、クライアント側はTool Callへタイムアウトを設定することが推奨されており、TypeScript SDK v2でもリクエスト単位でTimeout、Progress、最大実行時間を制御できます。

タイムアウトとTool Errorは違う

MCPではToolの失敗がすべて例外になるわけではありません。

現在のMCP仕様では、Toolのエラーは大きくProtocol ErrorとTool Execution Errorに分かれています。Unknown Toolや不正なリクエストなどはJSON-RPCのProtocol Errorとして扱われ、外部API失敗、入力値エラー、業務ロジック上の失敗などはisError: trueを含むTool Resultとして返せます。

たとえばTool自身が正常に処理を終え、

isErrorで返す例
{
  "resultType": "complete",
  "content": [
    {
      "type": "text",
      "text": "外部APIが503を返しました"
    }
  ],
  "isError": true
}

を返した場合、MCP通信そのものは完了しています。

一方タイムアウトでは、クライアントは最終的なTool Resultを受け取れていません。

TypeScript SDK v2のクライアントドキュメントでも、通常のTool実行失敗はisErrorを確認し、TimeoutなどのProtocol/SDK側の失敗はPromiseがrejectするものとして区別されています。

この違いは再試行を判断するときに重要です。

MCPツールがタイムアウトする主な原因

最も多いのは、Toolの中からさらに外部サービスを呼び出しているケースです。

たとえばMCP Toolが、

外部依存の例
MCP Client
↓
MCP Server
↓
GitHub API
↓
GitHub側が遅い

という構成なら、MCPサーバー自体は正常でもToolは終了できません。

RAGサーバーならVector DB、社内システム連携ならREST API、Coding AgentならシェルコマンドやGit操作がボトルネックになる場合があります。

特に、

ボトルネックになりやすい依存先
外部HTTP API
データベース
ブラウザ自動操作
Shell Command
巨大ファイル処理
AI API

を内部で利用するToolでは、MCPだけでなく依存先それぞれの処理時間を測る必要があります。

MCPのtools/callに60秒を設定していても、内部のHTTPクライアントが300秒待つ設計なら、MCP側が先にタイムアウトする可能性があります。

クライアントとサーバーでタイムアウト値が違う場合もある

MCP Toolでは一つのTimeoutだけが存在するわけではありません。

多層のTimeout
ブラウザ・ホスト
↓
MCP Client timeout
↓
HTTP Proxy timeout
↓
MCP Server
↓
外部API timeout
↓
外部サービス

という複数層があります。

たとえば外部APIを90秒まで待つMCP Serverを作っても、MCP Clientが30秒でキャンセルするならTool Resultは受け取れません。

Streamable HTTPでは、さらにリバースプロキシやロードバランサーのTimeoutも影響します。

MCP 2026-07-28のStreamable HTTPでは、各JSON-RPCメッセージは独立したHTTP POSTとなり、Tool Callのレスポンスは通常のJSONまたはそのリクエスト専用のSSEストリームとして返ります。stdioとStreamable HTTPの構造的な違いはMCPのstdioとStreamable HTTPの違い|ローカル・リモート構成の選び方で詳しく解説しています。

そのため、MCPだけでなく途中にあるHTTPインフラの制限も確認します。

MCP Client側でタイムアウトを明示する

TypeScript SDK v2では、callTool()のRequest OptionsへTimeoutを設定できます。

src/call-tool-timeout.ts
const result =
  await client.callTool(
    {
      name: "search_documents",
      arguments: {
        query:
          "MCP cancellation",
      },
    },
    {
      timeout: 30_000,
    },
  );

30秒以内に最終結果が返らなければ、待機を終了します。

SDK v2ではRequest TimeoutはSdkErrorSdkErrorCode.RequestTimeoutとして扱われます。v1のMcpError + ErrorCode.RequestTimeoutからエラー型が変更されているため、v2へ移行しているコードでは古いinstanceof McpError判定をそのまま使わないよう注意が必要です。

長時間ToolにはProgressを返す

30秒を超えるToolだから、

単純に延ばす例
timeout: 300_000

に変えるだけでは、Toolが停止しているのか正常に処理しているのか判断できません。

長時間処理ではMCPのProgress Notificationを使います。

MCP 2026-07-28では、クライアントがリクエストの_metaprogressTokenを付けると、サーバーはnotifications/progressによって進捗を返せます。progressは同じTokenに対して増加させる必要があり、totalや人間向けのmessageも付けられます。

TypeScript SDK v2ではonprogressを設定するとProgress Tokenの処理をSDK側で行えます。SDKはonprogressを指定した場合にのみprogressTokenをリクエストへ付与するため、後述するresetTimeoutOnProgressだけを指定してonprogressを省略すると、サーバーへProgress Tokenが渡らずタイムアウトが延長されません。

src/call-tool-with-progress.ts
const result =
  await client.callTool(
    {
      name:
        "export_orders",

      arguments: {
        format: "csv",
      },
    },
    {
      timeout: 30_000,

      onprogress:
        (progress) => {
          console.log({
            progress:
              progress.progress,

            total:
              progress.total,

            message:
              progress.message,
          });
        },
    },
  );

これでサーバーが処理中であることをクライアントから確認できます。

Progressが来たらタイムアウトをリセットできる

10万件のファイル処理では、30秒以内に完了しなくても、数秒おきに進捗が返っているなら正常かもしれません。

TypeScript SDK v2では、

resetTimeoutOnProgress
resetTimeoutOnProgress: true

を利用できます。

src/call-tool-reset-timeout.ts
const result =
  await client.callTool(
    {
      name:
        "export_orders",

      arguments: {
        format: "csv",
      },
    },
    {
      timeout: 30_000,

      onprogress:
        (progress) => {
          console.log(
            progress,
          );
        },

      resetTimeoutOnProgress:
        true,

      maxTotalTimeout:
        300_000,
    },
  );

この構成では、Progressが届けば通常のTimeout Clockをリセットしつつ、最長5分で必ず終了できます。

TypeScript SDK v2はresetTimeoutOnProgressでProgressごとにRequest Timeoutをリセットし、maxTotalTimeoutを絶対的な上限として利用できます。

MCP仕様自体も、Progress受信時にTimeoutをリセットすることは認めながら、Progressが来続ければ永久に待つような設計を避けるためMaximum Timeoutを必ず設定することを推奨しています。

ProgressをHeartbeat代わりに乱用しない

Progress通知が1秒ごとに届くようにすればTimeoutを回避できますが、

実質進んでいない通知
progress: 1
progress: 1
progress: 1
progress: 1

のように実際には進んでいない通知を送り続ける設計は避けます。

MCP 2026-07-28では同じProgress Tokenのprogress値は通知ごとに増加させる必要があります。さらに、Progress通知は処理完了後に停止する必要があります。

長時間の処理なら、

実際の処理単位と関連付ける
processed 100 / 1000
processed 200 / 1000
processed 300 / 1000

のように、実際の処理単位と関連付けます。

MCP ServerからProgressを送る

TypeScript SDK v2ではTool Handlerの第2引数からRequest Contextを取得できます。

src/process-files-tool.ts
import {
  McpServer,
} from "@modelcontextprotocol/server";

import { z } from "zod";

const server =
  new McpServer({
    name:
      "file-processor",

    version:
      "1.0.0",
  });

server.registerTool(
  "process_files",
  {
    description:
      "ファイルを順番に処理します",

    inputSchema:
      z.object({
        files:
          z.array(
            z.string(),
          ),
      }),
  },
  async (
    { files },
    ctx,
  ) => {
    const progressToken =
      ctx.mcpReq._meta
        ?.progressToken;

    for (
      let index = 0;
      index <
      files.length;
      index += 1
    ) {
      await processFile(
        files[index],
      );

      if (
        progressToken !==
        undefined
      ) {
        await ctx.mcpReq.notify({
          method:
            "notifications/progress",

          params: {
            progressToken,

            progress:
              index + 1,

            total:
              files.length,

            message:
              `${files[index]}を処理しました`,
          },
        });
      }
    }

    return {
      content: [
        {
          type: "text",
          text:
            `${files.length}件を処理しました`,
        },
      ],
    };
  },
);

TypeScript SDK v2ではctx.mcpReq.notify()からProgressを送信でき、クライアントがProgressを要求していない場合はprogressToken自体がありません。

タイムアウトしたらサーバー側の処理も止める

クライアントがTimeoutしただけで、MCP Server側の処理がそのまま10分間動き続けるとリソースを浪費します。

MCPにはCancellationがあります。

ただし2026-07-28では、stdioとStreamable HTTPでキャンセル方法が異なります。

stdioではRequestごとのStreamが存在しないため、クライアントは対象Request IDを含むnotifications/cancelledを送ります。

Streamable HTTPではRequestごとにレスポンスストリームが分かれているため、そのSSEレスポンスストリームを閉じること自体がCancellation Signalです。ServerはClient DisconnectをそのRequestのキャンセルとして扱います。

つまり2026-07-28のStreamable HTTPで、キャンセルするために別POSTでnotifications/cancelledを送る設計ではありません。

TypeScript SDK v2ではAbortSignalを使う

クライアント側からキャンセルする場合はAbortControllerを利用できます。

src/abort-tool-call.ts
const controller =
  new AbortController();

const promise =
  client.callTool(
    {
      name:
        "scan_archive",

      arguments: {
        pages: 10_000,
      },
    },
    {
      signal:
        controller.signal,

      timeout:
        60_000,
    },
  );

setTimeout(() => {
  controller.abort(
    "ユーザーが停止しました",
  );
}, 5_000);

await promise;

TypeScript SDK v2ではClient側のSignalがAbortされるとRequestがキャンセルされ、Server Handler側ではctx.mcpReq.signalがAbortされます。

2026-07-28のStreamable HTTPを使用している場合、SDKはAbortやTimeout時にそのRequestのSSE Response Streamを閉じます。stdioおよび旧Protocol Eraではnotifications/cancelledが利用されます。

Transportの違いをApplication Code側で個別実装する必要はありません。

Server側でもAbortSignalを確認する

キャンセル通知を受け取っても、Tool HandlerがSignalを一度も確認しなければ処理をすぐ止められません。

src/scan-archive-tool.ts
server.registerTool(
  "scan_archive",
  {
    description:
      "アーカイブを解析します",

    inputSchema:
      z.object({
        pages:
          z.number()
            .int()
            .positive(),
      }),
  },
  async (
    { pages },
    ctx,
  ) => {
    let scanned = 0;

    for (
      let page = 0;
      page < pages;
      page += 1
    ) {
      if (
        ctx.mcpReq.signal
          .aborted
      ) {
        console.error(
          "Tool was cancelled",
          ctx.mcpReq.signal
            .reason,
        );

        break;
      }

      await scanPage(page);

      scanned += 1;
    }

    return {
      content: [
        {
          type: "text",
          text:
            `${scanned}ページ処理しました`,
        },
      ],
    };
  },
);

TypeScript SDK v2はClient CancellationやConnection Closeに合わせてctx.mcpReq.signalをAbortします。SDK自身も長いLoopでは各処理単位の前にSignalを確認する実装を案内しています。

外部fetchにもSignalを渡す

MCP Handlerだけ停止しても、内部で実行したHTTP Requestが残っていては十分ではありません。

基本形
const response =
  await fetch(
    apiUrl,
    {
      signal:
        ctx.mcpReq.signal,
    },
  );

とします。

src/get-external-data-tool.ts
server.registerTool(
  "get_external_data",
  {
    description:
      "外部APIからデータを取得します",

    inputSchema:
      z.object({
        id:
          z.string(),
      }),
  },
  async (
    { id },
    ctx,
  ) => {
    const url =
      createApiUrl(id);

    const response =
      await fetch(
        url,
        {
          signal:
            ctx.mcpReq.signal,
        },
      );

    if (!response.ok) {
      return {
        content: [
          {
            type: "text",
            text:
              `API Error: ${response.status}`,
          },
        ],

        isError:
          true,
      };
    }

    return {
      content: [
        {
          type: "text",
          text:
            await response.text(),
        },
      ],
    };
  },
);

TypeScript SDKの現行ガイドでも、Handlerが開始したfetchなどのI/Oへctx.mcpReq.signalをそのまま渡し、キャンセルを下流処理まで伝播させる方法が案内されています。

AbortSignalに対応していない処理は自分で確認する

すべてのライブラリがAbortSignalを受け取れるわけではありません。

大量データ処理なら、処理単位の間に確認します。

手動確認の例
for (
  const row of rows
) {
  if (
    signal.aborted
  ) {
    throw new Error(
      "cancelled",
    );
  }

  await processRow(
    row,
  );
}

CPU負荷の高い処理で数十秒間JavaScript Threadをブロックすれば、その間はCancellationを確認できません。

長い処理を細かいUnitへ分割し、定期的にSignalを確認できる構造にします。

キャンセルは「ロールバック」ではない

ここが最も重要です。

Cancellationが届いたからといって、すでに完了した副作用が元に戻るわけではありません。

たとえば、

既に完了している例
send_email開始
↓
SMTP Serverへ送信成功
↓
MCP ClientがTimeout
↓
Cancellation

となった場合、メールはすでに送信済みです。

キャンセルしてもメールを取り消せません。

MCP仕様でもCancellationは協調的な仕組みであり、Serverは可能な限り処理を止めるべきですが、すでに完了した処理やキャンセル不能な処理では無視できるとされています。

したがって、

timeout = 処理失敗
timeout = 処理失敗

と判断してはいけません。

正確には、

timeoutの正しい意味
timeout = 最終結果を確認できなかった

です。

タイムアウトしたToolをすぐ再試行してはいけない

検索Toolなら、

search_documents
search_documents

を再試行しても、通常は同じ検索をもう一度行うだけです。

一方、

create_order
create_order

では事情が違います。

1回目の呼び出しが、

危険なタイミング
Client
↓
create_order
↓
Server
↓
注文作成成功
↓
Response送信直前に接続切断

となったとします。

Clientから見るとTimeoutです。

ここで同じToolを再試行すると、

二重作成
注文1作成
+
注文2作成

となる可能性があります。

MCP 2026-07-28ではResponse Streamが途中で切れたRequestの結果を再送する仕組みはなく、破損したRequestは新しいRequestとして再発行する必要があります。

だからこそ、書き込みToolでは冪等性が必要になります。

冪等性とは

冪等性とは、同じ操作を複数回実行しても最終的な結果が一度実行した場合と同じになる性質です。

たとえば、

get_user
get_user

は通常、同じIDで何回呼んでもデータを読むだけなので再試行しやすいToolです。

一方、

create_order
create_order

は何も対策しなければ呼ぶたびに新しい注文を作るため冪等ではありません。

そこでApplication側のoperationIdをTool Argumentとして渡します。

operationId付きの呼び出し
{
  "name": "create_order",
  "arguments": {
    "operationId": "04b40a44-1716-4c5c-9652-7e8ee3440ae8",
    "productId": "product-123",
    "quantity": 1
  }
}

同じ業務操作を再試行するときは、必ず同じoperationIdを使用します。

JSON-RPCのRequest IDを冪等性キーにしない

MCP RequestにもJSON-RPCのidがあります。

しかし、それを業務処理のIdempotency Keyとして使うのは避けます。

タイムアウト後の再試行は新しいRequestとして発行されるため、Request IDも変わる可能性があります。

また、MCP 2026-07-28はProtocol LayerでStatelessになっており、複数Requestを関連付ける必要がある状態は明示的な識別子で参照する設計が求められています。

したがって、

JSON-RPC request id
JSON-RPC request id

と、

業務上のoperationId
業務上のoperationId

は別に管理します。

create_orderを冪等化する

TypeScriptでoperationIdを必須にします。

src/create-order-tool.ts
server.registerTool(
  "create_order",
  {
    description:
      "注文を作成します",

    inputSchema:
      z.object({
        operationId:
          z.string()
            .uuid(),

        productId:
          z.string(),

        quantity:
          z.number()
            .int()
            .positive(),
      }),
  },
  async (
    {
      operationId,
      productId,
      quantity,
    },
    ctx,
  ) => {
    const existing =
      await db.orderOperation
        .findUnique({
          where: {
            operationId,
          },
        });

    if (existing) {
      return {
        content: [
          {
            type: "text",
            text:
              `既存の注文 ${existing.orderId} を返しました`,
          },
        ],

        structuredContent: {
          orderId:
            existing.orderId,

          duplicated:
            true,
        },
      };
    }

    if (
      ctx.mcpReq.signal
        .aborted
    ) {
      throw new Error(
        "cancelled",
      );
    }

    const order =
      await createOrder({
        productId,
        quantity,
      });

    await db.orderOperation
      .create({
        data: {
          operationId,
          orderId:
            order.id,
        },
      });

    return {
      content: [
        {
          type: "text",
          text:
            `注文 ${order.id} を作成しました`,
        },
      ],

      structuredContent: {
        orderId:
          order.id,

        duplicated:
          false,
      },
    };
  },
);

同じoperationIdで再試行された場合は新しい注文を作らず、前回の結果を返します。

ただし、このコードにはまだ改善すべき点があります。

確認してから作成するだけではRace Conditionが残る

次の処理では、

競合の例
SELECT
↓
存在しない
↓
INSERT

の間に、別Requestが同じoperationIdで実行される可能性があります。

そのためDB側にもUnique Constraintを設定します。

schema.sql
CREATE TABLE
mcp_tool_operations (
  operation_id UUID
    PRIMARY KEY,

  tool_name VARCHAR(100)
    NOT NULL,

  status VARCHAR(32)
    NOT NULL,

  result JSONB,

  created_at
    TIMESTAMPTZ
    NOT NULL
    DEFAULT NOW(),

  updated_at
    TIMESTAMPTZ
    NOT NULL
    DEFAULT NOW()
);

同じoperation_idを二重登録できない状態にします。

アプリケーションコードだけでなくDBのConstraintでも重複を防ぎます。

外部APIにもIdempotency Keyを渡す

MCP Toolが自社DBだけを変更するならTransactionで制御しやすいですが、決済APIやメールAPIなど外部サービスを呼ぶ場合はさらに注意が必要です。

外部APIがIdempotency Keyをサポートしているなら、自社のoperationIdをそのまま渡します。

src/charge-with-idempotency.ts
await paymentApi.charge({
  amount,
  currency:
    "JPY",

  idempotencyKey:
    operationId,
});

こうすれば、

下流への伝播
MCP Client Retry
↓
MCP Server Retry
↓
Payment API Retry

となっても、下流サービスまで同じ操作として扱えます。

外部APIが冪等性キーを提供していない場合は、MCP Server側にOperation Stateを永続化し、

状態の例
pending
executing
completed
failed
unknown

のような状態を管理する設計が必要になります。

Timeout後にunknown状態を持たせる

決済APIへRequestを送った直後にネットワークが切れた場合、

判別不能
成功したのか
失敗したのか

をMCP Server自身でも判断できないことがあります。

この状態を単純にfailedにしてはいけません。

unknownとして保存
status = unknown

として保存します。

src/operation-status.ts
type OperationStatus =
  | "pending"
  | "executing"
  | "completed"
  | "failed"
  | "unknown";

unknownになった操作は、外部APIのTransaction IDや注文検索APIなどから状態を照合します。

結果が確認できてから、

completedに確定
completed

または、

failedに確定
failed

へ変更します。

「Timeoutだったからもう一度作る」という設計より安全です。

再試行してよいToolを分類する

ToolごとにRetry Policyを定義すると扱いやすくなります。

src/tool-retry-policy.ts
type RetryPolicy =
  | "safe"
  | "idempotent_only"
  | "never";

type ToolPolicy = {
  retry:
    RetryPolicy;

  maxAttempts:
    number;
};

const policies:
  Record<
    string,
    ToolPolicy
  > = {
  search_documents: {
    retry: "safe",
    maxAttempts: 2,
  },

  get_weather: {
    retry: "safe",
    maxAttempts: 2,
  },

  create_order: {
    retry:
      "idempotent_only",
    maxAttempts: 2,
  },

  send_email: {
    retry:
      "idempotent_only",
    maxAttempts: 2,
  },

  delete_file: {
    retry: "never",
    maxAttempts: 1,
  },
};

これらの具体的な分類はMCP Protocolが自動で保証してくれるものではありません。

各Toolの業務上の副作用を理解して、Host側またはGateway側で定義します。

Protocol Errorをそのまま再試行しない

Unknown Toolや不正なArgument SchemaなどのProtocol Errorは、同じRequestを送り直しても直らない可能性が高いエラーです。

MCP仕様でも、Protocol ErrorはRequest Structure上の問題として扱われ、Tool Execution Errorよりモデルによる自己修正が難しいものとされています。

たとえば、

Unknown tool
Unknown tool: send_emali

というTypoを3回再試行しても成功しません。

Tool Listを再取得する、Tool Nameを修正する、Argumentを直すなど、原因を修正してから実行します。

Tool Execution Errorは内容を見て判断する

isError: trueなら何でも再試行する設計も避けます。

たとえば、

一時障害の例
503 Service Unavailable

なら一時障害なので再試行できる可能性があります。

一方、

永久障害の例
customer_id does not exist

なら同じ入力で再試行しても成功しません。

Tool Resultへ機械判定可能な情報を含めます。

一時エラーの戻り値
return {
  content: [
    {
      type: "text",
      text:
        "External API is temporarily unavailable",
    },
  ],

  structuredContent: {
    ok: false,
    code:
      "UPSTREAM_UNAVAILABLE",
    retryable:
      true,
  },

  isError:
    true,
};

入力値エラーなら、

永久エラーの戻り値
return {
  content: [
    {
      type: "text",
      text:
        "customer_idが存在しません",
    },
  ],

  structuredContent: {
    ok: false,
    code:
      "CUSTOMER_NOT_FOUND",
    retryable:
      false,
  },

  isError:
    true,
};

とします。

MCP仕様でもTool Execution ErrorはLLMが自己修正できるActionable Feedbackを含める用途を想定しており、ClientはそのエラーをLLMへ提供することが推奨されています。

Timeoutだけでは再試行可否を判断できない

Request Timeoutは一時的なネットワーク問題かもしれません。

しかし書き込みToolでは、前述したように処理済みかもしれません。

したがって再試行判断は、

不十分な基準
timeoutしたか

だけではなく、

確認すべき項目
Toolは読み取り専用か

副作用があるか

冪等化されているか

operationIdがあるか

外部APIも冪等か

まで確認します。

この設計がMCP Tool Retryの中心になります。

再試行には指数バックオフを使う

再試行可能なToolでも、失敗直後に連打するのは避けます。

src/backoff.ts
function sleep(
  ms: number,
): Promise<void> {
  return new Promise(
    (resolve) => {
      setTimeout(
        resolve,
        ms,
      );
    },
  );
}

function getBackoff(
  attempt: number,
): number {
  const base =
    Math.min(
      8_000,
      500 *
        2 ** attempt,
    );

  const jitter =
    Math.floor(
      Math.random() *
        300,
    );

  return (
    base +
    jitter
  );
}

1回目の失敗後は約500ms、次は約1秒というように待ち時間を増やします。

MCP自体が特定のBackoff秒数を規定しているわけではないため、依存先APIのRate LimitやRetry-Afterなどがある場合はそちらも考慮します。

MCP Tool用の再試行Wrapperを作る

Tool PolicyとTimeoutを組み合わせます。

src/call-tool-with-retry.ts
type CallToolInput = {
  name: string;

  arguments:
    Record<
      string,
      unknown
    >;
};

async function callToolWithRetry(
  input:
    CallToolInput,
) {
  const policy =
    policies[input.name] ?? {
      retry: "never",
      maxAttempts: 1,
    };

  let lastError:
    unknown;

  for (
    let attempt = 0;
    attempt <
    policy.maxAttempts;
    attempt += 1
  ) {
    try {
      const result =
        await client.callTool(
          input,
          {
            timeout:
              30_000,
          },
        );

      if (
        !result.isError
      ) {
        return result;
      }

      if (
        !isRetryableToolResult(
          result,
        )
      ) {
        return result;
      }

      if (
        policy.retry ===
          "never"
      ) {
        return result;
      }

      lastError =
        result;
    } catch (error) {
      lastError =
        error;

      if (
        !isRetryableMcpError(
          error
        )
      ) {
        throw error;
      }

      if (
        policy.retry ===
          "never"
      ) {
        throw error;
      }
    }

    if (
      attempt <
      policy.maxAttempts - 1
    ) {
      await sleep(
        getBackoff(
          attempt,
        ),
      );
    }
  }

  throw lastError;
}

重要なのはRetry WrapperへTool Nameを渡し、すべてのMCP Requestを同じ条件で再試行しないことです。

Timeoutエラーを判定する

TypeScript SDK v2ではRequest TimeoutがSDK Errorとして扱われます。

src/is-retryable-mcp-error.ts
import {
  SdkError,
  SdkErrorCode,
} from "@modelcontextprotocol/client";

function isRetryableMcpError(
  error: unknown,
): boolean {
  if (
    error instanceof
      SdkError
  ) {
    return (
      error.code ===
        SdkErrorCode
          .RequestTimeout ||
      error.code ===
        SdkErrorCode
          .ConnectionClosed
    );
  }

  return false;
}

ただし、ClientとServerのSDK Packageを同一Process内で扱うGatewayなどでは、Bundle境界によってinstanceofが期待どおり動かない場合があります。TypeScript SDK v2のMigration Guideでも、そのようなケースでは安定したerror.codeなどで判定する方法が案内されています。

ユーザーのキャンセルをRetryしない

Timeoutとユーザーキャンセルは意味が違います。

ユーザーが、

ユーザーの操作
停止

を押したのに、

誤った挙動
キャンセルされた
↓
一時エラーと判断
↓
自動Retry

してはいけません。

Client側では自分で作ったAbortControllerの状態を保持します。

src/user-cancel-check.ts
const controller =
  new AbortController();

try {
  await client.callTool(
    request,
    {
      signal:
        controller.signal,
    },
  );
} catch (error) {
  if (
    controller.signal
      .aborted
  ) {
    throw new Error(
      "user_cancelled",
    );
  }

  throw error;
}

特にUIの「停止」ボタンによるCancellationはRetry対象から除外します。

Timeout時にもCancellationを伝える

MCP仕様ではTimeoutしたRequestについて、Clientは単にPromiseをrejectするだけではなく、そのRequestをキャンセルして待機を終了することが推奨されています。

2026-07-28のStreamable HTTPならResponse Stream Close、stdioならnotifications/cancelledです。

TypeScript SDKを利用している場合はSDKがTransportに応じたCancellation処理を行うため、Application側では通常、

timeout
timeout

や、

signal
signal

を設定するだけで構いません。

Serverはキャンセル後にResponseを返さない

MCP 2026-07-28では、キャンセルを受けたServerは可能な限り処理を停止し、関連リソースを解放し、キャンセル済みRequestへのResponseを送らないことが推奨されています。

一方、Client側もCancellation後に遅れてResponseが届いた場合は無視することが推奨されています。

TypeScript SDK v2も、キャンセル済みHandlerが後からResultを返してもその結果を送信せず破棄する動作になっています。

そのためServer Handlerで、

必ず届くとは限らない例
return {
  content: [
    {
      type: "text",
      text:
        "Cancelled",
    },
  ],
};

を必ずClientへ届けられると考えないほうがよいでしょう。

キャンセル理由はApplication側の状態として管理します。

stdioでClientを終了するだけでは粒度が粗い

stdioでは複数のRequestが同じstdin/stdout Channelを共有できます。現行MCP仕様でも、ResponseやProgress通知などが同じChannel上を流れ、JSON-RPC IDで各Requestを対応付けます。

一つのToolが遅いからといってMCP Server ProcessそのものをKillすると、同時実行中の別Requestまで終了します。

単一Requestだけを止めたいならnotifications/cancelledを利用します。

Server Processが応答不能でCancellationすら処理できない場合に初めて、Process Restartを検討します。

stdioサーバーが落ちた場合はRetryできるが副作用に注意する

MCP 2026-07-28のstdio仕様では、Server Processが予期せず終了した場合、ClientはServerを再起動でき、ProtocolがStatelessであるためIn-flight Requestは失われ、新しいProcessに対してRequestを再試行できます。

ただし、

再試行できる
再試行できる

ことと、

安全に再試行できる
安全に再試行できる

ことは同じではありません。

Server Processが落ちる直前に外部DBへOrderを書き込んでいたなら、新しいProcessから同じcreate_orderを実行すると重複する可能性があります。

stdioでも書き込みToolにはApplication-level Idempotencyが必要です。

非常に長い処理は通常のtools/callで待たない

動画変換、CI Pipeline、大規模Data Importなど、数分から数時間掛かる処理を、

一本のTool Callで待ち続ける例
timeout: 3_600_000

として1本のTool Callで待ち続けるのは扱いにくくなります。

MCP 2026-07-28では長時間処理向けに公式のio.modelcontextprotocol/tasks拡張があります。

TasksではServerが通常のTool ResultではなくTask Handleを返し、Clientはtasks/getで状態を確認できます。TaskはClient Disconnect後もIDを使って確認でき、長時間処理、不安定な接続、Batch Processingなどに適しています。

Tasksの流れ
tools/call
↓
Task Handle
↓
HTTP接続終了

数秒後

tasks/get
↓
working

数秒後

tasks/get
↓
completed

という構造になります。

Tasksなら接続Timeoutから処理を分離できる

通常Toolでは、

通常Toolの制約
処理時間 = HTTP Requestを保持する時間

になりやすい問題があります。

Tasksなら、

Tasksで分離
処理時間 ≠ HTTP Request保持時間

にできます。

ServerはTaskをDurableに作成したあとTask IDを返します。

MCP Tasks Extensionでは、ServerはTaskを永続的に作成し、直後にtasks/getして取得できる状態になるまでCreateTaskResultを返してはいけません。

そのため長時間処理は、Timeoutを延ばし続けるよりTask化したほうが適している場合があります。

Tasksのキャンセルも強制停止とは限らない

Taskにはtasks/cancelがあります。

しかしTask Cancellationも協調的です。

ClientはいつでもCancelを要求できますが、Serverが実際の処理を必ず即時停止できるとは限りません。Taskが最終的にcancelled以外のTerminal Stateへ到達する可能性もあります。

外部のCI ServiceへJobを登録済みなら、MCP側のTaskをキャンセルするだけでJob自体が停止するとは限りません。

MCP Serverから下流のJob Cancellation APIまで伝播させます。

MCP 2026-07-28ではProtocol Sessionに依存しない

現行MCPはProtocol LayerでStatelessです。

Serverは同じConnectionから来たからといって、前のTool Callの状態が暗黙に続いていると判断してはいけません。複数Request間で必要な状態は明示的なIdentifierによって参照する必要があります。

そのため、

依存しない前提
前のRequestで注文処理を始めたはず

というServer Memoryだけに依存したRetry設計は避けます。

必要なら、

明示的な識別子
operationId
jobId
basketId
taskId
transactionId

などを明示して引き継ぎます。

MCPのTools仕様でも、Shopping CartやDatabase Transactionなど複数Callにまたがる状態は、Serverが明示的なHandleを返し、次のTool CallでそのHandleをArgumentとして受け取る設計が案内されています。

タイムアウト値はToolごとに分ける

すべてのMCP Toolを、

固定値の問題
timeout: 30_000

へ固定する必要はありません。

高速なDB Lookupと巨大File Exportでは正常な処理時間が違います。

src/timeout-by-tool.ts
const timeoutByTool:
  Record<
    string,
    number
  > = {
  get_user:
    5_000,

  search_documents:
    15_000,

  fetch_web_page:
    30_000,

  export_orders:
    60_000,
};

実際には各Toolのp95やp99処理時間を記録し、その値を基準に調整します。

MCP仕様もSDK/MiddlewareがRequest単位でTimeoutを設定できるようにすることを推奨しています。

外側のAgent Timeoutも確認する

MCP Toolだけ5分に変更しても、AI Agent全体が60秒で停止するなら意味がありません。

不一致の例
Agent max duration: 60秒

MCP Tool timeout: 300秒

では、Agent側が先に終了します。

AIエージェントの無限ループを防ぐ方法で解説したAgent Budgetと合わせて、

時間予算の配分例
Agent全体: 120秒

1 Tool: 最大30秒

MCP外部API: 最大20秒

のように上位から時間Budgetを配分します。

Toolが残り時間以上の処理を開始しないようにすることも重要です。

Tool実行時間をログへ保存する

MCPのTimeoutを改善するには、どこで時間を使ったかを測定します。

src/mcp-tool-log.ts
type McpToolLog = {
  traceId: string;

  toolName: string;

  durationMs: number;

  status:
    | "completed"
    | "tool_error"
    | "timeout"
    | "cancelled"
    | "protocol_error";

  retryCount:
    number;

  operationId:
    string | null;
};

Tool Server側でも外部処理を分けて計測できます。

計測の例
const startedAt =
  performance.now();

const apiStartedAt =
  performance.now();

const apiResult =
  await callExternalApi();

const apiDurationMs =
  performance.now() -
  apiStartedAt;

const durationMs =
  performance.now() -
  startedAt;

これにより、

切り分けられる項目
MCP Tool自体が遅い

DBが遅い

外部APIが遅い

Networkが遅い

を切り分けられます。

p95とTimeout率を見る

平均処理時間だけでは十分ではありません。

通常500msで終わるToolでも、5%だけ20秒掛かっている可能性があります。

DBには、

保存するカラム
duration_ms
timeout
retry_count
tool_name

を保存します。

PostgreSQLならp95を確認できます。

p95とTimeout率
SELECT
  tool_name,

  COUNT(*)
    AS calls,

  AVG(duration_ms)
    AS average_ms,

  PERCENTILE_CONT(0.95)
    WITHIN GROUP (
      ORDER BY duration_ms
    )
    AS p95_ms,

  COUNT(*) FILTER (
    WHERE
      status = 'timeout'
  )::NUMERIC
  /
  NULLIF(
    COUNT(*),
    0
  )
    AS timeout_rate

FROM mcp_tool_logs

WHERE
  created_at >=
    NOW()
    - INTERVAL '7 days'

GROUP BY
  tool_name;

export_ordersだけTimeout率が高ければ、そのToolだけProgressやTask化を検討できます。

Retry回数も監視する

自動Retryによってユーザーにはエラーが見えなくても、Serverが毎回2回目で成功している状態は正常ではありません。

Retry回数の集計
SELECT
  tool_name,

  AVG(retry_count)
    AS average_retries,

  MAX(retry_count)
    AS max_retries

FROM mcp_tool_logs

WHERE
  created_at >=
    NOW()
    - INTERVAL '7 days'

GROUP BY
  tool_name;

Retryが多いToolでは、Timeoutが短すぎるのか、依存先が不安定なのかを確認します。

「Retryで成功するから問題なし」と放置すると、Agent全体の待ち時間とAPI料金が増えます。

Tool Timeoutをモデルにそのまま再試行させない

MCP ClientがTool TimeoutをLLMへ、

単純なメッセージ
Tool timed out.
Try again.

だけで返すと、モデルが同じToolを同じArgumentで何度も実行する可能性があります。

AIエージェントの無限ループを防ぐ方法で解説したAgent Loop対策と組み合わせます。

Tool ResultやAgent Stateには、

含める情報
retryable

attempt

maxAttempts

operationId

を持たせます。

たとえば、

1回目
{
  "ok": false,
  "code": "TIMEOUT",
  "retryable": true,
  "attempt": 1,
  "maxAttempts": 2
}

とします。

2回目にも失敗したら、

2回目
{
  "ok": false,
  "code": "TIMEOUT",
  "retryable": false,
  "attempt": 2,
  "maxAttempts": 2
}

として、自動ループを止めます。

副作用ToolはHuman in the Loopとも組み合わせる

AIエージェントの無限ループを防ぐ方法で解説したHuman in the Loopは、Timeout Retryにも有効です。

たとえばsend_emailがTimeoutした場合、

危険な確認
もう一度送信しますか?

と聞くだけでは危険です。

1回目が実際には送信済みかもしれないからです。

まずoperationIdから送信状態を確認します。

確認フロー
operationId検索 → completed →「すでに送信済み」

operationId検索 → failed → 再送可能

operationId検索 → unknown → 状態確認または人間判断

という流れにします。

Human Approvalは冪等性の代わりではありません。

確認画面とIdempotencyの両方を使います。

Retry前に「処理済み確認Tool」を呼ぶ設計もある

外部サービスで状態照合できるなら、

create_order
create_order

のRetry前に、

get_order_by_operation_id
get_order_by_operation_id

を実行できます。

確認後のRetry
create_order
↓
timeout
↓
get_order_by_operation_id
↓
注文あり
↓
Retryしない

注文がなければ初めてRetryします。

書き込み系MCP Toolでは、「失敗したから再実行」ではなく「結果が存在するか確認してから再実行」という設計が安全です。

長時間Toolを細かく分割する方法もある

1回のToolで1000ファイルを処理するより、

分割した設計
create_export → export_id

get_export_status → processing

get_export_status → completed

download_export

と分けられる場合があります。

MCP 2026-07-28自体も、Protocol-level Sessionに依存せず、必要な状態は明示的なHandleで受け渡す設計を採っています。

数分以上の処理なら、独自Handle方式より公式Tasks Extensionを採用できるかも検討します。

MCPツールのタイムアウトに関するよくある質問

QMCP ToolのTimeoutは何秒にすべきですか

AすべてのToolに共通する正解はありません。短いDB検索なら5〜15秒、外部Web APIなら15〜30秒程度から実測し、長時間処理はProgressとmaxTotalTimeoutを使う方法があります。これらはMCP公式の固定推奨値ではありません。実際のp95・p99とAgent全体のTime Budgetから決めます。MCP仕様上もRequest単位でTimeoutを設定できることが推奨されています。

QProgressを送ればTimeoutしなくなりますか

ATypeScript SDK v2ではresetTimeoutOnProgress: trueを使えばProgressごとに通常Timeoutをリセットできます。ただしmaxTotalTimeoutも設定し、Progressが来続けるだけで無期限に待つ状態を防ぎます。またonprogressコールバックを設定しないとSDKがprogressTokenをリクエストへ付与しないため、resetTimeoutOnProgressだけを指定しても効果がない点にも注意します。

QStreamable HTTPではnotifications/cancelledを送りますか

A2026-07-28では通常送りません。Streamable HTTPでは対象RequestのSSE Response Streamを閉じることがCancellation Signalです。stdioではnotifications/cancelledを使用します。

QMCP Server側でキャンセルを検知できますか

ATypeScript SDK v2ではTool Handlerのctx.mcpReq.signalを確認できます。Clientがキャンセルした場合や接続が閉じた場合にSignalがAbortされます。外部fetchにもそのSignalを渡すことで下流処理までCancellationを伝播できます。

QTimeoutしたToolは自動でRetryしてよいですか

A読み取り専用で副作用がないToolなら再試行しやすいでしょう。書き込みToolは、1回目が実際には成功している可能性があるため、冪等化されていない状態で無条件Retryしてはいけません。operationIdや外部APIのIdempotency Keyを使い、同じ操作が複数回実行されても結果が一つになる設計にします。

QJSON-RPCのRequest IDをoperationIdにできますか

A業務上の冪等性キーとして依存するのは避けます。Timeout後の再発行は新しいRequestとして扱われますし、現行MCPは複数Request間の状態を明示的Identifierで扱うStateless Protocolです。

Q数十分掛かるToolはTimeoutを30分にすればよいですか

A動作はできますが、MCP Tasks Extensionのほうが向いている場合があります。Tasksでは長時間処理をDurable Taskへ変換し、ClientはTask IDを使ってtasks/getで状態を確認できます。接続切断後も処理状態を追跡できます。

まとめ

MCPツールがタイムアウトしたときは、単純にTimeout値を長くするだけではなく、

確認する項目
なぜToolが終わらなかったのか

Clientは何秒待っているのか

Server内部の外部APIは何秒待つのか

Progressは返っているのか

Cancellationが下流まで伝わるのか

Retryしても安全なToolなのか

を分けて確認します。

MCP 2026-07-28では、RequestにはTimeoutを設定し、時間切れになったらClientがRequestをキャンセルして待機を終了することが推奨されています。

Cancellation方法はTransportによって異なり、stdioではnotifications/cancelled、Streamable HTTPでは対象RequestのSSE Response Streamを閉じることがCancellation Signalになります。

TypeScript SDK v2のServerではctx.mcpReq.signalからCancellationを検知できるため、長いLoopで定期的に確認し、fetchなど下流I/Oにも同じAbortSignalを渡します。

処理に時間が掛かるものの正常に進んでいるToolにはnotifications/progressを使用します。Client側ではresetTimeoutOnProgressmaxTotalTimeoutを組み合わせることで、進捗中のToolを待ちながら絶対的な最大時間も設定できます。

そして最も重要なのがRetryです。

Timeoutは、

認識の誤り
Toolが失敗した

という証明ではありません。

正しい認識
結果を確認できなかった

だけの場合があります。

そのため検索・読み取り系ToolはRetryしやすい一方、メール送信、注文作成、決済、削除などの副作用を持つToolは、operationIdや下流サービスのIdempotency Keyを使って冪等化してからRetryします。

数分から数時間掛かる処理については、Timeoutを延ばし続けるのではなく、MCP 2026-07-28のio.modelcontextprotocol/tasks拡張によって非同期Taskへ切り替える方法も検討します。

MCP Toolの安定運用では、最終的に、

遵守する順序
Timeout
↓
Cancellation
↓
状態確認
↓
Retry可否判定
↓
必要なら同じoperationIdでRetry

という順序を守ることが重要です。

「タイムアウトしたらもう一度実行する」のではなく、「タイムアウトしても二重実行されない状態を先に作る」ことが、MCPツールの再試行設計の基本です。