MCPの公式チュートリアルで新時代の開発を見た

https://modelcontextprotocol.io/tutorials/building-mcp-with-llms

着手する前に、最後まで読んだ。
新時代のチュートリアルを見た感覚だ。
コード片は一切なく、LLMに与えるべき情報と守るべきルールが記載されている。
ページ全体をコピーするボタンすらついている。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2559587/fd93a017-2e39-4d8a-bf88-1841c06647d5.png)

## ChatGPTと相談
何のMCPサーバーを作ろうか相談しようとした。
`MCPサーバーを作ろうと思うんだ`って言ったら、`いいね！MCPサーバー（Minecraft Plugin Server）のチュートリアル用なら、...`って返されたので慌てて止めた。
その後、ちょっと噛み合っていない感じなので、チュートリアルに従ってコンテキストを与えることにした。
以下のページのURLを与えて、見てもらう：
- https://modelcontextprotocol.io/llms-full.txt
- https://github.com/modelcontextprotocol/python-sdk/blob/main/README.md

要約してくれて、私が認識している情報との確認もできた。

チュートリアルでまずやってみたいのが、外部APIから情報を取得してAIに接続するためのMCPサーバー。なので、定番の天気APIでMCPサーバーを作成することにした。

## CursorでMCPサーバー作成にトライ
まず、GitHub上でリポジトリを作り、Cursorでそのリポジトリを開いた。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2559587/84f089bb-b637-42f5-bf71-7fe9bbf21bdf.png)
これ使ってみる。
Webを選んで、上記2ページのURLを与えて...あ、そのままSendしてしまった。
（後記：多分ここでWebを選んだのは間違いだった。Web検索をしてしまったようだ）
特に何が作りたいとか言っていないのに、MCPを使用して天気予報データを取得・表示するWebアプリケーションを作り始めてしまった。
改めて、
```
Open-Meteo APIに接続して、ある地点の天気情報を取得するMCPサーバーを作りたい
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2559587/f2c10e85-d63b-4cca-ad08-fbfbce374356.png)

あっという間にできた。

でも、一発では動かなかった。
importの情報が古かったり、dependencyの書き方が間違っていたりした。一つ一つ修正して行って、動き始めた🎉
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2559587/95ff5aa1-b2d5-40b8-a18f-69b4752c8cdc.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2559587/f0eff6ae-3968-49b9-ba21-f4fc773f10a2.png)

次はクライアントか👀
修正を何周かした。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2559587/a28fad00-de07-43eb-9174-2acf59a0f567.png)

怖い。

途中でクライアントが動いたのに、Popout Terminalをクリックしたら失敗したと認識されて明後日の方向に修正し始めたので途中で止めた。巻き戻し過程でファイルが一つ消えた...
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2559587/7cf0e02f-ba22-4b5f-90dd-bee4ac47417e.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2559587/44f32eb6-f7a5-4766-ba56-cae178714bd0.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2559587/c33fdab0-b839-4f05-9d99-ee5583db82a5.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2559587/72ca829e-a820-4bc1-b33f-1281e058f7c6.png)

動いた🎉
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2559587/14280c60-286c-45c2-98a6-c9e560a41064.png)

### PRを作ってもらった

https://github.com/kaisumi/weather-mcp/pull/1

PRを作る時、ブランチの切り方とか間違えまくりでクレジットを多く消費してくださるので、Git操作は人間がやったほうが良いかも。。。概要欄を丁寧に書いてくれるのはとてもありがたいので、その部分では今後もあやかりたい。

## コードを見る
### ディレクトリ構造
```
app/
├─ __init__.py
├─ client.py
├─ main.py
├─ mcp_server.py
├─ server.py
└─ weather_service.py
```

### クライアント起動のコマンドから辿る
```zsh
python -m app.client
```
client.pyはこちら：
https://github.com/kaisumi/weather-mcp/pull/1/files#diff-fad8b102fa5b64491ee38b22f7558f96b7bed87f1fa549463a52a07eb9403058

```py
if __name__ == "__main__":
    asyncio.run(main()) 
