🔰

手を動かして学ぶ!MCPステップバイステップ実践ガイド for Beginners - Vol.7 MCP通信のエラー処理

に公開

はじめに

皆さん、こんにちは!「手を動かして学ぶ!MCPステップバイステップ実践ガイド for Beginners」へようこそ。このシリーズでは、Model Context Protocol (MCP) という仕組みを、Pythonを使って実際に手を動かしながら学んでいます。

前回 (Vol.6 MCPモデル情報の登録(POST)&更新(PUT)) は、クライアントからサーバーへ新しい情報を登録したり(POST)、既存の情報を更新したり(PUT)する方法を学びましたね。これで、MCPデバイスの情報を読み取るだけでなく、作成・更新もできるようになりました。

しかし、プログラムの世界では、予期せぬ出来事、つまり「エラー」がつきものです。特に、私たちのMCPクライアントとサーバーのようにネットワークを介して通信する場合、サーバーが応答しない、送ったデータが正しくない、途中で通信が途切れてしまうなど、様々なエラーが発生する可能性があります。

今回は、そんな「もしも」の事態に備えるための重要なテクニック、「エラー処理」について学びます。「転ばぬ先の杖」ということわざがあるように、事前にしっかりと対策をしておくことで、プログラムが突然止まってしまったり、ユーザーが混乱したりするのを防ぐことができます。Pythonの try...except という仕組みを使って、エラーに強い、より安定したプログラムを目指しましょう!

1. "転ばぬ先" のエラーたち: MCP通信でよく出会う問題

クライアントとサーバーがお互いにやり取り(通信)をするとき、どんな問題が起こりうるのでしょうか?いくつか代表的な例を見てみましょう。これらは、まるで人間同士のコミュニケーションで起こる行き違いやトラブルに似ています。

1. お店が開いていない (サーバーが起動していない)

  • 状況: あなたがお気に入りのレストランに電話をかけようとしたら、お店がまだ開店していなかったり、定休日だったりするケースです。
  • MCP通信では: クライアントがサーバーに接続しようとしたけれど、サーバープログラムが起動していない、または何らかの理由で停止している状態です。
  • Python (requestsライブラリ)でよく見るエラー: requests.exceptions.ConnectionError (接続エラー)

2. 宛先が違う・道に迷った (URLが間違っている)

  • 状況: 電話番号を間違えて別の人にかけてしまったり、お店の住所を間違えて全然違う場所に行ってしまったりするケースです。
  • MCP通信では: クライアントが指定したサーバーのURL(住所や電話番号のようなもの)が間違っている状態です。例えば、サーバーのアドレスやポート番号が違う、あるいは特定のリソース(例: /devices)へのパスに誤りがあるなど。
  • Pythonでよく見るエラー:
    • サーバー自体が見つからなければ requests.exceptions.ConnectionError
    • サーバーは見つかるが指定したページ(パス)がなければ、サーバーから「そんなページはありませんよ」という応答 (HTTP 404 Not Foundエラー) が返ってきて、requests.exceptions.HTTPError が発生することがあります。

3. 電話回線が混雑している・電波が悪い (ネットワークの問題)

  • 状況: 電話をかけても話し中だったり、携帯電話の電波が悪くて声が途切れたり、応答が非常に遅かったりするケースです。
  • MCP通信では: インターネット接続が不安定だったり、サーバーが多くのリクエストを処理していて応答が遅延したり、一定時間内に応答が返ってこない(タイムアウト)状態です。
  • Pythonでよく見るエラー: requests.exceptions.Timeout (タイムアウトエラー)、requests.exceptions.ConnectionError (接続エラー全般)

4. お店の人が注文を間違えた・厨房でトラブル (サーバー側の処理エラー)

  • 状況: レストランで注文した料理と違うものが出てきたり、厨房で何かトラブルがあって料理が提供できなかったりするケースです。
  • MCP通信では: クライアントからのリクエストはサーバーに届いたものの、サーバー内部のプログラムで予期せぬ問題が発生し、正常に処理を完了できなかった状態です。
  • サーバーからの応答: HTTP 500 Internal Server Error などの 5xx系のエラーステータスコード。
  • Pythonでよく見るエラー: requests.exceptions.HTTPError (HTTPエラーステータスコードを受け取った場合)

