無人実行のログを見ていて、手が止まった瞬間がありました。
終了コードは 0。エージェントは筋の通った要約を返している。けれど本来そこに載っているはずの、社内の集計サーバーから取った数字が、どこにも見当たりません。
エラーは1行も出ていませんでした。
原因は資格情報の欠落でした。MCPサーバーは起動し、ハンドシェイクも通り、そして提供したツールは0件。エージェントは「使えるツールがない」という前提で、手持ちの知識だけで答えを組み立てていました。破綻していないぶん、気づくのが遅れます。
この挙動は仕様として理解すれば当たり前ですが、実装を任せている側の感覚としては裏切られた気持ちになります。個人開発で夜間に回している処理では、翌朝まで誰も見ていません。
接続の成否と、ツールが生えているかは別の層にある
MCPの起動シーケンスは大きく2段です。initialize でプロトコル版と能力をすり合わせ、そのあと tools/list で実際に呼べるツールを取得します。
資格情報を見るのは、多くの実装で後者より更に奥、あるいは tools/list を組み立てる時点です。認証に失敗したサーバーが選ぶ振る舞いは、実装者の裁量に委ねられています。
- エラーを返して接続ごと落とす
- 接続は維持したまま、ツールを空で返す
- ツール表は返すが、呼び出し時に初めて失敗する
このうち2番目が厄介です。プロトコル上は何も間違っていません。JSON-RPC のレスポンスは result を持ち、error は含まれません。クライアント側の例外ハンドラは何も捕まえられません。
そして Claude Code は、使えるツールが減っていても実行を止めません。止めない設計は対話中には親切ですが、無人実行では危うさに反転します。
資格情報の有無だけを変えて測る
推測で書きたくなかったので、依存ゼロの最小サーバーを立てて実際に測りました。標準入出力でJSON-RPCを話すだけの、80行ほどのPythonです。
#!/usr/bin/env python3
"""最小のstdio MCPサーバー。REPORT_API_TOKEN があるときだけツールを公開する。"""
import json, os, sys
TOKEN = os.environ.get("REPORT_API_TOKEN")
TOOLS = [
{"name": "fetch_daily_report", "description": "Fetch the daily sales report",
"inputSchema": {"type": "object", "properties": {"date": {"type": "string"}}, "required": ["date"]}},
{"name": "list_report_dates", "description": "List available report dates",
"inputSchema": {"type": "object", "properties": {}}},
]
def respond(rid, result):
sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": rid, "result": result}) + "\n")
sys.stdout.flush()
def main():
for line in sys.stdin:
line = line.strip()
if not line:
continue
req = json.loads(line)
method, rid = req.get("method"), req.get("id")
if method == "initialize":
# 資格情報の状態に関わらずハンドシェイクは成功する
respond(rid, {
"protocolVersion": "2026-07-28",
"capabilities": {"tools": {}},
"serverInfo": {"name": "report-server", "version": "1.0.0"},
})
elif method == "notifications/initialized":
continue
elif method == "tools/list":
# 未認証時はエラーではなく「空配列」を返す
respond(rid, {"tools": TOOLS if TOKEN else []})
elif method == "shutdown":
respond(rid, {})
return
elif rid is not None:
respond(rid, {})
if __name__ == "__main__":
main()
計測側は、ハンドシェイクの成否・tools/list の成否・得られたツール名を1回の起動でまとめて拾います。
#!/usr/bin/env python3
"""stdio MCPサーバーを叩いて、ハンドシェイクの結果とツール表を観測する。"""
import json, os, subprocess, sys, time
def probe(cmd, env):
proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True, env=env, bufsize=1)
def call(method, params=None, rid=None):
msg = {"jsonrpc": "2.0", "method": method}
if params is not None:
msg["params"] = params
if rid is not None:
msg["id"] = rid
proc.stdin.write(json.dumps(msg) + "\n"); proc.stdin.flush()
if rid is None:
return None
return json.loads(proc.stdout.readline())
t0 = time.perf_counter()
init = call("initialize", {"protocolVersion": "2026-07-28", "capabilities": {},
"clientInfo": {"name": "preflight-probe", "version": "0.1"}}, rid=1)
call("notifications/initialized")
listed = call("tools/list", {}, rid=2)
elapsed_ms = (time.perf_counter() - t0) * 1000
call("shutdown", None, rid=3)
proc.wait(timeout=5)
tools = listed.get("result", {}).get("tools", [])
return {
"handshake_ok": "result" in init,
"server": init.get("result", {}).get("serverInfo", {}).get("name"),
"tools_list_ok": "result" in listed,
"tool_count": len(tools),
"tool_names": sorted(t["name"] for t in tools),
"elapsed_ms": round(elapsed_ms, 1),
}
if __name__ == "__main__":
server = [sys.executable, os.path.join(os.path.dirname(__file__), "report_server.py")]
for label, extra in (("credential present", {"REPORT_API_TOKEN": "test-token"}),
("credential missing", {})):
env = {k: v for k, v in os.environ.items() if k != "REPORT_API_TOKEN"}
env.update(extra)
print(f"--- {label} ---")
print(json.dumps(probe(server, env), ensure_ascii=False))
手元のLinux環境(Python 3.10)で走らせた出力です。
--- credential present ---
{"handshake_ok": true, "server": "report-server", "tools_list_ok": true, "tool_count": 2, "tool_names": ["fetch_daily_report", "list_report_dates"], "elapsed_ms": 19.8}
--- credential missing ---
{"handshake_ok": true, "server": "report-server", "tools_list_ok": true, "tool_count": 0, "tool_names": [], "elapsed_ms": 18.6}
事前の予想と逆だった点が、ここに出ています。
私は「認証が通らなければハンドシェイクの段階で異常が観測できる」と思い込んでいました。実際には handshake_ok も tools_list_ok も両方 true のまま、違いは tool_count の 2 と 0 だけ。所要時間も 19.8 ミリ秒と 18.6 ミリ秒で、有意な差はありません。
失敗が「遅くなる」でも「例外になる」でもなく、「静かに数が減る」形で現れます。監視対象を接続性に置いていると、この差はどこにも記録されません。
期待するツール表を固定して、差分で落とす
対処の方針はひとつに絞りました。あるべきツール名の集合をリポジトリにコミットし、実行前に観測値と突き合わせる。
バージョンロックと同じ発想です。依存パッケージの版を固定するのに、エージェントに渡す権限の輪郭を固定していなかったのが、そもそもの抜けでした。
設定は Claude Code の .mcp.json と同じ形をそのまま読みます。
{
"mcpServers": {
"report": {
"command": "python3",
"args": ["./report_server.py"],
"env": { "REPORT_API_TOKEN": "${REPORT_API_TOKEN}" }
}
}
}
preflight 本体です。ロック生成と検証を1本にまとめてあります。
#!/usr/bin/env python3
"""実効ツール表がロックファイルと違えば fail closed する preflight。"""
import json, os, subprocess, sys, time
CONFIG = os.environ.get("MCP_CONFIG", ".mcp.json")
LOCK = os.environ.get("MCP_TOOL_LOCK", "mcp-tools.lock.json")
TIMEOUT_S = float(os.environ.get("MCP_PROBE_TIMEOUT_S", "10"))
def expand(value: str) -> str:
# 設定内の ${VAR} は「起動時の環境」から解決される
if value.startswith("${") and value.endswith("}"):
return os.environ.get(value[2:-1], "")
return value
def probe(name, spec):
env = {k: v for k, v in os.environ.items()}
for k, v in (spec.get("env") or {}).items():
env[k] = expand(v)
try:
# Popen は try の内側に置く。コマンド名の誤りは FileNotFoundError として
# ここで飛び、外に漏らすと「設定ミス」が「preflightの障害」に化ける
proc = subprocess.Popen([spec["command"], *spec.get("args", [])],
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, text=True, env=env, bufsize=1)
def call(method, params=None, rid=None):
msg = {"jsonrpc": "2.0", "method": method}
if params is not None:
msg["params"] = params
if rid is not None:
msg["id"] = rid
proc.stdin.write(json.dumps(msg) + "\n"); proc.stdin.flush()
return json.loads(proc.stdout.readline()) if rid is not None else None
call("initialize", {"protocolVersion": "2026-07-28", "capabilities": {},
"clientInfo": {"name": "mcp-preflight", "version": "1.0"}}, rid=1)
call("notifications/initialized")
listed = call("tools/list", {}, rid=2)
call("shutdown", None, rid=3)
proc.wait(timeout=TIMEOUT_S)
return sorted(t["name"] for t in listed.get("result", {}).get("tools", []))
except Exception as exc: # 起動失敗・壊れたフレーム・タイムアウト
if "proc" in dir():
proc.kill()
return {"error": f"{type(exc).__name__}: {exc}"}
def main():
started = time.perf_counter()
servers = json.load(open(CONFIG))["mcpServers"]
observed = {name: probe(name, spec) for name, spec in servers.items()}
if "--update-lock" in sys.argv:
json.dump(observed, open(LOCK, "w"), indent=2, sort_keys=True)
print(f"wrote {LOCK}")
return 0
expected = json.load(open(LOCK))
failures = []
for name, want in expected.items():
got = observed.get(name)
if isinstance(got, dict):
failures.append(f"{name}: probe failed ({got['error']})")
continue
missing = [t for t in want if t not in (got or [])]
if missing:
failures.append(f"{name}: {len(missing)}/{len(want)} tools missing -> {missing}")
took = (time.perf_counter() - started) * 1000
if failures:
print(f"MCP preflight FAILED in {took:.0f} ms")
for f in failures:
print(f" - {f}")
return 78 # EX_CONFIG: 実行時障害ではなく配線の問題
print(f"MCP preflight OK in {took:.0f} ms ({sum(len(v) for v in expected.values())} tools verified)")
return 0
if __name__ == "__main__":
sys.exit(main())
ロックの生成と、両方の状態での検証結果です。
$ REPORT_API_TOKEN=test-token python3 preflight.py --update-lock
wrote mcp-tools.lock.json
$ cat mcp-tools.lock.json
{
"report": [
"fetch_daily_report",
"list_report_dates"
]
}
$ REPORT_API_TOKEN=test-token python3 preflight.py; echo "exit=$?"
MCP preflight OK in 23 ms (2 tools verified)
exit=0
$ env -u REPORT_API_TOKEN python3 preflight.py; echo "exit=$?"
MCP preflight FAILED in 23 ms
- report: 2/2 tools missing -> ['fetch_daily_report', 'list_report_dates']
exit=78
沈黙していた失敗が、行として出力され、終了コードを持ちました。
追加コストも測りました。同じサーバーを5本並べた構成で3回続けて実行したところ、116・116・117 ミリ秒。1本のときの約23ミリ秒に対しておよそ5倍で、サーバー数にほぼ線形に乗ります。1回の無人実行あたり0.2秒未満なら、私は迷わず払います。
preflight 自身がクラッシュした話
書いた直後、起動できないサーバーを混ぜて試したところ、preflight が例外で落ちました。
FileNotFoundError: [Errno 2] No such file or directory: './no-such-binary'
exit=1
subprocess.Popen を try の外に置いていたのが原因です。存在しないコマンドは Popen の時点で例外を投げるため、内側の except には届きません。
見た目には些細な位置の問題ですが、CI での意味はまったく違います。終了コード1のスタックトレースは「検証ツールが壊れた」ように見え、当番は preflight のバグを疑い始めます。実際には設定ファイルのコマンド名が違うだけ、という切り分けの遠回りが発生します。
Popen を try の内側へ移した後の出力です。
MCP preflight FAILED in 1 ms
- ghost: probe failed (FileNotFoundError: [Errno 2] No such file or directory: './no-such-binary')
exit=78
同じ失敗でも、読み手に次の一手が渡ります。検証ツールを書くときは、自分の異常系が誤診を招かないかまで含めて設計する必要があると、あらためて感じました。
終了コードとCIへの組み込み
終了コードに 78 を選んだのは、sysexits.h の EX_CONFIG(設定の問題)に対応させるためです。1 は「何かが失敗した」以上を語りません。番号で意味が分かれていると、リトライすべきか人を呼ぶべきかがワークフロー側で判断できます。
GitHub Actions での配置はこうしています。
- name: MCP preflight
env:
REPORT_API_TOKEN: ${{ secrets.REPORT_API_TOKEN }}
run: python3 tools/preflight.py
- name: Run unattended agent
run: claude --print --output-format json "$(cat prompts/nightly.md)"
順序が肝心です。エージェントを起動してから気づいても、消費したトークンは戻りません。preflight を先に置くと、権限の欠落が「実行前の設定エラー」として扱われます。
ロックの更新は、ツールを増やしたときに --update-lock を回して差分をレビューに乗せます。ここを自動更新にすると、気づかないうちにツールが消えても表が追従してしまい、検証の意味が消えます。ロックは人がレビューするものとして扱うのが、この仕組みの前提です。
どこまで厳密にするか
全一致で落とすか、部分集合で許すかは、少し悩みました。
私は「ロックにある名前がすべて存在すること」だけを条件にし、観測側に増えたツールは通す実装にしています。サーバー側が新しいツールを足しただけで夜間実行が止まるのは、割に合わないと判断しました。
| 状況 | 判定 | 理由 |
| ロックのツールが1つでも欠ける | 止める | エージェントの能力が想定より狭く、結果が静かに劣化する |
| 観測側に未知のツールが増えた | 通す | 能力の拡大は結果を壊さない。ロック更新時にレビューする |
| サーバーが起動しない | 止める | 設定・依存・権限のいずれかが壊れている |
| ツール名は同じで引数スキーマが変わった | 止めない(別途検出) | 名前の集合では捕まらない。呼び出し側の契約テストで見る |
最後の行は、この仕組みの限界です。名前の集合はあくまで粗い網であって、引数の互換性までは見ていません。そこを詰めたい場合は inputSchema のハッシュまでロックに含める手がありますが、サーバー側の記述ゆれで頻繁に落ちるようになり、私の環境では運用が続きませんでした。粗い網を毎回通すほうが、細かい網を無効化されるより実効性があります。
期限切れによる劣化のほうが心配な場合は、OAuthトークンのライフサイクル設計を併せて組むと、静かな失敗の入口を二重に塞げます。サーバー名そのものが解決されず一覧から消える事象については、名前空間をベンダーと共有する前提の設定にまとめてあります。資格情報の保存先そのものの扱いは、公式の認証に関するドキュメントが一次情報になります。
明日の実行の前に置けること
無人実行の信頼性は、失敗を減らすことよりも、失敗が失敗として観測できる状態を作ることで上がります。今回の件で私が学んだのは、その一点でした。
手元で試すなら、順序はこうなります。
- 現在の
.mcp.json に対して --update-lock を実行し、いまのツール表を書き出す
- 出てきた一覧を目で確認する(意図しないサーバーが混じっていないか)
- 資格情報を1つ外して preflight を回し、実際に落ちることを確かめる
- CI のエージェント起動ステップの直前に差し込む
3番目を飛ばさないでいただければと思います。検証ツールが検証できていない状態は、検証していないより質が悪いためです。私自身、Popen の位置を間違えたまま「通った」と信じかけました。
お読みいただきありがとうございました。同じ静けさに足を取られた方の、切り分けの時間が少しでも短くなれば嬉しく思います。