朝、Antigravity を開いたらツール一覧が空でした。前日まで動いていた MCP サーバーが、まとめて姿を消しています。
原因は、寝る前に足した最後のエントリで args を配列ではなく文字列で書いたことでした。1件の書き間違いが、無関係な他のサーバーを道連れにしていたわけです。ログを開いて原因の行にたどり着くまで、20分ほど溶かしました。
CLI 1.1.14(8月18日)で、この挙動は改善されています。不正な MCP サーバー設定が混ざっていても、そのエントリだけをログに記録してスキップし、残りは読み込まれるようになりました。ただ、更新が行き渡るまでの環境は道連れの挙動のままですし、設定ファイルが JSON として壊れている場合は、どのバージョンでも全滅します。パーサーが読めなければ、スキップすべき1件がどれなのかも決められないからです。
個人開発で複数のプロジェクトを行き来していると、MCP の設定ファイルは編集の頻度が高く、その分だけ壊す機会も増えます。私自身、この全部止まる状態を数えられる程度には踏みました。
起動してからログを読むより、起動する前に設定を読むほうが速い。そう考えて書いた 40 行ほどのスクリプトを置いておきます。
起動前に読めば足りること
MCP の設定ファイルで実際に踏んだ失敗を並べると、起動前の静的な検査で見つかるものがほとんどでした。
| 検査項目 | 実際に踏んだ形 | 起動前に分かるか |
|---|---|---|
| JSON として読めるか | 末尾のカンマ、閉じ括弧の抜け | 分かる |
command が PATH 上にあるか | バージョン管理ツールを入れ替えて uvx が消えた | 分かる |
args が配列か | 1個しか渡さないときに文字列で書いた | 分かる |
env の参照先が存在するか | 環境変数を参照する書き方にしたが、そのシェルには未設定だった | 分かる |
url がスキームを持つか | localhost:8931/mcp と書いてしまった | 分かる |
| サーバーが実際に応答するか | 起動はしたが握手が返らない | 分からない |
最後の1行だけは、実際に起動しないと確かめられません。逆に言えば、それ以外は起動を待たずに落とせます。検査の目的は「全部を保証すること」ではなく、ログを読む必要がある問題だけを残すことです。
40 行のプリフライトスクリプト
Node.js があれば動きます。設定ファイルのパスを1つ渡すだけです。
#!/usr/bin/env node
import { readFileSync, accessSync, constants } from "node:fs";
import { delimiter, join } from "node:path";
const file = process.argv[2];
if (!file) { console.error("usage: node mcp-preflight.mjs <settings.json>"); process.exit(2); }
let raw, cfg;
try { raw = readFileSync(file, "utf8"); }
catch (e) { console.error(`FATAL 読み込み失敗: ${e.message}`); process.exit(2); }
try { cfg = JSON.parse(raw); }
catch (e) {
console.error(`FATAL JSON として読めません: ${e.message}`);
const m = /position (\d+)/.exec(e.message);
if (m) console.error(` → ${raw.slice(0, Number(m[1])).split("\n").length} 行目付近を確認してください`);
process.exit(2);
}
const servers = cfg.mcpServers ?? {};
const names = Object.keys(servers);
if (names.length === 0) { console.error("FATAL mcpServers が空です"); process.exit(2); }
function which(cmd) {
if (cmd.includes("/")) { try { accessSync(cmd, constants.X_OK); return cmd; } catch { return null; } }
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
try { accessSync(join(dir, cmd), constants.X_OK); return join(dir, cmd); } catch {}
}
return null;
}
// JSON は同名キーを黙って上書きするため、生テキスト側で重複を数える
const declared = [...raw.matchAll(/^[\t ]*"([A-Za-z0-9_.-]+)"[\t ]*:[\t ]*\{/gm)].map((m) => m[1]);
const dupes = [...new Set(declared.filter((n) => names.includes(n) && declared.filter((x) => x === n).length > 1))];
for (const d of dupes) console.log(`WARN ${d} が複数回宣言されています(最後の1つだけが読まれます)`);
let bad = 0;
for (const name of names) {
const s = servers[name] ?? {};
const errs = [];
if (!/^[A-Za-z0-9_-]+$/.test(name)) errs.push("サーバー名に使えない文字が含まれています");
if (s.url) {
if (!/^https?:\/\//.test(s.url)) errs.push(`url が http(s) で始まっていません: ${s.url}`);
} else if (typeof s.command !== "string" || s.command.length === 0) {
errs.push("command が未設定です(url も無い場合は必須)");
} else if (!which(s.command)) {
errs.push(`command が PATH 上に見つかりません: ${s.command}`);
}
if (s.args !== undefined && !Array.isArray(s.args)) errs.push("args が配列ではありません");
for (const [k, v] of Object.entries(s.env ?? {})) {
if (typeof v !== "string") { errs.push(`env.${k} が文字列ではありません`); continue; }
const ref = /^\$\{?([A-Z0-9_]+)\}?$/.exec(v);
if (ref && !process.env[ref[1]]) errs.push(`env.${k} が参照する ${ref[1]} が未設定です`);
if (v === "") errs.push(`env.${k} が空文字です`);
}
if (errs.length) { bad++; console.log(`NG ${name}`); for (const e of errs) console.log(` - ${e}`); }
else console.log(`OK ${name}`);
}
console.log(`---\n${names.length} 件中 ${names.length - bad} 件が起動可能な設定です`);
process.exit(bad ? 1 : 0);いくつか、なぜこう書いたかを補足させてください。
which コマンドを呼ばずに PATH を自分で歩いているのは、外部コマンドに依存させたくないからです。CI コンテナのような最小構成のイメージには which が入っていないことがあります。ただし、この実装は Windows では不十分です。npx.cmd のような拡張子の補完がないため、PATHEXT を回して候補を作る分岐を足してください。
JSON のパースエラーで行番号を自分で数えているのは、位置(文字数)しか返さない処理系への保険です。手元の Node.js 22 では、エラーメッセージ自体に (line 6 column 5) まで含まれていました。新しい処理系なら、この数行は無くても困りません。
終了コードは、パースできない場合が 2、エントリ単位の不備が 1、問題なしが 0 です。シェルから条件分岐させたいので、警告と致命的な失敗を分けています。
わざと壊した設定に通す
5本のサーバーを書いた設定を用意し、そのうち3本にありがちな間違いを仕込んで通してみました。実際の出力です。
OK filesystem
OK sqlite
NG internal-api
- url が http(s) で始まっていません: localhost:8931/mcp
NG notes
- args が配列ではありません
- env.NOTES_TOKEN が参照する NOTES_TOKEN が未設定です
NG search
- env.SEARCH_ENDPOINT が空文字です
---
5 件中 2 件が起動可能な設定ですnotes のように、1つのエントリで2件の不備が同時に見つかることがあります。最初の1件で打ち切らず、エントリごとに全部の検査を通してから出力しているのはこのためです。1件直して起動して、また別の1件で落ちる往復が、地味に時間を食います。
末尾にカンマを1つ足したファイルを通すと、こうなりました。
FATAL JSON として読めません: Expected double-quoted property name in JSON at position 156 (line 6 column 5)
→ 6 行目付近を確認してくださいエントリ単位の検査までたどり着けないので、ここで打ち切ります。パーサーが読めないファイルに対して「どのサーバーが悪いのか」を推測しても、当たらない答えを出すだけです。
command が見つからないケースは、環境そのものが原因のことが多いので、Antigravity の MCP サーバーが spawn npx ENOENT で起動しないときの原因と対処のほうが具体的です。GUI から起動したときだけ PATH が違う、という話が中心になります。
JSON は同名キーを黙って上書きします
このスクリプトを書いていて、いちばん驚いたのがここでした。
同じサーバー名を2回書いた設定を通したところ、出力はこうなりました。
WARN notes が複数回宣言されています(最後の1つだけが読まれます)
NG notes
- command が PATH 上に見つかりません: bunx
---
1 件中 0 件が起動可能な設定ですファイルには2本書いてあるのに、「1 件中」と表示されています。JSON の仕様上、同じキーが2回現れたら後勝ちで上書きされ、前のほうは何の警告もなく消えます。エラーにはなりません。
これが厄介なのは、症状が「起動しない」ではなく「なぜかそのサーバーだけ古い設定で動いている」という形で出ることです。ログにも何も残りません。設定ファイルをコピー&ペーストで増やしていくと、名前を変え忘れた瞬間に静かに踏みます。
そのため、パース後のオブジェクトではなく、生のテキスト側で宣言の回数を数えています。整形の仕方によっては取りこぼす素朴な正規表現ですが、コピー&ペースト由来の重複はこれで拾えます。
続けられる置き場所にする
検査は、思い出したときに走らせるものにすると続きません。私はこのスクリプトを、次の3か所に置いています。
package.jsonのscriptsに"mcp:check": "node scripts/mcp-preflight.mjs .antigravity/settings.json"として登録する- シェルの関数にして、設定を編集した直後の手癖で叩けるようにする
- リポジトリで設定ファイルを共有しているなら、CI の最初のステップに入れる
3番目が効くのは、チームで MCP の設定を共有している場合です。手元では uvx が入っているので通るけれど、同僚の環境では入っていない、という差は、pull request の段階で分かるほうが穏やかです。終了コードを分けてあるので、CI での扱いも素直に書けます。
起動前の検査で残るのは、「設定は正しいのにサーバーが応答しない」という本当に調べる価値のある問題だけになります。そこから先は、どのツールをエージェントに渡すかという設計の話に変わります。渡す範囲を絞る考え方はエージェントに渡すMCPツールを絞る — 最小権限の許可リスト設計にまとめました。読者の方に支えていただいているプレミアム記事です。
まずは今お使いの設定ファイルを、このスクリプトに一度通してみてください。全部 OK で返ってくれば、次に起動が失敗したときはログを読む価値がある、と判断できます。
お読みいただきありがとうございました。