LLM生活へ向けてMCPを学んでみる
動機
o3-mini-highがかなり賢く、tool useも学習されてそうなので、そろそろ本格的にAI driven な生活実現を目指したい。
toolへのアクセス方法についてはMCPがスタンダードになる可能性があるので、一旦コンセプトを学び、何ができるのかを正しく理解する。
必要に応じて既存のMCPの利用と、自作なども進める
参考
公式ドキュメント
登場人物
- MCP Hosts: MCPへあくせすするAI側のツール clientを包含する
- MCP Clients: Protocol clients that maintain 1:1 connections with servers
- MCP Servers: データ提供の口
- Local Data Sources: Your computer’s files, databases, and services that MCP servers can securely access
- Remote Services: External systems available over the internet (e.g., through APIs) that MCP servers can connect to
Claude Desktopは Hostに当たる
Server
SDKが用意されていて、簡単に実装可能
pythonだと以下のように作る
- toolとしてさらしたい関数を @mcp.tool()デコレータで定義
- FastMCPをコンストラクトして mainで mcp.run()
- transport=stdioとあるが、これは標準出力でclientとやり取りをすることを意味する
from typing import Any
import httpx
from mcp.server.fastmcp import FastMCP
# Initialize FastMCP server
mcp = FastMCP("weather")
...(略)
@mcp.tool()
async def get_alerts(state: str) -> str:
"""Get weather alerts for a US state.
Args:
state: Two-letter US state code (e.g. CA, NY)
"""
url = f"{NWS_API_BASE}/alerts/active/area/{state}"
data = await make_nws_request(url)
if not data or "features" not in data:
return "Unable to fetch alerts or no alerts found."
if not data["features"]:
return "No active alerts for this state."
alerts = [format_alert(feature) for feature in data["features"]]
return "\n---\n".join(alerts)
@mcp.tool()
async def get_forecast(latitude: float, longitude: float) -> str:
"""Get weather forecast for a location.
Args:
latitude: Latitude of the location
longitude: Longitude of the location
"""
# First get the forecast grid endpoint
points_url = f"{NWS_API_BASE}/points/{latitude},{longitude}"
points_data = await make_nws_request(points_url)
if not points_data:
return "Unable to fetch forecast data for this location."
# Get the forecast URL from the points response
forecast_url = points_data["properties"]["forecast"]
forecast_data = await make_nws_request(forecast_url)
if not forecast_data:
return "Unable to fetch detailed forecast."
# Format the periods into a readable forecast
periods = forecast_data["properties"]["periods"]
forecasts = []
for period in periods[:5]: # Only show next 5 periods
forecast = f"""
{period['name']}:
Temperature: {period['temperature']}°{period['temperatureUnit']}
Wind: {period['windSpeed']} {period['windDirection']}
Forecast: {period['detailedForecast']}
"""
forecasts.append(forecast)
return "\n---\n".join(forecasts)
if __name__ == "__main__":
# Initialize and run the server
mcp.run(transport='stdio')
client
MCPのクライアントの基本的な処理は、大きく以下の流れになります。
ざっくりした流れ
1. セッションを作成(cmd+args 通常のcliで実行するのと同じような形)
2. 利用可能なツールを取得
3. ユーザーのクエリを処理
4. ツールを実行
5. 最終的なレスポンスを返す
6. 対話ループで繰り返し処理
1. セッションの作成
クライアントは、ClientSession を作成してMCPサーバーと通信します。
これには stdio_client を使用し、サーバーと標準入出力を介してやり取りを行います。
self.session = await self.exit_stack.enter_async_context(ClientSession(self.stdio, self.write))
await self.session.initialize()
このセッションの初期化 (initialize()) により、クライアントはサーバーとやり取りできる状態になります。
2. 利用可能なツールの取得
セッションが確立されたら、list_tools() を使ってサーバーが提供するツールの一覧を取得します。
response = await self.session.list_tools()
tools = response.tools
print("\nConnected to server with tools:", [tool.name for tool in tools])
ここで取得できる tools は、クライアントが実行できる機能(ツール)の一覧です。
3. クエリの処理
ユーザーからの入力 (query) に対して、Anthropic Claude の API を使って初期レスポンスを生成します。
response = self.anthropic.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=1000,
messages=messages,
tools=available_tools
)
このレスポンス (response.content) には通常のテキスト応答 (text) や、ツールの実行指示 (tool_use) が含まれます。
4. ツールの実行
もし Claude がツールの実行を指示した場合、それに従って call_tool() を使って実際にツールを実行します。
result = await self.session.call_tool(tool_name, tool_args)
実行結果 (result) は再び Claude に渡され、最終的なレスポンスの一部になります。
5. ユーザーへの出力
最終的に、ツールの結果を含めた Claude のレスポンスを "\n".join(final_text) でまとめて、ユーザーに表示します。
return "\n".join(final_text)
6. 対話ループ
クライアントは、ユーザーからの入力を受け付け、レスポンスを生成し続けるチャットループを実装しています。
while True:
query = input("\nQuery: ").strip()
if query.lower() == 'quit':
break
response = await self.process_query(query)
print("\n" + response)
この流れで、MCP クライアントはサーバーと連携し、LLM を活用してツールを呼び出しながらチャットを行います。
Client側として対応しているものの例
cursorなどもMCPに対応しているなど耳にするが、 resource , promptなどは対応がなく toolのみに対応しているなど、この表はありがたい。(resource, promptなどについては後述予定)
表の下の方に行くと詳しいサポート状況なども確認可能
以下2つは知らなかったが良いかもしれない
- 5ire ( ChatクライアントGUI)
- Bee Agent Framework(Agent frameworkっぽい)
Concept
architecture
ざっくり内部について触れられている。詳しく知りたい場合以外は読まなくて良さそう
transport層
- stdio: localでのやり取りに使われる
- http(sse): clientからpostして、 serverから sseで送られrてくる
それぞれのメッセージでは JSON-RPCという規格が使われているらしい
message形式は request-responseとnotificationのざっくり2種がある
Resource
概要
リソースはいわゆるAIがアクセスできるfileシステムのようなものだと思えばよく、パスベースで各種のデータへのアクセスを許可する仕組み
リソースの用途
- ファイル内容
- データベース記録
- API応答
- ライブシステムデータ
- スクリーンショットや画像
- ログファイル
リソースURI
リソースは一意のURIで識別されます。例えば:
- file:///home/user/documents/report.pdf
- postgres://database/customers/schema
- screen://localhost/display1
実装を見た方がイメージがつきやすいので先に実装サンプルを貼る。
Getting Startedでは Toolの解説だったが、結構書き方が異なるので全然別のサーバーとイメージしておくのが良さそう(APIとファイルシステム的な違い)
app = Server("example-server")
@app.list_resources()
async def list_resources() -> list[types.Resource]:
return [
types.Resource(
uri="file:///logs/app.log",
name="Application Logs",
mimeType="text/plain"
)
]
@app.read_resource()
async def read_resource(uri: AnyUrl) -> str:
if str(uri) == "file:///logs/app.log":
log_contents = await read_log_file()
return log_contents
raise ValueError("Resource not found")
# Start server
async with stdio_server() as streams:
await app.run(
streams[0],
streams[1],
app.create_initialization_options()
)
以下ほぼ和訳
リソースの種類
- テキストリソース
- UTF-8エンコードされたテキストデータ
- ソースコード、設定ファイル、ログファイル、JSON/XMLデータ、プレーンテキストに適しています
- バイナリリソース
- Base64エンコードされた生バイナリデータ
- 画像、PDF、音声ファイル、動画ファイル、その他非テキストフォーマットに適しています
リソースの発見
-
Direct resources
- サーバーが
/resources/list
エンドポイントを通じて具体的なリソースをリストとして提供
- サーバーが
-
resource template
- 動的なリソース用にURIテンプレートを提供( /user/:id 的な)
リソースの読み取り
クライアントはリソースURIを指定して、/resources/read
リクエストを送信します。サーバーは以下の形式で応答します:
{
"contents": [
{
"uri": "string",
"mimeType": "string",
"text": "string", // or blob: "string"
}
]
}
リソースの更新
MCPはリアルタイムのリソース更新をサポートします:
- リストの変更通知
-
notifications/resources/list_changed
通知
-
- コンテンツの変更通知
- クライアントが
/resources/subscribe
で特定のリソースを購読し、サーバーは変更時に通知を送信し、最新の内容は/resources/read
で取得可能
- クライアントが
ベストプラクティス
- 明確で説明的なリソース名やURIを使用する
- LLM理解を助けるために説明を含める
- 適切なMIMEタイプを設定する
- 動的コンテンツに対してリソーステンプレートを実装する
- 頻繁に変化するリソースのために購読を使用する
- エラーハンドリングを明確に行う
- 大規模なリソースリストに対してページネーションを考慮する
- リソース内容を適切にキャッシュする
- 処理前にURIを検証する
- カスタムURIスキームを文書化する
セキュリティ考慮事項
- すべてのリソースURIを検証する
- アクセスコントロールを実装する
- ディレクトリトラバーサルを防ぐためにファイルパスをサニタイズする
- バイナリデータの取り扱いに注意する
- リソース読み取りのレートリミティングを検討する
- リソースアクセスを監査する
- データ転送時に暗号化する
- MIMEタイプを検証する
- 長時間の読み取りに対してタイムアウトを実装する
- リソースのクリーンアップを適切に処理する
Prompt
いわゆるprompt管理系のツールと LLMアプリケーション(host)とを接続する規格だと思えば良さそう
Promptの構成
以下のようなmetaデータと実際の中身で構成される。
prompts/list でプロンプトのmetaデータリストを取得し、prompts/get
で実際の中身を取得する.
引数が与えれることから分かるように、prompt template的な役割を有する(例えば与えた引数をpromptに埋め込んで返す・指定したURLの画像をpromptに追加する、など)
極端な話 質問を 引数にしてserver側で RAGをしてcontextを埋め込んだ最終的なプロンプトを返すような自由度も有していると言える(それが良い設計かどうかは不明)
metaデータ
{
name: string; // Unique identifier for the prompt
description?: string; // Human-readable description
arguments?: [ // Optional list of arguments
{
name: string; // Argument identifier
description?: string; // Argument description
required?: boolean; // Whether argument is required
}
]
}
prompt/getの例
// Request
{
method: "prompts/get",
params: {
name: "analyze-code",
arguments: {
language: "python"
}
}
}
// Response
{
description: "Analyze Python code for potential improvements",
messages: [
{
role: "user",
content: {
type: "text",
text: "Please analyze the following Python code for potential improvements:\n\n```python\ndef calculate_sum(numbers):\n total = 0\n for num in numbers:\n total = total + num\n return total\n\nresult = calculate_sum([1, 2, 3, 4, 5])\nprint(result)\n```"
}
}
]
}
サーバー側の実装
@app.list_prompts()、@app.get_prompt() をデコレータとして実装すれば良い
Tools
Getting Startedにあったので割愛して良さそう
Overview
MCPのツールは、サーバーがクライアントによって呼び出され、LLM(大規模言語モデル)によってアクションを実行するために使用される実行可能な関数を公開することを可能にします。ツールの主要な側面には次のものが含まれます:
- 発見:クライアントはtools/listエンドポイントを通じて利用可能なツールをリストできます。
- 呼び出し:ツールはtools/callエンドポイントを使用して呼び出され、サーバーは要求された操作を実行し結果を返します。
- 柔軟性:ツールは単純な計算から複雑なAPIの相互作用まで多岐にわたります。 リソースと同様に、ツールは一意の名前で識別され、使用をガイドするための説明を含むことができます。しかし、リソースとは異なり、ツールは状態を変更したり外部システムと相互作用したりする動的な操作を表します。
Sampling
ServerからClient側にLLMでの生成を依頼する仕組みっぽい。 サーバーはAPIキーなどを持たなくても、内部でLLMを使える仕組みと言える。 Client側は serverからのSamlingの要求時にはユーザーに許可を求める実装がよしとされる(serverが勝手にAPIを使い込まないように)
ざっくりまとめ
MCPは基本的にはLLMを動かすホストアプリケーション(ex: Claude App) に対するプラグインの規格を定義したものと思えば良さそう。ホストアプリケーションとの繋ぎ方としてHttpを使ったAPIが一般的だと思うが、stdioを使ったローカルでのやり取りにも対応している点などが作り込まれていると言えそう。
定義している機能は4つあり、利用されそうな順で並べる
- Tools : いわゆるFunction Call/ Tool Callの呼び出し先を提供する枠組み。おそらく最も使われる
- Resource : ファイルシステムのような形式でリソースをLLMに提供する仕組み
- Prompts : Prompt Templateなどを提供する仕組み。いわゆるプロンプトマネージメントツールのインターフェイスを規格化したもの
- Sampling: MCP内部でホスト側のLLMで生成する仕組み。MCPサーバー側ではAPIKeyなどを保持せず、LLMを使えるメリットがある
実装は基本的APIを立てるか、SDKを用いた js or pythonのファイルを用意してプロセスとして動かせば良いという仕組みになっている。
MCPサーバーの例としてObsidian MCPを使う
smitheryというcliをもちいて npxでインストール・実行できる(nodeのインストールが必要)
以下のように実行してobsidianのデータディレクトリを指定すると claude アプリの設定に自動的に追記してくれる。
npx @smithery/cli install mcp-obsidian --client claude
smithery
Smitheryは、MCP(Model Context Protocol)に準拠した拡張機能を集めたプラットフォームで、言語モデルに外部ツールやデータソースを接続する役割を果たします。983以上のMCPサーバーを提供し、開発者が簡単に機能を追加できるように設計されています。例えば、Obsidian MCPはSmitheryを通じて提供されるサーバーの一つで、Obsidianのノート管理機能をAIに統合します。オープンソースのSmithery CLIを使えば、こうしたサーバーを手軽にインストール可能です。
MCP Obsidian
読み書き全てやってくれることを期待したが、読み込みと検索のみらしい。RAGの知識データが割りとして使うような用途になりそうだが、できれば書き込みをうまくやって欲しかったのでやや残念
filesystem MCPと組み合わせる
obsidianのデータの実態はシンプルなmarkdownファイルのタイトルとmarkdown なので、obsidianデータが入ったディレクトリをfile systemとして渡せば書き込みもできる。 この辺りMCPの組み合わせによる可能性を感じた