朝いちばん、いつもの作業を始めようとして手が止まりました。前日まで使っていたサーバが、一覧のどこにもありません。
代わりに並んでいたのは「接続に失敗しました」という行が七つ。設定は触っていない。ネットワークも生きている。首をかしげながら別の調べ物を五分ほどして、もう一度一覧を開いたら、七つのうち四つが何事もなかったように戻っていました。
壊れていたのではなく、まだ握手が終わっていなかっただけでした。
この日から、起動直後の一覧をそのまま信じるのをやめました。信じる代わりに、自分で握手して結果を分類するようにしました。今日はその実装と、十四台まで増やして測った数字を残しておきます。
「使えません」の中に、性質の違うものが混ざっていました
一覧に出る文言は一種類でも、その裏側で起きていることは同じではありません。手元で拾ったものを並べると、少なくとも四つに分かれていました。
| 結末 | 裏で起きていること | こちらの取れる手 |
| 応答なし | プロセスは生きているが initialize への返事が期限内に来ない | 待つ・並列度を上げる。設定をいじっても直らない |
| 起動失敗 | プロセスがすぐ落ちる。標準エラーに理由が出ている | その場で直せる。資格情報・パス・実行権限 |
| プロトコル拒否 | 握手は成立したが、サーバがこちらの申告を受け付けない | その場で直せる。バージョン整合・クライアント名 |
| 正常 | initialize が結果を返した | 何もしない |
肝心なのは、この四つに割り当てるべき行動が全部違うという点です。応答なしのサーバに資格情報を入れ直しても徒労ですし、起動失敗しているサーバを待っても永遠に戻ってきません。一覧が「接続に失敗しました」の一語でまとめてしまうと、この使い分けができないまま総当たりで時間が溶けます。
もうひとつ、ここに含まれない層があります。握手も tools/list も成功しているのに、ツールが一件も生えていない状態です。あれは接続の層ではなく権限の層の話なので、MCPサーバーは繋がっているのに、ツールが0件で返る の方で扱いました。今日は握手が終わる手前だけを見ます。
分類して返すプローブを書く
やることは単純です。サーバを起動し、initialize を一通だけ投げ、期限つきで一行読む。返ってきたものと、返ってこなかった理由で場合分けする。それだけです。
一点だけ、素直に書くと必ず踏む落とし穴があります。readline() には期限を渡せません。標準入出力の読み取りは、相手が黙り込んだらそのまま返ってこないので、timeout を書ける場所がないのです。ここは読み取りを別スレッドへ追い出し、スレッドの join(timeout) を期限として使います。
# probe.py — MCPサーバの起動時状態を四つに分類する
import json, os, subprocess, sys, threading, time
READY, TIMEOUT, EXITED, PROTOCOL_ERROR = "READY", "TIMEOUT", "EXITED", "PROTOCOL_ERROR"
def probe(name, command, env_overrides, timeout):
"""1台のMCPサーバへ initialize を投げ、(名前, 結末, 経過秒, 詳細) を返す。"""
env = dict(os.environ)
env.update(env_overrides)
t0 = time.monotonic()
try:
proc = subprocess.Popen(
command,
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, env=env,
)
except OSError as ex:
# 実行ファイルが無い・権限が無い。ここは握手以前の失敗
return (name, EXITED, round(time.monotonic() - t0, 3), str(ex))
request = {
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "startup-probe", "version": "1.0"},
},
}
box = {}
def read_one_line():
box["line"] = proc.stdout.readline() # 期限を渡せないのでスレッドへ隔離する
reader = threading.Thread(target=read_one_line, daemon=True)
try:
proc.stdin.write(json.dumps(request) + "\n")
proc.stdin.flush()
except (BrokenPipeError, OSError):
pass # 既に落ちている。下の EXITED 判定で理由を拾う
reader.start()
reader.join(timeout)
elapsed = round(time.monotonic() - t0, 3)
if reader.is_alive():
proc.kill()
return (name, TIMEOUT, elapsed, f"initialize への応答が {timeout}s 以内に無し")
line = box.get("line") or ""
if not line:
proc.kill()
stderr_tail = (proc.stderr.read() or "").strip().splitlines()
detail = stderr_tail[-1] if stderr_tail else "応答せずに標準出力を閉じた"
return (name, EXITED, elapsed, detail)
message = json.loads(line)
proc.kill()
if "error" in message:
return (name, PROTOCOL_ERROR, elapsed, message["error"]["message"])
return (name, READY, elapsed, message["result"]["serverInfo"]["name"])
proc.kill() を各分岐に置いているのは、握手だけ確かめて用済みにするからです。プローブが起動したプロセスを残したまま本体がもう一度起動すると、同じサーバが二重に立ち上がります。片付けを忘れると、調べるために作った仕組みが調べたい対象を汚します。
六通りの相手に投げて、出てきた結末
分類が意図どおり効くかを確かめるため、応答の仕方だけを変えたサーバを六つ用意して投げました。標準ライブラリだけで書いた小さなサーバに、即答・遅延・沈黙・起動時クラッシュ・プロトコル拒否を演じさせています。
| 相手 | 期限 | 結末 | 経過 | 詳細 |
| 即答 | 5.0s | READY | 0.018s | serverInfo を取得 |
| 3秒遅延 | 5.0s | READY | 3.022s | serverInfo を取得 |
| 3秒遅延 | 2.0s | TIMEOUT | 2.001s | 期限内に応答なし |
| 沈黙 | 5.0s | TIMEOUT | 5.001s | 期限内に応答なし |
| 起動時クラッシュ | 5.0s | EXITED | 0.021s | fatal: missing API key |
| プロトコル拒否 | 5.0s | PROTOCOL_ERROR | 0.019s | unsupported protocolVersion |
読みどころは三行目です。中身はまったく同じ健全なサーバなのに、期限を 5.0 秒から 2.0 秒へ詰めただけで、二行目の READY が TIMEOUT に変わっています。
そしてログの上では、この TIMEOUT は四行目の沈黙したサーバの TIMEOUT と一字も違いません。詰めた期限は、健全なサーバを「壊れているもの」の山へ静かに移します。
一方で、五行目と六行目はどちらも 0.02 秒で結末が出ています。こちらから直せる二種類は、待たなくても即座に分かるのです。「待てば分かるかもしれない」と長く待つ設計にしても、直せる側の発見は一秒たりとも早まりません。
台数が増えたとき効いたのは、待ち時間ではなく並列度でした
実際に十四台まで増やして測りました。内訳は、応答 0.2 秒の健全なサーバが九台、2.5 秒かかる健全なサーバが二台、まったく応答しないサーバが三台です。期限は全台 5.0 秒。
| プローブの方式 | 全台の所要時間 | READY | TIMEOUT |
| 逐次(1台ずつ順番に) | 22.03s | 11 | 3 |
| 並列(上限16スレッド) | 5.09s | 11 | 3 |
判定結果はまったく同じで、時間だけが四分の一以下になりました。並列側で最も遅かった READY は 2.573 秒。つまり全体の 5.09 秒のうち、健全なサーバの応答を待っていたのは半分ほどで、残りは沈黙した三台の期限が切れるのを待っていた時間です。
ここで、予想と違った点がひとつあります。並列にすると全体の所要時間は各台の最大値になるので、沈黙しているサーバが一台でも残っている限り、全体の時間は期限の値そのものに張り付きます。三台を二台に減らしても、一台に減らしても、5.09 秒は動きません。
私はしばらく「使わなくなったサーバを設定から消せば起動が軽くなる」と思い込んで、月に一度ほど設定の棚卸しをしていました。逐次で回している間はそのとおりでしたが、並列にした瞬間に効かなくなります。掃除が時間として報われるのは、沈黙する相手を一台残らず消しきったときだけです。逆に言えば、並列化さえしておけば、壊れたサーバを放置したままでも起動は重くなりません。
待ち時間を詰めると、健全なサーバが失敗に化けます
では期限は何秒が妥当か。応答時間を 0.05 秒から 3.4 秒までばらけさせた健全なサーバ十台に、沈黙する三台を混ぜて、期限だけを振って測りました。
| 期限 | READY と判定 | 健全なのに TIMEOUT にされた台数 | 全体の所要時間 |
| 1.0s | 7 | 3 | 1.06s |
| 2.0s | 8 | 2 | 2.07s |
| 3.0s | 9 | 1 | 3.07s |
| 5.0s | 10 | 0 | 5.06s |
| 8.0s | 10 | 0 | 8.07s |
素直な形をしています。全体の所要時間は期限の値とほぼ一致し、READY の数は健全な最遅サーバ(3.4 秒)を跨いだところで頭打ちになります。5.0 秒を 8.0 秒へ延ばしても得るものはなく、所要時間が 3 秒伸びるだけです。
面白いのは短くする側です。1.0 秒にすれば所要時間は 5.06 秒から 1.06 秒へ、たしかに 4 秒縮みます。ただしその 4 秒の対価として、健全なサーバ三台が失敗の山に移ります。しかも移った先で、本当に沈黙している三台と見分けがつきません。
つまりこの「速く諦める」設定の節約分は、沈黙しているサーバの数とは関係なく、必ず健全で遅いサーバの犠牲とセットで支払われます。速さと引き換えに差し出しているのが何なのかが見えていないと、この取引は割に合うように錯覚します。私は期限を「自分が待てる長さ」から決めていましたが、それは決め方として逆でした。決めるべきは、手元の健全なサーバのうち最も遅いものが何秒かかるかで、待てる長さはその後に確かめる制約です。
実務上は、健全側の最遅値に 1.5 倍ほどの余裕を持たせた値を置いています。手元では 3.4 秒に対して 5.0 秒。相手が外部サービスで、日によって応答が揺れる場合は、この余裕を先に取っておかないと、混雑した日だけ静かに台数が減ります。
四つの結末に、別々の対応を割り当てる
分類できたら、あとは結末ごとに行き先を変えるだけです。手元では次のように割り当てています。
| 結末 | その場での扱い | 人に見せるか |
| READY | 使う | 見せない |
| TIMEOUT | 一度だけ再試行して、駄目なら今回は無いものとして進む | 件数だけ |
| EXITED | 再試行しない。標準エラーの最終行をそのまま出す | 必ず出す |
| PROTOCOL_ERROR | 再試行しない。エラーメッセージをそのまま出す | 必ず出す |
TIMEOUT にだけ再試行を許しているのは、これが唯一「時間が解決しうる」結末だからです。冒頭の朝、七つのうち四つが五分後に戻ってきたのは、この種類でした。逆に EXITED と PROTOCOL_ERROR を再試行するのは、同じ失敗をもう一度買うだけです。
人に見せるかどうかを分けているのは、無人で走らせる用途を考えているからです。個人開発だと、朝の三十分をどこに使うかがその日の進み方をほぼ決めてしまうので、読むべき行を絞ることの価値がそのまま時間の価値になります。TIMEOUT を毎回全部書き出すと、ログが「今日も三台落ちています」で埋まり、その中に紛れた EXITED の一行を読み飛ばします。直せる失敗だけを前に出し、直せない失敗は件数へ畳む。これで、朝いちばんに読むべき行が一気に減りました。
無人実行の場合はもう一段あって、READY の集合が前回と違っていたら、進む前に止めるかどうかを決める必要があります。昨日まで十四台中十四台が READY だったのに今日は十一台という状況で、そのまま処理を完走させてよい仕事かどうかは、仕事の側の性質で決まります。集計や配信のように「欠けていても最後まで走ってしまう」処理では、READY の台数を事前条件として扱う方が安全でした。
起動のたびに測り直さないための常駐化
四つの結末は、変わりやすさがまったく違います。EXITED と PROTOCOL_ERROR は、設定を直すまでほぼ確実に同じ結果になります。一方 TIMEOUT は時間で変わります。ここを一律に扱わず、寿命を分けて覚えておくと、二回目以降の起動が軽くなります。
# registry.py — 結末ごとに寿命を変えて覚える
import json, os, time
from concurrent.futures import ThreadPoolExecutor
from probe import probe, READY, TIMEOUT, EXITED, PROTOCOL_ERROR
CACHE_PATH = os.path.expanduser("~/.cache/mcp-readiness.json")
TTL = {READY: 900, TIMEOUT: 0, EXITED: 3600, PROTOCOL_ERROR: 3600}
# 正常は15分 応答なしは毎回測り直す 直すまで変わらない二つは1時間
def _load():
try:
with open(CACHE_PATH) as fp:
return json.load(fp)
except (OSError, ValueError):
return {}
def refresh(servers, timeout=5.0, max_workers=16):
"""servers: [(name, command, env_overrides), ...]"""
cache, now, pending, results = _load(), time.time(), [], {}
for name, command, env_overrides in servers:
entry = cache.get(name)
if entry and now - entry["at"] < TTL.get(entry["status"], 0):
results[name] = entry # まだ寿命内。握手を省略する
else:
pending.append((name, command, env_overrides, timeout))
if pending:
with ThreadPoolExecutor(max_workers=max_workers) as pool:
for name, status, elapsed, detail in pool.map(lambda a: probe(*a), pending):
results[name] = {"status": status, "elapsed": elapsed,
"detail": detail, "at": now}
os.makedirs(os.path.dirname(CACHE_PATH), exist_ok=True)
tmp = CACHE_PATH + ".tmp"
with open(tmp, "w") as fp:
json.dump(results, fp, ensure_ascii=False)
os.replace(tmp, CACHE_PATH) # 途中で落ちても壊れた JSON を残さない
return results
TTL[TIMEOUT] を 0 にしてあるのが要点です。応答なしだけは毎回測り直します。ここを他と同じく長く持たせると、朝いちばんに一度だめだったサーバが、その日ずっと無いものとして扱われます。冒頭の四台は、まさにそれで一日ぶんの取りこぼしになりかけました。
書き出しに os.replace を挟んでいるのは、途中で中断されたときに壊れた JSON を置き去りにしないためです。次の起動でこのファイルが読めないと _load() が空を返し、全台を握手し直すことになります。動作としては安全側に倒れますが、毎回そうなるなら常駐化した意味がありません。
いまも決めきれていないこと
期限の値を、サーバごとに変えるかどうかは保留にしています。手元には応答が 0.05 秒のものと 3.4 秒のものが同居していて、全台に 5.0 秒を配るのは明らかに雑です。ただ、台ごとに値を持つと今度はその値の管理が増えます。過去の READY の経過時間から自動で決める方向も試しましたが、混雑した日の値を学習して期限が伸び続ける挙動になり、いったん戻しました。
もうひとつ、READY と判定した後に相手が落ちる場合の扱いも残っています。握手が通ったことは、その後ずっと使える保証ではありません。ここは接続の層ではなく呼び出しの層の問題なので、別の仕組みが要ります。
まず試すなら、いま設定してある MCP サーバの一覧に対して、上の probe.py を期限 5.0 秒で一周させてみてください。四つの結末に分かれた表が出てきた時点で、その日どれに手を付けるべきかが決まります。私の場合、七行の「接続に失敗しました」のうち、実際に直す価値があったのは二行だけでした。
私自身まだ手探りの部分が多い題材ですが、同じところで足を止めている方の参考になれば嬉しいです。お読みいただきありがとうございました。