GitHub MCP と Linear MCP を同じ Claude Agent SDK アプリに束ねた日、Sonnet 4.6 が「Linear のチケットを起票してください」というユーザー指示を受けて、迷わず GitHub Issues 側にチケットを作りました。両方の MCP サーバが create_issue という同じ名前のツールを公開しており、後から登録された GitHub MCP のスキーマで上書きされていたためです。原因が分かったのは、SDK のデバッグログにツール一覧をダンプしてからでした。
個人開発で 6 つのサイトを並行運営している立場から見ると、こうしたツール名の衝突は珍しい事故ではありません。私は 2014年からアプリを作り続けてきて、累計 5,000万 DL を超えるまでさまざまな外部 API を組み合わせてきましたが、MCP の世界に入ってからは「同じ意味の動詞」が複数のサーバで重なる頻度が体感で 3 倍ほどに増えました。今回は、その衝突を防ぐ名前空間の設計と、TypeScript で書ける動的調停(Reconciler)の実装を、自分が踏んだ失敗込みで整理します。
ツール名衝突が起きる構造を分解する
Claude Agent SDK は MCP サーバから取得したツール一覧をそのまま tools 配列に渡すだけで、衝突検出を呼び出し側に委ねています。Anthropic Messages API のスキーマでは、tools 配列の name フィールドは「英数字とアンダースコア、ハイフン」しか許されておらず、同名のエントリが 2 つあったときに後勝ちになるか先勝ちになるかは実装次第です。SDK のソースを読むと、Map に詰め直す箇所で後勝ちになる実装が多く、結果として「先に登録したサーバのツールが静かに消える」という挙動になります。
モデル側から見れば、ツールは「name」「description」「input_schema」の 3 点でしか区別できません。同じ name の 2 つのツールが渡されていても、API リクエストの段階で 1 つに畳まれてしまうため、モデルには差異が伝わりません。description だけ違って name が同じ場合、モデルは「片方の description しか見ていない」状態でツールを選びます。これが先ほど GitHub に Linear のチケットが作られた事故の根本原因でした。
衝突の典型例は次のような並びです。
動詞 候補となる MCP サーバ
create_issueGitHub MCP / Linear MCP / Jira MCP
searchNotion MCP / Slack MCP / Web Search MCP
uploadS3 MCP / Cloudflare R2 MCP / Stripe Files MCP
send_messageSlack MCP / Discord MCP / SendGrid MCP
私が運営しているサイト群(Claude Lab / Gemini Lab / Antigravity Lab / Rork Lab / Lacrima / Mystery)では、これらのうち 3 種類以上が同居するセッションが日常的に発生します。AdMob レポートを Slack に通知しつつ、GitHub に修正 PR を立て、Linear のチケットを閉じる、というシナリオは、ツール衝突の温床です。
名前空間の付け方を 3 つの方式で比較する
衝突を解く方法はいくつかありますが、私が試したうえで本番に残したのは「サーバ識別子をプレフィックスとして付ける」アプローチです。検討した 3 案を比較します。
第 1 案は ドット表記 です。github.create_issue のようにドットで区切ると人間には読みやすい一方、Anthropic Messages API の name フィールドはドットを許容しないため、そのままでは 400 を返します。エスケープや代替文字へ変換する処理を挟むことになり、bidirectional に元の名前へ戻すマッピングが必要になります。
第 2 案は スラッシュ表記 ですが、これも name フィールドの制約で使えません。第 1 案と同じ問題に行き着きます。
第 3 案が 二重アンダースコア区切り です。github__create_issue / linear__create_issue のように、サーバ識別子と元のツール名を __ で繋ぎます。仕様で許される文字だけで構成され、モデルから見たときも「github 側の create_issue」「linear 側の create_issue」と読み下せます。私が Sonnet 4.6 と Haiku 4.5 で同じプロンプトを 50 回ずつ動かして比較したところ、二重アンダースコア方式が最も誤呼び出し率が低く、gh_ のような短縮プレフィックスより 7% ほど安定しました。短いプレフィックスは「gh」と「ghp」など別の意味と取り違えるリスクがあり、説明文に頼ることになるため、推奨しません。
実運用では、サーバ識別子をプレフィックスに使う際に次のルールを敷きました。
プレフィックスは英小文字のみ。3〜10 文字に収める
元のツール名は変更しありません。プレフィックスを足すだけ
衝突がなければプレフィックスを付けありません。衝突したときだけ全エントリにプレフィックスを足す
3 番目が重要で、無条件にすべてのツール名を書き換えるとモデルが学習している慣用名(web_search 等)を変えてしまい、ツール選択精度が落ちます。衝突したサーバ群だけをスコープにプレフィックスを付けるのが、最もモデルに優しい設計です。
実は計測のセットアップ自体にも一工夫が必要でした。プロンプトを 50 回流すだけでは温度設定でゆらぐ結果が混ざるので、temperature: 0.0 で固定し、同じシステムプロンプトと同じユーザー指示を 50 回投げて誤呼び出し率を見ました。さらに、AdMob 関連の MCP 4 つ・GitHub 系 2 つ・Slack 系 1 つの計 7 サーバを束ねた状態で計測しないと、本番に近い「ツール 60 個前後」の負荷で何が起きるか見えません。本番の数字は手元の小規模実験よりも常に悪化方向にずれるので、計測条件は本番にできるだけ寄せておくのが結局のところ役に立ちます。
TypeScript で衝突調停器を実装する
ここからが実装です。複数の MCP サーバから取得したツール一覧を入力として、衝突を検出してリネームし、双方向の alias map を返す Reconciler を TypeScript で組みます。
// reconciler.ts
import type { Tool } from "@anthropic-ai/sdk/resources/messages.mjs" ;
export interface ServerToolBundle {
serverId : string ; // "github" | "linear" | ...
tools : Tool [];
}
export interface ReconciledTools {
tools : Tool []; // モデルに渡す最終形
forward : Map < string , { serverId : string ; original : string }>;
reverse : Map < string , string >; // serverId:original -> renamed
}
const PREFIX_SEP = "__" ;
const NAME_RE = / ^ [a-zA-Z0-9_-] {1,64}$ / ;
export function reconcile ( bundles : ServerToolBundle []) : ReconciledTools {
const seen = new Map < string , string []>(); // name -> [serverIds]
for ( const b of bundles) {
for ( const t of b.tools) {
const list = seen. get (t.name) ?? [];
list. push (b.serverId);
seen. set (t.name, list);
}
}
const conflictedNames = new Set (
[ ... seen. entries ()]. filter (([, ids ]) => ids. length > 1 ). map (([ n ]) => n),
);
const tools : Tool [] = [];
const forward = new Map < string , { serverId : string ; original : string }>();
const reverse = new Map < string , string >();
for ( const b of bundles) {
for ( const t of b.tools) {
const needsPrefix = conflictedNames. has (t.name);
const renamed = needsPrefix ? `${ b . serverId }${ PREFIX_SEP }${ t . name }` : t.name;
if ( ! NAME_RE . test (renamed)) {
throw new Error ( `rename produced invalid tool name: ${ renamed }` );
}
tools. push ({ ... t, name: renamed });
forward. set (renamed, { serverId: b.serverId, original: t.name });
reverse. set ( `${ b . serverId }:${ t . original ?? t . name }` , renamed);
}
}
return { tools, forward, reverse };
}
ポイントは 3 つあります。第 1 に、衝突しているツール名だけをプレフィックス付けの対象にしています。第 2 に、forward と reverse の双方向マップを作ることで、モデルが github__create_issue を呼んだときに「GitHub サーバの create_issue を起動する」という逆引きが O(1) で済みます。第 3 に、リネーム後の名前を正規表現で検証して、Anthropic 仕様外の文字が混入したらビルド時に落とすようにしました。
モデルがツールを呼んだ後の dispatcher 側はこうなります。
// dispatcher.ts
import type { ToolUseBlock } from "@anthropic-ai/sdk/resources/messages.mjs" ;
import { reconcile } from "./reconciler.js" ;
export class MCPDispatcher {
private forward : Map < string , { serverId : string ; original : string }>;
constructor ( private servers : Map < string , MCPClient >, bundles : ServerToolBundle []) {
const r = reconcile (bundles);
this .forward = r.forward;
}
async dispatch ( block : ToolUseBlock ) : Promise < unknown > {
const meta = this .forward. get (block.name);
if ( ! meta) {
throw new Error ( `unknown tool: ${ block . name }` );
}
const server = this .servers. get (meta.serverId);
if ( ! server) {
throw new Error ( `server ${ meta . serverId } not registered` );
}
return server. callTool (meta.original, block.input as Record < string , unknown >);
}
}
このディスパッチャは、モデルが返した tool_use ブロックを受けて、元のサーバの元のツール名に解決し直してから MCP クライアントを叩きます。MCPClient の callTool シグネチャは利用している MCP 実装に合わせて読み替えてください。
dispatcher のエラー処理にも一言。unknown tool が出たときに即座に例外を投げると、エージェントのループ全体が落ちます。私は最初の実装でそうしてしまい、Cowork のスケジュールタスク 1 本ぶんが空振りした記憶があります。今は「未知のツール名はモデルに tool_result でエラー文字列を返す」設計に変えていて、モデルが次の turn でリカバリを試みる余地を残しています。エラー文字列には「このツール名は存在しません。利用可能なツールは: github__create_issue, linear__create_issue, ...」のように選択肢を列挙すると、Sonnet 4.6 は次の turn でほぼ正解に戻ってきます。
description を「出所タグ」で正規化する
name のリネームだけで衝突は解けますが、私の観測では「description が同じ」 2 つのツールが残ると、モデルがプレフィックスを無視して片方を選び続ける現象が出ました。とくに github__create_issue と linear__create_issue のように、両方の description に「Create a new issue」とだけ書かれている場合、モデルは name の prefix を意味のあるシグナルとして十分に重視してくれません。
そこで Reconciler に description の正規化を足しました。
function tagDescription ( serverId : string , tool : Tool ) : Tool {
const labelMap : Record < string , string > = {
github: "[GitHub]" ,
linear: "[Linear]" ,
jira: "[Jira]" ,
slack: "[Slack]" ,
notion: "[Notion]" ,
};
const tag = labelMap[serverId] ?? `[${ serverId }]` ;
const original = tool.description ?? "" ;
if (original. startsWith (tag)) return tool;
return { ... tool, description: `${ tag } ${ original }` . trim () };
}
シンプルですが効きます。description の冒頭にタグを付けただけで、私の手元では誤呼び出し率が 38% から 6% まで下がりました。タグは角括弧で囲んで視覚的に切り分けるのがコツで、モデルはタグの中身を「補助的なラベル」として認識します。description が長いツールでは、タグの後ろに改行を入れて元の文章を保ったほうが読みやすいです。
MCP サーバを動的に追加・削除するための再構築
開発中の Claude Agent SDK アプリでは、ユーザーが「Slack を接続した」「Discord を切断した」のような操作で MCP サーバが増減します。サーバが増減するたびに tools 配列を再構築しなければなりませんが、Anthropic の prompt cache を併用していると、ツール一覧が変わった瞬間にキャッシュが破棄されて月額コストが跳ねます。私は AdMob 関連の MCP を 4 つ束ねている案件で、これに気づかず月額 12% ぶんのキャッシュ恩恵を失ったことがあります。
対策として、サーバの増減を吸収する MCPManager を一段挟みます。
// mcp-manager.ts
type Listener = ( state : ReconciledTools ) => void ;
export class MCPManager {
private bundles = new Map < string , ServerToolBundle >();
private listeners : Listener [] = [];
private current !: ReconciledTools ;
register ( b : ServerToolBundle ) {
this .bundles. set (b.serverId, b);
this . rebuild ();
}
unregister ( serverId : string ) {
this .bundles. delete (serverId);
this . rebuild ();
}
onChange ( l : Listener ) { this .listeners. push (l); }
private rebuild () {
this .current = reconcile ([ ... this .bundles. values ()]);
for ( const l of this .listeners) l ( this .current);
}
snapshot () { return this .current; }
}
MCPManager の onChange を購読しておけば、ツール一覧が変わるたびにキャッシュ・ブレークポイントの再設計や、進行中のエージェントセッションを「次の turn から新しいツール一覧で動かす」と切り替えられます。注意点は、進行中のセッションのまっただ中でツール一覧を入れ替えないことです。モデルが前の turn で覚えた github__create_issue を呼ぼうとして、次の turn でその name が消えているとリクエストが 400 を返します。turn の境界でだけ入れ替える設計にしました。
本番で踏んだ失敗パターンを 3 つ整理する
ここまでの実装を運用したうえで、私が本番で踏んだ失敗を 3 つ共有します。読者のプロジェクトでも同じ罠が待っていると思います。
第 1 の落とし穴は stale alias です。ある日 GitHub MCP がアップデートされてツール名が create_issue から issues_create に変わったとき、Reconciler は新しい名前を吸い込んだのですが、prompt cache に古いシステムプロンプトと古い tools 配列が残っていました。モデルは古い github__create_issue を呼ぼうとし、API は 400 を返し、エージェントが暴走的にリトライしてレートリミットに当たりました。対処は、Reconciler の出力ハッシュをキャッシュキーの一部に含め、ハッシュが変わったら明示的に新しい breakpoint を切ることです。
第 2 は description drift です。同じ name の 2 つのツールの description が、両方のサーバ側で別々に更新されていくと、片方だけ表現が変わっていって、ある日突然モデルが別の方を選び始める、という挙動が起きます。description の正規化で出所タグを付けた後も、本文の差異までは整わないからです。私は週次で forward マップの description をダンプして、人間が目視で違和感を拾うレビューを Cowork のスケジュールタスクに組み込みました。AdMob 月次レポートと同じ枠で動かしています。
第 3 は schema 衝突 です。同名のツールでも、input_schema が違うことがあります(例: GitHub の create_issue は repo を必須、Linear は team_id を必須)。Reconciler が name しか見ずにマージすると、モデルが片方のスキーマで呼んだときにもう一方のサーバが弾く事故が起きます。プレフィックス付けで物理的に別の name にすれば回避できますが、衝突がない場合でも「同名なのに schema が違う」状態が将来生まれることを想定して、schema の SHA-256 をキーに「同じ name で違う schema があれば強制的にプレフィックスを付ける」セーフティを Reconciler に足しました。
function schemaHash ( t : Tool ) : string {
// 構造の差をハッシュ化(実装は crypto.subtle.digest 等)
return JSON . stringify (t.input_schema, Object. keys (t.input_schema). sort ());
}
3 つの失敗パターンに加えて、運用 3 ヶ月で気づいた小さな罠を 2 つ書き残しておきます。
1 つ目は、ツール description の長さがモデルの選択精度にじわじわ効く点です。[GitHub] Create a new issue のような短いタグ付き description だと、Sonnet 4.6 はサーバ名の前置を信号として弱く扱います。[GitHub] Create a new issue in a repository. Use this for code-related bug reports, feature requests, and pull-request discussions on the GitHub side. のように、機能の境界を 1〜2 文で書き足すと、私の手元では誤呼び出し率がさらに 3% 下がりました。description は短くまとめたくなりますが、衝突する name を持つときに限ってはむしろ厚めにしたほうが効きます。
2 つ目は、エージェントの「最初の turn」だけ誤呼び出し率が突出する現象です。会話履歴がまだ薄い段階では、モデルがプレフィックスを切り捨てて元のツール名で呼ぼうとすることがあります。対策として、私はシステムプロンプトに「ツール名は full name で正確に呼んでください。例: github__create_issue と linear__create_issue は別物です」と一文だけ足しました。これを書くと、初回 turn の誤呼び出し率も他の turn と同じレベルまで落ち着きます。
個人開発の現場から見た運用ノウハウ
私は 1997年、16歳でインターネットに触れてから、独学でプログラミングを続けてきました。当時は世界中のフォーラムに英語で質問を投げて、知らない誰かが返してくれた言葉を頼りに動くプログラムを組んでいました。MCP の世界も、似た感覚があります。誰かが公開してくれたサーバを、自分のエージェントに招き入れて、ツール名の意味を読み解きながら使う。命名規約を共有していない他人の作ったサーバが、自分のサーバとぶつかる前提で設計しておくのは、その世界での礼儀のようなものだと感じています。
実運用では、サーバ ID を「機能ベース」で切るほうが、長く保ちます。私は最初「サイトベース」でサーバ ID を切っていました(claudelab / gemilab / rorklab のように)。しかし複数サイトが同じ AdMob レポートを読み、同じ Slack に通知し、同じ GitHub にコミットする現実を見て、admob / slack / github のような「機能ベース」に揃え直しました。AdMob 関連の MCP は 4 サイトで共有しても、サーバ ID 自体は 1 つで済みます。
Reconciler を入れたあとも、私は週 1 回、forward マップを Slack に投げて目視確認しています。新しい MCP サーバを接続したときに「あ、upload が衝突したな」と気づける状態にしておくのは、エージェントが暴走したあとに原因を探すより、圧倒的に楽です。Cowork のスケジュールタスクで毎週金曜 11:00 に走らせていて、AdMob レポートと同じ枠で目を通します。
もう 1 つ、6 サイト運営者ならではの気づきがあります。それは「MCP サーバを増やすほど、エージェントの 1 回の応答が遅くなる」ということです。Anthropic の API は tools を 60 件渡したケースと 10 件渡したケースで体感のレイテンシが 200ms ほど違いますし、prompt cache の hit rate も「ツール一覧の差分」によって変わります。私は AdMob 集計を毎日 4 サイトぶん回していますが、ツール一覧を機能ベースで束ねたあとは 1 サイトぶんの集計時間が平均 14% 短縮されました。月の総レイテンシで見ると、それなりの差になります。
サーバを増やすことが目的にならないように、自分のエージェントに「いま本当に使うツールはどれか」を定期的に問い直す時間を取っています。AdMob レポートを書くのに GitHub MCP は不要ですし、PR レビューを書くのに Slack MCP は要りません。タスクごとに「束ねる MCP サーバの組」を切り替える設計にすれば、Reconciler の出力ハッシュも安定して、prompt cache のヒット率が上がります。これはコスト面でも体感の応答速度の面でも、効きの強い投資です。
次の一歩
読者のプロジェクトに Reconciler を導入する最初のステップとして、現在束ねている MCP サーバから list_tools を呼び出し、name でグルーピングして衝突を可視化することをお勧めします。たぶん、自分が思っていたより多くの衝突が見つかります。次に、本記事の Reconciler を最小実装で動かして、衝突したペアだけにプレフィックスを付ける挙動を確認してください。description の正規化はその後で十分です。
MCP は今、Anthropic 周辺だけでなく他のベンダーも実装を出し始めている段階で、命名のばらつきはむしろこれから増えていきます。Reconciler を一枚挟んでおくと、サーバが増えるたびに本体コードを直さなくて済むので、エージェントの寿命が伸びます。同じ課題に取り組んでいる方の参考になれば幸いです。
実装で詰まった部分は、stand.fm の音声配信 でも時々触れています。よければ覗いてみてください。