📕

# MCPサーバー自作に挑戦_vol1:🎌 日本の気象庁データでAIエージェントをローカライズせよ!MCPサーバー知識ベース構築記

に公開

1.挑戦の動機と「ローカライズ」の壁

長らく続けてきたMCP(Model Context Protocol)の学習は、ついに「利用」から「開発」のフェーズへ進みました。MCPサーバーの開発は難易度が高いと思っていましたが、公式のクイックスタートに沿って進めた結果、予想以上に簡単にMCPサーバーを作成し、Claude Desktopで利用可能にできましたゼロから作るMCPサーバー

クイックスタートでは、米国気象庁のAPIを用いてラスベガスの天気予報を取得するサーバーの実装に成功しました。この体験は非常に素晴らしいものでしたが、同時に「このサーバーは日本の予報を取り込むことはできない」というモヤモヤも残りました。
カリフォルニア・デスバレーが50℃記録しても、Kyuusyu, Japanではニュースにもなりませんしね…。

これが、今回の挑戦の動機です。**「AIエージェントを日本のデータソースでローカライズする」**ことこそが、MCPの応用力を示す絶好のアイデアだと確信しました。


2.データソースの選定と要件定義

MCPサーバー開発の第一歩は、LLMに与える知識ベース、すなわちデータソースの選定です。

2.1. データソースの決定

チュートリアル的な実用性を問わない学習サーバーなので、外部APIの接続エラーやレート制限を回避するため、ローカルファイルをデータベースにすることに決定しました。

  • 選定データ: 気象庁の「日別平年値(1991~2020年)」ファイル
    気象庁ホーム > 各種データ・資料 > 過去の気象データ検索 > [平年値ダウンロード]
  • 理由: 過去の安定した気候データを扱うことで、MCPサーバーのプロトコルとツールロジックの開発に集中できます。

2. LLMへのプロンプトと要件定義

LLMの力を借りながら、以下の要件でツールを定義しました。これは、AIエージェントの「思考」に必要な情報(コンテキスト)を正確に設計するプロセスです。
LLMへの指示は、The Flipped Interaction Pattern (反転インタラクョンパターン)という手法を用いました。通常ユーザーがLLMに質問を投げますが、このパターンでは逆にLLMがユーザーに対して、情報を促す質問をします。こちらから情報を提供する形でより良い提案を引き出すことを目指します。LLMからの質問に関しては、最終的には自身で考える必要があります。ただし、サンプルを出力させることはできますので、サンプルの要件を出力させ、要件定義を進めていきました。

  • 目標: ツール名get_jp_past_weatherとして、アメダス地点の過去の平均気温や降水量を返すツールを作成する。
  • データソース: 気象庁の**「日別平年値(1991~2020年)」**ファイル。これは、API利用時のレート制限を避け、データ処理の学習に集中するために最適な静的データでした。
項目 定義 目的
ツール名 get_jp_past_weather 過去の気候データ取得ツールとして明確化。
入力 amedas_id (int), month (int), day (int) LLMが検索に必要なキー情報を正確に理解するよう訓練する。
出力 地点名、緯度・経度、平均/最高/最低気温、降水量 データと地理情報を両方提供し、LLMの推論能力を最大化する。
作成したMCPサーバー要件定義書

📄 MCPサーバー要件定義書:日本版過去天気サーバー (get_jp_past_N_weather)

1. プロジェクト概要

項目 定義 目的
MCPサーバー名 jp-climate (ローカルフレンドリ名) 複数のサーバーと統合する際の識別名。
プロジェクト目的 MCPのツール定義、データ処理、JSONスキーマの学習。 外部APIエラーやレート制限を回避し、プロトコル実装技術の習得に集中する。
データソース 気象庁 日別平年値(1991~2020年)ファイル ローカライズされたデータ処理の練習、安定した環境で動作検証を行う。
開発環境 Python / FastMCP SDK 既存の学習リソース(Fetchサーバー、Neo4jサーバーなど)との互換性を維持。

2. ツール仕様(get_jp_past_weather)

このツールは、LLMが指定した地点と日付の過去の気候データをデータベースから検索し、構造化された情報として返却します。

2.1. 入力スキーマ (Input Schema)

