0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

MCP で社内ツール群を束ねる — 複数システム横断エージェントの実装メモ

0
Posted at

3個目のツールを足した時に、設計が崩れた

先月、ある物流会社の案件で社内エージェントを組んでいた。前回書いた tool-use の設計(読むだけの LLM から「操作する」LLM へ)に沿って、在庫 DB を叩く関数、配送 API を叩く関数、Slack に通知を投げる関数、と順番に足していた。2 個目までは順調だった。3 個目を足した金曜の夕方、コードが一気にほつれ始めた。

問題は関数の数ではない。認証情報の持ち方が 3 個とも違い、エラー時のリトライ方針も違い、schema 定義が JSON Schema と Pydantic モデルで二重管理になり、しかも同じ在庫クエリを別の呼び出し経路からも投げたい、という要件が加わった。要は、tool の抽象化レイヤーが「LLM から見える形」と「実行系の都合」で癒着していた。ここで 30 分くらい詰まって、いったん手を止めて設計をやり直した。答えは Model Context Protocol (MCP) だった。

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_stockshipping.book_transferslack.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 エージェント案件を担当している。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?