0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Model Context Protocol完全解説 30日間シリーズ - Day 14【MCP実装 #14】エラー処理の基本:よくあるエラーパターンと対処法

Last updated at Posted at 2025-09-15

はじめに

この記事は、QiitaのModel Context Protocol(以下、MCP)解説シリーズの第14回です。
これまでMCPサーバーの基本的な機能を実装してきましたが、実際の開発では必ずエラー処理に直面します。今回は、MCPにおけるエラー処理の基本と、よくある失敗パターンとその対処法について解説します。

🛑 なぜエラー処理が重要なのか?

MCPサーバーは、LLMと外部の世界を繋ぐ「橋渡し役」です。外部のAPIがダウンしたり、ファイルが存在しなかったり、不正なデータが入力されたりすると、その「橋」が壊れてしまいます。適切にエラー処理を行わないと、LLMは予期しない動作をしたり、不正確な回答を生成したりする可能性があります。

エラー処理が不十分な場合の問題

  • LLMの混乱: エラー情報が伝わらず、LLMが不適切な判断を下す
  • ユーザー体験の悪化: 曖昧なエラーメッセージでユーザーが困惑する
  • システムの不安定性: 予期しない例外でサーバーがクラッシュする
  • デバッグの困難: エラーの原因特定が難しくなる

🧠 MCPにおけるエラー処理の基本

MCPでは、ToolsResourcesの実行中に発生したエラーをLLMに伝えることで、LLMが次の行動を自律的に判断できるようにします。

基本的なエラー処理フロー

  1. エラーの検出: Tools/Resourcesの実行中にエラーが発生
  2. エラーの構造化: 適切なエラー形式でレスポンスを作成
  3. LLMへの伝達: MCPプロトコル経由でエラー情報を送信
  4. LLMの判断: エラー内容を理解し、次のアクションを決定

📝 実装:堅牢なエラー処理を持つMCPサーバー

実際のコード例で、適切なエラー処理の実装方法を学びましょう。

🛠️ プロジェクトのセットアップ

mkdir mcp-error-handling-example
cd mcp-error-handling-example
npm init -y
npm install @modelcontextprotocol/sdk zod axios
npm install -D typescript ts-node @types/node @types/axios
npx tsc --init

💻 エラー処理を実装したサーバーコード

#!/usr/bin/env node
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
  CallToolRequestSchema,
  ListResourcesRequestSchema,
  ListToolsRequestSchema,
  ReadResourceRequestSchema,
  TextContent,
} from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
import * as fs from "fs";
import * as path from "path";
import axios from "axios";

// カスタムエラークラスの定義
class MCPError extends Error {
  constructor(
    message: string,
    public code: string,
    public details?: any
  ) {
    super(message);
    this.name = 'MCPError';
  }
}

class FileNotFoundError extends MCPError {
  constructor(filePath: string) {
    super(`ファイルが見つかりません: ${filePath}`, 'FILE_NOT_FOUND', { filePath });
  }
}

class APIError extends MCPError {
  constructor(message: string, statusCode?: number, apiResponse?: any) {
    super(message, 'API_ERROR', { statusCode, apiResponse });
  }
}

class ValidationError extends MCPError {
  constructor(message: string, validationErrors?: any) {
    super(message, 'VALIDATION_ERROR', { validationErrors });
  }
}

// 入力スキーマの定義
const ReadFileSchema = z.object({
  filePath: z.string().describe("読み取りたいファイルのパス")
});

const GetWeatherSchema = z.object({
  city: z.string().min(1).describe("天気を取得したい都市名"),
  units: z.enum(["metric", "imperial"]).optional().describe("温度の単位")
});

const WriteFileSchema = z.object({
  filePath: z.string().describe("書き込み先のファイルパス"),
  content: z.string().describe("書き込む内容")
});

class ErrorHandlingMCPServer {
  private server: Server;
  private readonly dataDir: string;

  constructor() {
    this.dataDir = path.join(process.cwd(), 'data');
    
    // データディレクトリの作成
    if (!fs.existsSync(this.dataDir)) {
      fs.mkdirSync(this.dataDir, { recursive: true });
    }

    this.server = new Server(
      {
        name: "error-handling-mcp-server",
        version: "1.0.0",
      },
      {
        capabilities: {
          tools: {},
          resources: {},
        },
      }
    );

    this.setupHandlers();
  }