パラメータ名 型(JSON Schema) 必須/オプション 役割と説明(LLMへのヒント)
amedas_id string 必須 アメダス地点番号(4桁のコード)。データ検索のキー。
month integer 必須 検索したい月(1〜12)。
day integer 必須 検索したい日(1〜31)。

2.2. 返却データ (Output Schema)

ツール実行結果としてLLMに提供されるデータ構造。

項目 型(JSON Schema) 単位 備考
location_name string N/A 観測地点名(例:熊本)。
latitude number 観測地点の緯度情報。
longitude number 観測地点の経度情報。
avg_temp number 日平均気温(平年値)。
max_temp number 日最高気温(平年値)。
min_temp number 日最低気温(平年値)。
avg_precipitation number mm 日平均降水量(平年値)。

3. 実行環境の設定

Claude Desktop の claude_desktop_config.json ファイルには、以下のように設定を記述する必要があります。

{
  "mcpServers": {
    /* ... 既存の filesystem, fetch, movies-neo4j の設定 ... */
    "jp-climate": {
      // uvコマンドがインストールされている場所を正確に指定する必要があります
      "command": "C:\\Users\\Koga Hiroaki\\AppData\\Local\\...\\uv.exe", 
      "args": [
        "--directory",
        "C:\\Users\\Koga Hiroaki\\path\\to\\weather_project",
        "run",
        "weather.py"
      ]
    }
  }
}

3.実践データソースの作成と大規模データ統合の記録

この要件に基づき、Google Colabolatory上でPython(Pandas)を用いてデータソースの作成を実践しました。

3.1. JMAデータの壁とロバストな読み込み

気象庁のデータは、特殊なエンコーディング(CP932)複雑なヘッダー形式を持つため、通常のpd.read_csv()では読み込みエラーが多発しました。
ダウンロードしたファイルを解凍して開くと以下のような意味不明の数字が入っていました。

25,12011,0500,30,1991,2020, 1,   -60,8,   -61,8,   -62,8,   -63,8,   -65,8,   -67,8,   -68,8,   -70,8,   -72,8,   -73,8,   -75,8,   -76,8,   -78,8,   -79,8,   -79,8,   -80,8,   -80,8,   -80,8,   -80,8,   -80,8,   -80,8,   -79,8,   -79,8,   -79,8,   -79,8,   -79,8,   -80,8,   -80,8,   -80,8,   -81,8,   -81,8

気象庁の資料によると、各数字にユニークな意味づけがされているようでした。

平年値種別25(固定),観測所番号,要素番号,資料年数,統計開始年,統計終了年,,1日,同RMK,2日,同RMK,3日,同RMK,…略…,30日,同RMK,31日,同RMK,

  • 解決策: ファイルをバイナリとして読み込み、デコードエラーを無視するロバストな読み込み関数を実装しました。これにより、エンコーディングの壁を乗り越え、データ処理に進むことができました。

3.2.🛠️ Pythonコード:データソース変換スクリプト

3.2.1.このコードは、アップロードされた2つのCSVファイル(気象データと地点情報)を読み込み、MCPサーバーが必要とする情報(地点名、緯度、経度、各種気温・降水量)を一つのテーブルに結合して、最終的なデータソースファイルとして保存します。

地点情報はcsvファイル : "amedas_station_index.csv"
気象データは複数あるのでzipファイル : "normal_amedas_daily.zip"
です。

ステップ 1: ファイルの確認と準備
JMAのファイル形式が特殊なため、まずは地点情報ファイルを読み込み、その構造を確認します。

Python script_1:ライブラリインポート、定義とエンコーディングの定義
import pandas as pd
import numpy as np
import io
import zipfile
import tempfile
from pathlib import Path
import re # 正規表現モジュールをインポート

# --- 1. 定数とエンコーディングの定義 ---
ENCODING = 'cp932'
ZIP_FILE = "normal_amedas_daily.zip"
INDEX_FILE = "amedas_station_index.csv"
OUTPUT_FILE = "mcp_weather_data_all.csv" # 全データ統合のためファイル名を変更

