変更履歴の1行で手が止まりました。「Claude Browser」が MCP サーバー名として予約された、という短い記述です。「Claude Preview」と同様に、ユーザーが設定したサーバーはその名前で登録できません。
読んだ瞬間に思ったのは、影響範囲ではなく、前提のずれのほうでした。私はずっと、.mcp.json の中の名前は自分が決めた識別子だと思っていたのです。ローカルのファイルに、自分で書いた文字列。誰と競合するはずもない、と。
そうではありませんでした。あの名前は、ベンダーと共有している名前空間の一部だったのです。
予約された名前が、自分の設定ファイルの中にあるかもしれない
個人開発で4サイト分の自動更新を回していると、.mcp.json は自然と増えます。私の場合、サーバー名は用途をそのまま書いていました。github、browser、sheets。短くて読みやすい、という理由だけで選んだ名前です。
短くて読みやすい名前は、ベンダーにとっても魅力的です。だから予約されます。
ここで起きることは、依存パッケージのバージョン衝突とは種類が違います。パッケージなら、インストール時に解決に失敗して赤い文字が出ます。こちらは、自分のファイルは1文字も変わらないまま、それを読む側の解釈だけが更新の向こう側で変わります。ファイルを見ても、何も起きていないように見えるのです。
名前の衝突が「エラー」ではなく「不在」として現れる
最初に確かめたのは、予約名とぶつかったときに何が返るか、でした。
期待していたのは、起動時の明示的な拒否です。「この名前は使えません」と言ってくれれば、そこで終わります。実際に手元で確認したかぎり、そういう一行は前に出てきませんでした。/mcp の一覧に、そのサーバーが並ばない。それだけです。
不在は、エラーメッセージを持ちません。
これは以前、設定したはずのMCPタイムアウトが効かず、毎回きっかり60秒で切れていた — request_timeout_ms をサーバー単位で取り戻す で書いた症状と、手触りがよく似ています。設定を書いた。書いたとおりに読まれていない。けれど画面上は静かで、原因が自分の書き方にあるのか実装側にあるのかが、しばらく判別できない。
違いは、タイムアウトのほうは60秒待てば必ず顔を出すのに対して、名前の衝突は待っても何も起きないところです。
無人実行では、ツールが1つ減っても処理は止まらない
対話的に使っているなら、まだ気づけます。頼んだ操作が実行されず、Claude が「そのツールは利用できません」と言えば、そこで手が止まるからです。
無人実行では止まりません。ここが、私がいちばん怖いと感じた部分です。
スケジュール実行のセッションに渡されるのは、そのとき実際にロードされたツールの集合だけです。1つ欠けていても、Claude はその集合を所与として受け取ります。存在しないツールの不在を嘆く理由がありません。手持ちの道具で、できる範囲の最善を尽くして、完走します。
そしてログには Status: SUCCESS と書かれます。
| 気づける層 | 対話実行 | 無人実行 |
| 起動時のエラー表示 | 出ない | 出ない |
| ツール呼び出しの失敗 | 画面で止まる | そもそも呼ばれない |
| 最終ステータス | 人間が違和感を持つ | SUCCESS で終わる |
| 発覚までの時間 | 数分 | 数日〜数週間 |
無音の成功が、いちばん高くつきます。私はこの手の欠落を、古い入力で無音劣化させない — 無人パイプラインの入力フレッシュネス契約 を書いたときにも一度踏んでいて、そのときの結論と同じ場所に戻ってきました。無人で回すものは、劣化ではなく停止を選ばせる必要があります。
三つの選択肢を並べて、一つを捨てた
対処の候補は3つありました。
案1: 予約されそうな名前を避けて、あとは祈る
browser や preview のような、ベンダーが使いそうな一般名詞を最初から避ける方針です。コストはゼロで、たいていの場合はうまくいきます。
うまくいかない日が、いつ来るかを知る手段がないのが難点です。予約リストは私の都合で増えるわけではありませんし、次に何が予約されるかを予測する仕事に、私は勝ち目を感じませんでした。
案2: 起動のたびに /mcp を目で確認する
確実です。そして、無人実行では実行できません。人間が見ていない時間帯に走るからこそスケジュールに載せているのであって、そこに目視を要求するのは、自動化の前提を壊しています。
案3: 宣言と実体を突き合わせて、ずれたら止める
.mcp.json に書いた名前の一覧が、実際にロードされた名前の一覧と一致しているか。この2つを機械的に比べて、差があれば非ゼロで終了させます。
私はこの案3を採用しました。理由は、予約リストの中身を知らなくても機能するからです。何が予約されたかを追いかける代わりに、「宣言したものが全部そこにあるか」だけを見ます。予約が原因でも、綴りの打ち間違いでも、パスの誤りでも、同じ1つの網にかかります。
原因の種類を先回りして数え上げる設計は、たいてい数え漏らします。結果の形だけを見る設計のほうが、長く保ちます。
宣言と実体を突き合わせる preflight を書く
実装は Node で60行ほどです。スケジュールタスクの本体を起動する前に、これを1回走らせます。
突き合わせの本体
#!/usr/bin/env node
// mcp-preflight.mjs
// .mcp.json の宣言と、実際にロードされた MCP サーバーを突き合わせる。
// 差があれば非ゼロ終了し、後続のスケジュールタスクを走らせない。
import { readFileSync } from "node:fs";
import { execFileSync } from "node:child_process";
// ベンダー予約名(判明しているぶんだけ。網羅は期待しない)
const RESERVED = new Set(["claude preview", "claude browser"]);
/** .mcp.json で宣言しているサーバー名 */
function declaredServers(path = ".mcp.json") {
const raw = JSON.parse(readFileSync(path, "utf8"));
return Object.keys(raw.mcpServers ?? {});
}
/**
* 実際にロードされたサーバー名。
* CLI の出力書式は契約ではないので、解釈はこの関数だけに閉じ込める。
* 書式が変わったら、直す場所はここ1箇所で済む。
*/
function loadedServers() {
const out = execFileSync("claude", ["mcp", "list"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
return out
.split("\n")
.map((line) => line.match(/^\s*([A-Za-z0-9_.\- ]+?)\s*:/))
.filter(Boolean)
.map((m) => m[1].trim())
.filter((name) => name.length > 0);
}
function main() {
const declared = declaredServers();
const loaded = new Set(loadedServers().map((n) => n.toLowerCase()));
const missing = declared.filter((n) => !loaded.has(n.toLowerCase()));
const collided = declared.filter((n) => RESERVED.has(n.toLowerCase()));
if (collided.length > 0) {
console.error(`予約名と衝突しています: ${collided.join(", ")}`);
}
if (missing.length > 0) {
console.error(`宣言したのにロードされていません: ${missing.join(", ")}`);
console.error(`ロード済み: ${[...loaded].join(", ") || "(なし)"}`);
process.exit(1);
}
console.log(`✅ MCP preflight: ${declared.length} 件すべてロード済み`);
}
main();
期待する出力は次のとおりです。
✅ MCP preflight: 3 件すべてロード済み
欠けている場合は、こうなります。
宣言したのにロードされていません: browser
ロード済み: github, sheets
手元では約 40ms で終わります。毎回のセッション起動に足しても、体感には出ません。
なぜ CLI の出力解釈を1関数に閉じ込めるのか
loadedServers() の正規表現は、私がいま観測している書式に依存しています。これは弱い前提です。書式は予告なく変わりますし、変わったときにこの preflight は「1件もロードされていない」と誤検知して、正常なパイプラインを止めます。
それでも私はこの形を選びました。誤検知は騒がしく失敗しますが、見落としは静かに成功するからです。止まったほうは、朝ログを見ればすぐ直せます。止まらなかったほうは、何週間も気づけません。
弱い前提を捨てられないなら、せめて1箇所に集めておきます。壊れたときに直す場所が分かるだけで、負債の性質はかなり変わります。
判定コマンドをパイプにつながない
一点だけ、実装より運用の話を挟みます。この preflight をシェルから呼ぶとき、出力を head や tail に流してはいけません。
# ❌ 終了コードが head のものになり、失敗しても 0 が返る
node mcp-preflight.mjs | head -5
# ✅ 判定は素で呼び、必要なら出力は変数に受ける
node mcp-preflight.mjs || exit 1
これは私自身が、別のゲートで実際に踏んだ落とし穴です。ゲートを足したつもりが、パイプが終了コードを飲み込んでいて、何も守っていませんでした。set -e も発火しません。せっかく fail-loud に設計しても、呼び出し側で無音に戻してしまっては意味がありません。
接頭辞規約で、そもそも当たらない場所に置く
preflight は検出です。衝突そのものは減りません。減らすほうは、名前の付け方で対処します。
私が4サイト分の設定に入れた規約は、1行で書けます。サーバー名には所有者の接頭辞を必ず付ける。
| 変更前 | 変更後 | 意図 |
browser | dolice-browser | 一般名詞を単独で占有しない |
github | dolice-github | ベンダー名と自分の設定を区別する |
sheets | dolice-sheets | 将来の公式コネクタと当たらない |
接頭辞は、名前空間の中に自分の区画を切る行為です。ベンダーが claude- の側を使い、私が dolice- の側を使う。互いに踏まない場所を、あらかじめ決めておきます。
規約を守っているかも、preflight に足せます。
const PREFIX = "dolice-";
function checkPrefix(declared) {
const offenders = declared.filter((n) => !n.startsWith(PREFIX));
if (offenders.length > 0) {
console.warn(`⚠️ 接頭辞なし: ${offenders.join(", ")}(将来の予約と衝突しうる)`);
}
return offenders.length === 0;
}
ここは警告に留めています。既存の名前を一斉に変えるとツールの参照側も直す必要があり、本番運用中に一度にやる作業ではないからです。警告を出し続けて、触るついでに直していく。この場合はそのくらいの速度が合っていました。
なお、複数のサーバーを束ねたときのツール名レベルの衝突は、また別の問題として整理しています。そちらは Claude Agent SDK で複数 MCP サーバを束ねるときのツール名衝突 — 名前空間と動的調停の設計 にまとめました。サーバー名とツール名は層が違うので、対処も別々に持っておくほうが混乱しません。
この設計が引き受けているトレードオフ
正直に書いておきたい部分です。
誤検知が増えます。 CLI の書式変更、起動の遅いサーバーがまだ一覧に出ていないタイミング、権限の一時的な失敗。どれも preflight を赤くします。私は起動待ちのぶんだけ、呼び出し側に短い待機を挟んで対処しましたが、根本的には競合状態を抱えたままです。
予約リストは常に古いままです。 RESERVED に手で足していく運用は、必ず遅れます。ただ、このリストは主目的ではありません。欠落の検出は missing 側が担っていて、RESERVED は原因を人間に伝えるためのヒントに過ぎません。古くても壊れない位置に置いてあります。
設定が1行増えます。 スケジュールタスクの先頭に preflight の呼び出しが入るぶん、手順は長くなります。短い手順は美しいのですが、静かに間違える手順よりは、長くて騒がしい手順のほうを私は好みます。
どれも解けていません。解けていないまま運用に載せた、というのが正確なところです。
明日の朝、最初に走らせる一行
もし手元に .mcp.json があるなら、まずこれだけ試してみてください。
node -e "const s=Object.keys(JSON.parse(require('fs').readFileSync('.mcp.json','utf8')).mcpServers||{});console.log(s.join('\n'))"
宣言しているサーバー名が並びます。その一覧と claude mcp list の出力を、一度だけ目で比べてみる。それだけで、いま欠けているものがあるかどうかは分かります。
差がなければ、それでいいのです。差があった日のために、preflight を書いておく。
自分のファイルに書いた文字列が、自分のものではなかった。この感覚は、しばらく残りそうです。名前は借りているのだと思って付けるほうが、これからは安全なのかもしれません。
私自身まだ手探りの部分が多い設計ですので、より良い形を見つけられたら、また書き直したいと思っております。お読みいただきありがとうございました。