```

- その.pyファイルが「直接実行」されたときだけ asyncio.run(main()) を動かす
- モジュール（import）されたときは動かない

```py
async def main():
    """メイン関数"""
    city = input("天気情報を取得する都市名を入力してください: ")
    await get_weather(city)
```
1. input() でユーザーに都市名を入力させる（同期処理）
2. 入力した都市名を get_weather(city) に渡して実行（非同期処理だから await）


以下はget_weatherの一部：
```py
async def get_weather(city: str):
    """指定した都市の天気情報を取得する"""
    try:
        # HTTPクライアントを初期化
        base_url = os.getenv("MCP_BASE_URL", "http://localhost:8000")
        async with httpx.AsyncClient(base_url=base_url) as client:
            # 天気情報を取得
            response = await client.post(
                "/mcp",
                json={
                    "method": "call_tool",  # MCPの決まり文句
                    "params": {
                        "name": "get_weather",  # 実行したいTool名
                        "arguments": {"city": city}  # Toolに渡す引数
                    }
                }
            )
            response.raise_for_status()
            data = response.json()
```

#### server側を見る
app/server.py
```py
from .mcp_server import mcp
...
@app.post("/mcp")
async def handle_mcp_request(request: Request):
    """MCPリクエストを処理するエンドポイント"""
    try:
        # リクエストボディを取得
        body = await request.json()

        # MCPリクエストを処理
        if body["method"] == "call_tool":
            tool_name = body["params"]["name"]
            arguments = body["params"]["arguments"]

            if tool_name == "get_weather":
                result = await mcp.call_tool(tool_name, arguments) # 核心
                return result
            else:
                return {
                    "success": False,
                    "error": f"未知のツール: {tool_name}"
                }
        else:
            return {
                "success": False,
                "error": f"未知のメソッド: {body['method']}"
            }

    except Exception as e:
        return {
            "success": False,
            "error": f"リクエストの処理中にエラーが発生しました: {str(e)}"
        }
```

app/mcp_server.py
```py
...
from app.weather_service import WeatherService
...
@mcp.tool()
async def get_weather(city: str) -> dict:
    """指定した都市の天気情報を返すTool"""
    if not city:
        return {"error": "都市名が指定されていません"}

    try:
        result = await weather_service.get_weather_for_city(city) # ここでAPIを叩くのかな
        return result
    except Exception as e:
        return {
            "error": str(e)
        } 
```
※@mcp.tool() は MCP SDK の「この関数を Tool（＝LLMが実行できるAPI）として登録するよ！」という宣言（デコレーター）。

app/weather_service.pyより、get_weather_for_cityの一部：
```py
    async def get_weather_for_city(self, city: str) -> Dict[str, Any]:
        """都市名から天気情報を取得する"""
        try:
            # 都市名から緯度経度を取得
            location = await self.get_coordinates(city)

            # 緯度経度から天気情報を取得
            weather_data = await self.get_weather(location["latitude"], location["longitude"])

            # 現在の天気情報を整形
            current = weather_data["current"]
            current_condition = self.get_weather_condition(current["weather_code"])
            ...
            
            # 結果を整形
            result = {
                "location": {
                    "name": location["name"],
                    "country": location["country"],
                    "latitude": location["latitude"],
                    "longitude": location["longitude"],
                    "timezone": weather_data["timezone"]
                },
                "current": {
                    "temperature": current["temperature_2m"],
                    "feels_like": current["apparent_temperature"],
                    "humidity": current["relative_humidity_2m"],
                    "wind_speed": current["wind_speed_10m"],
                    "wind_direction": current["wind_direction_10m"],
                    "precipitation": current["precipitation"],
                    "condition": current_condition,
                    "weather_code": current["weather_code"]
                },
                "forecast": daily_forecast
            }

            return result