5. 無茶な注文・伝え方が悪い (クライアント側のリクエスト不正)

  • 状況: メニューにない料理を注文したり、注文の仕方が悪くて店員さんに意図が伝わらなかったりするケースです。
  • MCP通信では: クライアントがサーバーに対して、正しくない形式のデータを送ったり、必要な情報を含めずにリクエストを送ったりした状態です。例えば、新しいデバイスを登録する際に必須のdeviceIdを付け忘れたり。
  • サーバーからの応答: HTTP 400 Bad Request、HTTP 404 Not Found (更新対象のデータが見つからない場合など)、HTTP 409 Conflict (既に存在するIDで登録しようとした場合など) といった 4xx系のエラーステータスコード。
  • Pythonでよく見るエラー: requests.exceptions.HTTPError

6. 期待していた情報と違うものが来た (レスポンス形式の不一致)

  • 状況: レストランのメニューを写真で頼んだつもりが、文字だけの説明書が渡されたようなケースです。
  • MCP通信では: クライアントはサーバーからJSON形式でデータが返ってくることを期待しているのに、実際にはHTMLやプレーンテキスト、あるいはエラーメッセージがJSONではない形式で返ってきた状態です。
  • Pythonでよく見るエラー: requests.exceptions.JSONDecodeError (または単に json.JSONDecodeError) (受け取ったデータをJSONとして解析しようとして失敗した場合)

これらのエラーは、プログラムを不安定にするだけでなく、使っている人を困惑させてしまいます。だからこそ、エラー処理が重要なのです。

2. クライアントの盾: try...exceptを使いこなす (client.pyの強化) 🛡️

Pythonには、こうしたエラーが発生する可能性のある処理を安全に実行するための仕組みとして try...except ブロックがあります。これは、まるで危険な技に挑戦する曲芸師が、万が一のために安全ネットを張っておくようなものです。

try...except とは?

基本的な形は下記の通りです。

try:
    # ここに、エラーが発生する可能性のある処理を書く
    # 例: サーバーへのリクエスト、ファイルの読み書きなど
    result = requests.get("http://example.com")
    result.raise_for_status() # エラーなら例外発生
    data = result.json()
except requests.exceptions.ConnectionError as e:
    # ConnectionError (例: サーバーが動いていない) が発生した場合の処理
    print(f"接続エラーが発生しました: {e}")
except requests.exceptions.Timeout as e:
    # Timeout (例: サーバーからの応答が遅い) が発生した場合の処理
    print(f"タイムアウトエラーが発生しました: {e}")
except requests.exceptions.HTTPError as e:
    # HTTPエラー (例: 404 Not Found, 500 Internal Server Error) が発生した場合の処理
    print(f"HTTPエラーが発生しました: {e.response.status_code} - {e}")
except requests.exceptions.JSONDecodeError as e:
    # JSONデコードエラー (例: サーバーの応答がJSON形式でない) が発生した場合の処理
    print(f"JSON解析エラーが発生しました: {e}")
except Exception as e:
    # 上記以外の予期せぬエラー全てをキャッチする場合
    print(f"予期せぬエラーが発生しました: {e}")
else:
    # tryブロック内の処理がエラーなく完了した場合に実行される (任意)
    print("処理は成功しました。")
finally:
    # エラーが発生したかどうかに関わらず、最後に必ず実行される処理 (任意)
    print("処理を終了します。")
  • tryブロック: エラーが発生するかもしれない「挑戦的な」コードをこの中に入れます。
  • except 特定のエラー種類 as 変数名 ブロック: tryブロック内で特定の種類のエラーが発生した場合に、対応するexceptブロックのコードが実行されます。as 変数名 とすることで、エラーオブジェクト自身をプログラム内で利用できます(エラーメッセージの詳細表示などに使えます)。
  • except Exception as e: 特定のエラーを指定せず、より広範囲なエラーをキャッチしたい場合に使います。ただし、あまりに広すぎるエラーキャッチは、問題の特定を難しくすることもあるので注意が必要です。
  • else ブロック (任意): tryブロックでエラーが発生しなかった場合にのみ実行されます。
  • finally ブロック (任意): エラーが発生したかどうかに関わらず、try...exceptブロックの最後に必ず実行されます。後片付け処理(ファイルを閉じるなど)によく使われます。

