コンテンツにスキップ

STUDY NOTES

第19回: MCP (Model Context Protocol) サーバ/クライアントの基礎 — Tool・Prompt・Resource を自作して Cursor から呼ぶ

2024年11月に Anthropic が公開した Model Context Protocol(MCP) の最小実装回。第18回までで作ってきた「LangChain / LangGraph のツール呼び出し」は クライアント側 SDK の中だけで完結する関数呼び出しだったが、MCP は「ツールやデータソースをプロセス外の独立サーバとして立て、LLM ホスト(Cursor、Claude Desktop、Claude Code 等)から JSON-RPC 2.0 over stdio/SSE で呼び出す」標準プロトコル。

比喩: LangChain の @tool は「関数呼び出し」、MCP は「USB-C」。LSP(Language Server Protocol)がエディタとコンパイラを切り離したのと同じ発想で、ホスト × ツール提供者 を疎結合化する。

このサンプルは公式 Python SDK の FastMCP を使い、Tool / Prompt / Resource の3プリミティブを1つずつ実装した最短サーバと、それを stdio で叩く検証クライアントの 2 ファイル構成。


全体像

sd_19/
├── server.py    ← FastMCP("sd-19-mcp") に @tool / @prompt / @resource を3つ登録
└── client.py    ← stdio_client で server.py を subprocess 起動し、3プリミティブを順に検証

クライアント・サーバの通信フロー(stdio トランスポート時):