  private setupHandlers() {
    // ツール一覧の取得
    this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
      tools: [
        {
          name: "read_file",
          description: "指定されたファイルの内容を読み取る",
          inputSchema: {
            type: "object",
            properties: {
              filePath: {
                type: "string",
                description: "読み取りたいファイルのパス"
              }
            },
            required: ["filePath"]
          }
        },
        {
          name: "write_file",
          description: "指定されたファイルに内容を書き込む",
          inputSchema: {
            type: "object",
            properties: {
              filePath: {
                type: "string", 
                description: "書き込み先のファイルパス"
              },
              content: {
                type: "string",
                description: "書き込む内容"
              }
            },
            required: ["filePath", "content"]
          }
        },
        {
          name: "get_weather",
          description: "指定された都市の現在の天気情報を取得する",
          inputSchema: {
            type: "object",
            properties: {
              city: {
                type: "string",
                description: "天気を取得したい都市名"
              },
              units: {
                type: "string",
                enum: ["metric", "imperial"],
                description: "温度の単位(metric: 摂氏、imperial: 華氏)",
                default: "metric"
              }
            },
            required: ["city"]
          }
        }
      ]
    }));

    // ツール実行の処理
    this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
      const { name, arguments: args } = request.params;

      try {
        switch (name) {
          case "read_file":
            return await this.handleReadFile(args);
          case "write_file":
            return await this.handleWriteFile(args);
          case "get_weather":
            return await this.handleGetWeather(args);
          default:
            throw new MCPError(`未知のツール: ${name}`, 'UNKNOWN_TOOL');
        }
      } catch (error) {
        return this.handleError(error);
      }
    });

    // リソース一覧の取得
    this.server.setRequestHandler(ListResourcesRequestSchema, async () => {
      try {
        const files = fs.readdirSync(this.dataDir);
        return {
          resources: files.map(file => ({
            uri: `file://${path.join(this.dataDir, file)}`,
            name: file,
            description: `データファイル: ${file}`,
            mimeType: this.getMimeType(file)
          }))
        };
      } catch (error) {
        console.error('リソース一覧の取得エラー:', error);
        return { resources: [] };
      }
    });

    // リソース読み取りの処理
    this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
      const { uri } = request.params;
      
      try {
        if (!uri.startsWith('file://')) {
          throw new ValidationError('サポートされていないURIスキーム', { uri });
        }

        const filePath = uri.replace('file://', '');
        
        if (!fs.existsSync(filePath)) {
          throw new FileNotFoundError(filePath);
        }

        const content = fs.readFileSync(filePath, 'utf-8');
        
        return {
          contents: [
            {
              uri,
              mimeType: this.getMimeType(filePath),
              text: content
            }
          ]
        };
      } catch (error) {
        throw error instanceof MCPError ? error : new MCPError(
          `リソース読み取りエラー: ${error.message}`,
          'RESOURCE_READ_ERROR'
        );
      }
    });
  }

  private async handleReadFile(args: any) {
    const parsed = ReadFileSchema.parse(args);
    const fullPath = path.resolve(this.dataDir, parsed.filePath);
    
    // セキュリティチェック:ディレクトリトラバーサル攻撃の防止
    if (!fullPath.startsWith(path.resolve(this.dataDir))) {
      throw new ValidationError(
        'ファイルパスが不正です。データディレクトリ外のファイルにはアクセスできません。',
        { filePath: parsed.filePath, resolvedPath: fullPath }
      );
    }

    if (!fs.existsSync(fullPath)) {
      throw new FileNotFoundError(fullPath);
    }

    try {
      const content = fs.readFileSync(fullPath, 'utf-8');
      return {
        content: [
          {
            type: "text",
            text: `ファイル ${parsed.filePath} の内容:\n\n${content}`
          } as TextContent
        ]
      };
    } catch (error) {
      throw new MCPError(
        `ファイル読み取りエラー: ${error.message}`,
        'FILE_READ_ERROR',
        { filePath: parsed.filePath }
      );
    }
  }

  private async handleWriteFile(args: any) {
    const parsed = WriteFileSchema.parse(args);
    const fullPath = path.resolve(this.dataDir, parsed.filePath);
    
    // セキュリティチェック
    if (!fullPath.startsWith(path.resolve(this.dataDir))) {
      throw new ValidationError(
        'ファイルパスが不正です。データディレクトリ外のファイルには書き込めません。'
      );
    }

    try {
      // ディレクトリが存在しない場合は作成
      const dir = path.dirname(fullPath);
      if (!fs.existsSync(dir)) {
        fs.mkdirSync(dir, { recursive: true });
      }

      fs.writeFileSync(fullPath, parsed.content, 'utf-8');
      
      return {
        content: [
          {
            type: "text",
            text: `ファイル ${parsed.filePath} に正常に書き込みました。`
          } as TextContent
        ]
      };
    } catch (error) {
      throw new MCPError(
        `ファイル書き込みエラー: ${error.message}`,
        'FILE_WRITE_ERROR',
        { filePath: parsed.filePath }
      );
    }
  }

  private async handleGetWeather(args: any) {
    const parsed = GetWeatherSchema.parse(args);
    
    // 環境変数からAPIキーを取得
    const apiKey = process.env.WEATHER_API_KEY;
    if (!apiKey) {
      throw new MCPError(
        'WEATHER_API_KEY環境変数が設定されていません',
        'MISSING_API_KEY'
      );
    }

    try {
      const response = await axios.get('https://api.openweathermap.org/data/2.5/weather', {
        params: {
          q: parsed.city,
          appid: apiKey,
          units: parsed.units || 'metric',
          lang: 'ja'
        },
        timeout: 10000 // 10秒のタイムアウト
      });

      const weatherData = response.data;
      const temp = Math.round(weatherData.main.temp);
      const description = weatherData.weather[0].description;
      const humidity = weatherData.main.humidity;
      
      return {
        content: [
          {
            type: "text",
            text: `${parsed.city}の現在の天気:
🌡️ 気温: ${temp}°${parsed.units === 'imperial' ? 'F' : 'C'}
🌤️ 天候: ${description}
💧 湿度: ${humidity}%`
          } as TextContent
        ]
      };
      
    } catch (error) {
      if (axios.isAxiosError(error)) {
        const status = error.response?.status;
        const message = error.response?.data?.message || error.message;
        
        if (status === 401) {
          throw new APIError('APIキーが無効です', status);
        } else if (status === 404) {
          throw new APIError(`都市 "${parsed.city}" が見つかりません`, status);
        } else if (error.code === 'ECONNABORTED') {
          throw new APIError('天気APIのタイムアウトが発生しました', 408);
        } else {
          throw new APIError(`天気API呼び出しエラー: ${message}`, status);
        }
      } else {
        throw new MCPError(
          `天気情報取得中に予期しないエラー: ${error.message}`,
          'UNEXPECTED_ERROR'
        );
      }
    }
  }

  private handleError(error: any) {
    console.error('Tool execution error:', error);

    let errorMessage: string;
    let errorCode: string;
    let isError = true;

    if (error instanceof z.ZodError) {
      // バリデーションエラー
      errorMessage = `入力データが不正です: ${error.errors.map(e => e.message).join(', ')}`;
      errorCode = 'VALIDATION_ERROR';
    } else if (error instanceof MCPError) {
      // カスタムエラー
      errorMessage = error.message;
      errorCode = error.code;
    } else if (error instanceof Error) {
      // 一般的なエラー
      errorMessage = `処理中にエラーが発生しました: ${error.message}`;
      errorCode = 'GENERAL_ERROR';
    } else {
      // 不明なエラー
      errorMessage = '不明なエラーが発生しました';
      errorCode = 'UNKNOWN_ERROR';
    }

    return {
      content: [
        {
          type: "text",
          text: `❌ エラー [${errorCode}]: ${errorMessage}`
        } as TextContent
      ],
      isError
    };
  }

  private getMimeType(filePath: string): string {
    const ext = path.extname(filePath).toLowerCase();
    const mimeTypes: Record<string, string> = {
      '.txt': 'text/plain',
      '.md': 'text/markdown',
      '.json': 'application/json',
      '.csv': 'text/csv',
      '.html': 'text/html',
      '.js': 'application/javascript',
      '.ts': 'application/typescript'
    };
    return mimeTypes[ext] || 'text/plain';
  }

  async start() {
    const transport = new StdioServerTransport();
    await this.server.connect(transport);
    console.error("Error Handling MCP Server が開始されました");
  }
}