client.py (Vol.6版) のエラー処理の確認と解説

実は、私たちが前回Vol.6で作成した client.py には、既にいくつかの try...except が含まれています。これらがどのように機能しているか、そしてどのように各種エラーに対応しているかを見ていきましょう。

環境設定:

エラー処理を施したコードを実行する前に、Pythonの環境と必要なライブラリがVol.6と同様に設定されていることを確認してください。

  • Python 3.x
  • requests ライブラリ (pip install requests または pip3 install requests でインストール)

get_specific_device_info 関数内のエラー処理: この関数では、特定デバイスの情報を取得します。

# client.py の get_specific_device_info 関数より抜粋
    try:
        response = requests.get(target_url, timeout=5) # タイムアウト設定

        if response.status_code == 200:
            device_data = response.json() # JSONデコードエラーの可能性
            print("--- 取得成功 ---")
            display_device_info(device_data)
        # ... (404などのステータスコード処理) ...
        else:
            print(f"--- エラー (ステータスコード: {response.status_code}) ---")
            # ... (エラー詳細表示) ...

    except requests.exceptions.ConnectionError: # サーバー未起動、ネットワーク不通
        print(f"エラー: サーバー ({target_url}) への接続に失敗しました。サーバーが起動しているか確認してください。")
    except requests.exceptions.Timeout: # サーバー応答遅延
        print(f"エラー: サーバー ({target_url}) への接続がタイムアウトしました。")
    except JSONDecodeError: # レスポンスがJSONでない
        print(f"エラー: サーバーからの応答 ({target_url}) が正しいJSON形式ではありません。")
        # ... (応答内容表示) ...
    except requests.exceptions.RequestException as e: # requestsに関連するその他のエラー
        print(f"エラー: リクエスト中に問題が発生しました ({target_url}): {e}")
  • timeout=5: requests.get を呼び出す際に timeout を設定することで、サーバーからの応答が5秒以上なければ requests.exceptions.Timeout エラーを発生させます。これは、「電話回線が混雑している」ケースに対応します。
  • if response.status_code == 200:: まずHTTPステータスコードを確認し、成功(200)以外の場合はエラーとして処理しています。これにより、「宛先が違う(404)」や「サーバー側の処理エラー(500)」、「クライアント側のリクエスト不正(400)」など、サーバーから返されるHTTPエラーに対応できます。
  • device_data = response.json(): ここで JSONDecodeError が発生する可能性があります。これは、「期待していた情報と違うものが来た」ケースです。
  • except requests.exceptions.ConnectionError: 「お店が開いていない」やネットワークレベルの接続問題をキャッチします。
  • except requests.exceptions.RequestException as e: これは requests ライブラリが投げる可能性のある、より広範なエラーをキャッチするためのものです。

get_all_device_infos 関数内のエラー処理:

全デバイス情報を取得するこの関数では response.raise_for_status() を使っています。

# client.py の get_all_device_infos 関数より抜粋
    try:
        response = requests.get(target_url, timeout=5)
        response.raise_for_status() # 200番台以外ならrequests.exceptions.HTTPErrorを発生

        data = response.json()
        # ... (成功時の処理) ...

    # ... (ConnectionError, Timeout のキャッチは同様) ...
    except requests.exceptions.HTTPError as e: # raise_for_status() によって発生
        print(f"エラー: HTTPエラーが発生しました。ステータスコード: {e.response.status_code}")
        # ... (エラー詳細表示) ...
    # ... (JSONDecodeError, RequestException のキャッチは同様) ...
  • response.raise_for_status(): この一行は便利で、レスポンスのステータスコードが400番台または500番台(つまりクライアントエラーかサーバーエラー)だった場合に、自動的に requests.exceptions.HTTPError を発生させます。これにより、エラーチェックのコードを少し簡潔に書けます。