sequenceDiagram
    participant Host as Host (client.py / Cursor)
    participant Server as MCP Server (server.py)

    Host->>Server: subprocess.Popen("python sd_19/server.py")
    Note over Host,Server: stdin/stdout を JSON-RPC 2.0 の双方向パイプとして使う

    Host->>Server: initialize (capabilities 交換)
    Server-->>Host: serverInfo + capabilities

    Host->>Server: prompts/list
    Server-->>Host: [greet_user]
    Host->>Server: prompts/get(name="greet_user", arguments={user_name:"sd-19"})
    Server-->>Host: [UserMessage, AssistantMessage]

    Host->>Server: resources/list
    Server-->>Host: [file://readme.md]
    Host->>Server: resources/read(uri="file://readme.md")
    Server-->>Host: README.md の中身

    Host->>Server: tools/list
    Server-->>Host: [hello]
    Host->>Server: tools/call(name="hello", arguments={name:"sd-19"})
    Server-->>Host: "Hello, sd-19!"

ポイントは「通信は1本のプロセス間パイプ」で完結すること。HTTP もポートも要らないので、Cursor や Claude Desktop は「サブプロセスを 1 個起動するだけ」でツールを増やせる。SSE モード(--transport sse)に切り替えれば、ローカルホストでなくリモートサーバとしても動かせる(HTTP + Server-Sent Events)。


MCP の3プリミティブ

MCP 仕様でサーバが提供できる主要な3種類のエンドポイント。それぞれ「LLM がいつ使うか」「誰が制御するか」が違うので、用途が明確に分かれている。

プリミティブ 役割 制御者
Tool LLM が自分で呼ぶ副作用つき関数 モデル(LLM が tool_use する) hello(name)、DB クエリ、API 呼び出し
Prompt ユーザーがスラッシュコマンド的に呼ぶテンプレート ユーザー(UI から選ぶ) /greet_user user_name=… で会話を開始
Resource LLM が読み込む静的データソース アプリ(ホストが文脈として注入) file://readme.md、Git の差分、DB スキーマ

対比: LangChain の @tool しか持っていない世界に、「ユーザーが選ぶプロンプトテンプレート」と「ファイルライクな読み取り専用データ」の概念が加わったのが MCP。ChatGPT の Custom GPT で言うと、actions(Tool) + conversation starters(Prompt) + knowledge(Resource)に近い棲み分け。


使用ライブラリ・原理

mcp[cli] パッケージ

公式 Python SDK。mcp.server.fastmcp.FastMCP が高レベル API で、デコレータベースで Tool/Prompt/Resource を登録できる(低レベル API は mcp.server.Server だが、定型コードが多くなる)。

FastMCP の内部メカニズム
mcp = FastMCP("sd-19-mcp")

@mcp.tool()
def hello(name: str) -> str: ...

これだけで FastMCP は次を自動でやっている:

  1. 関数シグネチャから JSON Schema を生成(型ヒント name: str{"type": "string"}
  2. docstring を description に流用(LLM が「いつこのツールを使うか」を判断する根拠)
  3. tools/list ハンドラに登録(JSON-RPC の method: "tools/list" で返す)
  4. tools/call 時のディスパッチャ作成(引数を Pydantic で validate → 関数呼び出し → 戻り値をシリアライズ)

@mcp.prompt() も同様で、戻り値の List[Message] をそのまま MCP の PromptMessage[] 形式にマップする。

トランスポート: stdio vs SSE
stdio SSE
通信 プロセス間パイプ(stdin/stdout) HTTP + Server-Sent Events
起動 ホストが subprocess を起動 サーバを事前起動、ホストが URL で接続
用途 ローカル統合(Cursor、Claude Desktop) リモート / 共有サービス
デバッグ ログは stderr に流す(stdout は JSON-RPC 専用) 通常の HTTP ログ

注意: stdio モードでは print()logging.StreamHandler(sys.stdout) 禁止。stdout は JSON-RPC のフレーミングに使われているので、混ぜると即パース失敗する。FastMCP はデフォルトで stderr に出すよう設定済み。


ファイル別の役割

ファイル 役割
sd_19/server.py FastMCP("sd-19-mcp") を作って Tool hello / Prompt greet_user / Resource file://readme.md を登録、--transport {stdio,sse} で起動
sd_19/client.py stdio_clientpython sd_19/server.py を subprocess 起動し、initialize → list_* → get_*/call_tool/read_resource の順で動作確認
pyproject.toml 依存は mcp[cli]>=1.2.1 のみ(CLI extras に mcp コマンドや Inspector が入る)
README.md uv セットアップ + Cursor への組み込み設定例

コード解説: server.py の中心ロジック

server.py:11-463プリミティブを宣言する最短コード。デコレータがフレームワーク仕事の99%を吸収している。

mcp = FastMCP("sd-19-mcp")                              # ①

@mcp.tool()                                              # ②
def hello(name: str) -> str:
    """与えられた名前に対して挨拶を返します。"""        # ③
    return f"Hello, {name}!"

@mcp.prompt()                                            # ④
def greet_user(user_name: str) -> List[Message]:
    """ユーザーに挨拶するための定型プロンプトを返します。"""
    return [
        UserMessage(content=f"{user_name}さん、こんにちは。"),   # ⑤
        AssistantMessage(content="どのようにお手伝いできますか?"),
    ]

@mcp.resource("file://readme.md")                        # ⑥
def readme() -> str:
    current_dir = os.path.dirname(os.path.abspath(__file__))
    readme_path = os.path.normpath(os.path.join(current_dir, "..", "README.md"))
    if not os.path.exists(readme_path):
        raise FileNotFoundError(f"README.md not found at: {readme_path}")
    with open(readme_path, "r") as f:
        return f.read()                                  # ⑦
やってること なぜそうする
FastMCP("sd-19-mcp") でサーバインスタンス生成 引数の文字列が initialize レスポンスの serverInfo.name になり、ホスト側で識別子として表示される
@mcp.tool()hello を Tool として登録 デコレータが裏でシグネチャを JSON Schema 化し tools/list に追加。LLM が tool_use で呼べるようになる
docstring を書く この docstring が description として LLM に渡され、「いつ呼ぶべきツールか」の判断材料になる。空欄や曖昧な docstring はそのまま LLM の判断ミスに直結する
@mcp.prompt() で会話テンプレを登録 ユーザーが Cursor/Claude Desktop の UI から「/greet_user」のようにスラッシュコマンドで呼ぶことを想定
UserMessage / AssistantMessage並べた配列を返す MCP の Prompt は単一文字列ではなく 会話履歴(few-shot 例示や役割分担を含められる)Assistant 側の発話を先に仕込めるのが Chat API の messages 配列と同じ思想
@mcp.resource("file://readme.md") で URI 固定の Resource を登録 Resource は URI で識別される静的データ。ここでは file:// スキームで擬似的にファイル扱い。実装はただの Python 関数で OK
read() した中身を文字列で返す Resource の戻り値は MCP の ResourceContents に自動マッピング。バイナリの場合は bytes を返せば base64 化される

server.py:50-69トランスポート切り替え:

def start_server(transport, host=DEFAULT_HOST, port=DEFAULT_PORT):
    if transport == "stdio":
        mcp.run(transport="stdio")                       # ⑧
    elif transport == "sse":
        mcp.run(transport="sse", host=host, port=port)   # ⑨
やってること なぜそうする
mcp.run(transport="stdio") stdin で JSON-RPC リクエストを受け、stdout で応答を返すブロッキングループに入る。Cursor/Claude Desktop はこのプロセスを subprocess として起動する
mcp.run(transport="sse", host=..., port=...) uvicorn を内部で立ち上げて GET /sse で SSE ストリーム、POST /messages で JSON-RPC を受ける Web サーバになる。リモート共有時はこちら

コード解説: client.py の中心ロジック

client.py:7-12 + client.py:15-58stdio で MCP サーバを subprocess 起動して 3 プリミティブを順に叩く

server_params = StdioServerParameters(                   # ①
    command="python",
    args=["sd_19/server.py"],
    env=None,
)

async def run():
    async with stdio_client(server_params) as (read, write):   # ②
        async with ClientSession(read, write) as session:      # ③
            await session.initialize()                          # ④

            prompts = await session.list_prompts()              # ⑤
            prompt = await session.get_prompt(
                "greet_user", arguments={"user_name": "sd-19"}
            )

            resources = await session.list_resources()          # ⑥
            resource = await session.read_resource("file://readme.md")

            tools = await session.list_tools()                  # ⑦
            result = await session.call_tool(
                "hello", arguments={"name": "sd-19"}
            )
やってること なぜそうする
StdioServerParameters で起動コマンドを宣言 python sd_19/server.py を subprocess で立ち上げる。env=None は親プロセスの環境変数を継承
stdio_client(...) のコンテキストマネージャで read/write ストリーム を取得 内部で asyncio.create_subprocess_exec してプロセスを起動し、stdin/stdout を anyio.MemoryObjectStream でラップ
ClientSession(read, write)プロトコル層を被せる フレーミング・JSON-RPC ID 管理・タイムアウトを抽象化。以降は高レベル API で叩ける
session.initialize()ハンドシェイク クライアント・サーバ双方の capabilities(対応している機能セット)を交換。これを呼ばないと他の API は全部エラー
list_prompts()get_prompt(name, arguments) プロンプトテンプレに引数を渡して展開済みメッセージ列を取り出す。実際の LLM 呼び出しはクライアント責務(このサンプルでは投げない)
list_resources()read_resource(uri) URI を指定して中身を読む。file://readme.md のように @mcp.resource(...) で登録した URI そのもの
list_tools()call_tool(name, arguments) サーバ側の hello(name="sd-19") が実行され、戻り値が TextContent でラップされて返る

session.call_tool() の戻り値は CallToolResult 型で、.contentTextContent | ImageContent | ... のリストが入る。文字列だけ欲しい場合は result.content[0].text で取り出す。


学んだこと(要点)

  • MCP = LSP の LLM 版。「ホスト(Cursor/Claude Desktop)× ツール提供者(サーバ)」を JSON-RPC 2.0 で疎結合化する仕様。2024-11 に Anthropic が公開、現在は Cursor / Claude Desktop / Claude Code / Continue 等が対応
  • 3プリミティブの使い分け: Tool(モデルが呼ぶ副作用関数)、Prompt(ユーザーがスラッシュコマンドで呼ぶテンプレ)、Resource(アプリが文脈注入する読み取り専用データ)。LangChain 経験者は「Tool しか知らない」状態になりがちなので意識的に学ぶ価値あり
  • FastMCP のデコレータは「型ヒント → JSON Schema」「docstring → description」を自動化。docstring の質がそのまま LLM の判断精度になる
  • stdio モードでは stdout に print 禁止。JSON-RPC フレーミングが壊れる。デバッグログは stderr へ
  • トランスポートは 2 種類: stdio(ローカル統合・subprocess)と SSE(リモート・HTTP)。コードはほぼ同じで mcp.run(transport=...) の引数だけ変える
  • Cursor への組み込みは「uv --directory ... run sd_19/server.py をコマンドとして登録するだけ」。Cursor 側が subprocess として起動してくれる
  • MCP Inspectoruv run mcp dev sd_19/server.py で起動可能)を使うと、ブラウザ UI でサーバの Tool/Prompt/Resource を対話的に叩ける。新規 MCP サーバ開発のデバッグに必須レベル

拡張アイデア

  1. ファイルシステム MCP サーバを作る: @mcp.tool()list_directory(path), read_file(path), write_file(path, content) を定義し、Cursor から「~/myproject の構造を見せて」と頼めるようにする(Anthropic 公式の filesystem MCP サーバの簡易版)
  2. Resource を動的化: 単一の file://readme.md ではなく @mcp.resource("file://{path}") のように URI テンプレートを使い、任意ファイルを読めるようにする(list_resource_templates で公開される)
  3. Zotero MCP との連携: 既に ~/mcp-servers/zotero/ に自作 MCP サーバがあるので、同じ FastMCP パターンで「文献検索」「ノート追加」ツールを増やす
  4. SSE モードで動かして Claude Code から接続: python -m sd_19.server --transport sse --port 8080 で起動 → Claude Code 側の MCP 設定に http://localhost:8080/sse を登録
  5. Sampling 機能を試す: MCP には「サーバが LLM 推論をホスト側にお願いする」逆方向の Sampling API もある。hello ツールの中で「クライアント側 LLM に名前の言語を判定してもらってから挨拶を変える」ような実装にすると、双方向通信の感覚が掴める
  6. Progress Notification: 長時間実行ツールで ctx.report_progress(progress, total) を使い、Cursor の UI に進捗バーを出す

現代版に移植するなら

  • コードはほぼ現代版(2025年5月時点)でそのまま動くmcp[cli]>=1.2.1 は十分新しく、FastMCP の API も安定している
  • 強いて挙げるなら:
  • mcp.server.fastmcp.prompts.base.Message のインポートパスは将来変わる可能性あり。SDK のメジャーバージョン更新時は requirements を確認
  • mcp dev コマンド(MCP Inspector) を README に追記する価値あり。uv run mcp dev sd_19/server.py でブラウザ UI が立ち上がり、client.py を書かなくても全プリミティブを叩ける
  • Streamable HTTP トランスポート(2025年に追加された SSE の後継)への移行: 仕様上は mcp.run(transport="streamable-http") で切り替え可能。SSE は段階的に deprecated 化される見込み

既知の不具合・注意点

  • client.pyargs=["sd_19/server.py"]カレントディレクトリ依存19/ ディレクトリで uv run python -m sd_19.client として実行する前提なので、別ディレクトリから起動するとサーバ起動に失敗する
  • server.pyreadme()__file__ 基準でパス解決しているので、シンボリックリンク経由で起動するとリンク先の .. を見にいく。実運用では pathlib.Path(__file__).resolve() を使うとより堅牢

記事参照

  • Software Design 2025年4月号 連載第19回「Model Context Protocol で LLM とツールを疎結合化する」(推定)
  • MCP 公式仕様: https://modelcontextprotocol.io/
  • Python SDK: https://github.com/modelcontextprotocol/python-sdk
  • 関連: 自作 Zotero MCP サーバ (~/mcp-servers/zotero/zotero_mcp_server.py) — 同じ FastMCP パターンで実装

作成: 2026-05-25 / 最終更新: 2026-06-10