# JMAデータ特有の文字化けエラーを回避するためのロバストな読み込み関数
def read_jma_csv_robust(file_path, **kwargs):
    """JMAデータ特有の文字化けエラーを無視してCSVを読み込む関数。"""
    try:
        # cp932で読み込み、デコードエラーを無視
        with open(file_path, 'rb') as f:
            content = f.read().decode(ENCODING, errors='ignore')
        return pd.read_csv(io.StringIO(content), **kwargs)
    except Exception as e:
        print(f"致命的なエラーが発生しました: {e}")
        return None
Python script_2:地点情報ファイル読み込み
# --- 2. 地点情報 (amedas_station_index.csv) のパース ---
print("--- 2. 地点情報ファイルのパースと座標変換 ---")
df_index = read_jma_csv_robust(INDEX_FILE, header=0)

if df_index is None:
    raise RuntimeError("地点情報ファイルの読み込みに失敗しました。")

# 必要なカラム名にリネームと情報抽出
df_index = df_index.rename(columns={
    'Station Number': 'amedas_id',
    'Station Name': 'location_name',
    'Latitude': 'lat_deg',
    'Latitude.1': 'lat_min',
    'Longitude': 'lon_deg',
    'Longitude.1': 'lon_min',
})

# 緯度・経度を10進数に変換 (DD = Degrees + Minutes/60)
for col in ['lat_deg', 'lat_min', 'lon_deg', 'lon_min']:
    df_index[col] = pd.to_numeric(df_index[col].astype(str).str.strip().replace(' ', np.nan), errors='coerce')

df_index['latitude'] = df_index['lat_deg'] + df_index['lat_min'] / 60
df_index['longitude'] = df_index['lon_deg'] + df_index['lon_min'] / 60

# amedas_idを整数型に変換し、結合用のクリーンなDFを作成
df_index_clean = df_index[['amedas_id', 'location_name', 'latitude', 'longitude']].dropna(subset=['amedas_id'])
df_index_clean['amedas_id'] = df_index_clean['amedas_id'].astype(int)
出力:地点情報ファイルdataflame表示
print(df_index_clean.head())
print(df_index_clean.info())
 amedas_id location_name   latitude   longitude
1      11001    宗谷岬         45.520000  141.935000
2      11016    稚内          45.415000  141.678333
3      11046    礼文          45.305000  141.045000
4      11061    声問          45.403333  141.801667
5      11076    浜鬼志別        45.335000  142.170000
<class 'pandas.core.frame.DataFrame'>
Index: 1298 entries, 1 to 1298
Data columns (total 4 columns):
 #   Column         Non-Null Count  Dtype  
---  ------         --------------  -----  
 0   amedas_id      1298 non-null   int64  
 1   location_name  1298 non-null   object 
 2   latitude       1298 non-null   float64
 3   longitude      1298 non-null   float64
dtypes: float64(2), int64(1), object(1)
memory usage: 50.7+ KB
None

3.2.2💻 Pythonコード:月日カラムの修正とワイド形式からロング形式への変換

地点情報ファイルの前処理が完了したので、次は気象データの番です。

このPythonコードは、以下の2点を達成します。

  • 月 (Month) のカラムインデックス=6を指定し、正しい月データを取り込みます。

  • JMAデータが持つワイド形式(1行に31日分のデータが含まれる)を、データベースとして使いやすいロング形式(1行が「特定の日付の特定の値」となる形式)に**アンピボット(Melt)**するロジックを実装します。

Python script_3:ワイド形式からロング形式へ変換する関数作成
# --- 3. 複数の観測要素を統合し、ワイド形式からロング形式へ変換する関数 ---

# JMAファイル構造から、必要なカラムのインデックスを定義します。
# 観測要素(平均気温など)は、ファイル内では Element Number (Index 2) で識別されます。
# 日別値カラムのインデックスを生成 (Index 7 から始まり、2つおきに値が入る)
DAY_VALUE_INDICES = [i for i in range(7, 69, 2)]

