無人実行のログを見ていて、手が止まった瞬間がありました。
終了コードは 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
同じ失敗でも、読み手に次の一手が渡ります。検証ツールを書くときは、自分の異常系が誤診を招かないか まで含めて設計する必要があると、あらためて感じました。
そして、この視点で読み直したときに出てきた穴が、ここで終わりではありませんでした。
返事が来ないサーバーで、preflight が止まらなくなる
同じ観点で自分の実装を読み直したところ、まだ塞げていない穴が続けて出てきました。
initialize には応じるのに tools/list へ何も返さないサーバーを混ぜて試したところ、preflight が終わりません。MCP_PROBE_TIMEOUT_S に 10 を渡しているにもかかわらず、10秒経っても20秒経っても戻ってきませんでした。外側から timeout 30 で殺して、ようやく制御が返ります。
$ REPORT_API_TOKEN=test-token MCP_PROBE_TIMEOUT_S=10 timeout 30 python3 preflight.py
exit=124 elapsed=30s # 124 は timeout(1) による強制終了
渡した秒数が proc.wait(timeout=...) にしか効いていないのが原因でした。実際に待たされるのはその手前の proc.stdout.readline() で、こちらは何秒でも待ちます。サーバーが黙り込んだ場合、wait にはそもそも到達しません。
無人実行の文脈では、これは最初の症状より質が悪い挙動です。ツールが0件でも処理自体は終わります。preflight が固まると、ジョブ枠を占有したまま何も進みません。
期限を1箇所に持たせて、読み取りのたびに残り時間を見るよう書き換えました。
#!/usr/bin/env python3
"""stdio プローブのための、期限つき1行読み取り。"""
import json, selectors, subprocess, time
class ProbeTimeout ( Exception ):
pass
def read_line_before (stream, deadline):
"""1行返す。期限を過ぎたら ProbeTimeout を送出する。"""
sel = selectors.DefaultSelector()
sel.register(stream, selectors. EVENT_READ )
try :
while True :
remaining = deadline - time.monotonic()
if remaining <= 0 :
raise ProbeTimeout( "no response before deadline" )
if not sel.select( timeout = remaining):
continue # 早く起きただけ。ループ先頭で時計を見直す
line = stream.readline() # 読める状態なので、ここではもうブロックしない
if line == "" :
raise ProbeTimeout( "server closed the stream" )
return line
finally :
sel.unregister(stream)
sel.close()
def probe (command, args, env, budget_s = 10.0 ):
deadline = time.monotonic() + budget_s
proc = subprocess.Popen([command, * args], stdin = subprocess. PIPE , stdout = subprocess. PIPE ,
stderr = subprocess. DEVNULL , text = True , env = env, bufsize = 1 )
try :
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(read_line_before(proc.stdout, deadline))
call( "initialize" , { "protocolVersion" : "2026-07-28" , "capabilities" : {},
"clientInfo" : { "name" : "mcp-preflight" , "version" : "1.1" }}, rid = 1 )
call( "notifications/initialized" )
listed = call( "tools/list" , {}, rid = 2 )
return sorted (t[ "name" ] for t in listed[ "result" ][ "tools" ])
finally :
proc.kill() # 期限を使い切った相手を、行儀よく待つ理由はない
proc.wait( timeout = 2 )
要点は3つです。
期限は呼び出しごとではなくプローブ全体に1つ 置きます。initialize で9秒使ったなら、tools/list に残るのは1秒です
sel.select() は何も返さずに戻ることがあるため、ループの先頭で必ず時計を見直します
空文字列は「サーバーが落ちた」を意味します。ここを見落とすと、すでに終了したプロセスを期限まで待ち続けます
同じ2台を、期限2秒で通した結果です。
healthy: tools=['fetch_daily_report', 'list_report_dates'] in 24 ms
stalled: ProbeTimeout(no response before deadline) after 2004 ms (budget 2.0s)
正常系は24ミリ秒のまま、異常系は2,004ミリ秒で戻りました。無期限に待つ実装との差は、CIの上では「気づかれない占有」と「読める失敗」の差になります。
期限の値そのものは、少し迷ったところです。私はこの budget をサーバー1台あたり2秒に寄せています。健全なプローブが20〜30ミリ秒で終わることを実測で確かめたうえで、その100倍を上限と見なす、という決め方です。ネットワーク越しのサーバーを含む構成では10秒に戻しますが、初期値を大きく取ると「たまに遅い」が「たまに固まる」を隠してしまいます。まず短く置いて、落ちたときに理由を見て緩めるほうが、私の運用には合っていました。
リモートのMCPサーバーには、この preflight は届いていなかった
ここまでの実装は command と args を持つ設定しか見ていません。つまり、手元でプロセスを起こす stdio のサーバー専用です。
2026年7月28日の仕様で MCP が双方向のステートフルな接続から request/response へ移ったことで、サーバーをサーバーレスやエッジに置く構成が現実的になりました。手元の .mcp.json にも type と url を持つ項目が増えています。preflight がそれらを黙って読み飛ばしていたのは、穴としては大きいほうでした。
request/response であること自体は、プローブを書く側には好都合です。セッションを維持する必要がないので、POSTを2回投げるだけで済みます。
def probe_remote (spec, budget_s):
"""リモート(HTTP)のMCPサーバーを叩く。保持するセッションはない。"""
url = spec[ "url" ]
headers = { "Content-Type" : "application/json" }
headers.update({k: expand(v) for k, v in (spec.get( "headers" ) or {}).items()})
deadline = time.monotonic() + budget_s
def rpc (method, params, rid):
remaining = deadline - time.monotonic()
if remaining <= 0 :
raise ProbeTimeout( "budget spent before request" )
req = urllib.request.Request(url, data = json.dumps(
{ "jsonrpc" : "2.0" , "id" : rid, "method" : method, "params" : params}).encode(),
method = "POST" , headers = headers)
with urllib.request.urlopen(req, timeout = remaining) as resp:
return json.loads(resp.read())
rpc( "initialize" , { "protocolVersion" : "2026-07-28" , "capabilities" : {},
"clientInfo" : { "name" : "mcp-preflight" , "version" : "1.1" }}, 1 )
listed = rpc( "tools/list" , {}, 2 )
return sorted (t[ "name" ] for t in listed[ "result" ][ "tools" ])
def probe (spec, budget_s = 10.0 ):
"""ツール名の並びを返す。失敗した場合は理由を持つ dict を返す。"""
handler = probe_remote if spec.get( "type" ) in ( "http" , "sse" ) or "url" in spec else probe_stdio
try :
return handler(spec, budget_s)
except Exception as exc:
return { "error" : f " { type (exc). __name__ } : { exc } " }
検証用に、認証ヘッダを見て応答を変えるだけの小さなHTTPサーバーを立てて測りました。
token present tool_count=2 names=['fetch_daily_report', 'list_report_dates'] elapsed_ms=[4.0, 1.7, 1.5]
token missing tool_count=0 names=[] elapsed_ms=[1.3, 1.3, 1.3]
stdio のときと同じでした。トークンが通らなくても HTTP のステータスは 200、JSON-RPC の応答は result を持ち、中身の配列だけが空になります。所要時間にも差は出ません(1.3〜4.0ミリ秒に散らばり、認証の有無による傾向は見えませんでした)。
transport を変えても失敗の形が変わらないことは、この記事の対処がそのまま効くという意味でもあります。
埋め込まれた ${VAR} を、最初の実装は展開できていなかった
リモートを足した直後、トークンを正しく渡しているつもりなのにツールが0件で返り続けました。原因は自分の expand() でした。
値の全体が ${VAR} のときしか展開しません。stdio の env は "REPORT_API_TOKEN": "${REPORT_API_TOKEN}" の形なので通っていましたが、認証ヘッダは "Bearer ${REPORT_API_TOKEN}" です。
exact-match expand (published version): header -> 'Bearer ${REPORT_API_TOKEN}'
regex expand (fixed): header -> 'Bearer test-token'
リテラルのまま送られたトークンをサーバーが拒み、ツール表が空になり、preflight は正しく落ちます。落ちること自体は安全側なのですが、原因は資格情報ではなく検証ツールの文字列処理です。沈黙する失敗を捕まえるための道具が、同じ形の沈黙を自分で作っていたことになります。
出現箇所すべてを置換する形に直しました。
VAR = re.compile( r " \$\{ ([ A-Za-z_ ][ A-Za-z0-9_ ] * ) \} " )
def expand (value: str ) -> str :
"""値の一部に埋め込まれた ${VAR} も展開する。"""
return VAR .sub( lambda m: os.environ.get(m.group( 1 ), "" ), value)
ロックの生成そのものが、壊れた状態を焼き付ける
両方の transport に対応させたあと、3台(stdio 正常・リモート正常・応答なし)の構成で --update-lock を回して、また手が止まりました。
{
"report": ["fetch_daily_report", "list_report_dates"],
"report-remote": ["fetch_daily_report", "list_report_dates"],
"stalled": { "error": "ProbeTimeout: no response before deadline" }
}
観測に失敗した結果が、そのまま期待値として書き込まれています。検証側は失敗を dict で判定するため、この項目は以後ずっと「probe failed」を報告し続けます。逆に、生成時にツールが0件だった場合は空配列が焼き付き、そのサーバーについては何を検証しても通る状態になります。
ロックは期待値です。観測に失敗した回の結果を、期待値へ昇格させてはいけません。生成側に門を1つ足しました。
if "--update-lock" in sys.argv:
broken = {n: v[ "error" ] for n, v in observed.items() if isinstance (v, dict )}
if broken:
print ( "refusing to write the lock: some probes failed" )
for n, e in broken.items():
print ( f " - { n } : { e } " )
return 78
json.dump(observed, open ( LOCK , "w" ), indent = 2 , sort_keys = True )
print ( f "wrote { LOCK } " )
return 0
3台構成での生成と、健全な2台で作り直したあとの検証結果です。
$ ... --update-lock # 応答のないサーバーを含む構成
refusing to write the lock: some probes failed
- stalled: ProbeTimeout: no response before deadline
exit=78
$ ... --update-lock # stdio + リモートの2台
wrote mcp-tools.lock.json
exit=0
$ REPORT_API_TOKEN=test-token python3 preflight.py; echo "exit=$?"
MCP preflight OK in 29 ms (4 tools verified)
exit=0
$ env -u REPORT_API_TOKEN python3 preflight.py; echo "exit=$?"
MCP preflight FAILED in 27 ms
- report: 2/2 tools missing -> ['fetch_daily_report', 'list_report_dates']
- report-remote: 2/2 tools missing -> ['fetch_daily_report', 'list_report_dates']
exit=78
資格情報を1つ外すと、stdio とリモートの両方が同じ形で欠けます。transport ごとに別の監視を用意しなくてよいのは、この設計の副産物でした。
応答のないサーバーを設定に残したまま検証すると、2,032ミリ秒で止まります。無期限に待っていた最初の実装との差が、そのまま数字に出ました。
MCP preflight FAILED in 2032 ms
- stalled: probe failed (ProbeTimeout: no response before deadline)
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 を回し、実際に落ちることを確かめる
応答を返さないサーバーを1台混ぜ、期限どおりに諦めることを確かめる
CI のエージェント起動ステップの直前に差し込む
3番目を飛ばさないでいただければと思います。検証ツールが検証できていない状態は、検証していないより質が悪いためです。私自身、Popen の位置を間違えたまま「通った」と信じかけました。
お読みいただきありがとうございました。同じ静けさに足を取られた方の、切り分けの時間が少しでも短くなれば嬉しく思います。