add_new_device update_device_info 関数内のエラー処理:

これらのデータ登録・更新関数でも同様に、ConnectionErrorTimeoutRequestException をキャッチしています。また、レスポンスのステータスコードに応じて処理を分岐し、サーバーから返されるエラーメッセージを表示するようになっています (例: 409 Conflict、400 Bad Request)。

これらの例からわかるように、client.py は既に多くの一般的なエラーケースを考慮して作られています。重要なのは、どの処理でどんなエラーが起こりうるかを予測し、それに対して適切なexceptブロックを用意することです。エラーメッセージも、何が起こったのか、どうすればよいのかがユーザーに伝わるように具体的に書くことが望ましいです。

今回のVol.7では、client.py のコード自体に大きな変更を加えるというよりは、この既存のエラー処理がなぜ必要なのか、どのように機能しているのかを深く理解することに重点を置きます。

3. サーバーの応答:親切なエラー通知 (app.pyの改善) 💬

クライアント側でエラーをしっかりキャッチすることも大切ですが、サーバー側が「何が問題だったのか」を分かりやすくクライアントに伝えることも同様に重要です。これにより、クライアント側の開発者はもちろん、エンドユーザーも問題解決の手がかりを得やすくなります。

HTTPステータスコードとエラーメッセージ

サーバーは、エラー発生時に主に以下の2つの方法でクライアントに情報を伝えます。

1. HTTPステータスコード:

  • 200 OK: 成功
  • 201 Created: リソースの作成成功 (POSTリクエスト成功時など)
  • 400 Bad Request: クライアントからのリクエストが無効 (例: 必要なデータがない、形式が違う)
  • 401 Unauthorized: 認証が必要 (Vol.8で扱います)
  • 403 Forbidden: アクセス権限がない
  • 404 Not Found: 要求されたリソースが見つからない
  • 409 Conflict: 要求が現在のリソースの状態と競合 (例: 重複IDでの作成)
  • 500 Internal Server Error: サーバー内部で予期せぬエラーが発生 これらのコードを適切に返すことで、クライアントはエラーの種類を大まかに把握できます。

2.レスポンスボディのJSONメッセージ:

ステータスコードだけでは情報が足りない場合が多いため、エラーの詳細をJSON形式でレスポンスボディに含めるのが一般的です。

{
    "error": "簡潔なエラーの表題",
    "message": "より詳細なエラーの説明や原因",
    "details": { /* さらに詳細な情報 (任意) */ }
}

前回Vol.6app.py でも、例えば deviceId が重複した場合に409エラーと共に以下のようなJSONを返していました。
return jsonify({"error": "Device with this ID already exists", "deviceId": new_device_id}), 409

app.py (Vol.6版) のエラー応答の確認

Vol.6app.py は、既に多くの箇所で適切なHTTPステータスコードとJSON形式のエラーメッセージを返すようになっています。

  • デバイス登録(POST)時:
    • データなし: jsonify({"error": "No data provided"}), 400
    • deviceIdなし: jsonify({"error": "deviceId is required"}), 400
    • deviceId重複: jsonify({"error": "Device with this ID already exists", ...}), 409
  • 特定デバイス更新(PUT)時:
    • 対象デバイスなし: jsonify({"error": "Device not found, cannot update", ...}), 404
    • データなし: jsonify({"error": "No data provided for update"}), 400
  • 特定デバイス取得(GET)時:
    • 対象デバイスなし: jsonify({"error": "Device not found", ...}), 404

これらは非常に良い実践です。

汎用的なエラーハンドラの追加 ( app.py Vol.7 版)

個別の処理でエラーを返すだけでなく、Flaskにはアプリケーション全体で特定のエラー(例えば、Flaskが自動で出す404 Not Foundや、予期せぬ500 Internal Server Error)を補足し、統一された形式でエラーレスポンスを返すための仕組みがあります。それが @app.errorhandler デコレータです。