def process_single_amedas_file(file_path):
    """単一のJMAファイルを読み込み、ロング形式に変換する。"""

    df_raw = read_jma_csv_robust(file_path, skiprows=5, header=None)
    if df_raw is None or df_raw.empty:
        return pd.DataFrame()

    # 必須のIDカラムを抽出 (Index 1: 地点ID, Index 6: 月, Index 2: 要素番号)
    df_id_month = df_raw.iloc[:, [1, 6, 2]].rename(columns={1: 'amedas_id', 6: 'month', 2: 'element_code'})

    # 日付データ (Day 1, Day 2, ...) を抽出
    df_daily_values = df_raw.iloc[:, DAY_VALUE_INDICES]

    # ----------------------------------------------------------------------------------
    # 3.1. ロング形式への変換 (Unpivot / Melt)

    # ID/月/要素番号を結合
    df_full = pd.concat([df_id_month, df_daily_values], axis=1)

    # 日付を示すカラム名 (1, 2, 3, ..., 31) を生成
    day_columns = {idx: day + 1 for idx, day in zip(df_daily_values.columns, range(31))}
    df_full = df_full.rename(columns=day_columns)

    # ID/月/要素番号をキーとして、日々の値カラムをメルト
    df_long = df_full.melt(
        id_vars=['amedas_id', 'month', 'element_code'],
        var_name='day',
        value_name='value'
    )

    # 値を数値に変換(欠損値処理)
    df_long['value'] = pd.to_numeric(
        df_long['value'].astype(str).str.strip().replace([')', ']', '#', '×', ' '], np.nan),
        errors='coerce'
    )

    # ----------------------------------------------------------------------------------
    # 3.2. 観測要素をワイド形式に戻す (Pivot)

    # 要素番号を対応するメトリック名にマッピング -> 修正
   # ★ 最終的な要素番号とメトリック名のマッピングを更新 ★
    ELEMENT_MAP = {
        510: 'avg_temp',          # 平均気温
        600: 'max_temp',          # 日最高気温
        700: 'min_temp',          # 日最低気温
        4000: 'avg_precipitation', # 日降水量
        3500: 'sunshine_duration' # 日照時間 (新しいカラム)
    }

    # 必要な要素のみにフィルター
    df_long = df_long[df_long['element_code'].isin(ELEMENT_MAP.keys())]
    df_long['metric'] = df_long['element_code'].map(ELEMENT_MAP)

    # メトリック名をカラム名にするためにピボット
    df_final_pivot = df_long.pivot_table(
        index=['amedas_id', 'month', 'day'],
        columns='metric',
        values='value',
        aggfunc='first' # 同じキーを持つ値は最初のものを採用
    ).reset_index()

    # ★ 修正ポイント: 温度を0.1倍する ★
    TEMP_COLUMNS = ['avg_temp', 'max_temp', 'min_temp']
    for col in TEMP_COLUMNS:
        # カラムが存在する場合のみ処理 (データに存在しない場合はスキップ)
        if col in df_final_pivot.columns:
            df_final_pivot[col] = df_final_pivot[col] * 0.1

    # キーを整数型に修正
    for col in ['amedas_id', 'month', 'day']:
        df_final_pivot[col] = df_final_pivot[col].astype(int)

    return df_final_pivot

これでロング形式の月日の列もできました。

3.2.3 全アメダスデータの統合

今回のプロジェクトのハイライトは、単一のファイルではなく、ZIPファイル内に格納された大量のCSVデータを統合した点です。
処理フロー: normal_amedas_daily.zipを展開し、
pathlibモジュールでサブディレクトリ内の全ファイル(nml_amd_d_*.csv)を再帰的に走査しました。

Python script_4 :ZIPファイル展開と統合する関数の作成と実行
# --- 4. 全データの統合と保存 ---

