はじめに / 対象と前提
Claude Code や自作の AI エージェントで MCP(Model Context Protocol)サーバーを何個も同時接続していると、ある日突然「さっきまで使えてたツールが呼べない」という現象にぶつかることがある。
この記事は以下を前提にしている。
- Claude Code や、MCP 対応の AI エージェントを普段使っている
- Notion / GitHub / ブラウザ操作(Playwright)など、複数の MCP サーバーを同時に繋いでいる、または繋ごうとしている
- 「ツールの数が増えるとコンテキストを圧迫する」問題に一度でも遭遇したことがある、または今後遭遇しそう
検証環境:Claude Code(2026年7月時点の最新版)、Python 3.13。
TL;DR
- MCP サーバーを複数繋ぐと、ツール定義(JSON Schema)の合計サイズがシステムプロンプトを圧迫し、コンテキストを無駄に食う
- 解決策は「名前だけ先出し→使う直前に検索してスキーマを解決する」遅延ロード方式
- 未ロードのツールをいきなり呼ぶと
InputValidationError系のエラーで落ちる。検索してから呼ぶ、の二段階を徹底すれば防げる - 自作エージェントに同じ仕組みを入れる場合、キャッシュと検索クエリの粒度設計でハマりやすい
手順 / 動かし方
症状の再現
MCP サーバーを 5〜6 個(Notion、Playwright、Figma、Gmail、Calendar、Drive など)同時接続した状態で、ツール一覧を見ると次のような表示になる。
The following deferred tools are now available via ToolSearch.
Their schemas are NOT loaded — calling them directly will fail with InputValidationError.
Use ToolSearch with query "select:<name>[,<name>...]" to load tool schemas before calling them:
mcp__notion__API-query-data-source
mcp__notion__API-post-page
mcp__playwright__browser_click
...(以下100個以上)
ここでツール名だけを見て、スキーマを取得せずにいきなり呼び出すと失敗する。
InputValidationError: tool "mcp__notion__API-query-data-source" schema not loaded
これは「壊れている」のではなく仕様で、ツール名の一覧(数百バイト)とフルスキーマ(1個あたり数百〜数千トークン)を分離することでシステムプロンプトの肥大化を防いでいる。MCP サーバーを 5 個も繋げばツール数は優に 100 を超え、全部フルスキーマで渡すと数万トークン単位でコンテキストを消費する。
解決策:検索してから呼ぶ、の二段階
Claude Code 側の作法はシンプルで、まず検索ツールでスキーマをロードしてから本体を呼ぶ。
1. ToolSearch(query="select:mcp__notion__API-query-data-source,mcp__notion__API-post-page")
→ 該当ツールのフルスキーマが会話に展開される
2. mcp__notion__API-query-data-source(...) を実際に呼ぶ
自作のエージェント基盤で同じパターンを実装するなら、Python で書くとこういう骨格になる。
class LazyToolRegistry:
def __init__(self, all_tools: dict[str, dict]):
# all_tools: {tool_name: full_json_schema}
self._all_tools = all_tools
self._loaded: dict[str, dict] = {}
def stub_list(self) -> list[str]:
"""LLM に渡すのは名前だけ。トークンをほぼ食わない。"""
return list(self._all_tools.keys())
def search(self, query: str) -> dict:
"""名前 or キーワードにマッチしたツールのフルスキーマをロード"""
hits = {
name: schema
for name, schema in self._all_tools.items()
if query.lower() in name.lower()
}
self._loaded.update(hits)
return hits
def call(self, tool_name: str, **kwargs):
if tool_name not in self._loaded:
raise ValueError(
f"tool '{tool_name}' schema not loaded. "
f"call search() first."
)
# 実際のツール呼び出し処理
return self._dispatch(tool_name, kwargs)
stub_list() はツール名だけを返すのでコンテキストにはほぼ影響しない。search() を挟んでからでないと call() が例外を吐く、という制約を意図的に入れることで「検索せずに呼ぶ」経路を潰している。
実行結果
100 個超のツールを持つ構成で試すと、全ツールをフルスキーマで渡した場合と、名前だけ+遅延ロードにした場合とで、初期プロンプトのトークン数がおおよそ 1/10〜1/20 まで下がる(ツールの複雑さによる)。自分の環境でも MCP サーバー 6 個構成でこの方式に切り替えてから、セッション開始直後のコンテキスト消費が体感で大きく減った。
ハマりどころ
1. 検索クエリが曖昧だと目的のツールにたどり着けない
search("notion") のような緩いクエリだと Notion 関連ツールが 20 個ヒットして、結局フルスキーマを大量にロードすることになり本末転倒になる。select:<正確なツール名> のような完全一致検索を優先し、キーワード検索は「ツール名がわからないとき専用」に絞ると無駄なロードを避けられる。
2. キャッシュしないと毎回検索コストがかかる
セッション内で同じツールを何度も呼ぶ場合、search() を毎回実行すると往復コストが積み上がる。_loaded に一度入れたツールは検索し直さない、というキャッシュを入れないと、意味なく検索ステップが増えていく。
3. OAuth 認証が必要な MCP サーバーは遅延ロードでは解決しない
非対話(headless)環境で動かしていると、認証待ちの MCP サーバーのツールは遅延ロードしても永遠に呼べない。
The following MCP servers require authentication before their tools can be used:
<server name>
This session is non-interactive, so the OAuth flow cannot run here.
これは遅延ロードのバグではなく別問題なので、「ツールが呼べない」エラーを見たら、まず InputValidationError(未ロード)なのか認証エラーなのかを切り分けるのが最初の一手になる。
背景・補足
LLM のシステムプロンプトはツール定義込みで毎リクエスト送信されるコストのかかる領域だ。ツール数が少ないうちは気にならないが、実用的な MCP サーバーを組み合わせ始めるとあっという間に数十〜数百ツールになり、フルスキーマを毎回全部載せる設計では本題に使えるコンテキストが目に見えて減っていく。名前だけ先出し+オンデマンド解決は、ツール数のスケールに対してシステムプロンプトのサイズをほぼ一定に保つための実装パターンとして理にかなっている。
まとめ
- MCP サーバーを複数繋ぐとツール定義の合計サイズがコンテキストを圧迫する
- 「名前だけ先出し→使用時に検索してスキーマロード」の二段階にすると解決する
- 未ロードのツールをいきなり呼ぶと
InputValidationErrorになる。検索を挟むのを徹底する - 検索クエリは完全一致優先+ロード済みツールはキャッシュする
- OAuth 待ちのツールは遅延ロードとは別問題として切り分ける