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で再試行・キャンセル・冪等性を実装する方法を解説します。
- MCPツールのタイムアウトとは
- タイムアウトとTool Errorは違う
- MCPツールがタイムアウトする主な原因
- クライアントとサーバーでタイムアウト値が違う場合もある
- MCP Client側でタイムアウトを明示する
- 長時間ToolにはProgressを返す
- Progressが来たらタイムアウトをリセットできる
- ProgressをHeartbeat代わりに乱用しない
- MCP ServerからProgressを送る
- タイムアウトしたらサーバー側の処理も止める
- TypeScript SDK v2ではAbortSignalを使う
- Server側でもAbortSignalを確認する
- 外部fetchにもSignalを渡す
- AbortSignalに対応していない処理は自分で確認する
- キャンセルは「ロールバック」ではない
- タイムアウトしたToolをすぐ再試行してはいけない
- 冪等性とは
- JSON-RPCのRequest IDを冪等性キーにしない
- create_orderを冪等化する
- 確認してから作成するだけではRace Conditionが残る
- 外部APIにもIdempotency Keyを渡す
- Timeout後にunknown状態を持たせる
- 再試行してよいToolを分類する
- Protocol Errorをそのまま再試行しない
- Tool Execution Errorは内容を見て判断する
- Timeoutだけでは再試行可否を判断できない
- 再試行には指数バックオフを使う
- MCP Tool用の再試行Wrapperを作る
- Timeoutエラーを判定する
- ユーザーのキャンセルをRetryしない
- Timeout時にもCancellationを伝える
- Serverはキャンセル後にResponseを返さない
- stdioでClientを終了するだけでは粒度が粗い
- stdioサーバーが落ちた場合はRetryできるが副作用に注意する
- 非常に長い処理は通常のtools/callで待たない
- Tasksなら接続Timeoutから処理を分離できる
- Tasksのキャンセルも強制停止とは限らない
- MCP 2026-07-28ではProtocol Sessionに依存しない
- タイムアウト値はToolごとに分ける
- 外側のAgent Timeoutも確認する
- Tool実行時間をログへ保存する
- p95とTimeout率を見る
- Retry回数も監視する
- Tool Timeoutをモデルにそのまま再試行させない
- 副作用ToolはHuman in the Loopとも組み合わせる
- Retry前に「処理済み確認Tool」を呼ぶ設計もある
- 長時間Toolを細かく分割する方法もある
- MCPツールのタイムアウトに関するよくある質問
- まとめ
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自身が正常に処理を終え、
{
"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だけが存在するわけではありません。
ブラウザ・ホスト ↓ 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を設定できます。
const result =
await client.callTool(
{
name: "search_documents",
arguments: {
query:
"MCP cancellation",
},
},
{
timeout: 30_000,
},
);
30秒以内に最終結果が返らなければ、待機を終了します。
SDK v2ではRequest TimeoutはSdkErrorとSdkErrorCode.RequestTimeoutとして扱われます。v1のMcpError + ErrorCode.RequestTimeoutからエラー型が変更されているため、v2へ移行しているコードでは古いinstanceof McpError判定をそのまま使わないよう注意が必要です。
長時間ToolにはProgressを返す
30秒を超えるToolだから、
timeout: 300_000
に変えるだけでは、Toolが停止しているのか正常に処理しているのか判断できません。
長時間処理ではMCPのProgress Notificationを使います。
MCP 2026-07-28では、クライアントがリクエストの_metaにprogressTokenを付けると、サーバーはnotifications/progressによって進捗を返せます。progressは同じTokenに対して増加させる必要があり、totalや人間向けのmessageも付けられます。
TypeScript SDK v2ではonprogressを設定するとProgress Tokenの処理をSDK側で行えます。SDKはonprogressを指定した場合にのみprogressTokenをリクエストへ付与するため、後述するresetTimeoutOnProgressだけを指定してonprogressを省略すると、サーバーへProgress Tokenが渡らずタイムアウトが延長されません。
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: true
を利用できます。
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を取得できます。
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を利用できます。
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を一度も確認しなければ処理をすぐ止められません。
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,
},
);
とします。
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 = 最終結果を確認できなかった
です。
タイムアウトしたToolをすぐ再試行してはいけない
検索Toolなら、
search_documents
を再試行しても、通常は同じ検索をもう一度行うだけです。
一方、
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
は通常、同じIDで何回呼んでもデータを読むだけなので再試行しやすいToolです。
一方、
create_order
は何も対策しなければ呼ぶたびに新しい注文を作るため冪等ではありません。
そこでApplication側のoperationIdをTool Argumentとして渡します。
{
"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
と、
業務上のoperationId
は別に管理します。
create_orderを冪等化する
TypeScriptでoperationIdを必須にします。
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を設定します。
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をそのまま渡します。
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にしてはいけません。
status = unknown
として保存します。
type OperationStatus = | "pending" | "executing" | "completed" | "failed" | "unknown";
unknownになった操作は、外部APIのTransaction IDや注文検索APIなどから状態を照合します。
結果が確認できてから、
completed
または、
failed
へ変更します。
「Timeoutだったからもう一度作る」という設計より安全です。
再試行してよいToolを分類する
ToolごとにRetry Policyを定義すると扱いやすくなります。
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: 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でも、失敗直後に連打するのは避けます。
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を組み合わせます。
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として扱われます。
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の状態を保持します。
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
や、
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など、数分から数時間掛かる処理を、
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などに適しています。
tools/call ↓ Task Handle ↓ HTTP接続終了 数秒後 tasks/get ↓ working 数秒後 tasks/get ↓ completed
という構造になります。
Tasksなら接続Timeoutから処理を分離できる
通常Toolでは、
処理時間 = HTTP Requestを保持する時間
になりやすい問題があります。
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では正常な処理時間が違います。
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を改善するには、どこで時間を使ったかを測定します。
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を確認できます。
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回目で成功している状態は正常ではありません。
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
を持たせます。
たとえば、
{
"ok": false,
"code": "TIMEOUT",
"retryable": true,
"attempt": 1,
"maxAttempts": 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
のRetry前に、
get_order_by_operation_id
を実行できます。
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側ではresetTimeoutOnProgressとmaxTotalTimeoutを組み合わせることで、進捗中の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ツールの再試行設計の基本です。