```

緯度経度取得部分：
```py
    GEOCODING_URL = "https://geocoding-api.open-meteo.com/v1/search"
    WEATHER_URL = "https://api.open-meteo.com/v1/forecast"

    async def get_coordinates(self, city: str) -> Dict[str, Any]:
        """都市名から緯度経度を取得する"""
        async with aiohttp.ClientSession() as session:
            params = {
                "name": city,
                "count": 1,
                "language": "ja",
                "format": "json"
            }
            async with session.get(self.GEOCODING_URL, params=params) as response:
                if response.status != 200:
                    raise Exception(f"Geocoding API error: {response.status}")

                data = await response.json()
                if not data.get("results"):
                    raise Exception(f"City '{city}' not found")

                return data["results"][0]
```
geocoding-apiというAPIを叩いていたのね。それで都市名（しかも日本語）から取得できていたのか👀

緯度経度から天気を取得する部分：
```py
    async def get_weather(self, latitude: float, longitude: float) -> Dict[str, Any]:
        """緯度経度から天気情報を取得する"""
        async with aiohttp.ClientSession() as session:
            params = {
                "latitude": latitude,
                "longitude": longitude,
                "current": ["temperature_2m", "relative_humidity_2m", "apparent_temperature", 
                           "precipitation", "weather_code", "wind_speed_10m", "wind_direction_10m"],
                "daily": ["temperature_2m_max", "temperature_2m_min", "precipitation_sum", 
                         "weather_code", "sunrise", "sunset"],
                "timezone": "auto"
            }
            async with session.get(self.WEATHER_URL, params=params) as response:
                if response.status != 200:
                    raise Exception(f"Weather API error: {response.status}")

                return await response.json()
```

### コードを読み進めてみて思ったこと
APIを叩いて、JSONでクライアントに返している。決まった形のJSONで返すんだったら、APIと変わらない気がする。MCPの良さとは？

## MCPの強み
ChatGPTに聞いてみた。
- API仕様を自動的にLLMが取得できるようにしたこと

- 外部実行を標準化したこと
- Contextでユーザーごとの情報を自動注入できること
- MCP対応クライアントは統一仕様で動けること

本当かな...
### API仕様を自動的にLLMが取得できるようにしたこと
これは公式で明記されていた。

> Resources are a core primitive in the Model Context Protocol (MCP) that allow servers to expose data and content that can be read by clients and used as context for LLM interactions.

https://modelcontextprotocol.io/docs/concepts/resources

つまり、API仕様をLLM側から取りに行く場所が決まっているので、LLMが使い方に困らないということ。
今回作ったMCPサーバーにはresourcesがなかったはず。
Cursorに直してもらっているけど、どつぼにハマったので自分で直したい。自分で直したいけどドキュメントがLLM前提に作られているような。

https://modelcontextprotocol.io/quickstart/server

ここ見たらいいかな。

## 感想
Cursorで頑張ってみたけど、実装ミスに振り回されて開発体験があんまり良くない...他のエージェントを試してみようかな。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2559587/acb89a95-8805-48b9-a2d6-fd61d67806ed.png)


## 今日MCPを頑張ってみてわかったこと
LLMは、そのままではAPIを叩けない。コンテキストとしてAPI仕様を入れる必要がある。だからMCPとして公開すれば、MCP Clientを搭載したLLMがそれらを使える利点がある。Resourcesを使えば統一した方法で仕様を入手できるのが目玉なのかと思ったけど、サポート表を見るとこの機能を使っているツールは少ない様子。つまり、ResourcesがなくてもLLMに使わせられるから別にいらないってことなのかな🤔でも、Resourcesがいらないなら、既存のAPIの仕様を事前に学習させておけばいい、またはリストを用意して見に行かせればいいよね。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2559587/3a8c81b4-16b1-4904-8c1e-4a0376a6d09d.png)