以前、「MCPとは何か」を概念だけ解説した回を書きました。今回はその実践編です。自作のローカルAIエージェント(p935)をMCPクライアントにして、外部の道具を実際に繋いで動かしてみます😊
ゴールは、ローカルLLM(完全オフライン)が、自分でMCPの道具を選んで呼ぶところまで。自作の最小サーバと、公式の「本物」サーバ(ファイル操作)の両方を繋ぎます。やってみると、概念だけ読んでいたときには見えなかったハマりどころがいくつも出てきました。
・MCPの仕組み(ホスト/クライアント/サーバ)を図でおさらい
・p935エージェントをMCPクライアントにする(実装コード)
・自作の最小MCPサーバと、公式のfilesystemサーバを繋ぐ
・ローカルLLMが自律的にMCPの道具を呼ぶまでを確認
・道具名の衝突を名前空間で解決するなど、ハマりどころを正直に
前回は「概念」、今回は「実際に動かす」
MCP(Model Context Protocol)は、ざっくり言うと「AIに外部の道具を持たせるための共通のつなぎ方」です。USB-Cのように、規格をそろえておけば、いろんな道具(ファイル操作・ブラウザ・Unity・Blenderなど)を同じやり方でAIに差し込めます。
これまでこのエージェントは、検索やファイル操作、PC操作を自前の道具として持っていました。ここにMCPを足すと、世の中に公開されているMCPサーバをそのまま道具にできるようになります。自分で全部作らなくてよくなる、というのが一番のうれしさです。
MCPの仕組みを図でおさらい
登場人物は3つです。ホスト(AIアプリ本体)、クライアント(サーバと話す係)、サーバ(道具を提供する側)。今回はエージェントがホスト兼クライアントになり、別プロセスのサーバに繋ぎます。
ポイントはサーバが別プロセスだということ。今回、自作サーバはPython、公式サーバはNode(npx)で動きますが、エージェント側から見れば全く同じです。ここが「共通規格」のうれしさですね。
エージェントをMCPクライアントにする(実装)
まず設定ファイル。Claude Desktop と同じ形式で、繋ぎたいサーバを書きます(mcp_config.json)。
{
"mcpServers": {
"demo": {
"command": "python3",
"args": ["examples/demo_mcp_server.py"]
}
}
}
クライアント本体は、依存を増やしたくなかったので自前の最小実装にしました。MCPは中身が stdio 上の JSON-RPC 2.0(改行区切り)なので、サーバを子プロセスで起動して、標準入出力で会話するだけです。起動時に「初期化 → 道具一覧(tools/list)」を1往復して、使える道具を覚えます。
class _Server:
"""1つのMCPサーバプロセスとの接続。"""
def __init__(self, name, command, args, ...):
self.proc = subprocess.Popen(
[_resolve(command), *args],
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
text=True, bufsize=1, creationflags=NO_WINDOW)
self._handshake() # initialize → tools/list
def call(self, tool, args):
result = self._rpc("tools/call",
{"name": tool, "arguments": args or {}})
# content は [{type:"text", text:...}] のリスト
return "\n".join(c["text"] for c in result.get("content", [])
if c.get("type") == "text")
あとは、繋いだサーバの道具をエージェントの「第一級の道具」として登録してあげれば、モデルが名前で直接呼べるようになります(後述の名前空間の話が、ここで効いてきます)。
自作の最小MCPサーバを繋ぐ
いきなり本物の複雑なサーバを繋ぐ前に、疎通確認用の最小サーバを自分で書きました。依存ゼロ、標準ライブラリだけです。道具は2つだけ——OS情報を返す system_info と、フォルダ一覧を返す list_dir。
def handle(req):
m = req.get("method")
if m == "initialize":
return {"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"serverInfo": {"name": "demo-mcp", "version": "1.0"}}
if m == "tools/list":
return {"tools": TOOLS} # system_info / list_dir の定義
if m == "tools/call":
name = req["params"]["name"]
if name == "system_info":
text = f"OS: {platform.system()} {platform.release()} / ..."
# …
return {"content": [{"type": "text", "text": text}]}
これだけで、MCPサーバとして成立します。標準入力から1行ずつJSONを読んで、id のある要求にだけ応答を返す——たったそれだけ。「サーバ」と聞くと身構えますが、中身は驚くほど素朴でした😊
ローカルLLMに道具を使わせてみる
ここが今回の山場です。ローカルLLM(Ministral 3・完全オフライン)に「MCPのdemoサーバでOS情報を調べて」と頼んでみます。うまくいけば、モデルが自分で system_info を呼ぶはずです。
mcp__demo__system_info を呼び、結果(Darwin 25.5.0…)を踏まえて日本語で答えたできました!ステータス欄に「MCP 2台」と出ていて、会話の中でモデルが自分で mcp__demo__system_info を呼び、返ってきたOS情報(Darwin 25.5.0 / arm64)をもとに回答しています。指示していないのに道具を選んでいるのがポイントです。
ちなみに、この mcp__demo__ という接頭辞には理由があります。そのまま system_info で登録すると、あとで別のサーバと名前がぶつかるんです。詳しくは後半のハマりどころで。
公式の「本物」サーバ(filesystem)も繋ぐ
自作サーバで疎通が取れたので、次は公式が配布している本物のサーバを繋ぎます。定番の filesystem サーバ(ファイルの読み書き・一覧・検索など)を、npx で起動する設定を足すだけです。
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem",
"/触ってよいフォルダの絶対パス"]
}
これで14個の道具(read_file / list_directory / search_files …)が一気に増えました。自分で1行も書かずに、です。最後の引数で「触ってよいフォルダ」を限定できるのも安心設計で、指定外のフォルダを触ろうとするとサーバ側が断ってくれます(実際に範囲外を頼んだら、モデルが「許可されたフォルダを確認します」と方針を切り替えたのが面白かったです😳)。
function callingと何が違う?
「道具を使わせるなら、これまでの function calling でもよかったのでは?」と思いますよね。違いは道具の増やし方にあります。
| 自前のfunction calling | MCP | |
|---|---|---|
| 道具を増やす | エージェントのコードに関数を書き足す | 設定に1行足すだけ(コード改修不要) |
| 再利用 | そのアプリ専用 | 他のAIアプリでも同じサーバが使える |
| 作る人 | 自分で全部 | 世界中の人が公開したサーバに乗れる |
| 言語 | アプリと同じ言語 | 別プロセスなので何語でもOK |
つまりMCPは、function callingを「外部化・共通化」した仕組みと言えます。エージェント本体を触らずに能力を足せるので、保守がとても楽になりました。
ハマったところ(道具名の衝突・起動・確認ゲート)
ここが実際にやった人にしか書けないところ。3つ、正直に共有します。
① 道具名の衝突(これが一番大きい)
filesystemサーバには read_file と write_file があります。ところが、エージェントはもともと自前の read_file を持っていました。同じ名前で登録してしまうと、モデルが内蔵の read_file を呼んだつもりがMCP側へ吸い込まれる、という取り違えが起きます。
直し方は、MCPの道具を mcp__サーバ名__道具名 という名前空間で公開すること。Claude Code など本物のMCPホストも同じ方式です。これで内蔵の道具とも、複数サーバ同士とも、ぶつからなくなります。
for m in self.mcp.list_tools():
# 道具名の衝突対策:mcp__サーバ__道具 という名前空間で公開する
public = f"mcp__{m['server']}__{m['name']}"
self.tools.append({"type": "function", "function": {
"name": public,
"description": f"[MCP:{m['server']}] {m['description']}",
"parameters": schema}})
self.mcp_tool_server[public] = m["server"] # 公開名 → サーバ
self.mcp_tool_real[public] = m["name"] # 公開名 → 実ツール名
先ほどのスクショで道具名が mcp__demo__system_info になっていたのは、この対策の結果です。
② サーバの起動(確認ゲート)
MCPの道具は「外部を操作する」ものなので、実行前に必ず確認を挟むようにしています。モデルが道具を呼ぼうとすると、こんなダイアログが出ます。
③ タイムアウトと応答待ち
サーバが応答を返さないと、クライアントは行を読み続けて固まります。自分のidの応答が来るまで待つ作りにしていますが、行が空(readline() が空文字)=サーバが落ちた、と判断してエラーにするようにしました。ここを入れておかないと、サーバが死んだときにアプリごと固まります。
WindowsでもMacと同じ?
このエージェントはMac/Windows共通コードで運用しています。今回もWindows(RTX 3060)で確認したところ、結論はほぼ同じでした。名前空間つきの道具(mcp__サーバ__道具)も、確認ゲートも、ローカルLLM(CUDA)が自分でMCP道具を呼ぶのも、Macと同じように動きます。filesystemサーバも同じ14道具で繋がりました。
mcp__demo__system_info が呼ばれ、結果は Windows 11 / AMD64 / python 3.12.10ただ、1か所だけWindows特有のワナにハマりました。ここは実際に踏まないと気づけない話なので、詳しく書きます。
ワナ:python3 が「中身の空っぽな偽物」に化ける
設定ファイルの "command": "python3" のまま、Windowsでdemoサーバが無応答になりました。原因を追うと、Windowsには ...\WindowsApps\python3.exe という0バイトの「Microsoft Store 実行エイリアス」が置かれていることがあります。これは実行すると「Storeでインストールしてね」と案内するだけのダミーで、python3 の本体ではありません。
やっかいなのは、コマンドを探す shutil.which("python3") が、このダミーを「見つかった」と返してしまうこと。すると python3→python のフォールバックが働く前にダミーを掴んでしまい、起動したプロセスはMCPをしゃべらず、こちらは応答待ちで固まる——というわけです😅
直し方は、「0バイトかつ WindowsApps 配下なら偽物とみなして無効化」という判定を1つ足すこと。これで、設定ファイルは python3 のままでも、Windowsが自動で本物の python にフォールバックします。Mac/Linuxでは常に無効判定にならないので、挙動は一切変わりません。
def _is_store_alias(path):
# Windowsの Store実行エイリアス(0バイト・WindowsApps配下)は本物ではない
if sys.platform != "win32" or not path:
return False
try:
return os.path.getsize(path) == 0 and "WindowsApps" in path
except OSError:
return True
def _resolve(command):
found = shutil.which(command) # Windowsの npx.cmd もこれで解決
if found and not _is_store_alias(found): # ダミーを掴んだら無効化
return found
alt = {"python3": "python", "python": "python3"}.get(command)
if alt: # python3⇔python を入れ替えて再解決
alt_found = shutil.which(alt)
if alt_found and not _is_store_alias(alt_found):
return alt_found
return found or command
逆に、安心して大丈夫だったところも書いておきます。
npx→npx.cmd:Windowsでの実体名の違いは、shutil.whichがnpx.CMDまで解決してくれるので設定変更は不要でした。filesystemサーバはそのまま繋がりました。- コンソール窓:子プロセス起動時の黒い窓のちらつきも、
[Errno 22]も再発なし(CREATE_NO_WINDOWが効いている)。 - Windowsパス(
C:\Users\...)を渡してもファイル一覧は正しく返りました。
まとめると、MCP機能はWindowsでも共通コードでそのまま動く。実際に差が出たのは python3 の偽物ワナ1点だけで、それも共通コード側で吸収できました。「OS差は設定ではなくコードで吸収する」という方針が、今回も効いてくれました😊
まとめ
概念だけ解説していたMCPを、今回は自作エージェントで実際に動かしてみました。
- エージェントをMCPクライアントにして、自作の最小サーバと公式のfilesystemサーバを繋いだ
- ローカルLLM(完全オフライン)が、自分でMCPの道具を呼ぶところまで確認できた
- MCPは function calling を外部化・共通化した仕組み。設定に1行足すだけで道具が増える
- ハマりどころは道具名の衝突→
mcp__サーバ__道具の名前空間で解決。外部操作は確認ゲートを必ず通す - OS差(
python3/python・npx.cmd・コンソール窓)は共通コードで吸収
「MCPとは」を言葉で知っていても、実際に繋いでみると、名前の衝突や起動コマンドの違いなど、手を動かさないと出会えない発見がたくさんありました。自作のAIが、世の中の道具を自分で選んで使い出す瞬間は、やっぱりちょっと感動しますね😊 これで、エージェントの能力をコードを触らずに増やせる土台ができました。
それでは、今回はここまで。最後までありがとうございました😊
【補足】MCPの仕様は Model Context Protocol 公式(modelcontextprotocol.io)、filesystemサーバは @modelcontextprotocol/server-filesystem(公式リファレンス実装)。動作環境=Mac(Apple Silicon)/モデルは Ministral 3 14B(ローカル・llama.cpp Metal)。コマンドやバージョンは変わることがあります。