// サーバー開始
async function main() {
  const server = new ErrorHandlingMCPServer();
  await server.start();
}

// グローバルエラーハンドリング
process.on('unhandledRejection', (reason, promise) => {
  console.error('Unhandled Rejection at:', promise, 'reason:', reason);
  // 本番環境では適切なログ出力とクリーンアップ処理を行う
});

process.on('uncaughtException', (error) => {
  console.error('Uncaught Exception:', error);
  // 本番環境では適切なログ出力後にプロセスを終了
  process.exit(1);
});

if (require.main === module) {
  main().catch((error) => {
    console.error("サーバー開始エラー:", error);
    process.exit(1);
  });
}

🔧 設定ファイルと環境変数

.envファイルを作成:

# 天気API用のキー(OpenWeatherMap)
WEATHER_API_KEY=your_api_key_here

claude_desktop_config.jsonの設定:

{
  "mcpServers": {
    "error-handling-server": {
      "command": "npx",
      "args": ["ts-node", "/path/to/your/project/server.ts"],
      "env": {
        "WEATHER_API_KEY": "your_actual_api_key"
      }
    }
  }
}

📊 よくあるエラーパターンと対処法

パターン1: ファイル操作エラー

問題: 存在しないファイルへのアクセスやパーミッションエラー

対処法:

  • ファイル存在チェックの実装
  • 適切なエラーメッセージの提供
  • セキュリティを考慮したパス検証

