3個目のツールを足した時に、設計が崩れた
先月、ある物流会社の案件で社内エージェントを組んでいた。前回書いた tool-use の設計(読むだけの LLM から「操作する」LLM へ)に沿って、在庫 DB を叩く関数、配送 API を叩く関数、Slack に通知を投げる関数、と順番に足していた。2 個目までは順調だった。3 個目を足した金曜の夕方、コードが一気にほつれ始めた。
問題は関数の数ではない。認証情報の持ち方が 3 個とも違い、エラー時のリトライ方針も違い、schema 定義が JSON Schema と Pydantic モデルで二重管理になり、しかも同じ在庫クエリを別の呼び出し経路からも投げたい、という要件が加わった。要は、tool の抽象化レイヤーが「LLM から見える形」と「実行系の都合」で癒着していた。ここで 30 分くらい詰まって、いったん手を止めて設計をやり直した。答えは Model Context Protocol (MCP) だった。

MCP とは何で、何ではないか
MCP は Anthropic が 2024 年末に公開したプロトコルで、2026 年 3 月時点で月間 SDK ダウンロード 9700 万件、GitHub star 8.1 万を超えている。主要 AI ベンダー(Anthropic / OpenAI / Google / Microsoft / AWS)がすべて対応済みで、Gartner は 2026 年末までに API gateway ベンダの 75% が MCP 機能を提供すると予測している。数字だけ見ると流行りの技術に見えるが、実装してみると印象が変わる。地味だ。しかしその地味さが良い。
誤解しやすいのだが、MCP はオーケストレーションエンジンではない。プロトコルレイヤーだけを担当する。「どの tool を、どの順で、どんな引数で呼ぶか」を決めるのは相変わらず LLM か、planner か、LangGraph などのエージェントフレームワークだ。MCP がやるのは「その tool 呼び出しを、統一されたスキーマと transport で運ぶ」ことに絞られている。
ここが個人的に一番刺さった部分で、私は最初 MCP を「エージェント OS」みたいなものだと勘違いしていた。実際は逆で、OS になろうとしないから広く受け入れられた、という理解が近い。役割分担を書くとこうなる。MCP クライアント(=LLM 駆動の agent 側)が構造化された tool invocation を発行し、MCP サーバが独立プロセスとして受けて、外部システムへの唯一の実行エンドポイントとして機能する。任意の API を持つシステムは、この MCP サーバとして wrap すれば、agent から見て tool として扱える。
2026 年 3 月改定の意味
技術者にとって重要なのは、2026 年 3 月に出た仕様改定(2026-07-28 リリース候補)で core が stateless になったことだ。以前は sticky session と共有セッションストアが前提で、ロードバランサ配下に置くのが面倒だった。今は普通の HTTP LB でスケールする。Mcp-Method ヘッダで routing でき、tools/list 応答はクライアントキャッシュ可能になった。実運用にやっと乗ったな、というのが正直な感想だ。
最小の MCP サーバを書いてみる
案件の在庫 DB を叩く tool を MCP サーバとして書き直す。Python FastMCP を使うと 30 行くらいで動くものが出来る。
import os
import asyncpg
from mcp.server.fastmcp import FastMCP
認証情報は runtime injection のみ。ソース管理には置かない
ハマりポイント: Docker image に埋め込むと leak 時に即漏洩。
開発 / staging / 本番でシークレットは必ず分離すること
DB_DSN = os.environ["WAREHOUSE_DB_DSN"]
mcp = FastMCP("warehouse-tools")
_pool: asyncpg.Pool | None = None
async def _get_pool() -> asyncpg.Pool:
global _pool
if _pool is None:
_pool = await asyncpg.create_pool(DB_DSN, min_size=1, max_size=8)
return _pool
@mcp.tool()
async def lookup_stock(sku: str) -> dict:
"""指定 SKU の在庫数と保管拠点を返す。"""
pool = await _get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT sku, on_hand, location FROM stock WHERE sku = $1",
sku,
)
if row is None:
return {"sku": sku, "found": False}
return {"sku": row["sku"], "on_hand": row["on_hand"], "location": row["location"], "found": True}
if name == "main":
# 本番では streamable-http、ローカル開発では stdio で試すのが早い
mcp.run(transport="streamable-http")
ポイントは 2 つある。1 つは、tool の docstring と型ヒントがそのまま LLM 向けのスキーマになる点。前回の tool-use で JSON Schema を手書きしていた煩わしさが消える。もう 1 つは、DSN を os.environ から取っていること。ここは絶対に譲れない。
クライアント側 — Claude から呼ぶ
クライアント側は SDK が discovery を自動でやってくれるので、こちらは短い。
import os
import anthropic
from anthropic.mcp import MCPServerStreamableHTTP
client = anthropic.Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
複数の MCP サーバを束ねる — ここが今回の主題
servers = [
MCPServerStreamableHTTP(url="https://mcp-warehouse.internal/mcp"),
MCPServerStreamableHTTP(url="https://mcp-shipping.internal/mcp"),
MCPServerStreamableHTTP(url="https://mcp-slack.internal/mcp"),
]
resp = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
mcp_servers=servers,
messages=[{
"role": "user",
"content": "SKU A-2201 の在庫を確認し、東京拠点で 100 個以上あれば北関東の配送センターに 50 個移送を予約して、完了を #logi に通知して",
}],
)
print(resp.content)
3 つの MCP サーバを渡しているだけで、agent から見ると 1 つのツール群として扱われる。tools/list はクライアント側でキャッシュされ、Mcp-Method ヘッダでサーバごとに routing される。ここが 2026 年改定の恩恵で、以前は自前で proxy を挟まないとこの構成が組めなかった。
ツール発見(discovery)と横断オーケストレーション
複数サーバをつないだ瞬間に発生する問題が 2 つある。名前空間の衝突と、状態管理だ。
名前空間はサーバ名で prefix される。warehouse.lookup_stock、shipping.book_transfer、slack.post_message という具合。ここは深く考えなくてよい。厄介なのは状態管理の方だ。上のプロンプトは 3 つのシステムを横断する複合トランザクションで、途中で失敗した時にどこまで巻き戻すか、が仕様として抜けやすい。
私の場合、この案件では途中まで「LLM に判断させればいい」と甘えていた。結果、実運用の 2 日目に、在庫は予約したが配送予約が失敗し、通知だけ「完了しました」で飛ぶ、という事故を起こしかけた(幸いステージング環境だった)。ここで学んだのは、複合オペレーションは MCP の外側 — planner か LangGraph 側 — に compensation を明示的に書くべき、ということ。MCP は運搬レイヤーであって、分散トランザクション調停器ではない。厳密には SAGA パターンの実装まで手を伸ばすことになるが、初手はもっと単純で、失敗した時に「取り消し用の tool」を LLM に呼ばせるだけでも大分マシになる。
認証・シークレット分離 — ここで手を抜くと事故る
2025 年 6 月以降の MCP 仕様で、MCP サーバは OAuth 2.1 Resource Server に分類されている。.well-known エンドポイント経由で Protected Resource Metadata を公開し、クライアントは自動 discovery する。MCP サーバは「自身向けに明示的に発行されたトークンのみを受け入れる」ことが MUST として書かれている。ここは仕様書を一度読んでおくと良い。
実運用で一番効いたのは、単一の高権限クレデンシャルを複数 MCP サーバで使い回さない、という原則。当たり前に聞こえるが、社内案件だと「開発が早いから」と共通の service account を使い回すことは今でも珍しくない。一箇所の compromise がシステム全体の incident に直結するので、面倒でも tool 単位で分ける。HashiCorp Vault や AWS Secrets Manager のような時限クレデンシャル発行が入れられる環境なら、それに寄せるのが安全だ。重要なのは、LLM は決してシークレット文字列を見ない、という設計にすること。tool の引数に token を渡す作りにしてはいけない。
まとめと次回
MCP を挟むと、tool の追加が「関数を書く」ではなく「サーバを 1 本立てる」に変わる。最初は大袈裟に感じるが、3 個目のツールを超えたあたりから、この分離が効いてくる。認証もスキーマも監査ログもサーバ側に閉じ込められるので、agent 本体のコードがようやく「意思決定ロジック」だけを扱えるようになる。
ここまで来ると、次に問題になるのは「本番で暴走させない」話だ。ツールが増えるほど、LLM が予期しない順で叩いたり、想定外の引数を渡したりする余地が広がる。次回は承認フロー・監査ログ・human-in-the-loop の設計 — つまり、動くエージェントを止められるようにする話 — を書く予定。GitHub の sample repo も次回に合わせて公開する。
筆者は 5years+ で 韓国・日本の中小企業向けに AI エージェント案件を担当している。