これを app.py に追加して、さらに堅牢なサーバーにしましょう。

app.py (Vol.7 版)

Vol.6の app.py をベースに、以下のエラーハンドラを追加します。

# app.py (Vol.7 版)
from flask import Flask, jsonify, request # request はVol.6で既にインポート済み
import logging # ロギングのために追加

app = Flask(__name__)

# ロガーの設定 (コンソールに見やすいログを出すため)
if not app.debug: # 本番環境などdebug=Falseの時だけ設定することが多い
    stream_handler = logging.StreamHandler()
    stream_handler.setLevel(logging.INFO)
    app.logger.addHandler(stream_handler)
    app.logger.setLevel(logging.INFO)
    # 必要に応じてフォーマットも設定
    # formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
    # stream_handler.setFormatter(formatter)

# --- Vol.6の all_devices_data と各ルートハンドラ (@app.route('/devices', ...), @app.route('/devices/<device_id>', ...)) はそのまま ---
# (前回のコードをここに挿入してください。変更はありません)
# サンプルのMCPデバイスデータ (リスト形式) - Vol.6 と同じ
all_devices_data = [
    {
        "modelName": "Smart Thermostat X1000",
        "deviceId": "THERMO-001-A",
        "location": "Living Room",
        "status": {"currentTemperature": 23.5, "targetTemperature": 24.0, "unit": "Celsius", "isActive": True, "mode": "auto"},
        "supportedModes": ["auto", "cool", "heat", "off"]
    },
    {
        "modelName": "Smart Light L200",
        "deviceId": "LIGHT-002-B",
        "location": "Bedroom",
        "status": {"brightness": 80, "color": "warm_white", "unit": "percent", "isActive": True},
        "supportedModes": ["on", "off", "dim"]
    },
    {
        "modelName": "Security Camera C300",
        "deviceId": "CAM-003-C",
        "location": "Entrance",
        "status": {"isRecording": False, "detectionMode": "motion", "isActive": True},
        "supportedEvents": ["motion_detected", "sound_detected"]
    }
]

# 全デバイス情報を返すエンドポイント (GET) と新しいデバイスを登録するエンドポイント (POST)
@app.route('/devices', methods=['GET', 'POST'])
def handle_devices():
    if request.method == 'POST':
        new_device_data = request.json
        if not new_device_data:
            app.logger.warning("POST /devices - No data provided by client.")
            return jsonify({"error": "No data provided", "message": "リクエストボディにデータが含まれていません。"}), 400

        new_device_id = new_device_data.get("deviceId")
        if not new_device_id:
            app.logger.warning(f"POST /devices - deviceId is missing. Data: {new_device_data}")
            return jsonify({"error": "deviceId is required", "message": "必須フィールド 'deviceId' がありません。"}), 400

        for device in all_devices_data:
            if device.get("deviceId") == new_device_id:
                app.logger.info(f"POST /devices - Device with ID {new_device_id} already exists.")
                return jsonify({"error": "Device with this ID already exists", "message": f"デバイスID '{new_device_id}' は既に存在します。", "deviceId": new_device_id}), 409
        
        all_devices_data.append(new_device_data)
        app.logger.info(f"POST /devices - New device created: {new_device_id}")
        return jsonify(new_device_data), 201
    
    app.logger.info("GET /devices - Returning all devices.")
    return jsonify({"devices": all_devices_data})

