「添付した仕様書を読んで、差分を要約してください」と頼みました。
返ってきたのは、まったく別のファイルの中身でした。ローカルの作業ディレクトリにある、名前が少し似ているだけの JSON です。
エージェントは間違えたと思っていません。ログを見ると read_file を呼び、成功を受け取り、その内容で要約を組み立てています。処理としては何ひとつ失敗していません。
手元の設定を数えて、ようやく理由が分かりました。私の構成には read_file という名前のツールが、別々のサーバーに 3 つありました。
初期化が落ちなくなったぶん、失敗が静かになりました
Antigravity 2.4.2 では、カスタマイズしたツール名が重複しているとエージェントの初期化そのものが失敗する不具合が修正されました。詳細は Antigravity の changelog に載っています。
修正としては正しい方向です。設定の書き間違いひとつで起動しなくなる状態は、無人で回す運用には向きません。
ただ、私にとっては予想と逆の結果になりました。
修正前の衝突は、起動時に大きな音を立てて止まってくれていました。何かがおかしいと即座に分かります。修正後の衝突は、起動が通り、ツール一覧が出そろい、エージェントが動き、そして片方のツールがもう片方を静かに覆い隠します。
壊れ方が「止まる」から「間違った答えを返す」に変わりました。運用の観点では、後者のほうがずっと厄介です。止まったものは気づけますが、それらしい答えを返してくるものは、何日も気づけません。
私はこの一件以降、MCP サーバーを 1 本足すたびに、必ず衝突検査を通すようにしています。
衝突は 3 つの層に分かれます
数えてみると、ひとくちに「名前がぶつかる」と言っても性質が違うものが混ざっていました。層を分けないと、機械で処理できる範囲と人が判断すべき範囲の線が引けません。
完全一致
read_file と read_file のように、文字列として同一のもの。これは機械で確実に検出でき、機械で改名しても意味が壊れません。自動処理の対象です。
意味的な近接
read_file と get_file_contents のように、名前は違うのに役割がほぼ同じもの。エージェントから見ると、どちらを選んでも成立してしまいます。
これは自動で改名できません。「同じ役割の 2 つを両方持つ必要が本当にあるのか」という設計の問いに変わるためです。検出して人の目に回すところまでが機械の仕事になります。
説明文の重なり
名前は違い、役割も違うのに、description の書き出しが似ているもの。エージェントはツール選択に説明文も使うため、ここが似ていると選択がぶれます。
3 層目は静的な検査では拾いきれない部分が残ります。今回のプリフライトでは 1 層目を自動処理、2 層目を警告として出し、3 層目は対象外としました。
起動前に落とすプリフライトを書きました
設定ファイルを読んで、ツール名を突き合わせ、完全一致があれば非ゼロで終了する。それだけの道具です。
#!/usr/bin/env python3
"""MCP ツール名の衝突を起動前に検出し、決定的な別名表を出力する。
exit 0: 衝突なし / exit 1: 完全一致の衝突あり / exit 2: 設定の読み込み失敗
"""
from __future__ import annotations
import hashlib
import json
import re
import sys
from collections import defaultdict
NAME_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
MAX_LEN = 64
# 語形の揺れを吸収する。左が正規形。
SYNONYMS = {
"get": "read", "fetch": "read", "load": "read",
"find": "search", "query": "search", "grep": "search",
"ls": "list", "dir": "list", "directory": "list",
"put": "write", "save": "write", "update": "write",
"rm": "delete", "remove": "delete",
"file": "file", "files": "file", "contents": "file", "content": "file",
"page": "page", "pages": "page",
}
def normalize(name: str) -> str:
"""比較用の正規形。区切り記号を落とし、語ごとに同義語を畳む。"""
words = [w for w in re.split(r"[^a-z0-9]+", name.lower()) if w]
folded = sorted({SYNONYMS.get(w, w) for w in words})
return "_".join(folded)
def short(server: str, budget: int) -> str:
"""サーバー名を安全な接頭辞へ。長い場合は決定的に縮める。"""
slug = re.sub(r"[^A-Za-z0-9]+", "_", server).strip("_").lower() or "srv"
if len(slug) <= budget:
return slug
digest = hashlib.sha1(server.encode("utf-8")).hexdigest()[:4]
return slug[: max(1, budget - 5)] + "_" + digest
def load(path: str) -> dict[str, list[str]]:
try:
with open(path, encoding="utf-8") as fh:
raw = json.load(fh)
except FileNotFoundError:
print(f"error: 設定ファイルが見つかりません: {path}", file=sys.stderr)
raise SystemExit(2)
except json.JSONDecodeError as exc:
# 設定を手で編集していると、ここで落ちるのが一番多い
print(f"error: JSON を解析できません ({exc.lineno}行目): {exc.msg}", file=sys.stderr)
raise SystemExit(2)
servers = raw.get("mcpServers")
if not isinstance(servers, dict):
print("error: mcpServers オブジェクトがありません", file=sys.stderr)
raise SystemExit(2)
catalog: dict[str, list[str]] = {}
for server, spec in sorted(servers.items()):
tools = spec.get("tools") if isinstance(spec, dict) else None
if not tools:
# tools を静的に持たないサーバーは起動して tools/list を叩く必要がある。
# 黙って飛ばさず、確認漏れとして明示する。
print(f"warn: {server}: tools 未記載のため静的検査から除外", file=sys.stderr)
continue
catalog[server] = list(dict.fromkeys(str(t) for t in tools))
return catalog
def audit(catalog: dict[str, list[str]]):
exact: dict[str, list[str]] = defaultdict(list)
near: dict[str, list[tuple[str, str]]] = defaultdict(list)
invalid: list[tuple[str, str]] = []
for server, tools in catalog.items():
for tool in tools:
if not NAME_RE.match(tool):
invalid.append((server, tool))
exact[tool].append(server)
near[normalize(tool)].append((server, tool))
exact_hits = {n: s for n, s in exact.items() if len(s) > 1}
near_hits = {}
for key, pairs in near.items():
distinct_names = {t for _, t in pairs}
distinct_servers = {s for s, _ in pairs}
if len(distinct_servers) > 1 and len(distinct_names) > 1:
near_hits[key] = sorted(pairs)
return exact_hits, near_hits, invalid
def alias_map(catalog: dict[str, list[str]], exact_hits: dict[str, list[str]]):
"""衝突した名前にだけ接頭辞を付ける。無衝突の名前は触らない。"""
taken = {t for tools in catalog.values() for t in tools}
mapping: dict[tuple[str, str], str] = {}
for server in sorted(catalog):
for tool in catalog[server]:
if tool not in exact_hits:
continue
budget = MAX_LEN - len(tool) - 1
if budget < 3:
# 名前自体が長すぎる。切り詰めではなく明示的な失敗にする
raise ValueError(f"{server}.{tool}: 別名が {MAX_LEN} 文字に収まりません")
candidate = f"{short(server, budget)}_{tool}"
if candidate in taken:
tail = hashlib.sha1(f"{server}/{tool}".encode()).hexdigest()[:4]
candidate = f"{candidate[: MAX_LEN - 5]}_{tail}"
taken.add(candidate)
mapping[(server, tool)] = candidate
return mapping
def main(argv: list[str]) -> int:
path = argv[1] if len(argv) > 1 else "mcp_config.json"
catalog = load(path)
total = sum(len(v) for v in catalog.values())
exact_hits, near_hits, invalid = audit(catalog)
print(f"検査対象: {len(catalog)} サーバー / {total} ツール")
for server, tool in invalid:
print(f"NG 形式違反 {server}.{tool}(^[A-Za-z0-9_-]{{1,64}}$ を満たしません)")
if exact_hits:
print(f"\n完全一致の衝突: {len(exact_hits)} 件")
for name in sorted(exact_hits):
print(f" {name} <- {', '.join(sorted(exact_hits[name]))}")
try:
mapping = alias_map(catalog, exact_hits)
except ValueError as exc:
print(f"NG 別名を生成できません: {exc}")
return 1
print("\n別名表(決定的・この順序で固定):")
for (server, tool), alias in sorted(mapping.items()):
print(f" {server}.{tool} -> {alias}")
if near_hits:
print(f"\n意味的に近い名前: {len(near_hits)} 件(自動改名の対象外・目視確認)")
for key in sorted(near_hits):
pairs = ", ".join(f"{s}.{t}" for s, t in near_hits[key])
print(f" [{key}] {pairs}")
if not exact_hits and not near_hits and not invalid:
print("衝突なし")
return 1 if (exact_hits or invalid) else 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
設計で意識した点をいくつか。
sorted() をサーバー名の走査とすべての出力に噛ませています。辞書の並び順は設定ファイルの記述順に左右されるため、そのままだと同じ構成でも実行のたびに別名の割り当てが変わりかねません。差分レビューに載せる出力が実行ごとに揺れると、検査そのものが信用されなくなります。
tools を静的に持たないサーバーは warn で明示して除外しています。ここを黙って読み飛ばす作りにすると、「検査を通ったのに衝突していた」という最悪の裏切りが起きます。検査できなかったことを検査結果に残すほうが、後で困りません。
手元の構成で走らせた結果
検査に使った構成は、ファイルシステム・GitHub・Notion 系・SQLite・Google Drive の 5 サーバー、合計 21 ツールです。個人開発の作業でひととおり触るものを並べただけの、特別なところのない組み合わせです。
検査対象: 5 サーバー / 21 ツール
完全一致の衝突: 4 件
list_directory <- filesystem, gdrive
read_file <- filesystem, gdrive, notion-lite
search <- gdrive, notion-lite
search_files <- filesystem, github
別名表(決定的・この順序で固定):
filesystem.list_directory -> filesystem_list_directory
filesystem.read_file -> filesystem_read_file
filesystem.search_files -> filesystem_search_files
gdrive.list_directory -> gdrive_list_directory
gdrive.read_file -> gdrive_read_file
gdrive.search -> gdrive_search
github.search_files -> github_search_files
notion-lite.read_file -> notion_lite_read_file
notion-lite.search -> notion_lite_search
意味的に近い名前: 1 件(自動改名の対象外・目視確認)
[file_read] filesystem.read_file, gdrive.read_file,
github.get_file_contents, notion-lite.read_file
| 指標 | 実測値 |
| サーバー数 / ツール数 | 5 / 21 |
| 衝突していた名前の種類 | 4 種類 |
| 衝突に巻き込まれたツール | 9 個(全体の 43%) |
| 最大の重なり | read_file が 3 サーバー |
| 意味的に近い名前のかたまり | 1 件(4 サーバーにまたがる) |
数える前は、せいぜい 1 つか 2 つだろうと思っていました。43% という数字を見たときは、手が止まりました。
とりわけ read_file の 3 重は効きます。ファイルを読むという操作は、エージェントが最も高い頻度で選ぶものです。そこが不定なら、後続の判断はすべて足元から崩れます。
意味的に近い名前として github.get_file_contents が同じかたまりに入った点も、見つかってよかったところでした。文字列としては 1 ミリも衝突していないのに、エージェントの選択肢としては競合します。
速度も測りました。24 サーバー・305 ツールの負荷用の設定を機械的に生成し、time.perf_counter() で最小値を取っています。
| 構成 | 所要時間(プロセス起動込み・5 回中の最小) |
| 5 サーバー / 21 ツール | 25 ミリ秒 |
| 24 サーバー / 305 ツール | 28 ミリ秒 |
ツール数が 14 倍になっても 3 ミリ秒しか増えていません。Python プロセスの起動時間が支配的で、検査そのものの負荷は誤差の範囲に収まっています。起動のたびに無条件で通しても体感には出ません。
なお 305 ツールの設定は語彙をランダムに組み合わせた合成データで、衝突率は現実の構成を反映しません。時間の計測にだけ使っています。
別名を機械で決める — 決定的であることが条件
別名の付け方そのものは、拍子抜けするほど単純です。サーバー名_元のツール名 を作り、MCP のツール名として許される ^[A-Za-z0-9_-]{1,64}$ に収まるかを確かめる。それだけです。
難しいのは、単純なまま壊さないことのほうでした。
まず、決定性です。設定ファイルのキーの並びを入れ替えて同じ検査を通し、出力が 1 バイトも変わらないことを確認しました。並び順で別名が変わる実装は、チームで共有した瞬間に破綻します。
次に、長さの境界です。^[A-Za-z0-9_-]{1,64}$ は上限が 64 文字なので、元の名前が 61 文字あると接頭辞を足した時点で溢れます。ここで元の名前を黙って切り詰める作りにすると、別名から元のツールを逆算できなくなります。
NG 別名を生成できません: alpha.aaaaaaaa…(61文字): 別名が 64 文字に収まりません
切り詰めではなく、明示的な失敗にしました。人が名前を短くするしかない場面で、機械が勝手に妥協しないほうが安全です。
サーバー名のほうが長い場合は話が別で、こちらは決定的に縮めます。SHA-1 の先頭 4 文字を足すことで、縮めた後も一意性が保たれます。
全部に接頭辞を付けるのはやめました
最初に書いた版では、衝突の有無にかかわらず、すべてのツールに サーバー名_ を付けていました。一貫していて、考えることが減るためです。
これは差し戻しました。理由は 2 つあります。
1 つは、名前の総量です。同じ 21 ツールで、付け方を変えて文字数を測りました。
| 方針 | ツール名の総文字数 | 元比 | 平均の名前長 |
| そのまま | 246 文字 | — | 11.7 文字 |
| 全ツールに接頭辞 | 433 文字 | +76% | 20.6 文字 |
| 衝突分のみ接頭辞 | 331 文字 | +35% | 15.8 文字 |
ツール一覧はシステムプロンプトに載り、毎回のリクエストに同行します。名前だけで 76% 増える扱いを、衝突していない 12 個のツールにまで課す理由がありません。
もう 1 つは、こちらのほうが本質的です。read_file や list_directory は、モデルが数え切れないほど見てきた名前です。srv_04_read_file に置き換えると、その馴染みを捨てることになります。名前は識別子であると同時に、選択の手がかりでもあります。
必要な場所だけを変える。触らずに済むものは触らない。個人的には、この判断が今回いちばん考えた部分でした。
実行の前に必ず通す場所へ埋める
検査は、思い出したときに走らせるものでは意味がありません。私は 2 か所に置いています。
- リポジトリの CI。MCP の設定ファイルに差分がある PR で走らせ、非ゼロ終了ならマージを止めます
- 手元の起動ラッパー。エージェントを起動するシェル関数の先頭で走らせ、失敗したら起動しません
#!/usr/bin/env bash
# agy-run — 衝突検査を通してから Antigravity CLI を起動する
set -euo pipefail
CONFIG="${MCP_CONFIG:-$HOME/.antigravity/mcp_config.json}"
if ! python3 "$(dirname "$0")/mcp_preflight.py" "$CONFIG"; then
echo "---" >&2
echo "ツール名が衝突しています。別名表を反映してから再実行してください。" >&2
exit 1
fi
exec antigravity "$@"
set -euo pipefail を付けているのは、検査の失敗を握り潰さないためです。ここを省くと、検査が落ちても後続の exec が走ります。検査を入れたつもりで入っていない状態が、いちばん質の悪い落とし穴になります。
CI 側は、この終了コードをそのまま使えます。
- name: MCP tool name preflight
run: python3 tools/mcp_preflight.py .antigravity/mcp_config.json
自動改名しないと決めた領域
意味的に近い名前は、検出しても自動では触りません。
filesystem.read_file と github.get_file_contents を機械が勝手に統合したり改名したりすると、直したつもりで別の壊れ方をします。両方が要る構成もあれば、片方を外すのが正解の構成もあり、その判断は設定を書いた人にしか下せません。
同じ理由で、description の重なりも自動処理から外しました。ここは静的な文字列比較では踏み込めない領域です。埋め込みで距離を測る手も考えましたが、判定の根拠が説明しづらくなるぶん、運用に載せたときに信用されにくくなります。今の私は、説明できる単純な検査のほうを選んでいます。
機械に任せるのは、間違えようがない部分だけ。残りは人が見る前提で並べる。境界を引いておくと、道具を長く使えます。
残っている限界
正直に書いておくと、この検査は静的です。
MCP サーバーの多くはツール一覧を実行時に返すため、設定ファイルに tools を書いていない構成では検査対象になりません。手元の環境では warn を出して除外していますが、根本的には各サーバーを立ち上げて tools/list を叩き、返ってきた名前で突き合わせるのが正しい形です。仕様は Model Context Protocol の公式ドキュメント で確認できます。
起動を伴う検査は本番運用の起動時間に影響するため、結果をキャッシュして設定のハッシュが変わったときだけ再取得する形を検討しています。まだ手をつけていません。
もうひとつ。同じサーバーをバージョン違いで 2 つ並べる場合、ツール名は当然すべて衝突します。これは検査の対象というより構成の問題で、今の実装だと大量の警告に埋もれます。サーバー単位の除外指定を足すべき箇所として残っています。
MCP サーバーを 3 本以上束ねているなら、一度数えてみることを推奨します。私自身、数えるまでは自分の構成が綺麗だと思い込んでいました。ツール名の衝突は、動かなくなって教えてくれるわけではありません。それらしい答えを返しながら、静かに間違え続けます。
お読みいただきありがとうございました。手元の構成で予想外の数字が出たら、それは道具が仕事をしたということかと思います。