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 の内部メカニズム¶
これだけで FastMCP は次を自動でやっている:
- 関数シグネチャから JSON Schema を生成(型ヒント
name: str→{"type": "string"}) - docstring を
descriptionに流用(LLM が「いつこのツールを使うか」を判断する根拠) tools/listハンドラに登録(JSON-RPC のmethod: "tools/list"で返す)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_client で python 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-46 — 3プリミティブを宣言する最短コード。デコレータがフレームワーク仕事の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-58 — stdio で 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 型で、.content に TextContent | 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 Inspector(
uv run mcp dev sd_19/server.pyで起動可能)を使うと、ブラウザ UI でサーバの Tool/Prompt/Resource を対話的に叩ける。新規 MCP サーバ開発のデバッグに必須レベル
拡張アイデア¶
- ファイルシステム MCP サーバを作る:
@mcp.tool()でlist_directory(path),read_file(path),write_file(path, content)を定義し、Cursor から「~/myproject の構造を見せて」と頼めるようにする(Anthropic 公式のfilesystemMCP サーバの簡易版) - Resource を動的化: 単一の
file://readme.mdではなく@mcp.resource("file://{path}")のように URI テンプレートを使い、任意ファイルを読めるようにする(list_resource_templatesで公開される) - Zotero MCP との連携: 既に
~/mcp-servers/zotero/に自作 MCP サーバがあるので、同じ FastMCP パターンで「文献検索」「ノート追加」ツールを増やす - SSE モードで動かして Claude Code から接続:
python -m sd_19.server --transport sse --port 8080で起動 → Claude Code 側の MCP 設定にhttp://localhost:8080/sseを登録 - Sampling 機能を試す: MCP には「サーバが LLM 推論をホスト側にお願いする」逆方向の Sampling API もある。
helloツールの中で「クライアント側 LLM に名前の言語を判定してもらってから挨拶を変える」ような実装にすると、双方向通信の感覚が掴める - 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.pyのargs=["sd_19/server.py"]はカレントディレクトリ依存。19/ディレクトリでuv run python -m sd_19.clientとして実行する前提なので、別ディレクトリから起動するとサーバ起動に失敗するserver.pyのreadme()も__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