# 特定のデバイスIDに基づいて情報を返す (GET) / 更新する (PUT) エンドポイント
@app.route('/devices/<device_id>', methods=['GET', 'PUT'])
def handle_specific_device(device_id):
    found_device_index = -1
    for i, device in enumerate(all_devices_data):
        if device.get("deviceId") == device_id:
            found_device_index = i
            break

    if request.method == 'PUT':
        if found_device_index != -1:
            updated_data = request.json
            if not updated_data:
                app.logger.warning(f"PUT /devices/{device_id} - No data provided for update.")
                return jsonify({"error": "No data provided for update", "message": "更新データがリクエストボディに含まれていません。"}), 400
            
            original_device_id_in_payload = updated_data.get("deviceId")
            if original_device_id_in_payload and original_device_id_in_payload != device_id:
                app.logger.warning(f"PUT /devices/{device_id} - Attempt to change deviceId in payload to {original_device_id_in_payload}. Denied or handled.")
                # 設計によってはここでエラーにすることも、URLのdevice_idを正とすることもある。
                # Vol.6ではペイロードのIDが異なっても警告付きで更新していた。ここではその振る舞いを維持。
                # ただし、より厳密にはペイロードのdeviceIdはURLのdevice_idと一致することを強制した方が良い場合が多い。
                # updated_data["deviceId"] = device_id # URLのIDを正とする場合
                all_devices_data[found_device_index] = updated_data
                app.logger.info(f"PUT /devices/{device_id} - Device updated. Payload deviceId '{original_device_id_in_payload}' differed from URL.")
                return jsonify({"warning": "Device ID in URL and payload mismatch. Resource updated based on URL.", "message": f"デバイスID '{device_id}' の情報が更新されましたが、リクエストデータ内のdeviceId ('{original_device_id_in_payload}') はURLと一致しませんでした。URLのIDに基づいて処理されました。", "updated_device": updated_data}), 200

            all_devices_data[found_device_index] = updated_data
            app.logger.info(f"PUT /devices/{device_id} - Device updated successfully.")
            return jsonify(updated_data), 200
        else:
            app.logger.warning(f"PUT /devices/{device_id} - Device not found for update.")
            return jsonify({"error": "Device not found, cannot update", "message": f"デバイスID '{device_id}' は見つからなかったため、更新できませんでした。", "requested_id": device_id}), 404
    
    # GETリクエストの場合
    if found_device_index != -1:
        app.logger.info(f"GET /devices/{device_id} - Returning device information.")
        return jsonify(all_devices_data[found_device_index])
    else:
        app.logger.warning(f"GET /devices/{device_id} - Device not found.")
        return jsonify({"error": "Device not found", "message": f"デバイスID '{device_id}' は見つかりませんでした。", "requested_id": device_id}), 404
# --- ここまで Vol.6 のコード (一部ロギングとメッセージ追加) ---

# 新しく追加するエラーハンドラ
@app.errorhandler(404)
def resource_not_found(e):
    # このハンドラは、Flaskが処理できないルートへのアクセスなど、
    # 明示的に定義されていないルートに対する404エラーを処理します。
    # handle_specific_device内の404は、そこで直接返されます。
    app.logger.warning(f"Unhandled route or resource not found: {request.path} - {e}")
    return jsonify(error="Not Found", message="お探しのリソースは見つかりませんでした。URLを確認してください。"), 404

@app.errorhandler(500)
def internal_server_error(e):
    # 予期せぬサーバー内部エラー
    # 詳細なエラー情報はサーバーのログに記録し、クライアントには汎用的なメッセージを返す
    app.logger.error(f"Server Error: {e}", exc_info=True) # exc_info=Trueでスタックトレースもログに出力
    return jsonify(error="Internal Server Error", message="申し訳ありません、サーバー内部で予期せぬエラーが発生しました。管理者にお問い合わせください。"), 500

@app.errorhandler(405) # Method Not Allowed
def method_not_allowed(e):
    app.logger.warning(f"Method Not Allowed: {request.method} for {request.path} - {e}")
    return jsonify(error="Method Not Allowed", message=f"メソッド '{request.method}' はこのURLでは許可されていません。"), 405

@app.errorhandler(400) # 汎用的なBad Request (Flaskが自動で出す場合など)
def bad_request_error(e):
    # Flaskがリクエストのパースに失敗した場合などにもこのハンドラが呼ばれることがある
    app.logger.warning(f"Bad Request: {request.path} - {e}")
    # e.description にFlaskが生成したエラーメッセージが含まれることがある
    detail_message = e.description if hasattr(e, 'description') else "リクエストの形式が正しくありません。"
    return jsonify(error="Bad Request", message=detail_message), 400


