AI エージェント開発実践 #6 MCP サーバーを自作する
LLM アプリ開発 第11回で MCP に初めて出会いました。あのときは、既存の MCP サーバーに Claude を接続する側でした。今回は反対側に立ちます。独自のツールを MCP サーバーとして自作し、私たちのエージェントだけでなく、Claude Desktop や Claude Code のような他のクライアントでも同じツールを使えるようにします。
いつサーバーに分離するのか #
これまでツールは、エージェントコードの中の Python 関数でした。この方式は単純で速いのですが、ツールがそのエージェントに閉じ込められます。同じツールを別のエージェントや別のツールで使うには、コードをコピーするしかありません。
MCP サーバーに分離すれば、ツールは独立したプロセスになり、MCP に対応するどのクライアントでも標準の方式で接続します。社内の注文システムを照会するツールを一度作れば、私たちのエージェントも、チームメンバーの Claude Desktop も Claude Code も、すべて同じサーバーを使う構図が可能になります。逆に、一つのエージェントしか使わないツールなら分離する理由はありません。関数で十分です。
FastMCP でサーバーを作る #
Python の MCP SDK に含まれる FastMCP を使うと、サーバーは関数数個ほどの規模に収まります。まずインストールします。
pip install "mcp[cli]"第11回まで使っていた注文ツール二つをサーバーに移してみます。
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("order-tools")
@mcp.tool()
def search_orders(query: str) -> str:
"""注文番号、顧客名、日付で注文を検索する。
ユーザーが特定の注文の状態や内訳を尋ねたら、まずこのツールで注文を探す。"""
results = do_search(query) # 実際の実装
return format_results(results)
@mcp.tool()
def get_order_detail(order_id: str) -> str:
"""注文番号で注文の詳細を照会する。例: ORD-2026-0001"""
return fetch_detail(order_id)
if __name__ == "__main__":
mcp.run() # デフォルトは stdio 方式で実行される@mcp.tool() デコレーターが、関数のシグネチャと docstring からツール名、説明、入力スキーマを作ってくれます。第2回のツール設計の原則がここでもそのまま適用されます。docstring がそのまま description になるので、いつ使うのかまで書きます。
mcp.run() のデフォルトの動作は stdio です。クライアントがこのスクリプトを子プロセスとして起動し、標準入出力で対話する方式なので、ローカルのツールサーバーに向いています。
私たちのエージェントループに接続する #
今度はエージェント側からこのサーバーに接続します。MCP クライアントでサーバーを起動し、サーバーが教えてくれるツールの一覧を Claude の tools 形式に変換し、Claude がツールを呼んだらサーバーに渡します。MCP クライアントは async ベースなので、ループも async に変わります。
import asyncio
import anthropic
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
client = anthropic.AsyncAnthropic()
async def run_agent(goal: str, max_steps: int = 20) -> str:
server = StdioServerParameters(command="python", args=["order_server.py"])
async with stdio_client(server) as (read, write):
async with ClientSession(read, write) as mcp_session:
await mcp_session.initialize()
# 1. サーバーのツール一覧を Claude の tools 形式に変換する
listed = await mcp_session.list_tools()
tools = [
{
"name": t.name,
"description": t.description,
"input_schema": t.inputSchema,
}
for t in listed.tools
]
# 2. ループは第1回と同じ。ツールの実行だけ MCP 呼び出しに変わる。
messages = [{"role": "user", "content": goal}]
for _ in range(max_steps):
response = await client.messages.create(
model="claude-opus-4-8",
max_tokens=16000,
tools=tools,
messages=messages,
)
if response.stop_reason == "end_turn":
return next(b.text for b in response.content if b.type == "text")
messages.append({"role": "assistant", "content": response.content})
tool_results = []
for block in response.content:
if block.type == "tool_use":
result = await mcp_session.call_tool(block.name, block.input)
text = "\n".join(c.text for c in result.content if c.type == "text")
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": text,
"is_error": bool(result.isError),
})
messages.append({"role": "user", "content": tool_results})
raise RuntimeError("ステップ上限内に作業を終えられませんでした。")
print(asyncio.run(run_agent("ORD-2026-0001 の注文状態を教えてください。")))ループの骨組みは第1回と同じです。変わったのは二か所だけです。ツールの一覧をコードにハードコードする代わりにサーバーから受け取り、ツールの実行が Python の関数呼び出しの代わりに call_tool になりました。サーバー側のツールを追加したり直したりしてもエージェントのコードはそのまま、というのがこの構造の力です。
他のクライアントでも同じサーバーを #
同じサーバーを Claude Desktop や Claude Code に登録すれば、コードを一行も追加せずに、そちらでも注文ツールを使えます。登録方法はクライアントごとに設定ファイルへサーバーの実行コマンドを書く形で、第11回で見た接続の流れそのままです。ツールを一度作ればすべてのクライアントが共有するという MCP の約束が、この地点で実現します。
よくつまずくところ #
- docstring を適当に書く — FastMCP では docstring がそのままツールの description になります。空だったり一語だけだったりすると、Claude がツールをいつ使うか判断する根拠がありません。
- isError を確認しない —
call_toolの結果にはエラーかどうかが入っています。これを無視して本文だけ渡すと、第1回で作ったエラー回復の回路が MCP ツールでは動かなくなります。 - すべてのツールをサーバーにする — 分離にはプロセス管理というコストが伴います。複数のクライアントが共有するツールだけサーバーにし、一つのエージェント専用のツールは関数のままにします。
まとめ #
今回は、ツールを MCP サーバーに分離しました。
- FastMCP を使えば、関数にデコレーターを付けるだけでツールサーバーになります。docstring がそのままツールの説明です。
- エージェントループはそのままにして、ツール一覧の受け取りとツールの実行だけ MCP 呼び出しに変えれば接続は完了です。
- 分離の価値は再利用です。複数のクライアントが使うツールだけサーバーにします。
これでシリーズで扱った部品がすべてそろいました。次回の「AI エージェント開発実践 #7 実践プロジェクト:イシュートリアージエージェント」では、堅牢なループ、ツール設計、ヒューマン承認、評価までまとめて、動くエージェントを一つ完成させてシリーズを締めくくります。