def process_all_jma_data(zip_file_path):
    
    all_weather_dfs = []
    
    with tempfile.TemporaryDirectory() as temp_dir:
        with zipfile.ZipFile(zip_file_path, 'r') as zip_ref:
            zip_ref.extractall(temp_dir)
            
        temp_path = Path(temp_dir)
        weather_files = list(temp_path.glob('**/nml_amd_d_*.csv')) 
        
        print(f"--- {len(weather_files)}個の気象データファイルを発見しました。 ---")
        
        for file_path in weather_files:
            try:
                # 単一ファイル処理を実行
                df_processed = process_single_amedas_file(file_path)
                if not df_processed.empty:
                    all_weather_dfs.append(df_processed)
            except Exception as inner_e:
                print(f"Warning: {file_path.name} の処理中にエラーが発生しスキップされました。エラー: {inner_e}")
                continue
                
    if not all_weather_dfs:
        print("処理すべき気象データファイルが見つかりませんでした。")
        return pd.DataFrame()

    # 全ファイルを縦に連結し、地点情報と結合
    df_weather_combined = pd.concat(all_weather_dfs, ignore_index=True)
    df_final = pd.merge(df_weather_combined, df_index_clean, on='amedas_id', how='left')

    # 最終的なMCPサーバー用データソースとして保存
    df_final.to_csv(OUTPUT_FILE, index=False)
    
    print("\n--- 5. 最終データソースの確認 ---")
    print(f"最終ファイル名: {OUTPUT_FILE}")
    print(f"統合されたデータの総行数: {len(df_final)}")
    print("最終データ構造 (Head):")
    print(df_final.head())
    print("最終データ構造 (Info):")
    print(df_final.info())
    
    return df_final

# --- 実行 ---
# このコードはZIPファイルが必要です。
process_all_jma_data(ZIP_FILE)
print("修正後のPythonコードが完成しました。MCPサーバのデータソース作成にご利用ください。")

3.2.4. 成果: 各ファイルを個別にクリーニングし、一つのデータフレームに連結することで、最終的に482856行、33.2+ MBの巨大でクリーンなデータソース(mcp_weather_data_all.csv)が完成しました。

出力:MCPサーバのデータベースとなるアメダス平年値dataflame情報表示
--- 1298個の気象データファイルを発見しました。 ---

--- 5. 最終データソースの確認 ---
最終ファイル名: mcp_weather_data_all.csv
統合されたデータの総行数: 482856
最終データ構造 (Head):
   amedas_id  month  day  avg_precipitation  avg_temp  max_temp  min_temp  \
0      73442      1    1                 15       2.3      12.2       3.4   
1      73442      1    2                 15       2.3      12.1       3.3   
2      73442      1    3                 16       2.3      12.0       3.3   
3      73442      1    4                 16       2.3      12.0       3.3   
4      73442      1    5                 16       2.3      11.9       3.3   

   sunshine_duration location_name   latitude   longitude  
0                 37    宇和島         33.226667  132.551667  
1                 37    宇和島         33.226667  132.551667  
2                 36    宇和島         33.226667  132.551667  
3                 36    宇和島         33.226667  132.551667  
4                 36    宇和島         33.226667  132.551667  
最終データ構造 (Info):
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 482856 entries, 0 to 482855
Data columns (total 11 columns):
 #   Column             Non-Null Count   Dtype  
---  ------             --------------   -----  
 0   amedas_id          482856 non-null  int64  
 1   month              482856 non-null  int64  
 2   day                482856 non-null  int64  
 3   avg_precipitation  482856 non-null  int64  
 4   avg_temp           482856 non-null  float64
 5   max_temp           482856 non-null  float64
 6   min_temp           482856 non-null  float64
 7   sunshine_duration  482856 non-null  int64  
 8   location_name      482856 non-null  object 
 9   latitude           482856 non-null  float64
 10  longitude          482856 non-null  float64
dtypes: float64(5), int64(5), object(1)
memory usage: 40.5+ MB
None
修正後のPythonコードが完成しました。MCPサーバのデータソース作成にご利用ください。

5.:MCPサーバー作成にむけて

今回の実践により、MCPサーバーの実装は、単に関数にデコレーター(@mcp.tool())を付けるだけでなく、その裏側にあるデータ処理と環境構築が成功の鍵を握ることがわかりました。
LLMにまかせてデータ前処理が完成した!と思っていましたが、何度か推敲を重ねるうちにデータのカテゴリが合ってないことに気付きました。AIが動くコードを作ってくれても、完成した出力のチェックは欠かせないという学びもありました。

次回は、今回作成したmcp_weather_data_all.csvファイルを読み込むカスタムMCPサーバー(jp-climate)をPythonで実装し、Claude Desktopを通じて**「日本の気候を問い合わせるAIエージェント」**を完成させます。

Discussion