if __name__ == '__main__':
    print("MCP Server (Vol.7) is running on http://127.0.0.1:5000")
    # ... (Vol.6と同じ起動メッセージ) ...
    print("To stop the server, press CTRL+C")
    # macOSやLinuxで python3 を使っている場合は、python3 app.py で実行してください。
    app.run(debug=True, port=5000)

コードのポイント:

  • import logging: Python標準のロギングライブラリをインポートします。サーバー側でエラーや処理の状況を記録(ログを取る)ことは、問題発生時の原因究明に非常に役立ちます。
  • app.logger.warning(...), app.logger.error(...), app.logger.info(...): Flaskのロガーを使って、サーバーのコンソールに情報を出力しています。エラーや警告、通常の処理情報などを記録できます。
  • @app.errorhandler(404): Flaskが「このURLに対応する処理がないよ」と判断した場合 (例えば、/non_existent_page にアクセスされた時) に、この関数が呼び出され、JSON形式で404エラーを返します。私たちが定義した /devices/<device_id> ルート内でデバイスが見つからなかった場合の404とは別に、未定義のURLへのアクセスに対する404です。
  • @app.errorhandler(500): サーバーのコードのどこかで予期せぬエラーが発生し、それが他のexceptブロックでキャッチされなかった場合に、最終的にこのハンドラが呼び出されます。クライアントには「サーバー内部でエラーが起きた」という一般的なメッセージを返しつつ、サーバーのログには exc_info=True によってエラーの詳細な情報(スタックトレース)を記録します。これにより、開発者は問題を修正しやすくなります。
  • @app.errorhandler(405): 例えば、GETリクエストしか許可していないエンドポイントにPOSTリクエストを送った場合などにFlaskが発生させる405エラーをキャッチし、JSONで返します。
  • @app.errorhandler(400): ルート内の処理で明示的に返していない400エラー (例えば、FlaskがリクエストボディのJSONをパースできなかった場合など) をキャッチします。

これらの汎用エラーハンドラを設定することで、どんなエラーが発生しても、クライアントは一貫したJSON形式でエラー情報を受け取れるようになり、サーバー側では詳細なログが残るため、より安定した運用が期待できます。

4. 実践!エラーを起こしてみよう (そして、華麗にキャッチ! ) 🎭

理論を学んだら、次は実践です!実際にいくつかのエラー状況を作り出し、クライアントとサーバーがどのように振る舞うかを確認しましょう。

準備:

1. 上記の app.py (Vol.7版) を保存し、ターミナルでサーバーを起動します。

python app.py

(macOSやLinuxの方は python3 app.py)

2. Vol.6の client.py を使います(今回はクライアント側のコード変更は主眼ではないため、既存のもので動作確認します)。別のターミナルで実行準備をします。

試してみるエラーシナリオ:

1. サーバー停止中にクライアント実行:

  • まず、app.py を実行しているターミナルで Ctrl+C を押してサーバーを停止します。
  • 次に、client.py を実行します。
python client.py

(macOSやLinuxの方は python3 app.py)

  • 期待される結果 (クライアント側): get_all_device_infos() などの関数内で requests.exceptions.ConnectionError がキャッチされ、「サーバー (...) への接続に失敗しました。サーバーが起動しているか確認してください。」といったメッセージが表示されるはずです。

2. 存在しないURLパスにアクセス (サーバーは起動中):

  • サーバー (app.py Vol.7版) を起動しておきます。
  • client.pyBASE_SERVER_URL を一時的に間違ったもの、例えば BASE_SERVER_URL = "http://127.0.0.1:5000/non_existent_path" に変更して実行してみます。
  • 期待される結果 (クライアント側): requests.exceptions.HTTPError が発生し、ステータスコード404と共にサーバーの @app.errorhandler(404) で定義したJSONメッセージ「お探しのリソースは見つかりませんでした。URLを確認してください。」が表示されるはずです。
  • 期待される結果 (サーバー側ログ): Unhandled route or resource not found: /non_existent_path ... のような警告ログが表示されます。

