深夜の定期実行が、ある工程でだけ判で押したように失敗していました。ログの経過時間を並べてみると、59.8秒、60.1秒、59.9秒。呼び出しの中身は毎回違うのに、切れる瞬間だけがきっかり60秒に張り付いている。私はそのMCPサーバーに、もっと長い待ち時間を設定していたはずでした。手元の設定ファイルを何度見直しても数字は正しい。けれど実際に効いているのは、私が書いた値ではなく、どこかにある既定の60秒でした。
この違和感の正体は、7月14日の更新で明かされました。--mcp-config や .mcp.json に書いたサーバー単位の request_timeout_ms が読み込まれず、グローバルの既定へ静かに落ちていたのです。修正は入りました。ただ、修正が返してくれるのは「書いた値がようやく効く」という前提までで、「どの値をどこに置くか」「それが本当に効いているか」までは決めてくれません。今日はそこを、個人開発の夜間運用に引きつけて設計し直した記録です。
長時間の応答をそもそも待たせない考え方は応答が返らないMCP呼び出しに無人実行が飲み込まれた話で扱いました。本稿はその手前、待ち時間の値そのものが設定通りに効くかという設定層の話に絞ります。
きっかり60秒は「設定した値」ではなく「拾われた既定」だった
タイムアウトの事故がやっかいなのは、切れること自体は正常な挙動に見えてしまう点です。ツールが重ければ待ち時間を超えることはあり、そのとき打ち切られるのは設計通りとも言えます。だから「たまたま今日は重かったのだろう」と流してしまいがちでした。
けれど、切れる時刻が毎回ほぼ同じ値に集まるなら、話は変わります。ツールの負荷は日によってばらつくのに、打ち切りだけが一定なら、それは負荷ではなく上限が固定されている証拠です。私の場合はその上限が、自分で書いた値ではなく既定の60秒でした。
背景を整理します。MCPツール呼び出しの待ち時間には、いくつかの層があります。ひとつはサーバーの起動を待つ時間、もうひとつは個々のツール実行を待つ時間です。後者にはグローバルな既定があり、環境変数 MCP_TOOL_TIMEOUT で全体を、.mcp.json のサーバー定義に置く request_timeout_ms で個別に上書きできる建て付けになっています。今回の不具合は、この個別指定が読み飛ばされ、常にグローバル既定へ落ちていた、というものでした。
「無言で既定に落ちる」がいちばん検知しにくい
もし設定ミスがエラーとして表に出るなら、まだ楽でした。起動時に「その値は無効です」と言ってくれれば、その場で気づけます。今回が厄介だったのは、指定が無視されても警告ひとつ出ず、動作としては何事もなく既定値で走り続けたことです。
無人実行では、この静けさが特に効いてきます。対話中なら「あれ、思ったより早く切れたな」と体感で気づけますが、定期実行のログはあとから読むもので、切れた事実だけが淡々と残ります。経過時間を意識して記録していなければ、それが設定通りの打ち切りなのか、拾われなかった既定なのか、区別がつきません。
だから最初にやるべきは、値を直すことではなく、切れ際の分布を見えるようにすることでした。同じ工程の打ち切り時刻を何回分か並べ、値が一点に集まっているかを見る。集まっていれば、負荷ではなく上限が支配しているサインです。この一手間があるかないかで、設定が効いているかどうかの自己診断が段違いに速くなります。本番運用でこの落とし穴に何度か当たった経験から、まず分布を疑い、設定値と実測を突き合わせて対処することを私は推奨します。
request_timeout_ms をサーバー単位で正しく置く
修正後の正しい置き方を整理します。.mcp.json では、各サーバー定義の中に request_timeout_ms をミリ秒で書きます。ここでのポイントは、単位がミリ秒であること、そしてグローバル既定より長い値を意図的に与えることです。
{
"mcpServers": {
"report-builder": {
"command": "node",
"args": ["./mcp/report-builder.js"],
"request_timeout_ms": 180000
},
"quick-lookup": {
"command": "node",
"args": ["./mcp/quick-lookup.js"],
"request_timeout_ms": 20000
}
}
}
report-builder のように、個人開発でAdMobの日次収益を集計してレポートを生成する重いツールには180秒を与え、quick-lookup のような軽い参照には20秒に絞っています。全部を一律に長くしないのが肝心です。軽いツールにも長い上限を与えてしまうと、そのツールがハングしたときに無駄な待ちが延び、後続の工程に残る時間を食い潰します。
優先順位も押さえておきます。サーバー単位の request_timeout_ms が最優先で、指定がないサーバーはグローバルの MCP_TOOL_TIMEOUT、それも未設定なら組み込みの既定という順に落ちていきます。今回の不具合は、この最優先であるべき層が読まれずに一段下へ落ちていた、という理解でおおむね合っています。
環境変数で全体の底上げだけしたい場合は、実行環境側でこう与えます。
# ツール実行全体の既定を120秒に。個別の request_timeout_ms があればそちらが勝つ
export MCP_TOOL_TIMEOUT=120000
# サーバー起動の待ち時間は別系統。起動が重いサーバー向けに広げておく
export MCP_TIMEOUT=30000
ツール実行の待ち時間と、サーバー起動の待ち時間は別物です。起動が遅いサーバーで MCP_TIMEOUT を触るべき場面と、ツールが重くて request_timeout_ms を伸ばすべき場面を混同すると、直したつもりの値が別の層に効いてしまいます。私は一度これを取り違え、起動タイムアウトを伸ばして「なぜツールの打ち切りが変わらないのか」と半日悩みました。
タイムアウト値はツールの応答特性から逆算する
では何秒にすればよいのか。ここは一律の正解がなく、ツールが返すまでの性質から逆算するのが現実的でした。私が実際に使っている目安を、判断の軸ごとに整理します。
| ツールの性質 | 典型的な応答時間 | request_timeout_ms の目安 | 考え方 |
| 単純な参照・検索 | 1〜3秒 | 15000〜20000 | 速いはずのものが遅いなら異常。短く切って早めに再試行に回す |
| 外部API連携(1リクエスト) | 3〜10秒 | 30000〜45000 | 相手側の遅延を1〜2回分は吸収できる余裕を持たせる |
| 集計・レポート生成 | 20〜90秒 | 150000〜180000 | 最長ケースの1.5〜2倍。ただし実行枠全体の予算を超えない |
| 大きなファイル処理 | 可変・数分 | 実行枠予算の1/3を上限に | 一回の呼び出しに全体予算を食わせない。分割を先に検討 |
数字そのものより、二つの原則が大事だと考えています。ひとつは、上限を「最長でこれくらい」の実測に対して1.5〜2倍で置くこと。ぴったりに設定すると、ふだんは通るのに負荷の高い日だけ落ちる不安定なゲートになります。もうひとつは、どんなに重いツールでも、一回の呼び出しに実行枠全体の予算の三分の一以上を割り当てないこと。残りの工程に時間を残せなくなるからです。この二つを外すと、値を丁寧に決めたつもりでも運用は荒れます。
修正後こそ、切れ際を自分で確かめる
更新で不具合が直っても、自分の設定が意図通り効いているかは別途確かめる価値があります。設定ファイルの値と、実際に打ち切られた時刻がずれていないか。これを毎回のログから拾って、既定値に張り付いていないかを見張る軽いチェッカを噛ませています。
import json
import statistics
from pathlib import Path
# .mcp.json から各サーバーの request_timeout_ms を読む(秒に換算)
def configured_limits(config_path: str) -> dict[str, float]:
cfg = json.loads(Path(config_path).read_text(encoding="utf-8"))
limits = {}
for name, server in cfg.get("mcpServers", {}).items():
ms = server.get("request_timeout_ms")
if ms:
limits[name] = ms / 1000.0
return limits
# 打ち切りが「設定値」ではなく「既定60秒」に集まっていないかを判定
def detect_default_cap(timeouts_sec: list[float], configured_sec: float,
default_sec: float = 60.0, tol: float = 1.5) -> str:
if not timeouts_sec:
return "no-data"
median = statistics.median(timeouts_sec)
near_default = abs(median - default_sec) <= tol
near_configured = abs(median - configured_sec) <= tol
if near_default and not near_configured and abs(configured_sec - default_sec) > tol:
# 設定は60秒でないのに、実測が60秒へ張り付いている = 既定に落ちている疑い
return "SUSPECT: cutoffs cluster at default, configured value not taking effect"
return "ok"
# 使い方: 同じ工程で打ち切られた経過秒のリストを渡す
limits = configured_limits(".mcp.json")
print(detect_default_cap([59.8, 60.1, 59.9], configured_sec=limits.get("report-builder", 60.0)))
やっていることは単純で、打ち切り時刻の中央値が設定値の近くにあるか、それとも既定の60秒に寄っているかを見るだけです。負荷でたまたま60秒になる日もあるので、一回の値では判断しません。何回分かの中央値が既定に張り付いて、かつ設定値からは離れているときに疑いを立てます。この判定を定期実行の末尾に一行仕込んでおくと、次に同じ不具合が別の形で戻ってきても、静かに見過ごさずに済みます。
夜間の自動運用にどう組み込むか
個人開発で複数の処理を夜間に委ねていると、タイムアウトは「速く失敗させる」ための道具になります。無人実行では、待ち続けるより早めに切って後続へ進む方が、朝に見たときの被害が小さいからです。
その観点で、私はサーバー単位の request_timeout_ms を実行枠全体の予算から逆算して決めています。全体の予算があり、その内側に工程ごとの見切りがあり、さらに内側に一回のツール呼び出しの上限がある。内側ほど短く、というこの入れ子を崩さないことが、静かな打ち切り事故を防ぐ土台になります。今回のように個別指定が無言で既定へ落ちると、この入れ子のいちばん内側が抜けて、外側の大きな上限に飲み込まれる。だからこそ、値を書いたら効いていることまで確かめる一手間を、運用に常設しておきたいのです。
設定は一度書けば終わりに見えて、実際にはこうして時々裏切ります。私自身まだ手探りの部分が多いのですが、切れ際を見える化しておく習慣は、同じ驚きを二度味わわずに済む静かな保険になっています。お読みいただきありがとうございました。