MCPサーバーを自作した話
はじめに
こんにちは。社会人2年目、AIエンジニア見習いのばんそこです。
最近はストーリー生成AIエージェントの開発を頑張っています。
AI(LLM)は面白いストーリーを生成してくれますが、記憶が儚く、すぐに忘れてしまいます。
生成したストーリーを守るために、生成したストーリーのファイルをローカルに保存したり、保存しているストーリーの情報を取得する仕組みが必要です。
ということで、ストーリー管理用のMCPサーバーを作って、LLMがストーリーファイルを扱えるようにしよう!というのがこの記事の目的になります。
環境・システム構成
| 技術スタック | 機能/説明 |
|---|---|
| Docker | Webアプリ、API(FastAPI) 、DB、Prismaの他に、MCPサーバー用のコンテナを作成します。 |
| FastAPI | ユーザーからのリクエストを受け付け、LLMを実行します。 LLMがユーザーの意図を解釈し、ツールを使用するか、どのツールを使用するかを判断します。 |
| FastMCP | ファイル操作などのツールが実装されているサーバーです。 LLMから依頼を受けて、ツールを実行し、結果を返却します。 |
実装内容
LLMとMCPサーバーを接続してツールを使えるようにするまでの流れを説明していきます。
環境構築
Dockerを用いているため、MCPサーバー用のコンテナを定義し、FastAPIなどの他のサービスと連携できるように設定します。
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y pipx
RUN pipx install poetry==2.1.3
ENV PATH="/root/.local/bin:$PATH"
COPY pyproject.toml poetry.lock ./
RUN poetry install --no-root --no-directory
COPY . .
EXPOSE 8001
CMD ["sh"]
Pythonのバージョン管理にpoetryを使っているため、poetry runで起動します。
mcp_server:
build:
context: ./mcp_server
ports:
- "8001:8001"
volumes:
- ./mcp_server:/app
networks:
- my-network
command: poetry run fastmcp run main.py:mcp --transport http --host 0.0.0.0 --port 8001
mcp_server/およびbackend/のディレクトリ構成は以下の通りです。
mcp_server/のディレクトリ構成
FastMCPで構築されたツール実行用のサーバーです。
FastMCPは公式ドキュメントを参考にプロジェクトにインストールします。
mcp_server/
├── main.py # MCPサーバーのエントリーポイント
├── pyproject.toml # 依存関係ファイル
├── poetry.lock
├── Dockerfile
├── stories/ # ストーリーデータ保存ディレクトリ
│ └── xxxxx.txt …
└── tools/ # MCPツール実装ディレクトリ
├── story_tools.py # ツール統合クラス
└── create_story.py # ストーリー作成ツール(他:読み取り、一覧表示、削除…)
backend/のディレクトリ構成
FastAPIアプリケーションのディレクトリです。LLMエージェントを実行し、ツール使用時はMCPサーバーとの通信を行います。
backend/
├── main.py # FastAPIアプリケーションのエントリーポイント
├── mcp_client.py # MCPサーバーとの通信クライアント
├── pyproject.toml
├── poetry.lock
├── Dockerfile
├── routers/ # FastAPIルーターディレクトリ
│ ├── chat.py # チャット機能(MCP連携の中核)
│ ├── prompt.md # AIエージェント用システムプロンプト
│ └── loadPrompt.py # プロンプト読み込みユーティリティ
└── tools/ # ツール定義ディレクトリ
└── story_tools.py # MCPツールのLangChain用定義
MCPサーバーとLLMの接続
まずは、LLMがMCPサーバーにツール実行を依頼できるように準備をします。
curlで色々試していたのですが、
はじめはなかなかMCPサーバーとLLMの接続ができず、Geminiとともに激闘した末に、
「コミュニケーションは、どこまでも丁寧に。」 という結論に至りました。
MCPサーバーは、例えるなら手続きが厳格な市役所の窓口。正しい手順を踏まないと、相手にしてもらえません。
MCPサーバーに接続する側の処理から作りながら、MCPサーバーの動きを追ってみます。
整理券の発行(セッション初期化)
まずは、総合受付で「どのようなご用件ですか?」と聞かれ、整理券を受け取るところから始まります。整理券(session_id)がなければ、そもそも待合室にすら入れません。
# 総合受付にて
init_response = requests.post(
MCP_URL,
json={
"jsonrpc": "2.0", "method": "initialize",
"params": {
# ...
"clientInfo": { "name": "backend-agent" } # ← 身分証明書です
},
# ...
}
)
# サーバーから「あなたの番号はこちらです」と整理券(セッションID)を受け取る
session_id = init_response.headers.get("mcp-session-id")
待合室での準備完了(初期化完了通知)
整理券を手に入れたら、待合室で自分の番号が呼ばれるのを待ちます。そして、「こちらの準備はできましたよ」と窓口に合図を送ります。
# 待合室にて
requests.post(
MCP_URL,
headers={"mcp-session-id": session_id}, # ← 整理券を提示
json={
"jsonrpc": "2.0",
"method": "notifications/initialized", # ←「準備OKです」の合図
"params": {}
}
)
窓口での正式な依頼(ツール呼び出し)
最後に、手続き用の整理券を持って、担当窓口に行きます。
「〇〇というツール(手続き)をお願いします」と実際に依頼をすることができます。
# 担当窓口にて
tool_response = requests.post(
MCP_URL,
headers={"mcp-session-id": session_id}, # ← 整理券を再度提示
json={
"jsonrpc": "2.0", "method": "tools/call",
"params": {
"name": tool_name, # ←「住民票発行ツール」をお願いします
"arguments": arguments # ← 必要な書類(引数)はこちらです
},
# ...
}
)
ツールの使い方
LLMがMCPツールを呼び出す準備が整ったため、いよいよ実際にLLMが使うツールを実装します。
ここでは、LLMが生成したストーリーをファイルに書き込んで保存するcreate_storyツールを実装します。
使えるツールをMCPサーバー側でまとめて定義
mcp_serverディレクトリ内に、ツールをまとめて定義するファイルを作成します。mcp_server/main.pyで直接ツールの実装内容を書くこともできますが、拡張性のためファイルを分離します。
from fastmcp import FastMCP
from .create_story import create_story as create_story_impl
class StoryTools:
def __init__(self, mcp: FastMCP, stories_dir: str):
self.mcp = mcp
self.stories_dir = stories_dir
self.register_tools()
def register_tools(self):
"""
全ツールを登録
"""
self.mcp.tool(self.create_story)
def create_story(self, title: str, content: str) -> dict:
"""
作成したストーリーをテキストファイルとして保存する
"""
return create_story_impl(title, content, self.stories_dir)
FastAPIはbackend内のディレクトリしか見ないので、LLM用のツール定義ファイルが別に必要です。
上記mcp_server内のMCPツール定義ファイルから、各ツールの定義部分を抜き出したファイルを作成します。
(割愛しますが、mcp_server/tools/story_tools.pyの内容からbackend側に自動でファイルを生成することもできます。)
from langchain_core.tools import tool
@tool
def create_story(title: str, content: str) -> dict:
"""作成したストーリーをテキストファイルとして保存する"""
pass
tools = [create_story]
実際のツール(create)の処理内容
LLMが生成したストーリーをファイルに書き込んで保存するcreate_storyツールを作成します。
import logging
import os
def create_story(title: str, content: str, stories_dir: str) -> dict:
"""
作成したストーリーをテキストファイルとして保存する
"""
logging.info(f"ストーリー作成")
filename = f"{title}.txt"
filepath = os.path.join(stories_dir, filename)
if os.path.exists(filepath):
logging.warning(f"ストーリー '{title}' は既に存在します。")
return {"error": "Story with this title already exists"}
try:
with open(filepath, "w", encoding="utf-8") as f:
f.write(content)
logging.info(f"ストーリーを保存しました:{filepath}")
return {"message": "Story created successfully", "title": title}
except Exception as e:
logging.error(f"ストーリー保存中にエラーが発生しました: {e}")
return {"error": f"Failed to save story file: {e}"}
LLMのツール呼び出し処理
ツールができたので、backend側でLLMがcreate_storyを呼び出せるように、処理を追加します。
エージェントの定義
llm.bind_tools(tools)は、Pythonの関数定義をLLMが解釈可能な形式(OpenAI ToolsのJSON Schema)に変換し、モデルに紐付ける重要な処理です。これにより、LLMは各ツールの機能、引数、説明を理解し、思考の選択肢として扱えるようになります。
from tools.story_tools import tools
from .loadPrompt import load_prompt_from_file
# LLMにツールを認識させ、プロンプトやパーサーと連結してエージェントを作成
llm_with_tools = llm.bind_tools(tools)
# プロンプトテンプレート作成・読み込み
prompt = ChatPromptTemplate.from_messages([
("system", load_prompt_from_file("prompt.md")),
MessagesPlaceholder(variable_name="chat_history"),
("user", "{user_input}"),
MessagesPlaceholder(variable_name="agent_scratchpad"),
])
agent = (
preprocesser
| prompt
| llm_with_tools
| OpenAIToolsAgentOutputParser()
)
ツールの呼び出しと応答生成の処理
- エージェントの最初の判断を仰ぐ
-
agent.invokeを初回呼び出し。LLMはユーザーの入力と履歴から、ツールを使うべきか(Act)、直接応答すべきか(Finish)を判断します。
-
- 実行
- LLMがツール使用を判断すると、応答はlist形式(
ToolAgentActionのリスト)で返されます。ここからツール名と引数を抽出し、call_mcp_toolでMCPサーバーに実行を依頼します。
- LLMがツール使用を判断すると、応答はlist形式(
- 最終処理
- ツールの実行結果を
intermediate_stepsに格納し、再度agent.invokeを呼び出します。これが観測ステップです。LLMは「ツールを使ったらこの結果が得られた」という事実を元に、ユーザーへの最終的な応答を考え、AgentFinishとして返します。
- ツールの実行結果を
# 1. ユーザー入力をもとに、エージェントの最初の判断を仰ぐ
response = agent.invoke({
"user_input": user_input.text,
"chat_history": chat_history_items,
"intermediate_steps": []
})
# 2. エージェントの判断に応じて処理を分岐
# パターンA: ツールを使わず、直接応答を返す場合
if isinstance(response, AgentFinish):
ai_response_content = response.return_values['output']
# パターンB: ツールを使う指示が返ってきた場合
elif isinstance(response, list):
response_action = response[0]
tool_name = response_action.tool
tool_args = response_action.tool_input
# 3. 指示されたツールを実行
mcp_result = call_mcp_tool(tool_name, tool_args)
# 4. ツールの実行結果をエージェントにフィードバックし、最終的な応答を生成させる
final_response = agent.invoke({
"user_input": user_input.text,
"chat_history": chat_history_items,
"intermediate_steps": [(response_action, json.dumps(mcp_result))]
})
if isinstance(final_response, AgentFinish):
ai_response_content = final_response.return_values['output']
else:
ai_response_content = "ツールの実行結果から最終的な応答を生成できませんでした。"
プロンプトによる制御
仕上げに、ユーザーがストーリーの作成を依頼した時にLLMがツールを呼び出せるようプロンプトで指示してあげることで、以下の流れでストーリーを作成・保存できるようになりました!
ユーザー: 「ストーリー作成して」
↓
LLM: 詳細確認(ジャンル・要素)
↓
ユーザー: 詳細回答
↓
LLM: ストーリー生成 → create_story ツール実行指示
↓
MCP: ファイル保存 → 結果返却
↓
LLM: 「保存しました」
まとめ
ストーリー管理用のMCPサーバーを作ったことで、LLMがストーリーファイルを扱い、安全安心なストーリー作成と保存ができるようになりました👏👏
色々苦労したこともありますが、MCPサーバーを自作した経験はプライスレス。
便利なのでみなさんも作ってみてください!
Discussion