MCPツール呼び出しのタイムアウト予算:長時間ツールを切る経路上の全制限と、進捗通知・Tasks拡張での対処【MCP Hosting・2026-07-28仕様】
ホスティングしたMCPツールが29秒・60秒・100秒で切れる理由を、経路ごとの制限一覧(Cloudflare 524、API Gateway、ALB、nginx、Cloud Run、Vercel、Lambda、Claude Code、Codex、TypeScript SDK)で解説。無通信型と総時間型の違い、実測スクリプト、Python SDK 2.xでの進捗通知、Tasks拡張とジョブハンドルによる非同期化まで。
最終更新:2026年10月19日 / 対象:MCP仕様 2026-07-28 と
io.modelcontextprotocol/tasks拡張。各プラットフォームとクライアントの制限値は2026年10月時点の公式ドキュメントに基づく。検証スクリプトは Node.js 22.22.0 と MCP Python SDK 2.2.0 で実行
結論(30秒で読める要約)
MCPのツール呼び出しがタイムアウトするのは、エージェントからサーバーまでの経路のどこか(クライアント、SDK、CDN、ロードバランサー、ホスティング基盤)が、ツールの完了より先に待つのをやめたときです。MCP Hostingでは経路上の各段にそれぞれ制限時間があり、ツールが使える時間は一番短い段で決まります。自分のコードで何分に設定していても、実際の上限は60秒や100秒であることがよくあります。
- 制限には2種類あります。「無通信(idle)型」は、データが流れるたびにリセットされます(nginxの
proxy_read_timeout60秒、AWS ALB 60秒、Cloudflareの100秒、Claude Codeの5分の無応答チェック)。「総時間型」はリセットされません(API Gatewayの29秒、Cloud Runの既定5分、Vercelの既定300秒、Codexのツールごと60秒)。 - 途中で接続が切れると、ツールはキャンセル扱いになります。2026-07-28仕様では、Streamable HTTPでは「SSEレスポンスストリームを閉じることがキャンセルの合図」であり、サーバーは切断をキャンセルとして扱わなければなりません(MUST)。再開機能も削除されたので、クライアントは最初からリクエストを送り直します。副作用のあるツールは2回実行されることがあります。
- 無通信型の制限には、進捗通知で対処します。クライアントが
progressTokenを送ってきたら、10〜20秒ごとにnotifications/progressを送ります。実測では、3秒の無通信で切るプロキシを通した10秒のツールが、進捗通知なしでは3.1秒で切断され、進捗通知ありでは10.2秒で正常に完了しました。 - 総時間型の制限には、リクエストを開いたまま待たない設計で対処します。すぐにハンドルを返し、クライアントにポーリングさせます。プロトコルとしてはTasks拡張(
resultType: "task"を返し、tasks/getでポーリング)があり、どのクライアントでも今すぐ使える方法としてstart_jobとget_jobの2つのツールに分ける方法があります。
経路ごとの制限値の一覧は3章、実測用のスクリプトは5章、対策の選び方は6章にあります。
目次
- MCPツール呼び出しのタイムアウトとは
- タイムアウトすると何が起きるか(キャンセルと再送)
- MCP Hostingのタイムアウト予算:経路の各段と既定の制限値
- 無通信型の制限と総時間型の制限
- どの段で切れているかを突き止める(実測スクリプト)
- 対策の選び方:進捗通知、制限の引き上げ、非同期化
- 進捗通知の実装(TypeScript と Python SDK 2.x)
- 非同期化:Tasks拡張とジョブハンドル
- チェックリスト
- よくあるハマりどころ(症状→原因→対処)
- NodeFlareならこう解決できる
- FAQ
1. MCPツール呼び出しのタイムアウトとは
MCPツール呼び出しのタイムアウトとは、経路上のどこかの部品がサーバーの結果を待ちきれずに諦め、tools/call リクエストが失敗することです。タイムアウトした時点で、サーバーはまだ処理中かもしれませんし、もう処理を終えているかもしれません。この点が、この種の不具合を分かりにくくしています。
ローカルのstdioサーバーなら、タイムアウトするのはクライアントとSDKだけです。リモートにホスティングすると、リクエストが通る部品が増え、それぞれが独自の時計を持ちます。
エージェント / クライアント ─▶ SDK ─▶ (CDN) ─▶ ロードバランサー / APIゲートウェイ ─▶ 実行基盤 ─▶ MCPサーバー ─▶ 外部API
この記事が対象にするのは、数秒以上かかるツールです。レポート生成、ブラウザ操作、CIの実行、重いクエリ、応答の遅い外部APIの呼び出しなどが当てはまります。起動時の遅さは別の問題で、MCPサーバーの初回呼び出しはなぜ遅い?で扱っています。この記事は、サーバーが起動済みの状態から始めます。
2. タイムアウトすると何が起きるか(キャンセルと再送)
経路のどこかが接続を閉じると、プロトコル上はキャンセルとして扱われ、クライアントは最初からやり直します。2026-07-28仕様で関係する決まりは次の3つです。
| 決まり | 仕様の記述 | ホスティングしたツールへの影響 |
|---|---|---|
| 切断はキャンセル | Streamable HTTPでは「SSEレスポンスストリームを閉じることがキャンセルの合図」。サーバーはクライアントの切断をそのリクエストのキャンセルとして扱わなければならない(MUST) | クライアントがキャンセルしていなくても、プロキシのタイムアウトでツールが止まる |
| 再開機能なし | 「レスポンスストリームが切れると処理中のリクエストは失われる。クライアントは新しいIDで新しいリクエストとして送り直さなければならない(MUST)」 | 再送は処理を最初からやり直し、同じ制限に再び引っかかることがある |
| タイムアウトは前提 | 実装は「送ったすべてのリクエストにタイムアウトを設けるべき(SHOULD)」。進捗通知を受けたら「タイムアウトの時計をリセットしてもよい(MAY)」が、「最大タイムアウトは必ず設けるべき(SHOULD)」 | 進捗通知を完璧に送っても無限には続けられない。どのクライアントにも上限がある |
ここから、実務上の注意点が2つ出てきます。
- 副作用が繰り返されることがあります。ツールの処理は終わったのにレスポンスが失われた場合、再送でもう一度実行されます。作成・送信・課金をするツールには、冪等性キーを持たせます。
- クライアントがいなくなっても処理を続ける基盤があります。Cloud Runは、タイムアウト時に「接続が閉じられ504エラーが返される」一方で、インスタンスは停止されず処理を続けることがあると書いています。そこへ再送が来ると、放棄された処理と並行して同じ処理が走り、負荷が倍になります。
3. MCP Hostingのタイムアウト予算:経路の各段と既定の制限値
ツールが実際に使える時間は、経路上で一番短い制限で決まります。2026年10月時点で公式に書かれている既定値は次のとおりです。
| 段 | 既定の制限 | 種類 | 引き上げられるか | 返るエラー |
|---|---|---|---|---|
| Cloudflareのプロキシ(オレンジ雲のDNS) | 100秒の「Proxy Read Timeout」 | オリジンの応答待ち。実際にはストリーム途中の無通信にも効く | Enterpriseのみ、最大6,000秒。またはプロキシを通さないDNSのみ(グレー雲)のサブドメインを使う | 524 |
| nginxのリバースプロキシ | proxy_read_timeout 60秒 |
無通信型(「連続する2回の読み取りの間」) | 可能。任意の値 | 504 |
| AWS Application Load Balancer | アイドルタイムアウト60秒 | 無通信型 | 1〜4,000秒 | 504 |
| Amazon API Gateway(REST) | 統合タイムアウト29秒 | 総時間型 | リージョンAPIとプライベートAPIなら延長可能。ただし「アカウント単位のスロットリング上限の引き下げが必要になる場合がある」 | 504 |
| Google Cloud Run | リクエストタイムアウト5分(300秒) | 総時間型 | 最大60分 | 504 |
| Vercel Functions(Fluid compute) | 最大実行時間300秒。ストリーミング中のレスポンスも含む | 総時間型 | Pro/Enterpriseは800秒、ベータで1,800秒 | 504 FUNCTION_INVOCATION_TIMEOUT |
| AWS Lambda | 自分で設定する関数タイムアウト。最大15分 | 総時間型 | 15分が上限 | 関数のタイムアウト |
| Cloudflare Workers | 「クライアントが接続している間は実行時間の上限なし」。CPU時間は既定30秒、最大5分(有料プラン) | CPU時間(経過時間ではない) | CPU上限を最大5分まで | CPU時間の超過 |
| Claude Code | ネットワーク経由のサーバーで、5分間「応答も進捗通知もない」と中断 | 無通信型 | CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT、.mcp.json のサーバーごとの timeout、全体の MCP_TOOL_TIMEOUT |
ツール呼び出しのエラー |
| Codex CLI | tool_timeout_sec 60秒 |
総時間型 | 可能。config.toml でサーバーごとに設定 |
ツールのタイムアウト |
| MCP TypeScript SDK(自作エージェント) | 1リクエスト60,000ms。resetTimeoutOnProgress は既定で false |
設定しなければ総時間型 | timeout、resetTimeoutOnProgress、maxTotalTimeout |
McpError -32001「Request timed out」 |
表の読み方です。
- 一番見落とされやすいのは、Cloudflareの100秒です。VPSやコンテナの前にCloudflareを置いていても、それをMCPの経路の一部だと意識していない人が多くいます。100秒を超えてからJSONでまとめて返すツールは、必ず
524になります。すぐに始まり、データを流し続けるSSEストリームは通るという報告が一般的ですが、Cloudflareの524の解説記事はストリーミングに触れていないので、5章のスクリプトで確かめてください。Cloudflare自身は、長いリクエストには「状態のポーリングを実装する」か、プロキシを通さない構成を勧めています。 - Lambdaの前にAPI Gatewayを置くと、使える時間は15分ではなく29秒です。統合タイムアウトを引き上げない限り、そこで切れます。
- Claude Codeは長く待ってくれますが、無通信型です。全体の上限がずっと長くても、5分間何も送らないツールは中断されます。また v2.1.212 以降は、2分たっても終わらない呼び出しをバックグラウンドのタスクに移すので(
CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS)、長い呼び出しでセッションが止まることはありません。 - TypeScript SDKで作ったエージェントは、60秒で失敗します。長い
timeoutを渡すか、resetTimeoutOnProgress: trueを設定する必要があります。
4. 無通信型の制限と総時間型の制限
対策は制限の種類で変わるので、まず各段がどちらかを見分けます。
| 無通信型 | 総時間型 | |
|---|---|---|
| 測っているもの | 最後にデータが流れてからの時間 | リクエスト開始からの時間 |
| 例 | nginxの proxy_read_timeout、ALBのアイドルタイムアウト、Cloudflareの100秒(ストリーミング時)、Claude Codeの無応答チェック |
API Gatewayの29秒、Cloud Run、Vercel、Lambda、Codexの tool_timeout_sec、TypeScript SDKの既定値 |
| 進捗通知で防げるか | 防げる(制限より短い間隔で送れば) | 防げない |
| 制限の引き上げで防げるか | 防げる | 基盤の上限までなら防げる |
| 非同期化で防げるか | 防げる | 防げる。上限を超えるならこれしかない |
進捗通知がクライアントに届くのは、レスポンスをストリーミングしている場合だけです。Streamable HTTPでは、SSEのレスポンス(Content-Type: text/event-stream)を返すことを意味します。素の application/json で返すサーバー(JSONレスポンスモード)は、結果が出るまで何も送れないので、どの段から見ても無通信に見えます。2026-07-28仕様では、notifications/progress のようにリクエストに紐づく通知は、そのリクエストのレスポンスストリームで送られます。途中のプロキシがこのストリームをバッファリングしてはいけません。nginxでは proxy_buffering off を設定するか、サーバーから X-Accel-Buffering: no を返します。また、Cloudflareのようにオリジンの最初の応答を待つ段もあるので、レスポンスヘッダーは呼び出しが始まったらすぐに返します。
5. どの段で切れているかを突き止める(実測スクリプト)
所要時間と進捗通知の間隔を自分で決められるツールで、失敗を再現します。何秒で切れたかで、どの段が原因かが分かります。29秒ならAPI Gateway、60秒なら無通信型のプロキシ・Codex・SDK、100秒ならCloudflare、300秒ならCloud RunかVercelの既定値です。
次のサーバーは依存パッケージなしのNode.jsです。tools/call に WORK_S 秒後に応答し、リクエストに progressToken があれば HEARTBEAT_S 秒ごとに進捗通知をストリーミングします。仕様どおり、ストリームが閉じたら処理を止めます。
// slow-tool.mjs - tools/call に WORK_S 秒かかる、MCP形式のエンドポイント
import http from "node:http";
const WORK_S = Number(process.env.WORK_S ?? 20);
const HEARTBEAT_S = Number(process.env.HEARTBEAT_S ?? 5);
http.createServer((req, res) => {
let body = "";
req.on("data", (c) => (body += c));
req.on("end", () => {
const msg = JSON.parse(body || "{}");
const token = msg.params?._meta?.progressToken;
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "X-Accel-Buffering": "no" });
const send = (m) => res.write(`data: ${JSON.stringify(m)}\n\n`);
const started = Date.now();
let done = false;
const hb = token === undefined ? null : setInterval(() => {
const s = Math.round((Date.now() - started) / 1000);
send({ jsonrpc: "2.0", method: "notifications/progress",
params: { progressToken: token, progress: s, total: WORK_S, message: `working ${s}/${WORK_S}s` } });
}, HEARTBEAT_S * 1000);
const t = setTimeout(() => {
done = true; clearInterval(hb);
send({ jsonrpc: "2.0", id: msg.id, result: { resultType: "complete", content: [{ type: "text", text: "done" }] } });
res.end();
}, WORK_S * 1000);
// Streamable HTTP では、ストリームが閉じたことがキャンセルの合図なので処理を止める
res.on("close", () => { if (!done) { clearTimeout(t); clearInterval(hb); console.error(`cancelled after ${(Date.now() - started) / 1000}s`); } });
});
}).listen(Number(process.env.PORT ?? 8000));
本番と同じCDN・ロードバランサー・基盤の後ろにデプロイし、進捗トークンあり・なしで呼び出します。
call() {
curl -sN -w '\nstatus=%{http_code} total=%{time_total}s\n' -X POST "$MCP_URL" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d "$1"
}
# A: 進捗トークンなし。結果が出るまでサーバーは何も送らない
call '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"slow","arguments":{}}}'
# B: 進捗トークンあり。HEARTBEAT_S 秒ごとに進捗通知が届く
call '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"slow","arguments":{},"_meta":{"progressToken":"p1"}}}'
ローカルで、ロードバランサーを模した小さなプロキシの後ろに置いて試しました。このプロキシは、8秒間データが流れないとリクエストを切ります(無通信型)。3回目は、合計12秒でも切るようにしました(総時間型)。WORK_S=20、HEARTBEAT_S=5 での結果です。
| 実行 | プロキシの制限 | 結果 | 経過時間 | サーバーのログ |
|---|---|---|---|---|
| A:進捗トークンなし | 無通信8秒 | 切断、空の応答(curlの終了コード52) | 8.0秒 | cancelled after 8.005s |
| B:5秒ごとに進捗通知 | 無通信8秒 | 200、進捗通知3回、結果を受信 |
20.0秒 | (完了) |
| C:5秒ごとに進捗通知 | 無通信8秒+合計12秒 | 進捗通知2回の後に切断(curlの終了コード18) | 12.0秒 | cancelled after 11.993s |
Bから、進捗通知は無通信型の制限を越えられることが分かります。Cから、総時間型の制限は、早く終わらせるか非同期化する以外に越えられないことが分かります。AとCでは、サーバーが切断を検知して処理を止めています。これが仕様の求めるキャンセルの動きです。
6. 対策の選び方:進捗通知、制限の引き上げ、非同期化
ツールの最悪の所要時間と、経路上で一番短い制限から対策を選びます。
| ツールの最悪の所要時間 | 経路上で一番短い制限 | 対策 |
|---|---|---|
| 一番短い制限より十分短い | どれでも | 何もしなくてよい。ただしツール自身のタイムアウトを一番短い制限より短くし、途中で切られる前にきれいに失敗させる |
| 無通信型の制限は超えるが、総時間型の制限には収まる | 無通信型(nginx、ALB、ストリーミング時のCloudflare、Claude Code) | SSEのレスポンスで10〜20秒ごとに進捗通知を送る(7章) |
| 引き上げられる総時間型の制限を超える | 総時間型(Cloud Runの5分、nginx、ALB) | 基盤側で制限を引き上げる。進捗通知も送る |
| 引き上げられない総時間型の制限を超える | Vercelの上限、Lambdaの15分、Cloud Runの60分、利用者が変えないクライアントの上限(Codexの60秒など) | 非同期化する:Tasks拡張かジョブハンドル(8章) |
| 不明、または上限がない(CI、人の承認、バッチ処理) | どれでも | 最初から非同期で設計する |
目安です。
- 対応するクライアントのうち、一番厳しいものに合わせます。既定設定のCodexから呼ばれるなら、基盤が何分許しても予算は60秒です。
- 余裕を残します。普段50秒のツールは、ときどき70秒かかります。同期で返すツールは、一番短い制限の半分程度に収めるのが目安です。
- 制限を引き上げても、問題の場所が移るだけのことがあります。Cloud Runを60分にしても、ツールが黙れば無通信型のプロキシで切れますし、接続が1回切れれば呼び出し全体が失われます。
7. 進捗通知の実装(TypeScript と Python SDK 2.x)
進捗通知のハートビートとは、ツール呼び出し自身のレスポンスストリームで、notifications/progress を一定間隔で送ることです。条件は3つあります。クライアントが progressToken を送ること、サーバーがストリーミングすること(JSONレスポンスモードではなくSSE)、送る間隔が経路上で一番短い無通信型の制限より短いことです。仕様では、progress の値は通知のたびに増えなければならず、双方が送信頻度を抑えるべきとされているので、10〜20秒に1回で十分です。
Python(MCP Python SDK 2.x)。SDK 2.xでは FastMCP が MCPServer に名前を変えました(from mcp.server.mcpserver import MCPServer)。mcp.server.fastmcp をimportする古いサンプルをそのまま使うと、2.xでは ModuleNotFoundError になります。ツールは Context から進捗を送ります。
# heartbeat_server.py - 無通信タイマーを毎回リセットする、時間のかかるツール
import asyncio
from mcp.server.mcpserver import MCPServer, Context
mcp = MCPServer("reports")
@mcp.tool()
async def build_report(rows: int, ctx: Context) -> str:
"""Build a report. Takes about 1 second per 100 rows."""
done = 0
while done < rows:
await asyncio.sleep(1) # 実際の処理1回分の代わり
done = min(rows, done + 100)
await ctx.report_progress(done, rows, f"{done}/{rows} rows")
return f"report ready: {rows} rows"
if __name__ == "__main__":
mcp.run(transport="streamable-http", port=8000)
MCP Python SDK 2.2.0で動かし、約10秒かかる build_report(rows=1000) を、3秒の無通信で切るプロキシ経由で呼びました。
| 呼び出し方 | 結果 | 経過時間 |
|---|---|---|
| 直接、進捗コールバックなし | 結果を受信 | 10.2秒 |
| 3秒プロキシ経由、進捗コールバックあり | 進捗通知10回、結果を受信 | 10.2秒 |
| 3秒プロキシ経由、進捗コールバックなし | 接続が切れてクライアントでエラー | 3.1秒 |
大事なのは3行目です。クライアントが progressToken を送っていないと、report_progress は何もしません。ハートビートが効くのは、クライアントが進捗を求めたときだけです。Claude Codeの無応答チェックは進捗通知を数えます。自作のエージェントでは、進捗コールバックを渡してください。
TypeScriptのクライアント側。TypeScript SDKの自作エージェントでは、進捗を求め、進捗でタイムアウトを延長しつつ、上限も設けます。
const result = await client.callTool(
{ name: "build_report", arguments: { rows: 1000 } },
undefined,
{
onprogress: (p) => console.log(`${p.progress}/${p.total ?? "?"} ${p.message ?? ""}`),
timeout: 60_000, // 1区間あたりのタイムアウト
resetTimeoutOnProgress: true, // 進捗通知が届くたびにリセット
maxTotalTimeout: 15 * 60_000, // 仕様が勧める最大タイムアウト
},
);
resetTimeoutOnProgress は既定で false です。設定しないと、進捗通知が届いていてもSDKは60秒で失敗します。
進捗トークンがない場合。クライアントが進捗を求めなくても、サーバーはSSEのコメント行(: ping のように : で始まる行)でプロキシを起こしておけます。SSEのクライアントはコメント行を無視します。プロキシの無通信タイマーはリセットされますが、応答と進捗通知を数えるClaude Codeの無応答チェックはリセットされません。使っているSDKやフレームワークでコメント行を直接書き込めるか、先に確認してください。
1ステップが長い処理のハートビート。1つのステップが何分もブロックする場合(重いSQL1本や外部API呼び出し1回など)は、その処理をバックグラウンドのタスクで動かし、待っている間にタイマーから進捗を送ります。ステップの合間にしか送らないハートビートは、1ステップが無通信型の制限より長いと役に立ちません。
8. 非同期化:Tasks拡張とジョブハンドル
ツールが総時間型の制限を超えうるなら、すぐにハンドルを返し、クライアントにポーリングさせます。そうすれば、どの段にも短いリクエストしか流れません。MCPではプロトコルの拡張として用意されているほか、普通のツールの組み合わせでも作れます。
Tasks拡張(io.modelcontextprotocol/tasks)
2026-07-28版で、実験的だったtasksはコアプロトコルから公式の拡張に移りました。新しい拡張は「ブロッキングする tasks/result を、tasks/get によるポーリングに置き換え」、サーバーが「リクエストごとの事前指定なしに」タスクのハンドルを返せるようになっています。流れは次のとおりです。
- クライアントは、各リクエストの
_metaで送るio.modelcontextprotocol/clientCapabilitiesのextensionsにio.modelcontextprotocol/tasksを入れます。サーバーはserver/discoverでこの拡張を公開します。 - 時間のかかるリクエストに対し、サーバーは
resultType: "task"のCreateTaskResultを返します。中身はtaskId、初期状態、ttlMs、pollIntervalMsです。タスクは「レスポンスを送る前に永続的に作成」されていなければなりません。 - クライアントは
pollIntervalMsの間隔でtasks/getを呼び、終了状態になるまでポーリングします。completedになると、resultに同期で返したはずの結果が入っています。 - タスクが入力を必要とすると
input_requiredになり、クライアントはtasks/updateで答えます。tasks/cancelでキャンセルを依頼できますが、協調的なもので、必ず止まるとは限りません。
| 状態 | 意味 | 終了状態か |
|---|---|---|
working |
処理中 | いいえ |
input_required |
クライアントからの入力待ち(inputRequests) |
いいえ |
completed |
完了。result に結果 |
はい |
failed |
error にJSON-RPCエラー |
はい |
cancelled |
キャンセル済み(必ず守られるとは限らない) | はい |
ホスティングの面では、守るべきことが2つあります。1つ目は、「対応を宣言していないクライアントにタスクを返してはいけない」ことです。リクエストごとに機能宣言を確認し、なければ同期の結果かジョブハンドルで返します。2つ目は、タスクを作ったプロセスがなくなってもタスクが残ることです。タスクの状態はメモリではなく、データベースか永続ディスクに置きます。そうしないと、再デプロイ、スケールtoゼロ、レプリカの切り替えのたびに、実行中のタスクがすべて「不明なタスク」になります。この拡張への対応状況はクライアントによって違うので、頼る前に拡張のクライアント対応表を確認してください。
ジョブハンドル:今どのクライアントでも使える非同期化
クライアントがまだTasksに対応していないなら、同じ形を普通のツール2つで提供します。2026-07-28仕様が状態の扱いとして勧める「明示的なハンドル」の考え方を、長時間ジョブに当てはめたものです。
# jobs.py - 開始とポーリングの2ツール。どちらの呼び出しも1秒以内に返る
import asyncio, json, secrets
from mcp.server.mcpserver import MCPServer
mcp = MCPServer("reports")
JOBS: dict[str, dict] = {} # デモ用。本番では Redis、Postgres、永続ディスクを使う
async def run_report(job_id: str, rows: int) -> None:
for done in range(100, rows + 1, 100):
await asyncio.sleep(1)
JOBS[job_id]["progress"] = done
JOBS[job_id].update(status="completed", result=f"report ready: {rows} rows")
@mcp.tool()
async def start_report(rows: int) -> str:
"""Start building a report in the background. Returns job_id at once.
Then call get_report with that job_id every ~10 s until status is completed."""
job_id = "job_" + secrets.token_urlsafe(12)
JOBS[job_id] = {"status": "working", "progress": 0, "total": rows}
asyncio.get_running_loop().create_task(run_report(job_id, rows))
return json.dumps({"job_id": job_id, "status": "working"})
@mcp.tool()
async def get_report(job_id: str) -> str:
"""Get the status of a report started with start_report."""
job = JOBS.get(job_id)
if job is None:
return json.dumps({"error": f"unknown or expired job_id {job_id}; call start_report again"})
return json.dumps({"job_id": job_id, **job})
MCP Python SDK 2.2.0で試したところ、start_report(rows=300) は0.04秒で job_id を返し、get_report は working を2回返した後に結果付きの completed を返しました。存在しないIDには、失敗せずに「start_reportをもう一度呼ぶ」よう促すエラーを返しました。
ジョブハンドルを作るときの注意点です。
- ポーリングの仕方をツールの説明文に書きます(「
get_reportを10秒ごとに呼ぶ」)。モデルはこの指示によく従いますし、モデルに伝えられる場所は説明文しかありません。 start_*を冪等にします。クライアントが指定するキーを受け付け、再送された開始要求には新しいジョブを作らず、既存のjob_idを返します。- 持ち主を確認します。認証付きのサーバーでは、ジョブに呼び出し元の識別情報を保存し、
get_*のたびに照合します。推測や漏えいしたジョブIDで、他人の結果が見えてはいけません。 - バックグラウンドの処理を生かしておきます。レスポンスを返した後も動くジョブは、インスタンスが動き続けることが前提です。スケールtoゼロの基盤では、リクエストのないインスタンスは止められることがあります。インスタンスより長生きするキューやワーカーで処理するか、1台を常時起動にします。
9. チェックリスト
- [ ] クライアントからサーバーまでの経路(CDNを含む)で、一番短い無通信型の制限と一番短い総時間型の制限を把握している
- [ ] 同期で返すツールは、一番短い総時間型の制限の半分程度で完了するか、自分のタイムアウトで失敗する
- [ ] 長い呼び出しにはSSEで応答し、経路上の何もそれをバッファリングしない(
proxy_buffering off、X-Accel-Buffering: no) - [ ]
progressTokenがあるとき、長いツールは10〜20秒ごとにnotifications/progressを送る - [ ] 自作エージェントは進捗コールバックを渡し、
resetTimeoutOnProgressとmaxTotalTimeoutを設定している - [ ] ストリームが閉じたら、ツールは処理を止める(切断はキャンセル)
- [ ] 副作用のあるツールは冪等性キーを受け付け、クライアントの再送で同じ操作を繰り返さない
- [ ] 総時間型の制限を超えうるツールは、待たせずにタスクかジョブのハンドルを返す
- [ ] タスクとジョブの状態は、再デプロイ、スケールtoゼロ、レプリカの変更を越えて残る場所に置いている
- [ ] 5章のスクリプトを本番と同じCDNと基盤の後ろで実行し、切れる秒数が表と一致することを確認した
10. よくあるハマりどころ(症状→原因→対処)
| 症状 | 原因 | 対処 |
|---|---|---|
ちょうど100秒で 524 になる |
Cloudflareのプロキシの読み取りタイムアウト。サーバーがJSONで返しているか、無通信になっている | 最初からSSEでストリーミングして進捗を送る、非同期化する、またはDNSのみのサブドメインで提供する。制限の引き上げはEnterpriseのみ |
29〜30秒ほどで 504 になる |
API Gateway(REST)の統合タイムアウト | 引き上げる(リージョンAPIかプライベートAPI)、Lambdaの関数URLを直接呼ぶ、または非同期化する |
60秒で 504 になる |
nginxの proxy_read_timeout かALBのアイドルタイムアウト |
進捗通知を送るか、アイドルタイムアウトを引き上げる |
自作エージェントで60秒後に MCP error -32001: Request timed out |
TypeScript SDKの既定のリクエストタイムアウト | timeout を渡すか、resetTimeoutOnProgress: true と進捗コールバックを使う |
| Codexからだけ60秒で失敗する | tool_timeout_sec の既定値 |
config.toml で引き上げるか、非同期化する |
| 何も送らないツールがClaude Codeで5分後に中断される | ネットワーク経由のサーバーに対する無応答チェック | 進捗通知を送る。CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT かサーバーごとの timeout を引き上げる |
| Cloud Runで5分で失敗する | 既定のリクエストタイムアウト | 最大60分まで引き上げる |
| 進捗通知が最後にまとめて届く | プロキシがSSEストリームをバッファリングしている | proxy_buffering off、X-Accel-Buffering: no |
| ハートビートのコードは動いているのに切れる | クライアントが progressToken を送っていない、またはサーバーがJSONレスポンスモード |
クライアントで進捗を求める。長いツールはSSEで返す |
| チケットやメールが2回作られる | レスポンスが失われ、クライアントが再送した | 副作用のあるツールに冪等性キーを持たせる |
| タイムアウト後にサーバーのCPUが倍になる | 基盤が放棄されたリクエストを処理し続け、その間に再送が走る | ストリームが閉じたら処理を止める。非同期化する |
デプロイ後や待機後に get_job が「不明なジョブ」を返す |
ジョブの状態をプロセスのメモリに置いていた | ジョブとタスクの状態を共有ストアか永続ディスクに移す |
11. NodeFlareならこう解決できる
NodeFlareは、GitHubリポジトリのMCPサーバー(stdio専用のものを含む)を https://<your-server>.nodeflare.tech/mcp のHTTPSエンドポイントとしてデプロイするMCP Hostingサービスです。この記事で挙げた課題には、次のように対応します。
| この記事の課題 | NodeFlareでの対応 |
|---|---|
| どのツールが、何秒でタイムアウトしているのか分からない | リクエストログに tool_name、duration_ms、http_status、outcome、error_message、session_id が記録されます。ツール名、エラーメッセージ、メソッド、結果、クライアントIPで検索でき、期間は1時間・24時間・7日・30日から選べます(Freeは1時間固定)。保存期間はFreeが7日、ProとTeamが30日です |
| コールドスタートがタイムアウト予算を食う | 待機中のサーバーはスナップショットから1秒未満で復元されます。Always-On(Pro以上)なら常に1台を起動しておき、コールドスタートをなくせます |
| バックグラウンドのジョブと、リクエストの合間のポーリング | Always-Onでレプリカを動かし続ければ、start_report で始めたジョブが、インスタンスを生かすための着信リクエストに頼らずに済みます |
| タスクやジョブの状態を、再デプロイや待機を越えて残したい | オプションの永続ディスクは、スケールtoゼロ、再デプロイ、再起動を越えて残ります(月額 ¥15/GB、Pro以上)。書き込みは1台のみなので、複数レプリカも必要なら外部のデータベースを使います |
| メモリ不足でツールが遅くなる | メモリは256MB、512MB、1GB、2GBから選べます。ライトサイジングは約1か月の使用状況から推奨サイズを提案し、メモリ不足のエラーが出たら自動で元に戻します |
| 新旧のクライアントを1つのエンドポイントで受けたい | Streamable HTTPと旧来のHTTP+SSEトランスポート(2024-11-05)の両方に対応しています |
| 長時間のツールで料金が膨らむ | 計算資源は1GB時間あたり ¥5 で、動いている間だけ課金されます。月の利用上限に達するとHTTP 402を返して止まり、サーバーごとの上限も設定できます |
ドキュメントに書かれていないこと:NodeFlareのドキュメントには、プロキシ段のリクエストタイムアウトの値は載っていません。5章のスクリプトをNodeFlareのURLに対して実行して経路を測り、1分を超えるツールには進捗通知かジョブハンドルを使ってください。
料金:Freeプランは ¥0 で、MCPサーバー1台、月30 GB時間の計算資源が使えます。Proは月額 ¥2,980 で、サーバー数無制限、計算資源300 GB時間とストレージ10GBが含まれます。
最小の始め方:
- nodeflare.tech にサインインし、MCPサーバーのGitHubリポジトリを選びます。
- 環境変数を設定してデプロイします。NodeFlareは実際のMCPの
initializeが成功したことを確かめてから、デプロイを成功にします。 - 一番遅いツールを1回呼び、リクエストログでその
duration_msとoutcomeを確認します。
1サーバーまで無料で試せます → nodeflare.tech
12. FAQ
MCPのツール呼び出しの既定のタイムアウトは何秒?
クライアントと、途中の各段によって違います。Codexはツールごとに既定60秒、MCP TypeScript SDKはリクエストを60,000msでタイムアウトさせ、Claude Codeはネットワーク経由のサーバーで応答も進捗もないまま5分たつと中断します。ホスティング側にも、Cloudflareの100秒やAPI Gatewayの29秒といった独自の制限があります。
MCPのツール呼び出しがちょうど100秒で失敗するのはなぜ?
ほぼ確実に、Cloudflareのプロキシを経由しています。既定のProxy Read Timeoutが100秒で、エラー524を返します。引き上げられるのはEnterpriseプランだけで、最大6,000秒です。それ以外では、すぐにSSEのレスポンスを始めて100秒よりずっと短い間隔で進捗を送るか、ジョブやタスクのハンドルを返してポーリングするか、DNSのみのサブドメインでMCPエンドポイントを提供します。
「MCP error -32001: Request timed out」はどう直す?
このエラーはSDKのリクエストタイムアウトから来ており、TypeScript SDKでは既定で60秒です。リクエストのオプションで長めの timeout を渡すか、進捗コールバックと maxTotalTimeout を付けて resetTimeoutOnProgress: true を設定します。何分もかかりうるツールなら、非同期にします。
MCPの進捗通知でタイムアウトは防げる?
無通信型のタイムアウトは防げます。nginxの proxy_read_timeout、ALBのアイドルタイムアウト、Claude Codeの5分の無応答チェックは、データが流れ続ける限り切れません。一方、API Gatewayの29秒、Cloud Runのリクエストタイムアウト、Codexのツールごと60秒のような総時間型の制限は防げません。また、クライアントが progressToken を送り、サーバーがSSEで返している場合にしか効きません。
MCPサーバーは進捗通知をどのくらいの頻度で送るべき?
経路上で一番短い無通信型の制限より、十分短い間隔で送ります。よくある60秒のアイドルタイムアウトなら、10〜20秒ごとで安全です。仕様は双方に送信頻度を抑えるよう求めているので、1秒に何回も送っても負荷が増えるだけです。
クライアントがタイムアウトすると、ツールの処理はどうなる?
Streamable HTTPではストリームが閉じたことがキャンセルの合図で、サーバーは切断をキャンセルとして扱わなければなりません。2026-07-28仕様は再開機能も削除したので、クライアントは新しいリクエストとして呼び出しを送り直します。切断されたら処理を止め、副作用のあるツールは冪等にします。
MCPのTasks拡張とは?
時間のかかるリクエストのための公式拡張で、識別子は io.modelcontextprotocol/tasks です。サーバーが taskId 付きで resultType: "task" を返し、クライアントはタスクが completed・failed・cancelled のいずれかになるまで tasks/get でポーリングします。2026-07-28版でコアプロトコルから拡張に移り、クライアントとサーバーの両方が対応を宣言する必要があります。
MCPのツールを15分以上動かすには?
1つのリクエストをそれほど長く開いたままにしないことです。すぐにハンドル(Tasks拡張の taskId か、start_* ツールが返す job_id)を返し、処理はバックグラウンドのワーカーやキューで動かし、クライアントに結果をポーリングさせます。ジョブの状態は、再起動を越えて残るストアに置きます。
Claude CodeのMCPタイムアウトは延ばせる?
延ばせます。.mcp.json でサーバーごとに timeout をミリ秒で指定するか、全サーバー向けに MCP_TOOL_TIMEOUT を設定します。ネットワーク経由のサーバーの無応答チェックは既定で5分で、CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT(Claude Code v2.1.187以降)で変えられます。進捗通知もこの無応答チェックをリセットします。
Cloud Runで時間のかかるMCPツールは動かせる?
ある程度までは動かせます。リクエストタイムアウトは既定5分で、最大60分まで引き上げられます。Googleは15分を超えるタイムアウトでは再試行と再接続の処理を勧めており、タイムアウトしたリクエストはインスタンス上で動き続けることがあります。もっと長い、または所要時間が読めない処理には、非同期の設計を使います。
まとめ
ホスティングしたMCPツールが使える時間は、経路上で一番短いタイムアウトで決まります。その経路には、CDN、ロードバランサー、実行基盤、SDK、クライアントが含まれます。まずその制限を見つけます。よくある原因は、Cloudflareの100秒、API Gatewayの29秒、そしてnginx・ALB・Codex・TypeScript SDKの60秒の既定値で、5章のスクリプトでどれが効いているかが分かります。無通信型の制限は、SSEのストリームで進捗通知を送れば越えられます。総時間型の制限は、早く終わらせるか、Tasks拡張やジョブハンドルで非同期にするしかありません。ストリームが切れると呼び出しはキャンセルされ、クライアントは最初からやり直すので、切断されたら処理を止め、副作用を冪等にし、長いジョブの状態はインスタンスより長生きする場所に置きます。
ホスティング先の選び方は、MCP Hosting 2026年版:MCPサーバーをリモートで動かす6つの方法と、MCPサーバーホスティング比較ランキングを参照してください。
参考資料
- MCP Specification 2026-07-28: Key Changes
- MCP Specification 2026-07-28: Cancellation(タイムアウトを含む)
- MCP Specification 2026-07-28: Progress
- MCP Extensions: Tasks
- MCP TypeScript SDK: RequestOptions
- Claude Code: Environment variables
- OpenAI Codex: MCP configuration
- Cloudflare: Error 524
- Cloudflare Workers: Limits
- Google Cloud Run: リクエストタイムアウトの構成
- AWS: API Gateway の統合タイムアウトを29秒超に
- AWS: Application Load Balancer の属性(アイドルタイムアウト)
- AWS Lambda: Quotas
- Vercel: Functions limits
- nginx: ngx_http_proxy_module(proxy_read_timeout)
