OCI生成AIサービスで指定した地域のラーメン屋情報を教えてくれるMCPサーバを作ってみた

MCPの勉強がてら、指定した地域のラーメン屋を教えてくれるMCPサーバを作ってみました。
例えば「つくばのラーメン屋を教えて。」といった質問を想定しています。
実装にあたり「ホットペッパーグルメ Webサービス」のAPIを利用させて頂きました。
[![hotpepper-s.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33182/41736acb-2a32-479a-8b96-70021d7b9a97.gif)](http://webservice.recruit.co.jp/)

ちなみにラーメン屋を対象とした背景は、バイクでツーリングした先のラーメン屋を巡るのが趣味だからです。。

## 環境情報

以下サーバをOCI Computeで作成して動かしました。

- OS: Oracle Linux 8
- CPU: 1 OCPU
- Memory: 16 GB

生成AIはOCI生成AIサービスに最近追加された `cohere.command-a-03-2025` を使いました。

## 環境セットアップ

[こちらのMCPチュートリアル](https://modelcontextprotocol.io/quickstart/server)を参考にセットアップします。
まずはPython仮想環境を作るために `uv` をインストールします。

```bash
$ curl -LsSf https://astral.sh/uv/install.sh | sh
```

仮想環境を作ります。

```bash
# 本プロジェクト用にディレクトリを新規作成
$ uv init ramen
$ cd ramen

# 仮想環境を作ってアクティベート
$ uv venv
$ source .venv/bin/activate
```

必要なPythonパッケージを導入します。

```bash
$ uv add "mcp[cli]" httpx langchain_community langchain_mcp_adapters langgraph oci
```

OCI生成AIサービスと連携するには、oci-cliのインストールも必要です。
導入手順は以下の記事がご参考になります。
[Oracle Cloud : コマンド・ライン・インタフェース(CLI) をインストールしてみた](https://qiita.com/shirok/items/e63be9a22158fef10ba9)

## MCPサーバ実装

ホットペッパーグルメwebサービスから今回の要件に合う情報を取得するには、以下手続きを踏みます。

1. 指定された地域のエリアコードを、エリアマスタAPI経由で取得
1. 取得したエリアコードをグルメサーチAPIに渡し、ラーメン屋情報を取得

以上から、ツールとしては1.と2.に対応するものを用意するため、計2つのツールを作成しています。
以下コードを `ramen.py` というファイル名で保存します。

```python
import os
from dotenv import load_dotenv
from typing import Annotated
import httpx
from mcp.server.fastmcp import FastMCP

# ホットペッパーグルメwebサービス利用に必要なAPIキーを.envファイルから取得
load_dotenv()
API_KEY = os.environ['API_KEY']

# FastMCPサーバを初期化
mcp = FastMCP("ramen")

# APIエンドポイント、エージェント名を設定
API_BASE = "http://webservice.recruit.co.jp/hotpepper"
USER_AGENT = "ramen-app/1.0"

# MCPサーバを定義
@mcp.tool()
async def get_small_area_code(
    area_name: Annotated[str, "小エリアコードを取得したい市区名 (例: 京都市)"]
) -> str:
    """
    リクルートWebサービスから市区名をもとに小エリアコードを取得する。
    """
    url = f"{API_BASE}/small_area/v1/?key={API_KEY}&format=json"
    headers = {
        "User-Agent": USER_AGENT,
        "Accept": "application/json"
    }
    async with httpx.AsyncClient() as client:
        try:
            response = await client.get(url, headers=headers, timeout=30.0)
            response.raise_for_status()
            area_code = ""

            for each_area in response.json()["results"]["small_area"]:
                if area_name in each_area["name"]:
                    area_code = each_area["code"]
                    break
            return area_code

        except Exception as e:
            return f"データ取得中にエラーが発生しました: {repr(e)}"

@mcp.tool()
async def get_gourmet_info(
        small_area_code: Annotated[str, "ラーメン屋の情報を取得したい小エリアのコード (例: X010)"]
) -> str:
    """
    リクルートWebサービスから小エリアコードをもとに指定地域のラーメン屋情報を取得する。
    """
    url = f"{API_BASE}/gourmet/v1/?key={API_KEY}&small_area={small_area_code}&genre=G013&format=json"
    headers = {
        "User-Agent": USER_AGENT,
        "Accept": "application/json"
    }
    async with httpx.AsyncClient() as client:
        try:
            response = await client.get(url, headers=headers, timeout=30.0)
            response.raise_for_status()
            shop_info_list = ""

            for each_shop in response.json()["results"]["shop"]:
                shop_info_list = shop_info_list + f"""
店名: {each_shop["name"]}　{each_shop["name_kana"]}
特徴: {each_shop["catch"]}

"""
            return shop_info_list

        except Exception as e:
            return f"データ取得中にエラーが発生しました: {repr(e)}"
    
if __name__ == "__main__":
    mcp.run(transport="stdio")
```

## MCPクライアント実装

MCPクライアント実装にあたり、以下記事を参考にさせて頂きました。
[MCPで変わるAIエージェント開発 - MCPクライアントのコード](https://qiita.com/ksonoda/items/1c681a563a95a93975ff#mcp%E3%82%AF%E3%83%A9%E3%82%A4%E3%82%A2%E3%83%B3%E3%83%88%E3%81%AE%E3%82%B3%E3%83%BC%E3%83%89-1:~:text=%E4%BF%9D%E5%AD%98%E3%81%97%E3%81%BE%E3%81%99%E3%80%82-,MCP%E3%82%AF%E3%83%A9%E3%82%A4%E3%82%A2%E3%83%B3%E3%83%88%E3%81%AE%E3%82%B3%E3%83%BC%E3%83%89,-%E5%9F%BA%E6%9C%AC%E6%A7%8B%E9%80%A0%E3%81%AF)

以下コードを `client.py` というファイル名で保存します。
ちなみに入力プロンプトをハードコーディングしており、次の質問を投げています。
>  "つくばのラーメン屋を教えて下さい。"

```python
import asyncio
import oci
from langchain_community.chat_models import ChatOCIGenAI
from langchain_mcp_adapters.client import MultiServerMCPClient
from langgraph.prebuilt import create_react_agent

# OCI認証情報の定義
config = oci.config.from_file("~/.oci/config", "DEFAULT")

# 連携するLLMの定義
model = ChatOCIGenAI(
    model_id="cohere.command-a-03-2025",
    service_endpoint="https://inference.generativeai.ap-osaka-1.oci.oraclecloud.com",
    compartment_id="your compartment id",
    model_kwargs={"temperature": 0.7, "max_tokens": 500}
)

async def main():
   # MCPサーバーの定義
    client = MultiServerMCPClient(
        {
            "ramen": {
                "command": "python",
                "args": ["/home/opc/ramen/ramen.py"],
                "transport": "stdio",
            }
        }
    )

    # MCPサーバーをツールとして定義
    tools = await client.get_tools()

    # エージェントの定義
    agent = create_react_agent(model, tools)

    # 入力プロンプトの定義
    agent_response = await agent.ainvoke({
        "messages": "つくばのラーメン屋を教えて下さい。"
    })

    # 出力結果の表示
    messages = agent_response.get("messages")
    for each_message in messages:
        # メッセージタイプを出力
        print(f"\n--- {type(each_message).__name__} ---")
        # メッセージ本文を出力
        print(each_message)

# エージェントの実行
if __name__ == "__main__":
    asyncio.run(main())
```

## 実行してみる

MCPクライアントを以下のように実行します。

```bash
$ uv run client.py
```

結果は以下のようになります。
※主要な出力のみ抜粋した上で整形しています。

```
--- HumanMessage ---
content='つくばのラーメン屋を教えて下さい。'

--- AIMessage ---
content='まず、つくばのラーメン屋を検索するために、つくばの「小エリアコード」を取得します。その後、そのコードを用いてラーメン屋情報を検索します。...
tool_calls=[{'name': 'get_small_area_code', 'args': {'area_name': 'つくば'}, 'id': '25379111eba44ea492970dca4ed241f5', 'type': 'tool_call'}]

--- ToolMessage ---
content='X587' name='get_small_area_code' id='d95115c7-ebd7-47b9-bce2-6b6f69dd429a' tool_call_id='25379111eba44ea492970dca4ed241f5'

--- AIMessage ---
content='つくばの小エリアコードはX587でした。次に、このコードを用いてラーメン屋情報を検索します。...
tool_calls=[{'name': 'get_gourmet_info', 'args': {'small_area_code': 'X587'}, 'id': 'c44e5614981742359bd8ba0d5ecce0d2', 'type': 'tool_call'}]

--- ToolMessage ---
content='\n店名: 山岡家 つくば中央店\u3000やまおかや\u3000つくばちゅうおうてん\n特徴: \n\n\n店名: 天下一品 つくば店\u3000てんかいっぴん\u3000つくばてん\n特徴: TVでおなじみの店 超濃厚こってりスープ\n\n\n店名: 丸源ラーメン つくば店\u3000まるげんらーめん\u3000つくばてん\n特徴: イベント毎日開催中☆ 女性一人でも入りやすい店\n\n\n店名: 龍郎\u3000たつろう\n特徴: 安い！！ 野菜増し無料！\n\n\n店名: 活龍\u3000かつりゅう\n特徴: 龍神ぎょうざ 人気！とんこつ醤油\n\n' name='get_gourmet_info' id='106cc1d4-39ec-4ef1-8756-368789906dd2' tool_call_id='c44e5614981742359bd8ba0d5ecce0d2'

--- AIMessage ---
content='以下のラーメン屋がつくばにあります。\n\n- 山岡家 つくば中央店\n- 天下一品 つくば店\n- 丸源ラーメン つくば店\n- 龍郎\n- 活龍'
additional_kwargs={'documents': [{'id': 'get_gourmet_info:0:3:0', 'output': '\n店名: 山岡家 つくば中央店\u3000やまおかや\u3000つくばちゅうおうてん\n特徴: \n\n\n店名: 天下一品 つくば店\u3000てんかいっぴん\u3000つくばてん\n特徴: TVでおなじみの店 超濃厚こってりスープ\n\n\n店名: 丸源ラーメン つくば店\u3000まるげんらーめん\u3000つくばて ん\n特徴: イベント毎日開催中☆ 女性一人でも入りやすい店\n\n\n店名: 龍郎\u3000たつろう\n特徴: 安い！！ 野菜増し無料！\n\n\n店名: 活龍\u3000かつりゅう\n特徴: 龍神ぎょうざ 人気！とんこつ醤油\n\n'}]
```

以上の通り、AIエージェントが自律的に適切なツールを適切な順番で呼び出し、最終的に欲しい回答を導き出している様子が分かります。
結果を見て実際に使えそうな印象を受けましたので、今度ツーリングする際は情報収集に使おうと思いました。

またMCPサーバは今回の方法で手軽に追加できそうなため、色々と機能拡張を試したいです。