AI エージェント開発実践 #6 MCP サーバーを自作する

読了 5分

LLM アプリ開発 第11回で MCP に初めて出会いました。あのときは、既存の MCP サーバーに Claude を接続する側でした。今回は反対側に立ちます。独自のツールを MCP サーバーとして自作し、私たちのエージェントだけでなく、Claude Desktop や Claude Code のような他のクライアントでも同じツールを使えるようにします。

いつサーバーに分離するのか #

これまでツールは、エージェントコードの中の Python 関数でした。この方式は単純で速いのですが、ツールがそのエージェントに閉じ込められます。同じツールを別のエージェントや別のツールで使うには、コードをコピーするしかありません。

MCP サーバーに分離すれば、ツールは独立したプロセスになり、MCP に対応するどのクライアントでも標準の方式で接続します。社内の注文システムを照会するツールを一度作れば、私たちのエージェントも、チームメンバーの Claude Desktop も Claude Code も、すべて同じサーバーを使う構図が可能になります。逆に、一つのエージェントしか使わないツールなら分離する理由はありません。関数で十分です。

FastMCP でサーバーを作る #

Python の MCP SDK に含まれる FastMCP を使うと、サーバーは関数数個ほどの規模に収まります。まずインストールします。

terminal
pip install "mcp[cli]"

第11回まで使っていた注文ツール二つをサーバーに移してみます。

order_server.py
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 に変わります。

agent_with_mcp.py
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 実践プロジェクト:イシュートリアージエージェント」では、堅牢なループ、ツール設計、ヒューマン承認、評価までまとめて、動くエージェントを一つ完成させてシリーズを締めくくります。

X