概要
先日公開されたgpt-ossくん、公式サイトを見てみると、どうもFunction Callingが最初から使える状態のようです。
ということは、ローカルLLMでMCPサーバを呼び出すことも可能なのでは...?
というわけで今回は、gpt-ossを使ったMCPを呼び出すことができるAIエージェントを自作していこうと思います。
やりたいこと
- チャットベースで命令を送り、場合に応じてMCP呼び出しもできるAIエージェントを作成
- ollamaの呼び出しはAPI経由でlocalhostにリクエストを送る
- ollama上でMCPの利用が必要だと判断した場合、適宜MCPサーバにリクエストを送信
前提
- ollamaがPCにインストールされていること
- ollamaにgpt-ossを導入していること
- uvの実行環境があること
ollamaやgpt-ossのインストール方法など細かくはこちらの記事で
Function Callingとは
Function Callingとは、LLM内で外部関数を呼び出し、LLM内だけでは完結できない機能を可能にする機能になります。
Function CallingとMCP
MCPはそれをもう少し拡張したものという理解です。Function Callingのみだと、呼び出したい関数によってどんな応答を返すべきかなどを関数ごとに設計する必要があります。
そのLLMに対するリクエストやLLMが生成する各関数に対するリクエスト、およびそれらのレスポンスを共通化し、エージェントの実装によらずLLMが自律的に外部関数を使えるように整えられたインターフェースをMCP(Model Context Protocol)と定めた、という理解です。
Function Callingするためのやり取り情報をMCPに沿った形式で行えるようにすることで、LLM側で自律的に判断していろんなMCPツールを簡単に利用できるようになります。
Function CallingとMCPに関しては筆者が実装しながらなんとなく理解した部分です。もし間違いあればご指摘ください。
実装
作成したコード全量はこちらです。
ollamaの呼び出し
ollamaの呼び出しは、ローカルAPI経由で行います。python用のollama-python
というライブラリもありますが、今回は使わずに実装します。
ollamaは、特に設定せずともインストールするとhttp://localhost:11434
でアクセス可能な状態になっています。例えば以下のようなリクエストをAPITesterなどから行うと、きちんと応答が返ってきます。
> Invoke-WebRequest -Uri http://localhost:11434/api/generate -Method POST -body '{ "model": "gpt-oss:20b", "prompt": "Hello", "stream": false}'
StatusCode : 200
StatusDescription : OK
Content : {"model":"gpt-oss:20b","created_at":"2025-08-10T09:16:58.218301Z","response":"Hello! 👋 How can I help you today?","thinking":"The user says \"Hello\". Lik
ely we respond greet. We might add a friendly...
RawContent : HTTP/1.1 200 OK
Content-Length: 1101
Content-Type: application/json; charset=utf-8
Date: Sun, 10 Aug 2025 09:16:58 GMT
{"model":"gpt-oss:20b","created_at":"2025-08-10T09:16:58.218301Z","response...
Forms : {}
Headers : {[Content-Length, 1101], [Content-Type, application/json; charset=utf-8], [Date, Sun, 10 Aug 2025 09:16:58 GMT]}
Images : {}
InputFields : {}
Links : {}
ParsedHtml : System.__ComObject
RawContentLength : 1101
なので、pythonからは適当な形のリクエストボディを含むHTTPリクエストを送信してあげれば、ollamaの呼び出しが可能です。
async def chat_with_data(self, messages: List[Dict[str, str]]) -> Dict[str, Any]:
"""Ollamaとチャット形式で対話し、レスポンスデータも返す"""
ollama_messages = [
OllamaMessage(role=msg["role"], content=msg["content"])
for msg in messages
]
request = OllamaRequest(
model=self.model,
messages=ollama_messages,
stream=False
)
try:
response = await self.client.post(
f"{self.base_url}/api/chat",
json=request.model_dump(),
headers={"Content-Type": "application/json"}
)
response.raise_for_status()
response_data = response.json()
return response_data
MCPサーバの一覧情報をリクエスト時に送信
ollamaにリクエストを送信する際、messageには次のような形式でリクエストを送っています。
messages = [
{"role": "system", "content": self._create_system_prompt()},
{"role": "user", "content": user_message}
]
user
のほうはCLIから渡されたメッセージですが、system
のほうは固定で渡すシステムの振る舞いにかかわるメッセージです。
こちらに渡すメッセージは、MCPのTool一覧を渡すよう、以下のようにコーディングされています。
def _create_system_prompt(self) -> str:
"""システムプロンプトを生成"""
if not self.tools:
return "あなたは便利なAIアシスタントです。"
tools_info = []
for tool in self.tools:
tool_name = tool.get('name', 'Unknown')
tool_desc = tool.get('description', 'No description')
tool_info = f"- **{tool_name}**: {tool_desc}"
# inputSchemaから引数情報を抽出
if 'inputSchema' in tool and 'properties' in tool['inputSchema']:
properties = tool['inputSchema']['properties']
required = tool['inputSchema'].get('required', [])
args_info = []
for prop_name, prop_details in properties.items():
prop_type = prop_details.get('type', 'any')
prop_desc = prop_details.get('description', '')
enum_values = prop_details.get('enum', [])
arg_line = f" - {prop_name} ({prop_type})"
if prop_name in required:
arg_line += " *必須*"
if prop_desc:
arg_line += f": {prop_desc}"
if enum_values:
arg_line += f" [{', '.join(map(str, enum_values))}]"
args_info.append(arg_line)
if args_info:
tool_info += "\n" + "\n".join(args_info)
tools_info.append(tool_info)
tools_description = "\n\n".join(tools_info)
return f"""
以下のツールを使用できます:
{tools_description}
必要に応じてツールを呼び出してください。"""
MCPToolがある場合はそのToolの一覧を、ない場合は単純にLLMとして振る舞うようにシステムプロンプトを構成するようになっています。
今回適当に作成したMCPサーバで行くと、以下のようなプロンプトが毎回LLMに渡されるようになっており、これをもとにLLMが判断し適宜MCPを使えるようになっています。
以下のツールを使用できます:
- **calculator**: 簡単な計算を実行します
- operation (string) *必須*: 実行する演算 [add, subtract, multiply, divide]
- a (number) *必須*: 最初の数値
- b (number) *必須*: 2番目の数値
- **greeting**: 指定された名前で挨拶を生成します
- name (string) *必須*: 挨拶する相手の名前
必要に応じてツールを呼び出してください。
MCPサーバの呼び出し
MCPサーバの呼び出し部分は単純かつ汎用的に作ります。必要な機能は
- MCPで使えるToolの一覧を取得する
- MCPを実際に呼び出す
の2部分ですが、MCPのインターフェースに沿った形であればどんなMCPでも呼び出せるように汎用的な形で実装しています。
async def list_tools(self) -> List[Dict[str, Any]]:
"""利用可能なツールの一覧を取得"""
response = await self._send_request("tools/list")
if response.error:
raise Exception(f"Failed to list tools: {response.error}")
result = response.result or {}
return result.get("tools", [])
async def call_tool(self, name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
"""ツールを実行"""
response = await self._send_request("tools/call", {
"name": name,
"arguments": arguments
})
if response.error:
raise Exception(f"Tool call failed: {response.error}")
return response.result or {}
実行
実際にやり取りしてみたのが以下になります。
> uv run python src/main.py chat
MCPエージェントを初期化中...
初期化完了!
MCPエージェントとのチャットを開始します。終了するには 'quit' または
'exit' を入力してください。
あなた: 4+8は?
考え中...
エージェント: 4に8を足すと12になります。
ちょっと実際にMCPを呼び出しているかがわかりにくいので、デバッグのログとしてMCPへのリクエスト、およびレスポンスを出すようにしてみます。
あなた: 30円のお菓子5個と100円のジュース2個買った時の合計金額は?
考え中...
反復 1/10
Ollama応答:
tool_calls検出: [{'function': {'name': 'calculator', 'arguments': {'a': 150, 'b': 200, 'operation': 'add'}}}]
Ollama tool_callsからツール検出: {'name': 'calculator', 'arguments': {'a': 150, 'b': 200, 'operation': 'add'}}
ツール実行: calculator with {'a': 150, 'b': 200, 'operation': 'add'}
MCPリクエスト送信: tools/call
パラメータ: {
"name": "calculator",
"arguments": {
"a": 150,
"b": 200,
"operation": "add"
}
}
MCPレスポンス受信:
結果: {
"content": [
{
"type": "text",
"text": "計算結果: 350"
}
]
}
MCPサーバー応答: {
"content": [
{
"type": "text",
"text": "計算結果: 350"
}
]
}
反復 2/10
Ollama応答: 30円のお菊を5個、100円のジュースを2個買うと、合計は **350円** です。
エージェント: 30円のお菊を5個、100円のジュースを2個買うと、合計は **350円** です。
微妙に思った通りになっていない(先に30*5
と100*2
をLLM側で計算しちゃっている)気がしますが、ちゃんとMCPサーバを呼び出し、その結果から出力を生成しているフローがわかりました。
改めて動作フロー
アクター
- ユーザ:チャットベースで命令を打ち込み
- エージェント:ユーザからの命令をもとにollamaやMCPサーバへの仲立ちを行う
- ollama:命令を受け取りLLMで応答を生成する
- MCPサーバ:使えるToolの一覧の返却、関数の実行
動作
- [エージェント] 初期化処理を行う。MCPからToolsの情報を取得したり、チャットの生成など
- [ユーザ] メッセージを入力
- [エージェント] システムプロンプトとしてMCPTools情報を、またユーザプロンプトとして入力されたメッセージをollamaに送信
- [ollama] 入力されたプロンプトをもとに応答を生成。MCPを使う必要があると判断すれば、MCPへのリクエスト情報を生成
- [エージェント] ollamaからの応答を受け取る。MCPへのリクエスト情報があればMCPサーバに対してリクエストを行う
- [MCPサーバ] エージェントから送信されたリクエスト情報をもとに関数を実行し、レスポンスを返す
- [エージェント] MCPサーバからのレスポンスをもとに、再度ollamaへのリクエストを送信する
- [ollama] エージェントから送られたプロンプトをもとにさらに応答を生成
- [エージェント] ollamaからの応答にMCPリクエストが含まれなくなるまで5~8を繰り返す。最終的な応答を標準出力に出す
感想
応答に関しては一部をCPUにオフロードしていることもあり1~2分かかってしまうのであまり実用的ではないのですが、実際に作ってみてAIエージェントの動作原理をかなり理解することができました。
自作なので特定の命令に対するフックなども自由に実装でき(ここではexitなど)、柔軟に動作するAIエージェントとして利用できそうです!
もうちょっとPCスペックを強くすれば、ある程度のスピード/精度でかつ自分好みにカスタムしまくった無料AIも夢ではない...?
(どう考えてもハードウェアへの課金のほうが高くつきますが)セキュリティや単純にロマンとして、ぜひ自作AIクライアントを作ってみてはいかがでしょうか?