保存したファイルをLLMとやりとりする。MCPサーバーを三通りで作ってみた
実験ページとZIP
この記事で扱う三種類のMCPサーバー(FastMCP版・手書き版・汎用ファイル版)は、実験用ページからそのまま試せます。ZIPでまとめたダウンロードもあります。
実験ページを開く
この記事の前提: AI(LLM)に「このフォルダの中身を教えて」「このファイルを読んで」と任せたかった。そのために、MCP(Model Context Protocol)という「AIとツールを標準でつなぐ仕様」のサーバーを、自分で三パターン用意した話です。スマホで読めるよう、段落は短めにしています。
「このフォルダのファイル一覧を出して」「このJSONのこのキーの値を教えて」——そういうのを、いちいち自分で開かずにAIに任せたいと思ったことがある人は多いと思います。かといって、AIに好きなパスを渡すのは危ない。そこで、見せていいフォルダを一つに決めて、その中だけを読める小さなサーバーを作り、AI(Cursor など)からそのサーバー経由でファイルに触れさせる形にしました。その仕組みが MCP(モデル・コンテキスト・プロトコル)です。
MCP は、Anthropic などが提唱している「AIクライアントとツール・データを、標準プロトコルでつなぐ」仕様です。クライアント(Cursor や他のエディタ)が、標準入出力(stdio) でサーバープロセスとやりとりし、JSON-RPC 2.0 で「どんなツールがあるか」「このツールをこういう引数で実行して」を送受信します。サーバー側は「ツール一覧」と「ツール実行」に答えるだけ。中身は自分で実装するので、「このフォルダだけ読める」「このDBだけ SELECT できる」 のように範囲を決められます。
下のデモは、そのやりとりの流れを簡略化したものです。クライアントが initialize → ツール一覧取得 → ツール呼び出し、の順で進み、サーバーがそれぞれ返します。
三種類のサーバーを用意した理由
今回、同じ「MCP サーバー」を三通りで作りました。FastMCP でさっと動かす版、プロトコルを手書きして中身を理解する版、「保存したファイルをLLMとやりとりする」に特化した版です。目的が違います。
- FastMCP 版(mcp): 既存の Python 用 MCP ライブラリ(FastMCP)を使い、
pip install mcp して数行でツールを登録する。MCP を触り始めるときの入口として。
- 手書き版(mcp-diy): stdio と JSON-RPC を自前で読んで書き、ツールのリストと実行だけ実装する。「プロトコルってこういうメッセージの往復なんだ」 をコードで追えるようにしたかった。
- 汎用ファイル版(mcp-file): 指定した一つのルートフォルダの中だけを、一覧・読み取り・検索・メタ情報で公開する。「保存してあるファイルをLLMに渡す」 という用途に絞り、返りは「要約+詳細」でそろえて LLM が扱いやすい形にした。
三つの関係を、下のデモで比較できます。スマホではカードをタップすると説明が開きます。
プロトコルの流れを手書きで確かめる
手書き版(mcp-diy)では、クライアントが送るメッセージは「Content-Length 付きの JSON」か「1行 JSON」です。一方、Cursor が受け取る返答は「JSON の1行+改行」だけを期待していました。最初、こちらの返答にも Content-Length ヘッダーを付けて送っていたら、「"Content-Length: 158" is not valid JSON」とエラーになりました。クライアント側が「1行目をそのまま JSON としてパースする」実装になっていたためです。そこで、返すときはヘッダーをやめて、JSON 本文と改行だけにするよう直しました。
読み取りは、1行読んで Content-Length: ならバイト数を取り、空行を読んでから本体を読む。書き込みは、json.dumps した文字列を UTF-8 で出力し、末尾に改行を付ける。それだけです。
def write_message(obj):
body = json.dumps(obj, ensure_ascii=False)
sys.stdout.buffer.write(body.encode("utf-8"))
sys.stdout.buffer.write(b"\n")
sys.stdout.buffer.flush()
このように「送信は JSON 1行+改行」に統一したら、Cursor から問題なくツールを呼び出せるようになりました。プロトコル仕様と、実際のクライアントの期待が少し違うことがある、という学びでした。
ツール呼び出しの処理は、tools/call で渡ってきた name と arguments を受け取り、TOOLS リストから同じ name の handler を実行して、返り値を content に入れて返すだけです。echo や get_time のような単純なツールなら、lambda で足ります。
汎用ファイル版の設計:ルート一つ、読み取りだけ、要約で返す
「保存してあるファイルをLLMとやりとりする」を汎用化するとき、次のように決めました。
- ルートを一つに固定する: 環境変数
MCP_FILE_ROOT で「見せていいフォルダ」を指定。すべての path はこのルートからの相対パスに正規化し、ルートの外へは絶対に出さない。
- 読み取り専用: 一覧(list_dir)・内容読み取り(read_file / read_file_lines)・メタ情報(get_file_info)・ファイル名検索(search_filename)だけ。書き込みツールは持たない。
- 返りは「要約+詳細」でそろえる: どのツールも、返すテキストの先頭に
summary: で始まる一行を付け、その後に一覧や本文を付ける。打ち切ったときは truncated や「read_file_lines で続きを取得可能」と書く。LLM が「何が返ってきたか」を把握しやすくするためです。
- ファイルの種類: テキスト系(.txt, .md, .json, .py など)は内容を返す。それ以外は「バイナリのため表示不可」とし、get_file_info でサイズ・更新日時だけ返す。
パスは pathlib.Path で受け取り、resolve() で絶対パスにしてから、ルートの resolve() に対する relative_to でルート内かどうかを判定しています。ルート外なら None を返してエラーにします。
def safe_relative_path(path_str: str) -> Path | None:
if not path_str or not path_str.strip():
return ROOT
p = (ROOT / path_str.strip().lstrip("/")).resolve()
try:
p.relative_to(ROOT)
except ValueError:
return None
return p
こうしておけば、../ で外に出るような指定はすべて弾けます。ルートは起動時に環境変数から一度だけ決め、変更しません。
ルートと五つのツールの関係は、次のデモのようなイメージです。
list_dir と read_file の返し方
list_dir は、指定 path の直下のファイル・フォルダを、名前・種類・相対パス・サイズのリストで返します。件数が多いときは 200 件で打ち切り、truncated: true と「path を指定して絞って再実行可」を summary に含めます。.env や .git などは一覧から外しています。
read_file は、テキスト系の拡張子なら内容を返し、それ以外は「バイナリのため内容は返せません」とだけ返します。読み取りは 500KB または 2000 行で打ち切り、summary に「read_file_lines で続きを取得可能」と書くようにしました。LLM が「まだある」と分かり、次のツールを選びやすくするためです。
ツールの description も、「いつ使うか・何が返るか」を一文で書くようにしています。例えば list_dir は「ルート内の指定 path の直下にあるファイル・フォルダ一覧を返す。path は空または相対パス。まず path を空で呼び、必要なら path を指定して再呼び出し。」のように。LLM がツールを選ぶときの手がかりになるように、です。
ユーザーからLLMまでの一本の流れ
実際の利用では、ユーザーが Cursor のチャットに「このフォルダの readme を要約して」と書く。Cursor が MCP サーバー(mcp-file)に list_dir を呼んで一覧を取り、read_file で readme を読む。その結果を LLM に渡し、LLM が要約を返す。そんな流れになります。サーバーは「どのフォルダを見せるか」を環境変数で固定しているので、ユーザーやLLMがパスを指定しても、ルートの外には出ません。
下のデモは、その全体の流れを簡略化したものです。スマホでは縦に並ぶようにしています。
振り返り
- MCP は「AIクライアントとツールを標準プロトコルでつなぐ」仕様で、stdio と JSON-RPC でメッセージの往復をしている。
- FastMCP 版は数行でツールを足せる入口。手書き版はプロトコルの実装をコードで追うためのもの。汎用ファイル版は「保存したファイルをLLMとやりとりする」に特化し、ルート一つ・読み取り専用・返りは要約付きで統一した。
- Cursor はレスポンスを「JSON 1行+改行」で期待していた。Content-Length 付きで返すとエラーになったので、送信形式を合わせて解消した。
- パスは必ずルート内に正規化し、外へのアクセスは返さない設計にした。テキスト以外のファイルは内容を返さず、メタ情報だけにした。
実験用ページでは、三種類それぞれの説明とMermaid図、ZIP ダウンロードリンクを置いています。Cursor で MCP を有効にし、mcp-file のルートにプロジェクトフォルダを指定すれば、チャットから「このフォルダの一覧を出して」「このファイルの内容を教えて」と依頼できます。
さらに見る・試す