はじめに
この記事は、QiitaのModel Context Protocol(以下、MCP)解説シリーズの第14回です。
これまでMCPサーバーの基本的な機能を実装してきましたが、実際の開発では必ずエラー処理に直面します。今回は、MCPにおけるエラー処理の基本と、よくある失敗パターンとその対処法について解説します。
🛑 なぜエラー処理が重要なのか?
MCPサーバーは、LLMと外部の世界を繋ぐ「橋渡し役」です。外部のAPIがダウンしたり、ファイルが存在しなかったり、不正なデータが入力されたりすると、その「橋」が壊れてしまいます。適切にエラー処理を行わないと、LLMは予期しない動作をしたり、不正確な回答を生成したりする可能性があります。
エラー処理が不十分な場合の問題
- LLMの混乱: エラー情報が伝わらず、LLMが不適切な判断を下す
- ユーザー体験の悪化: 曖昧なエラーメッセージでユーザーが困惑する
- システムの不安定性: 予期しない例外でサーバーがクラッシュする
- デバッグの困難: エラーの原因特定が難しくなる
🧠 MCPにおけるエラー処理の基本
MCPでは、ToolsやResourcesの実行中に発生したエラーをLLMに伝えることで、LLMが次の行動を自律的に判断できるようにします。
基本的なエラー処理フロー
- エラーの検出: Tools/Resourcesの実行中にエラーが発生
- エラーの構造化: 適切なエラー形式でレスポンスを作成
- LLMへの伝達: MCPプロトコル経由でエラー情報を送信
- 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:実用的な応用週間に突入し、データベース連携などより高度なテーマを扱います。今回のエラー処理の知識は、これらの応用実装の土台となるでしょう。お楽しみに!