朝いちばんに Antigravity を開いて、前日の続きを頼みました。返ってきた要約はそれらしく、読んでいて引っかかるところもありませんでした。
手が止まったのは、しばらく経ってログを遡ったときです。繋いだはずの MCP サーバーのツール呼び出しが、どこにも見当たりませんでした。起動時に初期化が失敗していて、私はそれに気づかないまま作業を進めていたのです。
同じ状態はフォーラムにも上がっています。起動のたびに MCP の初期化に失敗するという報告では、手動で更新をかけたあとで初めて接続が作られ、サーバー自体は単体なら正常に動く、という切り分けまで済んでいます。
厄介なのは直し方ではありません。揃っていない状態で始まってしまったことに、いつ、どうやって気づくかのほうです。
欠けていることを、エージェントは教えてくれません
MCP のツールが読み込まれていないとき、エージェントは止まりません。手元にある別の手段——web 検索、ファイル走査、記憶している一般論——へ黙って移り、それらしい答えを返してきます。
出力は一見まともです。だからこちらも疑いません。私も最初のうち、結果が返っていれば繋がっているものと見なしておりました。結果は芳しくありませんでした。半日ぶんの作業を、参照していないはずの情報を根拠にして進めていたことが、あとから分かったのです。
欠落が見えるのは、ツールが「失敗した」ときだけです。**黙って代替へ落ちた場合、失敗という出来事そのものが発生していません。**ログにエラーが無いのは、正常だからではなく、何も試みられていないからでした。私が見張るべきだったのは失敗の数ではなく、試みの数だったのだと気づいたのは、そのあと出所を辿り直しているときでした。
ここで設定ファイルを開いても、たいてい何も見つかりません。文法は正しく、サーバーは手で叩けば動きます。設定ファイルの静的な検査は起動前の 40 行で足りますが、今回の症状はその先——実際にハンドシェイクが成立したかどうか——にあります。
手元で tools/list を叩いて、名前を目で見る
確かめ方はひとつです。エディタの表示ではなく、自分で MCP サーバーへ接続して、返ってくるツール名を読むことです。
MCP は標準入出力の上を流れる JSON-RPC ですので、initialize を送り、notifications/initialized を返し、tools/list を投げれば、見えるはずのツールが名前の配列で戻ってきます。次のスクリプトは設定ファイルに並んだサーバー全部にこれを行い、必須のツールが揃っていなければ終了コード 1 で落ちます。
// mcp-readiness.mjs — 設定のサーバーへ実際に接続し、見えているツール名を出します
// 使い方: node mcp-readiness.mjs <mcp_config.json> [必須ツール名 ...]
import { spawn } from 'node:child_process';
import { readFileSync } from 'node:fs';
const [configPath, ...required] = process.argv.slice(2);
const config = JSON.parse(readFileSync(configPath, 'utf8'));
const servers = config.mcpServers ?? config.servers ?? {};
const PROTOCOL_VERSION = '2025-06-18';
const TIMEOUT_MS = 8000;
function listTools(name, spec) {
return new Promise((resolve) => {
const child = spawn(spec.command, spec.args ?? [], {
env: { ...process.env, ...(spec.env ?? {}) },
stdio: ['pipe', 'pipe', 'pipe'],
});
let buffer = '';
let settled = false;
const done = (result) => {
if (settled) return;
settled = true;
child.kill();
resolve({ name, ...result });
};
const timer = setTimeout(
() => done({ ok: false, reason: `${TIMEOUT_MS}ms 以内に応答がありません` }),
TIMEOUT_MS,
);
child.on('error', (e) => { clearTimeout(timer); done({ ok: false, reason: e.message }); });
const send = (msg) => child.stdin.write(JSON.stringify(msg) + '\n');
child.stdout.on('data', (chunk) => {
buffer += chunk;
let index;
while ((index = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, index).trim();
buffer = buffer.slice(index + 1);
if (!line) continue;
let msg;
try { msg = JSON.parse(line); } catch { continue; } // ログ行が混ざることがあります
if (msg.id === 1) {
send({ jsonrpc: '2.0', method: 'notifications/initialized' });
send({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
}
if (msg.id === 2) {
clearTimeout(timer);
done({ ok: true, tools: (msg.result?.tools ?? []).map((t) => t.name) });
}
}
});
send({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: PROTOCOL_VERSION,
capabilities: {},
clientInfo: { name: 'mcp-readiness', version: '1.0.0' },
},
});
});
}
const results = await Promise.all(
Object.entries(servers).map(([name, spec]) => listTools(name, spec)),
);
const seen = new Set();
for (const r of results) {
if (r.ok) {
r.tools.forEach((t) => seen.add(t));
console.log(`OK ${r.name} ${r.tools.length} tools: ${r.tools.join(', ')}`);
} else {
console.log(`NG ${r.name} ${r.reason}`);
}
}
const missing = required.filter((t) => !seen.has(t));
if (missing.length > 0) {
console.log(`\n欠けている必須ツール: ${missing.join(', ')}`);
process.exit(1);
}
console.log('\n必須ツールは揃っています');こう書いたのには理由があります。数ではなく名前を出すようにしたのは、サーバーが起動していても公開されるツールが版によって減ることがあったからです。「3 本見えている」では足りず、「その 3 本のうちに必要な 1 本があるか」まで確かめないと、同じ取り違えを繰り返します。
標準出力の JSON 解析で catch { continue; } としているのも実地の都合です。起動直後に人間向けのログを混ぜて吐くサーバーがあり、行ごとに読み飛ばせないと最初の 1 行で落ちてしまいます。
タイムアウトを 8 秒に置いたのは、初回に依存を取りに行くサーバーで 3 秒では足りず、逆に 30 秒にすると「立ち上がらない」ことに気づくのが遅れたためです。
欠けていたときの三つの分岐
確かめる手段ができても、その先を毎回考えていては手が止まります。分岐は先に決めておきます。
| 状況 | 取る手 | 払うもの |
|---|---|---|
| 手動の再読み込みで名前が揃った | そのまま続けます。ただし揃ったことをもう一度名前で確認してからにします | 数十秒 |
| 再読み込みしても 1 本だけ立たない | その 1 本を前提にした頼み方をやめ、必要なものは自分で取得して貼り付けます | 手間は増えますが、参照元は確実になります |
| 書き込み系・権限を伴うサーバーが立たない | そのセッションでは着手しません。設定の側を直してから改めます | その日の予定が 1 件ずれます |
分け方の軸は、そのツールが取りに行くものか書き込むものかです。
直感に反しますが、危ないのは取りに行く側でした。書き込み系が欠けていれば、変更が起きないのですぐ分かります。取りに行く側が欠けたときは、それらしい代替が返り、そのまま成果物に混ざります。あとから出所を辿り直す時間のほうが、はるかに高くつきました。順序が逆であれば、もう少し早く手を止められたかもしれません。
いまは、読み取り系が 1 本でも欠けていたらその作業を止める、という線引きにしています。エージェントの能力の問題ではなく、私が結果を信じてよい根拠が無い、というだけの話です。
止める合図は、自分の手順の側に置く
この一件で決めたのは、確認をエージェントに依頼しないことでした。「MCP は使えていますか」と尋ねても、返事は当人の見えている範囲に依存します。見えていないことは、見えていないままです。
揃っているかどうかは、エージェントにではなく自分の手順に聞く。 この線引きだけは、急いでいる日でも崩さないようにしています。
やっていることは素朴です。私は Lab のサイト群を毎日の自動更新で回しておりますが、そこでも同じ形にしています——参照するデータが空だったら、必ず 1 行ログに残して、そこで止めます。無言で先へ進んだ結果は、あとで必ず自分に返ってくるからです。MCP も同じで、mcp-readiness.mjs が終了コード 1 で落ちたら、その日はそのサーバーを当てにしない、と決めておくだけで迷いが消えました。
渡すツールの範囲そのものを絞っておくと、確認する対象も短くなります。エージェントに渡す MCP ツールを絞る設計と併せると、朝の確認は 30 秒で終わります。
明日の朝、最初にすること
まずは手元の設定ファイルに対して、上のスクリプトを一度だけ走らせてみてください。必須ツール名を引数に渡さず、名前の一覧を眺めるところから始めれば十分です。
想定していた名前が並んでいれば、それは今日のセッションを信じてよい根拠になります。並んでいなければ、今日はその作業を持ち越す日でした——それが分かっただけで、半日は守られます。
お読みいただきありがとうございました。同じところで手を止めた方の、朝の 30 秒が軽くなれば幸いです。