3. 必須データなしでデバイス登録 (サーバーは起動中):

  • サーバー (app.py Vol.7版) を起動しておきます。
  • client.pyadd_new_device 関数を呼び出す部分で、送信するデータから deviceId を削除してみます。
# client.py の if __name__ == '__main__': 内を一部変更
new_smart_speaker = {
    "modelName": "Incomplete Speaker",
    # "deviceId": "SPEAKER-005-E", # ← コメントアウトまたは削除
    "location": "Test Room",
    "status": {"volume": 30, "isPlaying": False, "isActive": True}
}
add_new_device(new_smart_speaker)
  • 期待される結果 (クライアント側): add_new_device 関数内でステータスコード400が検出され、「登録失敗 (400 Bad Request)」「エラーメッセージ: deviceId is required」といった表示がされるはずです。
  • 期待される結果 (サーバー側ログ): POST /devices - deviceId is missing. という警告ログと、400エラーを返したログが表示されます。

4. (おまけ) サーバーで意図的に500エラーを発生させてみる (サーバーは起動中):

  • これは少し高度ですが、app.pyの特定のルート処理の途中で、例えば test_var = 1 / 0 のようなゼロ除算エラーを意図的に挿入してみます。(実際に試す場合は、変更後に必ず元に戻してください!)
  • そのルートにクライアントからアクセスします。
  • 期待される結果 (クライアント側): requests.exceptions.HTTPError が発生し、ステータスコード500と共にサーバーの @app.errorhandler(500) で定義したJSONメッセージ「申し訳ありません、サーバー内部で予期せぬエラーが発生しました。」が表示されるはずです。
  • 期待される結果 (サーバー側ログ): Server Error: division by zero といったエラーログと共に、スタックトレースが出力されます。

これらの実験を通して、エラー処理が実際にどのように機能し、プログラムをクラッシュから守り、問題解決の手がかりを提供してくれるかを体感できるでしょう。

5. まとめと次回予告

今回は、MCP通信におけるエラー処理の重要性と、Pythonの try...except を使った具体的な対処法、そしてサーバー側での親切なエラー応答の返し方について学びました。

今回の学びのポイント:

  • よくある通信エラー: サーバー未起動、URL間違い、ネットワーク問題、サーバー内部エラー、不正なリクエスト、レスポンス形式不一致など、様々なエラーが発生しうること。
  • try...except: エラーが発生しそうな処理をtryブロックで囲み、発生したエラーをexceptブロックで捕まえて対処する、Pythonの基本的なエラー処理構文。
  • クライアント側の堅牢化: requestsライブラリが提供する様々な例外 (ConnectionError, Timeout, HTTPError, JSONDecodeErrorなど) を適切にキャッチすることで、クライアントプログラムが予期せぬエラーで停止するのを防ぎ、ユーザーに分かりやすい情報を提供できること。
  • サーバー側の親切な応答: HTTPステータスコードとJSON形式のエラーメッセージを適切に返すことで、クライアントがエラーの原因を特定しやすくなること。
  • Flaskの@app.errorhandler: アプリケーション全体で発生する可能性のあるエラー(404, 500など)に対して、統一されたエラーレスポンスを返すための強力な仕組み。
  • ロギングの重要性: 特にサーバー側でエラーや処理の状況をログに記録しておくことが、問題解決において不可欠であること。

エラー処理は、一見地味かもしれませんが、プログラムの品質と信頼性を大きく左右する非常に重要な要素です。今回の学びを活かして、より安定した、ユーザーフレンドリーなプログラム作成を目指してください。

さて次回、Vol.8 のテーマは「秘密の合言葉!APIキーでMCPアクセスに認証を追加する」です。

これまでのMCPサーバーは誰でもアクセスできましたが、実際のシステムではセキュリティが重要です。次回は、APIキーという「合言葉」を使って、許可されたクライアントだけがサーバーの機能を利用できるようにする「認証」の仕組みを導入します。

どうぞお楽しみに!

Discussion