自作 MCP サーバーに「Tools」だけ生やして満足していたが、Claude Code 側から @ でファイルのようにデータを参照させたくなり Resources を実装した。ドキュメント通りに書いたはずが「一覧に出ない」「中身が文字化けする」で数時間溶かしたので、動かし方とハマりどころを残す。
対象と前提
- 想定読者: MCP サーバーを1つ以上自作したことがあり、Tools は動いている人
- 前提知識: Python の基本、stdio 経由の MCP サーバー接続
- 環境: Python 3.13 / MCP Python SDK 1.x / Claude Code v2 系(2026年8月時点)
TL;DR
- Resources は「LLM が読むための読み取り専用データ」。副作用のある処理は Tool、参照させたいデータは Resource と割り切る
-
@mcp.resource("scheme://path")で生やせるが、引数付き(URI テンプレート)は一覧 API が別なので「出てこない」と勘違いしやすい - 返り値の mimeType とテキスト/バイナリの対応を間違えると静かに壊れる
- 更新通知(
notifications/resources/updated)は購読されていないと届かない。ポーリング前提で設計する
最小実装
FastMCP を使うと数行で生える。
# server.py
import json
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("inventory")
@mcp.resource("config://runtime", mime_type="application/json")
def runtime_config() -> str:
"""現在のランタイム設定を返す(静的リソース)"""
return json.dumps({"region": "ap-northeast-1", "max_workers": 4}, ensure_ascii=False)
@mcp.resource("report://daily/{date}", mime_type="text/markdown")
def daily_report(date: str) -> str:
"""日付を指定して日次レポートを返す(URI テンプレート)"""
# date は "2026-08-18" 形式で渡ってくる
return f"# {date} のレポート\n\n- 処理件数: 128\n- 失敗: 0\n"
if __name__ == "__main__":
mcp.run()
登録と動作確認:
claude mcp add inventory -- python3 /path/to/server.py
claude mcp list # ✓ Connected を確認
Claude Code のセッション内で /mcp を開き、対象サーバーの「Resources」を選ぶと一覧が見える。プロンプト内では @inventory:config://runtime のように参照できる。
claude を起動せずに素の JSON-RPC で叩くなら:
echo '{"jsonrpc":"2.0","id":1,"method":"resources/list","params":{}}' | python3 server.py
resources 配列に config://runtime が入っていれば成功。
ハマりどころ1: URI テンプレートが resources/list に出ない
report://daily/{date} を実装したのに一覧に現れず、実装ミスを疑って1時間潰した。原因は仕様どおりの挙動で、引数を持つリソースは別メソッドで返る。
resources/list → 静的リソースのみ
resources/templates/list → {param} を含む URI テンプレート
クライアント側が resources/templates/list を叩いていなければ、当然どこにも出ない。回避策は2つ。
- よく使う値は静的リソースとしても登録しておく(例:
report://daily/latest) - 「日付を指定して取りたい」ような動的取得は、素直に Tool として公開する
Resource は「一覧から選んで読ませる」もの、Tool は「引数を組み立てて呼ばせる」もの、と役割で切ったほうが結局ハマらない。
ハマりどころ2: mimeType とテキスト/バイナリの不一致
MCP のリソース内容は text か blob(base64)のどちらかで返る。ここで JSON を返すつもりで dict をそのまま返すと、SDK が str 化してシングルクォート混じりの Python 表現が送られ、受け側のパースが静かに失敗する。
# NG: dict をそのまま返す → "{'region': 'ap-northeast-1'}" になりうる
@mcp.resource("config://ng")
def ng() -> dict:
return {"region": "ap-northeast-1"}
# OK: 明示的に JSON 文字列へ
@mcp.resource("config://ok", mime_type="application/json")
def ok() -> str:
return json.dumps({"region": "ap-northeast-1"}, ensure_ascii=False)
画像や PDF を返す場合は bytes を返し mime_type="image/png" などを指定する。SDK が base64 化して blob に載せてくれる。text に base64 文字列を自前で詰めないこと(二重エンコードになる)。
確認は resources/read のレスポンスを見るのが早い。
echo '{"jsonrpc":"2.0","id":1,"method":"resources/read","params":{"uri":"config://ok"}}' \
| python3 server.py | python3 -m json.tool
contents[0] に text が入っているか blob が入っているかで判別できる。
ハマりどころ3: 更新通知は「届かない前提」で書く
リソースが変わったことを知らせる notifications/resources/updated は、クライアントが resources/subscribe を送っていて、かつ通知に対応している場合のみ意味を持つ。未対応のクライアントに通知を投げても何も起きず、サーバー側だけが「更新は伝えた」つもりになる。
実運用では次のどちらかに寄せた。
- 内容にタイムスタンプを埋め、読んだ側が鮮度を判断できるようにする
- 「最新を取り直す」動作を Tool として用意し、必要なときに明示的に呼ばせる
@mcp.resource("status://current", mime_type="application/json")
def status() -> str:
return json.dumps({
"generated_at": "2026-08-18T08:00:00+09:00", # 実際は現在時刻を入れる
"healthy": True,
}, ensure_ascii=False)
読み手(LLM)は「いつ時点のデータか」が本文に書いてあれば古さを自分で判断できる。通知機構に頼るより堅い。
背景: なぜ Tool ではなく Resource なのか
Tool は「モデルが自分で呼ぶ関数」、Resource は「人間かアプリが選んで文脈に載せるデータ」という設計思想の違いがある。Resource にしておくと毎ターン勝手に呼ばれてトークンを食うことがなく、参照したいときだけ @ で差し込める。設定ファイル・スキーマ定義・仕様書のような「参照はするが更新はしない」データとは相性がいい。
まとめ
- Resources は読み取り専用データの提供口。
@mcp.resource()で数行 - URI テンプレートは
resources/templates/list側。一覧に出なくてもバグではない - JSON は必ず
json.dumpsしてmime_typeを明示。バイナリはbytesを返して SDK に任せる - 更新通知は届かない前提。本文にタイムスタンプを埋めるほうが確実
- 迷ったら「一覧から選ばせたい → Resource / モデルに判断させたい → Tool」で切り分ける