パターン2: 外部API呼び出しエラー

問題: ネットワークエラー、認証エラー、レート制限

対処法:

  • タイムアウト設定の実装
  • HTTPステータスコード別のエラーハンドリング
  • リトライ機能(必要に応じて)

パターン3: データ検証エラー

問題: 不正な入力データによるエラー

対処法:

  • Zodによる厳密な入力検証
  • わかりやすいバリデーションエラーメッセージ
  • デフォルト値の適切な設定

🧪 エラー処理のテスト方法

テスト用のサンプルファイル作成

mkdir -p data
echo "Hello, MCP!" > data/test.txt
echo '{"message": "This is a JSON file"}' > data/test.json

Claude Desktopでのテスト例

1. 正常ケース:
"data/test.txtファイルの内容を読み取って"

2. ファイルが存在しない場合:
"data/nonexistent.txtファイルの内容を読み取って"

3. 不正なパス:
"../../etc/passwdファイルの内容を読み取って"

4. API呼び出しエラー:
"存在しない都市名12345の天気を取得して"

5. バリデーションエラー:
"空の都市名で天気を取得して"

🎯 エラー処理のベストプラクティス

1. 段階的なエラーハンドリング

// レベル1: 入力検証
const parsed = schema.parse(input);

// レベル2: ビジネスロジックの実行
try {
  const result = await businessLogic(parsed);
  return success(result);
} catch (error) {
  // レベル3: エラーの分類と処理
  return handleError(error);
}

2. エラー情報の構造化

  • エラーコード: 機械的な判断のため
  • エラーメッセージ: 人間が理解しやすい説明
  • 詳細情報: デバッグ用の追加情報

3. セキュリティを考慮したエラー処理

  • 機密情報をエラーメッセージに含めない
  • パストラバーサル攻撃の防止
  • 適切な権限チェックの実装

4. ログ出力とモニタリング

private logError(error: any, context: any) {
  const logEntry = {
    timestamp: new Date().toISOString(),
    error: error.message,
    code: error.code || 'UNKNOWN',
    context,
    stack: error.stack
  };
  
  console.error('MCP Error:', JSON.stringify(logEntry));
  // 本番環境では適切なログシステムに送信
}

🚀 実運用での考慮事項

監視とアラート

  • エラー発生率の監視
  • 特定のエラータイプの傾向分析
  • 重要なエラーに対するアラート設定

復旧とフェイルオーバー

  • 一時的な障害に対する自動復旧
  • 代替手段の提供
  • グレースフル・デグラデーション

パフォーマンス

  • エラー処理によるレスポンス時間への影響
  • メモリリークの防止
  • 適切なリソース管理

📝 まとめ

適切なエラー処理は、堅牢なMCPサーバーを構築するための基盤です。今回学んだ内容:

  • 構造化されたエラー処理: カスタムエラークラスによる分類
  • セキュリティ: 入力検証とパストラバーサル攻撃の防止
  • ユーザビリティ: わかりやすいエラーメッセージの提供
  • 保守性: 適切なログ出力とモニタリング

次回から、Week 3:実用的な応用週間に突入し、データベース連携などより高度なテーマを扱います。今回のエラー処理の知識は、これらの応用実装の土台となるでしょう。お楽しみに!

🔗 参考リンク

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?