夜中の 2 時に走らせているバッチが、朝になっても半分しか進んでいませんでした。ログの最後の行は「MCP サーバーに接続しました」で止まっていて、エラーも例外も出ていません。接続は通っていたのです。ただ、その次に投げたツール一覧の取得が、返ってこないまま朝を迎えていました。
個人開発で複数のサイトを回していると、こういう「エラーですらない停止」がいちばん厄介です。落ちてくれれば再起動できます。けれど無言で待ち続けられると、翌朝ログを開くまで気づけません。この記事は、Antigravity CLI 1.1.3 が塞いだ「無応答の MCP サーバーで無限に待つ」経路を出発点に、自分の自動化を同じ穴で止めないための境界ごとのタイムアウト設計を、動くコードとして残すものです。
1.1.3 が塞いだのは「無限に待つ」経路
Antigravity CLI 1.1.3(2026-07-16)のリリースノートには、MCP まわりの修正がひとつ入っています。MCP サーバーが応答を返さないまま、エージェントを無限に止めてしまう問題を、接続・ツール一覧取得・ツール呼び出しのそれぞれにタイムアウトを設けて解消した、という内容です。
地味に見えますが、自動でタスクを回している側からすると前提が変わる修正です。これまでは、握手には成功したのに以降が沈黙するサーバーが 1 つでもあると、エージェント全体がそこで固まりました。タイムアウトが入ったことで、少なくとも「いつまでも待つ」ことはなくなります。
ただ、CLI が内部で待たなくなったからといって、自分が書いた自動化の外側が安全になったわけではありません。CLI を agy -p のヘッドレスで呼び、その周りを cron やシェルで囲んでいる場合、CLI が打ち切ったあとにどう振る舞うかは自分の設計次第です。ここを詰めておかないと、「CLI は 120 秒で諦めたが、その失敗を握りつぶして次のジョブへ進み、結局全部が中途半端」という別の形の停止に化けます。
この記事では、CLI の内部修正に頼りきりにせず、MCP を使う自動化の外側に自分でタイムアウトの層を敷く方法を扱います。CLI を非対話で回す土台そのものについては、Antigravity CLI を非対話で回す — CI と cron に載せる前の設計で先に触れています。あわせて読むと、内側(CLI)と外側(自分の運用)のどちらでタイムアウトを持つべきかが見えてきます。
なぜ一律のタイムアウトでは足りないのか
「全部まとめて 60 秒でタイムアウト」と書きたくなります。実際、最初に私が入れたのはそれでした。けれど 3 つの境界は失敗の出方がまったく違うので、同じ数字を当てると必ずどこかが破綻します。
| 境界 | 典型的な所要時間 | 止まる原因の例 | 再試行の安全性 |
| 接続(connect) | 数十ミリ秒〜数秒 | プロセス未起動・ポート待ち・TLS ハンドシェイク停止 | 安全(副作用なし) |
| ツール一覧(list tools) | 数百ミリ秒 | 初期化中のロック・スキーマ生成の無限ループ | 安全(読み取りのみ) |
| ツール呼び出し(call tool) | 数百ミリ秒〜数十秒 | 外部 API の遅延・大きな入力の処理・書き込み待ち | ツール次第(冪等でないと危険) |
接続に 120 秒を許すと、起動し損ねたサーバーを 2 分間待つことになります。逆にツール呼び出しに 5 秒しか与えないと、正常に重い処理をしているツールを誤って切ってしまいます。しかも呼び出しだけは、再試行の安全性がツールの冪等性に依存します。一覧取得は何度投げても読み取りなので気楽ですが、書き込みを伴うツールを闇雲にリトライすると、二重登録のような別の事故を生みます。
つまり、タイムアウトは 1 つの数字ではなく、境界ごとの予算として持つべきものです。私の夜間バッチでは、接続 15 秒・一覧取得 20 秒・呼び出し 120 秒を出発点にしています。この配分は環境に強く依存しますので、あとで実測して詰めます。
防御ラッパー:境界ごとにタイムアウトを敷く
まず、境界ごとに別のタイムアウトを当てられる最小のラッパーを作ります。ここでは MCP クライアントの具体実装は抽象化し、connect / list_tools / call_tool の 3 つを持つオブジェクトを受け取る前提にします。asyncio.wait_for を境界ごとに包むのが核心です。
import asyncio
from dataclasses import dataclass
@dataclass(frozen=True)
class TimeoutBudget:
connect: float = 15.0 # 秒: 握手まで
list_tools: float = 20.0 # 秒: ツール一覧
call_tool: float = 120.0 # 秒: 1回の呼び出し
class BoundaryTimeout(Exception):
"""どの境界で時間切れになったかを保持する例外。"""
def __init__(self, server: str, boundary: str, limit: float):
self.server = server
self.boundary = boundary
self.limit = limit
super().__init__(f"{server}: {boundary} timed out after {limit}s")
async def _guard(coro, *, server: str, boundary: str, limit: float):
try:
return await asyncio.wait_for(coro, timeout=limit)
except asyncio.TimeoutError as exc:
# 一律の TimeoutError では「どこで」止まったかが消える。
# 境界名とサーバー名を載せ替えて投げ直すのが要点。
raise BoundaryTimeout(server, boundary, limit) from exc
async def open_session(client, server: str, budget: TimeoutBudget):
await _guard(client.connect(), server=server, boundary="connect", limit=budget.connect)
tools = await _guard(
client.list_tools(), server=server, boundary="list_tools", limit=budget.list_tools
)
return tools
ポイントは、asyncio.TimeoutError をそのまま上に流さないことです。標準の例外は「時間切れになった」ことしか伝えません。あとで通知や切り分けをするとき、必要なのは「どのサーバーの、どの境界で」という 2 つの情報です。BoundaryTimeout に載せ替えておくと、この 2 つが最後まで消えずに残ります。1.1.3 が stderr に必要な情報を出すようになったのと同じ発想で、失敗そのものを診断可能な形にしておくわけです。
指数バックオフとサーキットブレーカーを足す
タイムアウトだけでは、無応答のサーバーに毎回律儀に 120 秒を捧げてしまいます。夜間に同じサーバーへ 10 回タスクを投げるなら、無応答が続くと 20 分が溶けます。ここで効くのが、指数バックオフとサーキットブレーカーの組み合わせです。
再試行は「安全な境界だけ」に限ります。接続と一覧取得は読み取りなので何度でも試せます。ツール呼び出しは冪等なものだけ再試行し、そうでなければ 1 回で諦めるのが安全側です。
import asyncio
import time
from dataclasses import dataclass, field
@dataclass
class Breaker:
"""連続失敗が閾値を超えたら、一定時間そのサーバーへの試行を止める。"""
threshold: int = 3
cooldown: float = 300.0 # 秒: 開いている間はスキップ
fails: int = 0
opened_at: float = 0.0
def allow(self) -> bool:
if self.fails < self.threshold:
return True
if time.monotonic() - self.opened_at >= self.cooldown:
# クールダウンが明けたら半開き状態で 1 回だけ試す
self.fails = self.threshold - 1
return True
return False
def record(self, ok: bool) -> None:
if ok:
self.fails = 0
else:
self.fails += 1
if self.fails == self.threshold:
self.opened_at = time.monotonic()
class BreakerOpen(Exception):
pass
async def with_retry(make_coro, *, server, boundary, limit, breaker,
attempts=3, base_delay=1.0, retryable=True):
if not breaker.allow():
raise BreakerOpen(f"{server}: breaker open, skipping {boundary}")
last = None
tries = attempts if retryable else 1
for i in range(tries):
try:
result = await _guard(make_coro(), server=server, boundary=boundary, limit=limit)
breaker.record(ok=True)
return result
except BoundaryTimeout as exc:
last = exc
breaker.record(ok=False)
if i < tries - 1:
# 指数バックオフ: 1s, 2s, 4s ... 無応答サーバーへの再突撃を間引く
await asyncio.sleep(base_delay * (2 ** i))
raise last
make_coro を関数として受け取っているのは、再試行のたびに新しいコルーチンを作り直す必要があるためです。一度 await したコルーチンは使い回せません。ここを値渡しにしてしまうと、2 回目以降が RuntimeError で即死します。私が最初に踏んだのがまさにこの罠で、「バックオフを入れたのに 1 回しかリトライされない」という症状で 1 時間ほど悩みました。
サーキットブレーカーは、無応答のサーバーを一定時間そっとしておくための仕掛けです。3 回連続で時間切れになったら、以降 5 分間はそのサーバーへの試行そのものをスキップします。夜間の残りのジョブを、死んだサーバーに毎回 120 秒ずつ捧げずに済みます。半開きの復帰も入れてあるので、サーバーが息を吹き返せば自動で再開します。この「止まったまま生きている」対象をどう扱うかは、止まったまま生きているエージェントを見抜く — 進捗ハートビートとウォッチドッグの設計でエージェント側の視点からも整理しています。
どのサーバーがどこで止まったかを、失敗時だけ知らせる
自動化の通知でいちばんやってはいけないのは、成功のたびに鳴らすことです。毎晩「成功しました」が届くと、人はすぐに見なくなります。届いてほしいのは、止まったときに、どのサーバーがどの境界で止まったかだけです。
import json
import sys
def notify_failure(server: str, boundary: str, limit: float, run_id: str) -> None:
"""失敗時だけ 1 行の JSON を stderr に出す。上流の通知に拾わせる。"""
payload = {
"run_id": run_id,
"server": server,
"boundary": boundary, # connect / list_tools / call_tool
"limit_seconds": limit,
"kind": "mcp_timeout",
}
print(json.dumps(payload, ensure_ascii=False), file=sys.stderr)
async def run_task(client, server, budget, breaker, run_id):
try:
await _guard(client.connect(), server=server, boundary="connect", limit=budget.connect)
tools = await with_retry(
client.list_tools, server=server, boundary="list_tools",
limit=budget.list_tools, breaker=breaker,
)
return tools
except BoundaryTimeout as exc:
notify_failure(exc.server, exc.boundary, exc.limit, run_id)
raise
except BreakerOpen as exc:
notify_failure(server, "breaker_open", 0.0, run_id)
raise
境界名を JSON に載せているのは、翌朝の切り分けを一目にするためです。boundary が connect なら、そのサーバーはそもそも起動していない可能性が高い。list_tools で止まるなら、握手はできているので初期化かスキーマ生成のあたりを疑います。call_tool なら、特定のツールが叩いている外部 API を見にいきます。止まった位置が分かるだけで、朝いちばんの調査は数分で方向づけできます。この「失敗だけを、診断可能な形で出す」姿勢は、1.1.3 のヘッドレス修正で採られた設計と地続きです。CLI 側のソフト拒否と allow ルールの扱いは、黙って通していた確認ツールを、ソフト拒否から allow ルールに起こすにまとめました。
実際に回して出た数字
この層を、6 サイトを回す夜間バッチの MCP 呼び出しに一週間かぶせて動かしました。数字はあくまで私の環境のものですが、傾向は参考になると思います。
| 指標 | 導入前 | 導入後 |
| 無応答サーバー遭遇時の最大待ち時間 | 実測で平均 8 分(外側で気づくまで) | 上限 120 秒で確実に打ち切り |
| 死んだサーバーへの無駄な試行 | ジョブごとに毎回 | 3 回で遮断し 5 分休止 |
| 翌朝の原因切り分け | ログを遡って 15〜20 分 | 境界名の 1 行で数分 |
| 1 週間の中断ジョブ | 4 件(うち 2 件は無言停止) | 3 件(すべて通知が届いた) |
中断ジョブの件数そのものは大きく減っていません。サーバーが不安定なこと自体は、このラッパーでは直らないからです。変わったのは、止まり方が「無言の停止」から「通知の届く失敗」になったことです。無応答サーバーに当たったときの最大待ち時間は、外側で気づくまでの平均 8 分から上限 2 分へ、約 75% 短くなりました。個人開発では、問題をゼロにすることより、問題に朝すぐ気づける状態にしておくことのほうが、たいてい価値があります。接続 15 秒・呼び出し 120 秒という初期値は、一週間の実測で呼び出しを 90 秒まで詰められました。正常な呼び出しの 99 パーセンタイルが 70 秒台に収まっていたためです。
よくある落とし穴
コルーチンを値で渡してしまう
前述のとおり、再試行では毎回新しいコルーチンを作る必要があります。with_retry(client.list_tools()) と括弧を付けて呼ぶと、1 回目で消費され 2 回目が壊れます。関数参照(client.list_tools)を渡すのが正解です。
asyncio.wait_for はコルーチンを止めても、下のソケットは残ることがある
タイムアウトでコルーチンはキャンセルされますが、MCP サーバー側のプロセスやソケットが即座に片付くとは限りません。次の接続で「ポートが使用中」に化ける環境では、タイムアウト後に明示的なクローズを挟む必要があります。
サーキットブレーカーの状態をプロセス間で共有しようとする
夜間バッチをジョブごとに別プロセスで起動していると、ブレーカーの fails はプロセスをまたいで引き継がれません。跨ぎたい場合は、状態を SQLite や小さなファイルに落とす設計が要ります。まずは 1 プロセス内で完結させ、必要になってから外部化するのが安全です。
冪等でないツールにも呼び出しの再試行を一律で許す
読み取りは何度でも試せますが、書き込みや課金を伴うツールを闇雲にリトライすると、タイムアウトしただけで実は成功していた処理を二重に走らせます。retryable はツールの性質を見て個別に判断すべきフラグです。
手を止める線引き
最後に、境界ごとのタイムアウトを敷いたうえで、私がどこで機械に任せるのをやめているかを書いておきます。
個人開発で夜間の自動化を任せるほど、この線引きを自分の言葉で持っておくことが効いてきます。読み取り(接続・一覧取得)は、迷わず機械に任せます。何度失敗しても副作用がないので、リトライもバックオフも自動で回して構いません。書き込みを伴うツール呼び出しは、冪等性が保証できるものだけ自動リトライにし、そうでないものは 1 回で失敗を確定させて通知に回します。無応答が続くサーバーは、ブレーカーで自動的に遮断しますが、遮断が起きたこと自体は必ず人に届くようにしています。本番運用では、この自動遮断の通知が最後の砦になります。自動で静かに切り離されて、切り離されたことに誰も気づかない、というのがいちばん怖い状態だからです。
次の一歩として、まずは自分の MCP 呼び出しに connect / list_tools / call_tool の 3 つのタイムアウトを別々に当ててみてください。一律の 1 つを 3 つに割るだけで、止まったときに「どこで」が残るようになります。そこから先の予算の詰め方は、一週間の実測が教えてくれます。私自身まだ配分を調整し続けている途中ですが、無言の停止が通知の届く失敗に変わっただけでも、夜間の自動化はずいぶん任せやすくなりました。