🔌 MCPサーバーが落ちてもセッションが止まらない仕組み
大学生のころ月10万だった副業収入が、掛け持ちで月60万まで育ったとき、会社都合の解雇でいったん0になりました。そこから半年でClaude Code自律環境を一から作り直し、今は月商120万を安定させています。環境が止まらなければ、売上も止まらない。そう学んでからは、コードより先に「障害があっても倒れない設計」を優先するようになりました。
なぜこの仕組みが効くのか
MCPサーバーが落ちると何が起きていたか
Claude Codeを使った自律化を始めた当初、いちばん嫌いな障害がありました。夜中に動かしたバッチがMCPサーバーのタイムアウトで丸ごと止まっているやつです。朝起きると「connection refused」のログだけが積み上がっていて、前夜から一切進んでいない。SNS投稿の自動化ならともかく、クライアント向けの納品物生成が止まっていたときは本当に焦りました。
MCPというのは、Claude Codeがブラウザ操作・DB参照・外部API呼び出しなどをツールとして使うための仕組みです。Claude側からすると「mcp__obsidian__search」のような名前のツールを呼ぶだけですが、その裏ではローカルプロセスやリモートHTTPサーバーとの通信が走っています。そのサーバーが何らかの原因で応答しなくなると、Claude Codeはツール呼び出しをエラーとして返し、セッション全体の流れが詰まります。
問題は「なぜ止まったか」だけではありませんでした。429(レート制限)で止まっているのに、そのまま30秒後に再呼び出しする→また429→また止まる、というループを繰り返すケースがありました。503(サービス一時停止)なのに、401(認証切れ)と同じ判定をして再認証フローを無駄に走らせるケースもありました。ステータスコードごとに戦略を変えなければ、どんなに高速なモデルを使っても無駄です。
「作業」でなく「環境」を整えることの本質
月商120万の自律環境を維持していて気づいたのは、稼ぎを増やすために追加するコードよりも、「止まらないための仕組み」に費やした時間のほうが、長期ROIが高いということです。
月商60万のころは「タスクを増やせば稼ぎが増える」と思っていました。解雇されて0になったあと、もう一度作り直す過程で考え方が変わりました。タスクを増やしても、環境が不安定だとスループットが上限に張り付く。むしろインフラ的な問題を1つ潰すだけで、既存タスク全体の完走率が上がります。
MCPのヘルスチェックがその典型例です。~/.claude/scripts/hooks/mcp-health-check.jsをフックに組み込むことで、Claude Codeがツールを呼ぶ前にHTTPプローブを走らせ、応答ステータスごとに「即ブロック」「バックオフ後に再試行」「reconnectコマンドを実行して再プローブ」を振り分けます。判定結果は~/.claude/mcp-health-cache.jsonに永続化するため、コンテキストが圧縮されても健全性の記録は引き継がれます。
なぜキャッシュが「コンテキスト圧縮対策」になるか
Claude Codeのセッションは長時間動かすと会話履歴が圧縮されます。過去に「このサーバーは落ちていた」という情報があっても、圧縮後のコンテキストにはそれが残りません。結果として、すでに不健全と判明しているサーバーに対して何度もツール呼び出しを試み、そのたびにエラーを受け取るという無駄が発生します。
ファイルベースのキャッシュはコンテキストとは独立しています。~/.claude/mcp-health-cache.jsonはセッションが圧縮されようと消えません。次のターンでヘルスチェックフックが走ったとき、ファイルから前回の状態を読み込み、nextRetryAtを過ぎるまでは再プローブすらしないで即ブロックします。コンテキストウィンドウの外側に状態を持つ、というアイデアが本質的に重要です。
読者のよくある誤解
「エラーハンドリングはClaude側のプロンプトでやれば?」と思う方がいます。実際に試しました。「このツールがエラーになったら別の方法を試して」というシステムプロンプトは、単発のエラーにはそれなりに効きます。しかし、MCPサーバーがダウンしていて連続して失敗する状況では、モデルは「試みている」ことに大量のトークンを消費します。また、次のターンでまた同じサーバーを呼ぼうとします。「サーバーが死んでいる」という事実をコンテキスト外に書き出して、フックレベルでブロックするほうが圧倒的にクリーンです。
もう一つよくある誤解は「MCP自体のリトライ設定で足りる」というものです。MCPプロトコルにはトランスポート層のリトライはありますが、ステータスコードを読んで戦略を切り替える機能はありません。429と503では適切なバックオフ時間が異なりますし、401/403はリトライでなく再認証が必要なケースです。この振り分けをアプリケーション層で実装するのが、このフックの役割です。
全体の流れ
フックが介入するタイミング
mcp-health-check.jsはClaude Codeの2種類のフックイベントに応答します。コード冒頭のコメント(7〜12行目)にそのまま書かれています。
- PreToolUse: probe MCP server health before MCP tool execution
- PostToolUseFailure: mark unhealthy servers, attempt reconnect, and re-probe
PreToolUseはツールが実行される前に呼ばれます。ここでプローブを打ち、サーバーが生きているかを確認してから実行を許可するか、exit code 2でブロックするかを決めます。PostToolUseFailureはツールがエラーを返したあとに呼ばれます。エラーテキストを解析して失敗パターンを特定し、サーバーを不健全とマークしてreconnectを試みます。
全体フロー図
Claude Code が mcp__* ツールを呼ぶ
│
▼ PreToolUse フック起動
┌──────────────────────────────────────────────────────┐
│ mcp-health-check.js │
│ │
│ ① mcp-health-cache.json を読む │
│ status=healthy かつ expiresAt が未来? │
│ YES ─────────────────────────────────────────→ │ exit 0
│ NO ↓ │ (ツール実行へ)
│ │
│ ② nextRetryAt が未来(unhealthy クールダウン中)? │
│ YES → ブロック ──────────────────────────────→ │ exit 2
│ NO ↓ │ (ツールをスキップ)
│ │
│ ③ プローブ実行 │
│ HTTPサーバー → GET リクエスト(5秒タイムアウト) │
│ stdioサーバー → プロセス起動(5秒生存確認) │
│ │
│ レスポンスのステータスコード判定 │
│ ┌──────────────────────────────────────────┐ │
│ │ ECONNREFUSED / ENOTFOUND / タイムアウト │──→ │
│ │ → 即 markUnhealthy & exit 2 │ │
│ ├──────────────────────────────────────────┤ │
│ │ 401 / 403 / 429 / 503 │──→ │
│ │ → reconnect コマンドを実行 │ │
│ │ → 成功すれば再プローブ │ │
│ │ → 再プローブ OK → markHealthy & exit 0 │ │
│ │ → 再プローブ NG → markUnhealthy & exit 2│ │
│ ├──────────────────────────────────────────┤ │
│ │ 200 系 / 400 / 401 / 403 / 405 / 406 │ │
│ │("到達できた"証明として healthy 扱い) │──→ │ exit 0
│ └──────────────────────────────────────────┘ │
│ │
│ ④ 状態を mcp-health-cache.json に書き出す │
└──────────────────────────────────────────────────────┘
│
▼ ツール実行後にエラーが出た場合
┌──────────────────────────────────────────────────────┐
│ PostToolUseFailure フック │
│ エラーテキストを FAILURE_PATTERNS と照合 │
│ failureCode 特定 → markUnhealthy → reconnect試行 │
│ → 再プローブ OK なら markHealthy │
└──────────────────────────────────────────────────────┘
定数が語る設計思想
実際のコードを読むと、設計の意図が数値に表れています(22〜26行目)。
const DEFAULT_TTL_MS = 2 * 60 * 1000; // 2分
const DEFAULT_TIMEOUT_MS = 5000; // 5秒
const DEFAULT_BACKOFF_MS = 30 * 1000; // 30秒(初回バックオフ)
const MAX_BACKOFF_MS = 10 * 60 * 1000; // 10分(上限)
TTLが2分という値は、「プローブを毎回走らせたくないが、古い結果を持ちすぎたくない」というトレードオフです。Claude Codeで連続してツールを呼ぶ場面では、2分以内に同じサーバーへ複数回アクセスすることは珍しくありません。キャッシュがあれば、そのたびにHTTPプローブを走らせる必要がなく、レイテンシが下がります。
タイムアウト5秒は、ローカルのstdioサーバーが「プロセスとして起動できる」ことを確認するための閾値です。起動完了ではなく「5秒間プロセスが生き続けられる」ことをもって正常と判断します(428〜472行目のprobeCommandServerのタイマーロジック)。起動に5秒以上かかる重いサーバーでも、環境変数ECC_MCP_HEALTH_TIMEOUT_MSで調整できます。
指数バックオフの計算式
markUnhealthy関数(213〜229行目)のバックオフ計算は1行に圧縮されています。
const nextRetryDelay = Math.min(
backoffBase * (2 ** Math.max(failureCount - 1, 0)),
MAX_BACKOFF_MS
);
backoffBaseはデフォルト30秒です。failureCountが1のときは2 ** 0 = 1なので30秒、2回目は2 ** 1 = 2で60秒、3回目は120秒、4回目は240秒と倍増し、最大600秒(10分)で頭打ちになります。最初の失敗で30秒待ち、そこで回復しなければ1分、2分、4分と間隔を広げていくため、落ちているサーバーに対してプローブを無駄に打ち続ける事態を避けられます。
ステータスコードの振り分けロジック
HEALTHY_HTTP_CODESの定義(32行目)が一見して奇妙に見えます。
const HEALTHY_HTTP_CODES = new Set([
200, 201, 202, 204,
301, 302, 303, 304, 307, 308,
400, 401, 403, 405, 406
]);
400番台の一部が「正常」扱いになっています。コードのコメント(29〜32行目)にその理由が書かれています。
// The preflight HTTP probe only checks reachability; it does not have access to
// Claude Code's stored OAuth bearer token. Treat auth-gated responses as
// reachable so the real MCP client can attempt the authenticated call. A
// Streamable HTTP MCP server can also return 406 to a bare GET that omits
// Accept: text/event-stream; that still proves the endpoint is alive.
プリフライトプローブはOAuthトークンを持たないGETリクエストです。認証が必要なエンドポイントは401や403を返しますが、それは「認証なしのリクエストを正しく拒否している」ことを意味し、サーバーが生きている証拠です。406はAccept: text/event-streamヘッダなしのリクエストへの正常な拒否応答です。これらはすべて「到達できた」ことを証明するコードです。
一方、reconnectの対象になるのはサブセットです(33行目)。
const RECONNECT_STATUS_CODES = new Set([401, 403, 429, 503]);
401と403はプローブコンテキストでは「到達できた」扱いですが、実際のツール呼び出し後に返ってきた場合は意味が違います。PostToolUseFailureでdetectFailureCode関数が呼ばれたとき、エラーメッセージ中の401/403は「認証失敗」として捉えられ、reconnectの引き金になります。プローブ判定と失敗後の判定が異なるレイヤーで動いているのがポイントです。
FAILURE_PATTERNSとテキスト解析
MCPのエラーはHTTPステータスコードとして返ってくるとは限りません。stdioサーバーのエラーはテキストで返ってきます。FAILURE_PATTERNS(34〜40行目)はそのテキストから失敗種別を正規表現で特定する仕組みです。
const FAILURE_PATTERNS = [
{ code: 401, pattern: /\b401\b|unauthori[sz]ed|auth(?:entication)?\s+(?:failed|expired|invalid)/i },
{ code: 403, pattern: /\b403\b|forbidden|permission denied/i },
{ code: 429, pattern: /\b429\b|rate limit|too many requests/i },
{ code: 503, pattern: /\b503\b|service unavailable|overloaded|temporarily unavailable/i },
{ code: 'transport', pattern: /ECONNREFUSED|ENOTFOUND|EAI_AGAIN|timed? out|socket hang up|connection (?:failed|lost|reset|closed)/i }
];
「unauthorized」「auth expired」「rate limit」「ECONNREFUSED」などの文字列が含まれるエラーメッセージから、自動的に失敗コードを特定します。transportコードはHTTPステータスではなく、ネットワーク接続自体が切れているケースです。これは「サーバーに到達すらできない」状態であり、reconnectよりもバックオフが優先されます。
キャッシュファイルの構造
~/.claude/mcp-health-cache.jsonはsaveState関数(95〜102行目)がJSON.stringifyで整形して書き出します。実際のファイルは次のような構造になっています。
{
"version": 1,
"servers": {
"obsidian": {
"status": "healthy",
"checkedAt": 1753666200000,
"expiresAt": 1753666320000,
"failureCount": 0,
"lastError": null,
"lastFailureCode": null,
"nextRetryAt": 1753666200000,
"lastRestoredAt": 1753666200000,
"source": "~/.claude/settings.json"
},
"agentmemory": {
"status": "unhealthy",
"checkedAt": 1753665900000,
"expiresAt": 1753665900000,
"failureCount": 3,
"lastError": "ECONNREFUSED 127.0.0.1:3001",
"lastFailureCode": "transport",
"nextRetryAt": 1753666020000,
"lastRestoredAt": null
}
}
}
expiresAtが未来であれば再プローブなしでパス、nextRetryAtが未来であれば再試行すらしないでブロック、という2段構えのゲートになっています。failureCountが蓄積するほどバックオフが伸びるため、繰り返しダウンしているサーバーはどんどん試行間隔が広がります。lastRestoredAtは「いつ復旧したか」の記録で、稼働率の事後分析に使えます。
sourceフィールドは設定ファイルのパスです。configPaths関数(54〜72行目)がカレントディレクトリの.claude.json→カレントの.claude/settings.json→ホームの.claude.json→ホームの.claude/settings.jsonという順で検索し、最初に見つかったものが使われます。プロジェクトごとのMCP設定を持っている場合でも正しいコンフィグが参照されます。
reconnectコマンドの設定方法
reconnectCommand関数(516〜527行目)は環境変数からコマンドを読みます。
const key = `ECC_MCP_RECONNECT_${String(serverName).toUpperCase().replace(/[^A-Z0-9]/g, '_')}`;
const command = process.env[key] || process.env.ECC_MCP_RECONNECT_COMMAND || '';
サーバー名がagentmemoryならECC_MCP_RECONNECT_AGENTMEMORYという環境変数を探します。見つからなければECC_MCP_RECONNECT_COMMANDというグローバルなフォールバックを使います。コマンド文字列に{server}が含まれていれば、サーバー名に展開されます(524〜526行目)。
たとえばプロセスマネージャとしてPM2を使っているなら、次のように設定できます。
export ECC_MCP_RECONNECT_COMMAND="pm2 restart {server}"
これでagentmemoryサーバーが401や503を返したとき、自動的にpm2 restart agentmemoryが走り、再プローブで正常を確認してからツール実行を許可します。reconnectが成功した場合、markHealthyのrestoredByフィールドにreconnect-commandと記録されます(607〜609行目)。
ここまでで、このフックの全体像がつかめたと思います。次のセクションでは実際のつまずきポイントと、設定で陥りやすい罠について掘り下げます。
実装の詳細
extractMcpTarget — サーバー名をツール名から分解する
フックが最初に行うのは「どのMCPサーバーへの呼び出しか」を特定することです。extractMcpTarget関数(133〜167行目)がそれを担います。
if (!toolName.startsWith('mcp__')) {
return null;
}
const segments = toolName.slice(5).split('__');
if (segments.length < 2 || !segments[0]) {
return null;
}
return {
server: segments[0],
tool: segments.slice(1).join('__')
};
mcp__obsidian__search_notesという名前があれば、slice(5)でobsidian__search_notesになり、split('__')で['obsidian', 'search', 'notes']になります。segments[0]がサーバー名、残りをアンダースコア2つで結合したものがツール名です。
ただしツール名の解析はあくまでフォールバックです。先に明示的なserverフィールドを探します(135〜145行目)。
const explicitServer = input.server
|| input.mcp_server
|| input.tool_input?.server
|| input.tool_input?.mcp_server
|| input.tool_input?.connector
|| null;
複数のパスを辿るのは、フックイベントのスキーマがClaude Codeのバージョンや接続方式(HTTP/stdio)によってフィールド位置がずれることがあるためです。さらにJSONパースが失敗してtruncatedフラグが立った場合は、extractMcpTargetFromRaw(169〜179行目)が生の文字列に正規表現を当てて同じ情報を取り出します。パースできなくても諦めない設計です。
handlePreToolUse の2段ゲート
handlePreToolUse(567〜630行目)は単純に見えますが、2段のキャッシュチェックが連続しています。
// 第1ゲート: healthy かつキャッシュ有効 → プローブなしで通過
if (previous.status === 'healthy' && Number(previous.expiresAt || 0) > now) {
return { rawInput, exitCode: 0, logs };
}
// 第2ゲート: unhealthy かつクールダウン中 → プローブなしでブロック
if (previous.status === 'unhealthy' && Number(previous.nextRetryAt || 0) > now) {
logs.push(
`[MCPHealthCheck] ${target.server} is marked unhealthy until ${new Date(previous.nextRetryAt).toISOString()}; skipping ${target.tool || 'tool'}`
);
return { rawInput, exitCode: shouldFailOpen() ? 0 : 2, logs };
}
「healthy かつ TTL 内」は通過、「unhealthy かつバックオフ中」はブロック、どちらでもない場合(unhealthyだがバックオフが切れた、またはキャッシュが空)のみプローブを実行します。高頻度にツールを呼ぶセッションではほとんどの呼び出しが第1ゲートで通過するため、HTTPリクエストのレイテンシは実質ゼロです。
reconnectの起動条件も同様に注意が必要です(601〜603行目)。
let reconnect = { attempted: false, success: false, reason: 'probe failed' };
if (probe.failureCode || previous.status === 'unhealthy') {
reconnect = attemptReconnect(target.server);
「プローブが失敗した」だけではreconnectは走りません。probe.failureCode(reconnectが有効なステータスコード)または「前回から既にunhealthy」という条件が必要です。ECONNREFUSEDで接続不能なのにreconnectコマンドを毎回実行するのは無駄ですが、429や503のようにサーバーは存在するが使えない状態では積極的に再接続を試みる、という判断がここに込められています。
probeCommandServer — stdioサーバーの「5秒生存確認」
HTTPサーバーはrequestHttpでGETを打てばわかります。stdioサーバーはどう確認するか。probeCommandServer(301〜481行目)のアプローチは独特です。
実際にプロセスを起動して5秒間見守る。生き続けていれば正常、その前に終了したら異常。
timer = setTimeout(() => {
// タイムアウト到達 = 5秒間プロセスが生存 → 正常
// ただし: ロードされたマシンではexitイベントがタイマーより
// わずかに遅れて届くことがある。高速クラッシュを見逃さないため
// プロセスの状態を再確認する
if (child.exitCode !== null || child.signalCode !== null) {
attemptFinish({
ok: false,
statusCode: child.exitCode,
reason: stderr.trim() || `process exited before handshake (...)`
});
return;
}
// SIGTERMで終了させ、200ms後にSIGKILLで確実に始末する
child.kill('SIGTERM');
setTimeout(() => {
try { child.kill('SIGKILL'); } catch { /* ignore */ }
}, 200).unref?.();
attemptFinish({
ok: true,
statusCode: null,
reason: `${serverName} accepted a new stdio process`
});
}, timeoutMs);
コメントの通り、「高速クラッシュサーバー」の問題は実際に厄介です。起動直後にプロセスが終了した場合、exitイベントとタイマーのコールバックがほぼ同時に到達する可能性があります。タイマーが先に走ればchild.exitCodeチェックなしにok: trueを返してしまいます。その対策として、タイマーコールバック内でchild.exitCode !== nullを改めて確認し、すでに死んでいればok: falseに落とします。
Windowsでの挙動も作り込まれています(329〜334行目、438〜461行目)。npxのように拡張子なしのコマンドはWindowsではnpx.cmdとして解決される必要があり、しかもNode 18.20以降はCVE-2024-27980対応でshell経由での.cmd実行が制限されました。コードはcandidates配列にcommand.cmd/command.exe/command.batのフォールバックを並べ、ENOENT時に次の候補を順番に試します。また.cmd/.batファイルはshell経由で実行しますが、コマンド文字列にシェルメタキャラクタ(&|<>^%()等)が含まれていればshell経由を拒否する安全弁も持っています(339行目、UNSAFE_SHELL_CHARS)。
failureSummary — エラーテキストの全方位収集
failureSummary(231〜244行目)が地味に重要です。
const pieces = [
typeof input.error === 'string' ? input.error : '',
typeof input.message === 'string' ? input.message : '',
typeof input.tool_response === 'string' ? input.tool_response : '',
typeof output === 'string' ? output : '',
typeof output?.output === 'string' ? output.output : '',
typeof output?.stderr === 'string' ? output.stderr : '',
typeof input.tool_input?.error === 'string' ? input.tool_input.error : ''
].filter(Boolean);
return pieces.join('\n');
MCPのエラーテキストがどのフィールドに入るかは、サーバーの実装とClaude Codeのバージョンによって異なります。errorに入るケース、tool_responseに入るケース、output.stderrに入るケース、すべて実際に遭遇しました。pieces.join('\n')で全フィールドを結合した文字列をdetectFailureCodeに渡すことで、どのフィールドにエラーテキストが入っていても正規表現でキャッチできます。
fail-open とPostToolUseFailureのexit code
shouldFailOpen(557〜559行目)は環境変数一行です。
function shouldFailOpen() {
return /^(1|true|yes)$/i.test(String(process.env.ECC_MCP_HEALTH_FAIL_OPEN || ''));
}
unhealthyなサーバーへのツール呼び出しを通す設定です。開発時に「フックは動いているがブロックしないで様子を見たい」ときに使います。PreToolUseではexit 2(ブロック)の代わりにexit 0(通過)を返します。
PostToolUseFailureのexitCodeは常に0です(677行目)。これは設計上重要な点で、失敗後フックはツール実行のブロックではなく状態の記録が目的です。すでに失敗したツール呼び出しのexit codeを変えても意味はなく、ログとキャッシュ更新だけを行って次のPreToolUseに状態を引き渡します。
私が詰まった話
この仕組みを自分の環境に組み込んでから安定するまで、3つの場面でハマりました。症状と原因と直し方を順番に書きます。
詰まり①: stdioサーバーが「起動できる」のに毎回エラーになった
症状: agentmemoryサーバーがstatus: healthyでキャッシュに残っているのに、実際のツール呼び出しではECONNREFUSEDが返ってくる。ヘルスチェックの意味がないように見えた。
原因: stdioサーバーのプローブは「プロセスが5秒間生存する」ことを確認するだけです。プロセスが起動して5秒後にSIGTERMで終了させ、その後に本物のツール呼び出しが来たとき、サーバーはまたゼロから起動します。ただし本物の起動では、MCPのハンドシェイク(stdioでのJSONメッセージ交換)が完了するまでに時間がかかります。そのハンドシェイク中に別のエラーが起きていました。
具体的には、環境変数ANTHROPIC_API_KEYが設定されていないまま起動を試みたサーバーが、起動直後にクラッシュしていました。プローブ時は5秒間生き続けたのでhealthyに記録されましたが、実際のハンドシェイク時には数秒後にクラッシュするという状態でした。
直し方: サーバープロセスの環境変数が揃っているかを先に確認する。mcp-health-check.jsはconfig.envをプロセスの環境変数にマージして起動します(306〜309行目)。本番の起動と同じ環境変数が設定ファイルのenvセクションに入っているかを確認することが先決です。「プローブで正常」と「ツール呼び出しで正常」は厳密には別の判定であると理解してから、ようやくデバッグの方向が決まりました。
詰まり②: reconnectなしの503が積み重なってサーバーが長時間使えなかった
症状: Obsidianのリモートサーバーが夜間に一時的に503を返した。翌朝確認すると、サーバー自体はとっくに復旧しているのに、mcp-health-cache.jsonのnextRetryAtが未来の時刻を指していてずっとブロックされていた。
原因: RECONNECT_STATUS_CODESに503が含まれているため、503が来るとreconnectを試みます。しかしECC_MCP_RECONNECT_OBSIDIANもECC_MCP_RECONNECT_COMMANDも設定していませんでした。reconnectコマンドがなければattemptReconnectはattempted: falseを返し(531行目)、markUnhealthyだけが走ってfailureCountが蓄積します。503が3回来るとfailureCount=3になり、バックオフは30 * (2 ** 2) = 120秒。次の30分で5回来ればfailureCount=5で30 * (2 ** 4) = 480秒。夜間に十数回エラーが積み重なった結果、バックオフが上限の600秒(10分)に張り付いていました。サーバーが復旧しても10分ごとにしかプローブが走らず、その間のツール呼び出しは全てブロックされます。
直し方: reconnectコマンドを設定するか、ECC_MCP_HEALTH_BACKOFF_MSを小さい値(例:10000=10秒)に設定してバックオフの基底値を下げるかのどちらかです。私はlaunchd plistにECC_MCP_RECONNECT_COMMAND=echo noop(何もしないダミー)を設定し、reconnect「試行済み」扱いにすることでfailureCountの蓄積を抑制しました。正攻法はPM2やsystemdでサービスを管理して本物のreconnectコマンドを設定することです。
export ECC_MCP_RECONNECT_OBSIDIAN="pm2 restart obsidian-mcp"
この設定が入ってから、503が来てもPM2が再起動→再プローブで正常確認→即座にhealthy復帰、というサイクルが動くようになりました。
詰まり③: プロジェクトディレクトリのsettings.jsonがホームの設定を隠した
症状: 特定のプロジェクトディレクトリでClaude Codeを起動したとき、MCPヘルスチェックが「No MCP config found for obsidian」とログに出してプローブをスキップするのに、ツール呼び出し自体は走った。フックが機能しているようで機能していない状態でした。
原因: configPaths関数(54〜72行目)はカレントディレクトリの設定ファイルを先に探します。
return [
path.join(cwd, '.claude.json'),
path.join(cwd, '.claude', 'settings.json'), // ← これが先に見つかる
path.join(home, '.claude.json'),
path.join(home, '.claude', 'settings.json')
];
そのプロジェクトには.claude/settings.jsonがあり、そこにはプロジェクト固有のMCPサーバー(playwrightなど)しか定義されていませんでした。resolveServerConfig('obsidian')はリスト順に検索して最初のファイルを見つけた時点で止まるわけではなく、そのファイルにobsidianがなければ次のファイルへ進みます。しかしreadJsonFileのループでdata?.mcpServers?.[serverName]がnullならnullを返してループを継続するのですが——実際に問題になったのは私が環境変数ECC_MCP_CONFIG_PATHをホームの設定ファイルに向けており、かつそのパスに打ち間違いがあったケースでした。
# 誤(タイポあり)
export ECC_MCP_CONFIG_PATH="/Users/~/.claude/settings.json"
# 正
export ECC_MCP_CONFIG_PATH="${HOME}/.claude/settings.json"
path.resolveが/Users/~/.claude/settings.jsonという実在しないパスを返し、readJsonFileがnullを返して設定が見つからない扱いになっていました。ECC_MCP_CONFIG_PATHを設定すると通常の検索パスが完全に上書きされるため(55〜61行目)、タイポがあれば全てのサーバー設定が消えます。
直し方: ECC_MCP_CONFIG_PATHに絶対パスを使う。チルダ展開はshellが行うものでNodeのpath.resolveは行いません。launchd plistに書く場合は~が展開されないため、/Users/自分のホームディレクトリか${HOME}(シェルスクリプト経由)かos.homedir()相当のパスで書く必要があります。
<key>ECC_MCP_CONFIG_PATH</key>
<string>/Users/lily/.claude/settings.json</string>
実際のホームディレクトリパスを直書きするのが最も確実です。
詰まり④: ECC_MCP_HEALTH_FAIL_OPEN を本番環境に残した
症状: フックは正しく動いていて、キャッシュにもunhealthyと記録されている。なのにツール呼び出しが通ってしまい、エラーがClaudeのコンテキストに流れ込んでいた。1回のセッションで同じエラーを20回受け取るというトークンの無駄が起きました。
原因: デバッグ中に設定したECC_MCP_HEALTH_FAIL_OPEN=1をlaunchd plistから削除し忘れていました。この設定がある限り、shouldFailOpen()がtrueを返し、unhealthyなサーバーへのツール呼び出しが全てexit 0(通過)になります。フックは動いているが何もブロックしない状態です。
mcp-health-cache.jsonを見ればstatus: unhealthyが記録されているのに、ツール呼び出しが通るという不整合が発生していたわけです。
直し方: ECC_MCP_HEALTH_FAIL_OPENはデバッグ専用のフラグとして扱い、常用のlaunchd plistには書かない。デバッグが終わったらすぐに削除する運用ルールを自分の中に設けました。フックが意図通りブロックしているかを確認するには、~/.claude/mcp-health-cache.jsonを直接読んでstatus: unhealthyのサーバーが存在する状態でツール呼び出しが走ったとき、フックのstderrログにskippingの文言が出るかを確認するのが手っ取り早いです。
# フックのログをリアルタイムで確認(stderrはClaude Codeのhookログに流れる)
tail -f ~/.claude/logs/hooks.log | grep MCPHealthCheck
この4つのハマりポイントを乗り越えてから、環境は安定しました。今は夜中に走るバッチが朝起きると100件の処理を終えている状態が普通になっています。MCPサーバーのダウンでバッチが止まったのは、この仕組みを導入してから一度もありません。
つまずきポイント
前段で4つの詰まりを詳述しました。ここでは実際に遭遇した、または見落としやすい落とし穴を箇条書きで網羅します。P2と重複するものは省いています。
-
CLAUDE_HOOK_EVENT_NAMEが渡っていないとき、全呼び出しがPreToolUseとして処理されるコード704行目に
const eventName = process.env.CLAUDE_HOOK_EVENT_NAME || 'PreToolUse';があります。この環境変数が欠けていればイベント判定がPreToolUseに固定されます。PostToolUseFailureとして登録したつもりのフックが実際にはPreToolUseとして動いていた、というケースがあります。Claude Codeが正しくENVを渡しているか、まずprocess.envをstderrにdumpして確認するのが切り分けの第一歩です。 -
stdinが1MBを超えると
truncatedフラグが立ち、fail-openでなければ即ブロックになるMAX_STDIN = 1024 * 1024(22行目)がその上限です。ファイル内容をtool_inputに丸ごと詰め込むような大きなツール呼び出しでフック入力がこの値を超えると、パースが不完全のままターゲットサーバーを特定しようとします。ターゲットが特定できればECC_HOOK_INPUT_MAX_BYTESで上限を引き上げることができますが、完全にパースできない場合はfail-openでない限りexit 2でブロックされます(695〜701行目)。この挙動を知らないと「特定のツールだけフックが誤ブロックする」という謎の現象に見えます。 -
saveStateはエラーを握りつぶす設計なので、書き込み失敗が無音で継続する95〜102行目の
saveStateはtry/catchで全エラーを捕捉し、コメント通り「Never block the hook on state persistence errors.」で黙って続行します。ディスク満杯・パーミッション不足・~/.claude/ディレクトリが消えている、どのケースでもフックはexit 0かexit 2を返し続けます。しかしキャッシュが書き出されないため、healthy判定がTTL後に消えて毎回プローブが走るようになります。「なぜか毎回HTTPプローブが走る」と感じたら、df -h ~/.claude/でディスク確認とls -la ~/.claude/mcp-health-cache.jsonでパーミッション確認から始めてください。 -
resolveServerConfigがどのファイルを使っているか、sourceフィールドを確認しないとわからない
resolveServerConfig(181〜197行目)はconfigPaths()の順に全ファイルを走査し、サーバー名が見つかった時点でsourceフィールドにそのパスを記録します。プロジェクト固有の.claude/settings.jsonとホームの~/.claude/settings.jsonに同名サーバーが両方定義されていた場合、プロジェクト側が勝ちます。意図と違う設定が使われていないかは~/.claude/mcp-health-cache.jsonのsourceフィールドで確認できます。「ホームの設定を直したのに反映されない」症状の8割はこれです。 -
reconnectコマンドはshell:trueで実行されるため、コマンド文字列の構造には注意が必要
attemptReconnect(528〜555行目)はspawnSync(command, { shell: true, ... })で実行します。&&や;が含まれたコマンド連結は正常に動きますが、$(date)のようなコマンド置換が意図せず展開されることがあります。ECC_MCP_RECONNECT_COMMANDの値はpm2 restart {server}のような単純な1コマンドにとどめるのが安全です。{server}プレースホルダ(524〜526行目)を使えばサーバー名ごとに別個の環境変数を設定しなくても済みます。 -
failureCountはmarkHealthyが呼ばれるまでリセットされず、バックオフが上限に張り付いたまま回復しない
markUnhealthyはprevious.failureCount + 1を毎回蓄積します(215行目)。一方、markHealthyはfailureCount: 0に強制リセットします(200〜210行目)。問題はreconnectコマンドが設定されておらずバックオフが上限(600秒)に張り付くと、次の再プローブまで10分待たされる点です。その間のツール呼び出しは全てブロックされます。長期間不安定だったサーバーが復旧した後でも10分ごとにしか回復を確認しません。この状態を素早く解消するには、対象サーバーのエントリをキャッシュファイルから手動削除するのが最速です。python3 -c " import json, pathlib p = pathlib.Path.home() / '.claude/mcp-health-cache.json' s = json.loads(p.read_text()) s['servers'].pop('agentmemory', None) p.write_text(json.dumps(s, indent=2)) print('reset done') " -
stdioサーバーのconfig.envに漏れた環境変数が「launchdから起動すると動かない」の原因になる
probeCommandServer(306〜309行目)はプロセス起動時にconfig.envをprocess.envにマージします。インタラクティブなzshセッションではANTHROPIC_API_KEYが.zshrcから自動的に引き継がれますが、launchdから起動したClaudeはloginシェルの環境変数を持ちません。「手で試すと動く・夜間バッチで失敗する」という症状の多くはここに原因があります。全サーバーのconfig.envセクションに必要なAPIキーを明示的に列挙することが根本対策です。 -
HTTPサーバーへのプローブが429を返し続けてreconnectループに入るケースがある
RECONNECT_STATUS_CODESに429が含まれているため、プローブで429が返るとreconnectが走ります(33行目)。reconnectコマンドが実行されても根本的な問題(レート制限)が解消しなければ再プローブも429を返し、markUnhealthyが走ってfailureCountが増えます。プローブ自体がレート制限を消費している悪循環です。これを防ぐにはECC_MCP_HEALTH_TTL_MSを長めに設定してプローブ頻度を下げるか、対象サーバーのプローブURLを認証不要なヘルスエンドポイントに変更する方法があります。 -
PostToolUseFailureフックのexit codeが0でもブロックが機能していると思い込む
handlePostToolUseFailureの戻り値は常にexitCode: 0です(677行目)。これは設計上正しい挙動で、「すでに失敗したツール呼び出し」に対してブロックを返しても手遅れです。このフックの仕事は「次のPreToolUseでブロックするための状態書き込み」であり、exit codeには意味がありません。「PostToolUseFailureフックが走っているのにブロックされない」という報告の多くは、次のPreToolUseでキャッシュが正しく読まれているかを確認することで解決します。
ベストプラクティス
半年間、月商120万の自律環境をこのフックで支えてきた中で、徹底していることを12点まとめます。
1. reconnectコマンドはダミーでもいいから必ず設定する
reconnectコマンドなしでRECONNECT_STATUS_CODESのエラーが積み重なると、failureCountが雪だるまになりバックオフが上限に張り付きます。復旧しているサーバーへのアクセスが長時間ブロックされ続けます。PM2管理下なら本物を設定し、そうでなければecho noopだけでもreconnect「試行済み」扱いになってfailureCountの蓄積を抑制できます。
# PM2管理下なら本物を
export ECC_MCP_RECONNECT_COMMAND="pm2 restart {server}"
# とりあえず蓄積を止めるだけなら
export ECC_MCP_RECONNECT_COMMAND="echo noop"
2. ECC_MCP_HEALTH_FAIL_OPENはlaunchd plistに書かない
この変数が立っているとshouldFailOpen()(557〜559行目)がtrueを返し、unhealthyなサーバーへのツール呼び出しが全てexit 0(通過)になります。フックが動いているように見えて何もブロックしない状態です。デバッグ専用フラグとして扱い、デバッグが終わったらすぐに削除する運用ルールを作ってください。
3. ECC_MCP_CONFIG_PATHには絶対パスを書く
launchd plistでは~が展開されません。/Users/あなたのホームディレクトリ名/.claude/settings.jsonと直書きするのが最も確実です。path.resolveはNodeの機能で、シェルのチルダ展開を行いません(55〜61行目)。タイポがあれば全サーバーの設定が消えます。
4. キャッシュ確認・リセット・ログ監視のaliasを登録する
問題が起きたときにすぐ動けるよう、.zshrcにaliasを入れておきます。
alias mcp-health="python3 -m json.tool ~/.claude/mcp-health-cache.json"
alias mcp-reset="echo '{\"version\":1,\"servers\":{}}' > ~/.claude/mcp-health-cache.json"
alias mcp-logs="tail -f ~/.claude/logs/hooks.log | grep MCPHealthCheck"
5. stdioサーバーが必要とする全APIキーをconfig.envに明示する
launchdから起動したClaudeはloginシェルの環境変数を持ちません。~/.claude/settings.jsonの各サーバーのenvセクションに必要なキーを全て列挙することで、インタラクティブ動作とバッチ動作の挙動を一致させます。
6. TTLはセッションの使用頻度に合わせて調整する
デフォルトの2分(DEFAULT_TTL_MS = 2 * 60 * 1000、23行目)は汎用的な値です。夜間バッチのように1サーバーを高頻度で呼ぶ環境では5〜10分に延ばすことでプローブ回数が大幅に減りスループットが上がります。デバッグ中は30秒に縮めると問題の再現が速くなります。
# 夜間バッチ環境
export ECC_MCP_HEALTH_TTL_MS=600000 # 10分
# デバッグ中
export ECC_MCP_HEALTH_TTL_MS=30000 # 30秒
7. バックオフ基底値はサーバーの特性に合わせる
デフォルトの30秒はローカルプロセスの再起動を想定しています。外部のAPIサーバーで復旧に1〜5分かかる場合はECC_MCP_HEALTH_BACKOFF_MS=60000(1分)に上げると、回復確認の間隔が実態に合います。逆にローカルのstdioサーバーが数秒で復旧するなら10秒でも十分です。
8. 重要サーバーはPM2でプロセス管理する
PM2のオートリスタートとこのフックのreconnect→再プローブサイクルが組み合わさると、一時障害からの復旧が完全自動化されます。サーバーがクラッシュする→PM2が再起動する→次のPreToolUseフックがreconnectコマンドを呼ぶ→PM2が「既に起動済み」で成功を返す→再プローブでhealthyを確認→ツール実行を許可、というサイクルです。この組み合わせにしてから「翌朝サーバーが死んでいた」ケースはゼロになりました。
9. sourceフィールドで設定ファイルの解決結果を定期的に確認する
~/.claude/mcp-health-cache.jsonの各エントリにあるsourceフィールドが、どの設定ファイルからサーバー設定が読まれているかを示します。プロジェクトディレクトリに.claude/settings.jsonを追加した後は、意図した設定が使われているかをこのフィールドで確認してください。「ホームの設定を直したのに動かない」の大半はここで解決します。
10. フックのログをリアルタイムで確認する習慣を持つ
emitLogs(561〜565行目)はstderrにログを出力します。Claude Codeのフックログに流れるため、tail -fで確認できます。skippingの文字が出ていればブロックが機能しています。connection restoredが出ていればreconnectが成功しています。ログを見る習慣があれば、フックが意図通りに動いているかを数秒で確認できます。
11. フック登録はホームの設定に一元化する
プロジェクトの.claude/settings.jsonにもhooksを書けますが、ヘルスチェックフックはホームの~/.claude/settings.jsonのみに書くことを強く勧めます。プロジェクトごとに分散させると「あのプロジェクトでだけフックが効かない」という穴が生まれます。全プロジェクトで一様に保護を受けるためにホームの設定を正とします。
12. failureCountが異常に大きくなったらエントリを手動削除してリセットする
markHealthyが呼ばれれば自動的にfailureCount: 0にリセットされますが、バックオフが長すぎてmarkHealthyへの道が閉ざされているときは手動削除が最速です。前述のpython3ワンライナーをaliasに登録しておくと、特定サーバーの状態を10秒でリセットできます。
まとめ
会社都合で解雇されて月商0になったあと、自律環境を一から作り直すにあたって一番恐れていたのは「夜中に動かしたバッチが朝までに全滅している」シナリオでした。それをいちばん確実に防ぐのは、モデルを強くすることでも、プロンプトを磨くことでもなく、障害を検出してコンテキストの外側に記録しておく仕組みでした。
mcp-health-check.jsの設計を一言でまとめると、「コンテキストが圧縮されても健全性の情報を失わない」です。mcp-health-cache.jsonはセッションの外にあります。バックオフ計算はコードが担います。モデルは「このサーバーは落ちているかもしれない」という推論をしなくていい。その分のトークンと注意力を、本来のタスクに向けられます。
月商120万の環境が「月商120万」であり続けているのは、夜中のバッチが翌朝まで倒れないからです。倒れないのはこのフックが静かに仕事をしているからです。
実装は721行ありますが、コアは3点に集約されます。
- コンテキストの外に状態を永続化する — セッション圧縮に関わらず記録が残る
- ステータスコードで戦略を分岐させる — 429と503とECONNREFUSEDは別物として扱う
- バックオフに上限を設ける —
MAX_BACKOFF_MSの10分で完全放棄を防ぐ
この3点が理解できれば、チューニングも問題の切り分けも自力でできるようになります。
仕組みの全体像・月120万の内訳・30日手順は有料noteにまとめています。
📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート
Lily(@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています
- AIで「寝てても回る仕組み」を作って月120万にした話は noteの有料記事 に💰
- OSS: github.com/bokuwalily 🐙
- 最新情報・お問い合わせは X @bokuwalily へ🌍
皆さんの ❤️ やシェアが励みになります!