T-CREATOR

MCP サーバー 設計ベストプラクティス:ツール定義、権限分離、スキーマ設計の要点まとめ

MCP サーバー 設計ベストプラクティス:ツール定義、権限分離、スキーマ設計の要点まとめ

MCP(Model Context Protocol)は、LLM(大規模言語モデル)と外部のツール、データソースを効率的に連携させるための標準プロトコルです。この記事では、MCP サーバーの設計におけるベストプラクティスを、ツール定義、権限分離、スキーマ設計の 3 つの観点から詳しく解説します。

初めて MCP サーバーを構築する方でも、セキュアで保守性の高いシステムを実現できるよう、実践的な知識を分かりやすくお届けしますね。

背景

MCP とは何か

MCP(Model Context Protocol)は、AI アプリケーションのための統一的なインターフェースを提供するプロトコルです。 いわば「AI アプリのための USB-C ポート」のような役割を果たし、LLM と外部システムの接続を標準化します。

従来、LLM を外部ツールやデータソースと連携させるには、それぞれのシステムに合わせた独自の実装が必要でした。 これにより、開発コストの増加、保守性の低下、セキュリティリスクの増大といった課題が生じていました。

MCP のアーキテクチャ構成

以下の図は、MCP の基本的なアーキテクチャを示しています。

mermaidflowchart TB
    host["MCP ホスト<br/>(Claude Desktop など)"]
    client["MCP クライアント<br/>(LLM 連携層)"]
    server["MCP サーバー<br/>(ツール提供層)"]
    external["外部システム<br/>(DB / API / ファイル)"]

    host -->|メッセージ送信| client
    client -->|JSON-RPC リクエスト| server
    server -->|ツール実行| external
    external -->|結果返却| server
    server -->|JSON-RPC レスポンス| client
    client -->|結果表示| host

MCP は以下の 3 つの主要コンポーネントで構成されます。

#コンポーネント役割具体例
1MCP ホストユーザーインターフェース層Claude Desktop、エディタ拡張
2MCP クライアントLLM とサーバーの仲介層プロトコル変換、認証管理
3MCP サーバーツール・リソース提供層データベースアクセス、API 統合

MCP が解決する課題

MCP の導入により、以下のような課題が解決できます。

まず、プロトコルレベルの分離により、LLM(クライアント)とツール(サーバー)の間に明確な境界線が引かれます。 すべてのやり取りが JSON-RPC メッセージとして構造化されるため、安全性、互換性、監査可能性が飛躍的に向上するのです。

次に、再利用可能なモジュール設計により、一度作成した MCP サーバーを複数の AI アプリケーションで共有できます。 これにより開発効率が大幅に向上し、保守コストも削減できますね。

さらに、標準化されたセキュリティモデルにより、認証、認可、入力検証といったセキュリティ対策を統一的に実装できます。

課題

MCP サーバー設計における主要課題

MCP サーバーを実装する際には、以下のような課題に直面します。

ツール定義の複雑性

LLM が理解しやすく、かつ実用的なツールをどのように定義すべきか、という問題があります。 単純に CRUD 操作(Create, Read, Update, Delete)を提供するだけでは、LLM が適切にツールを選択・連携できないことがあるのです。

例えば、「ユーザー作成」というツールがあっても、それが「新規会員登録」なのか「管理者による手動追加」なのかを LLM が判断できない場合があります。

権限分離とセキュリティ

MCP サーバーは外部システムにアクセスするため、適切な権限管理が不可欠です。 しかし、以下のようなセキュリティ上の懸念が存在します。

#課題リスク影響範囲
1過剰な権限付与クライアントが必要以上のツールにアクセスデータ漏洩、不正操作
2入力検証の不足コマンドインジェクション、SQL インジェクションシステム全体の侵害
3認証情報の漏洩クレデンシャルの不適切な保存・共有外部システムへの不正アクセス
4監査ログの欠如不正アクセスの検知遅延インシデント対応の困難化

スキーマ設計の難しさ

JSON Schema を用いたツールパラメータの定義は、LLM がツールを正しく呼び出すための鍵となります。 しかし、スキーマが不十分だと、以下のような問題が発生する可能性があります。

まず、型の不一致により、LLM が誤った型のデータを渡してしまうことがあります。 次に、必須パラメータの欠落により、ツールの実行が失敗してしまうケースも考えられます。

さらに、曖昧な説明文により、LLM が適切なパラメータ値を推測できないこともありますね。

以下の図は、不適切なスキーマ設計がもたらす問題の流れを示しています。

mermaidflowchart LR
    llm["LLM"] -->|曖昧な説明| wrong["誤った判断"]
    wrong -->|不正なパラメータ| tool["ツール呼び出し"]
    tool -->|エラー| fail["実行失敗"]
    fail -->|再試行| llm

    style fail fill:#ffcccc
    style wrong fill:#fff3cd

このような課題を解決するために、次のセクションでは具体的な解決策を見ていきましょう。

解決策

ツール定義のベストプラクティス

単一責任の原則

各ツールは、明確で単一の目的を持つべきです。 CRUD 操作ではなく、ドメイン固有のアクションとして定義することで、LLM が理解しやすくなります。

推奨例:ドメイン固有のツール

#ツール名目的LLM の理解しやすさ
1submit_expense_report経費精算を提出★★★★★ 明確
2approve_leave_request休暇申請を承認★★★★★ 明確
3schedule_meeting会議をスケジュール★★★★★ 明確

非推奨例:CRUD 操作

#ツール名目的LLM の理解しやすさ
1create_recordレコードを作成★★☆☆☆ 曖昧
2update_dataデータを更新★☆☆☆☆ 非常に曖昧
3delete_itemアイテムを削除★★☆☆☆ 曖昧

ツール定義の構造

MCP のツールは、以下の 3 つの要素で定義されます。

typescriptinterface ToolDefinition {
  name: string; // ユニークな識別子
  description: string; // 人間が読める説明
  inputSchema: object; // JSON Schema 形式のパラメータ定義
}

TypeScript 実装例:基本的なツール定義

typescriptimport { McpServer } from '@modelcontextprotocol/sdk';
import { z } from 'zod';

const server = new McpServer();

ツールリストの登録

typescriptserver.setRequestHandler(
  ListToolsRequestSchema,
  async () => ({
    tools: [
      {
        name: 'calculate_sum',
        description: '2 つの数値を加算します',
        inputSchema: {
          type: 'object',
          properties: {
            a: {
              type: 'number',
              description: '1 つ目の数値',
            },
            b: {
              type: 'number',
              description: '2 つ目の数値',
            },
          },
          required: ['a', 'b'],
        },
      },
    ],
  })
);

ツール実行ハンドラの実装

typescriptserver.setRequestHandler(
  CallToolRequestSchema,
  async (request) => {
    const { name, arguments: args } = request.params;

    if (name === 'calculate_sum') {
      const { a, b } = args;
      const result = a + b;

      return {
        content: [
          {
            type: 'text',
            text: `計算結果: ${result}`,
          },
        ],
      };
    }

    throw new Error(`Unknown tool: ${name}`);
  }
);

このコードでは、まずツールのメタデータを登録し、次に実際の実行ロジックを定義しています。 inputSchema で型と必須パラメータを明示することで、LLM が正しい引数を渡せるようになりますね。

Python による実装例

Python では mcp.FastMCP を使うことで、よりシンプルに実装できます。

FastMCP の初期化

pythonfrom mcp import FastMCP
import httpx

mcp = FastMCP("weather-service")

デコレータを使ったツール定義

python@mcp.tool()
async def get_forecast(latitude: float, longitude: float) -> str:
    """
    指定された緯度・経度の天気予報を取得します。

    Args:
        latitude: 緯度(-90 から 90 の範囲)
        longitude: 経度(-180 から 180 の範囲)

    Returns:
        天気予報の文字列
    """
    async with httpx.AsyncClient() as client:
        response = await client.get(
            f"https://api.weather.gov/points/{latitude},{longitude}"
        )

        if response.status_code != 200:
            return f"エラー: 天気情報を取得できませんでした"

        data = response.json()
        forecast_url = data["properties"]["forecast"]

        forecast_response = await client.get(forecast_url)
        forecast_data = forecast_response.json()

        return format_forecast(forecast_data)

フォーマット関数の実装

pythondef format_forecast(data: dict) -> str:
    """天気予報データを読みやすい形式に変換します"""
    periods = data["properties"]["periods"]
    result = []

    for period in periods[:3]:  # 最初の 3 期間のみ
        result.append(
            f"{period['name']}: {period['temperature']}°{period['temperatureUnit']}, "
            f"{period['shortForecast']}"
        )

    return "\n".join(result)

Python の @mcp.tool() デコレータを使うと、関数のシグネチャから自動的に JSON Schema が生成されます。 型ヒントとドキュメント文字列を丁寧に書くことで、LLM が理解しやすいツール定義になるのです。

権限分離とセキュリティのベストプラクティス

多層防御(Defense in Depth)の実装

MCP サーバーのセキュリティは、複数の防御層を組み合わせることで実現します。

以下の図は、多層防御の構造を示しています。

mermaidflowchart TB
    subgraph layer1["第 1 層:ネットワーク分離"]
        network["localhost バインディング<br/>HTTPS 通信"]
    end

    subgraph layer2["第 2 層:認証・認可"]
        auth["OAuth 2.0 認証<br/>Origin 検証"]
    end

    subgraph layer3["第 3 層:入力検証"]
        validation["JSON Schema 検証<br/>セマンティック検証"]
    end

    subgraph layer4["第 4 層:出力サニタイゼーション"]
        sanitize["XSS 対策<br/>機密情報のマスキング"]
    end

    subgraph layer5["第 5 層:監査・監視"]
        audit["アクセスログ<br/>異常検知"]
    end

    network --> auth
    auth --> validation
    validation --> sanitize
    sanitize --> audit

図で理解できる要点

  • セキュリティは 5 つの層で構成され、各層が独立して機能します
  • 1 つの層が突破されても、他の層が防御を継続します
  • すべての層を通過しないと、外部システムにはアクセスできません

入力検証の実装

すべてのクライアント入力は、厳格に検証する必要があります。 Zod ライブラリを使った実装例を見ていきましょう。

Zod によるスキーマ定義

typescriptimport { z } from 'zod';

// ユーザー ID のスキーマ
const UserIdSchema = z
  .string()
  .min(1, 'ユーザー ID は必須です')
  .max(50, 'ユーザー ID は 50 文字以内です')
  .regex(
    /^[a-zA-Z0-9_-]+$/,
    '英数字、ハイフン、アンダースコアのみ使用可能です'
  );

経費精算のスキーマ定義

typescriptconst ExpenseReportSchema = z.object({
  userId: UserIdSchema,
  amount: z
    .number()
    .positive('金額は正の数である必要があります')
    .max(
      1000000,
      '金額は 1,000,000 円以下である必要があります'
    ),
  category: z.enum(
    ['travel', 'meals', 'equipment', 'other'],
    {
      errorMap: () => ({
        message: '有効なカテゴリーを選択してください',
      }),
    }
  ),
  description: z
    .string()
    .min(10, '説明は 10 文字以上必要です')
    .max(500, '説明は 500 文字以内です'),
  receiptUrl: z
    .string()
    .url('有効な URL を入力してください')
    .optional(),
});

ツールハンドラでの検証実装

typescriptserver.setRequestHandler(
  CallToolRequestSchema,
  async (request) => {
    const { name, arguments: args } = request.params;

    if (name === 'submit_expense_report') {
      // スキーマ検証
      const validationResult =
        ExpenseReportSchema.safeParse(args);

      if (!validationResult.success) {
        return {
          content: [
            {
              type: 'text',
              text: `検証エラー: ${validationResult.error.message}`,
            },
          ],
          isError: true,
        };
      }

      // 検証済みデータを使用
      const expenseData = validationResult.data;

      // ビジネスロジックの実行
      const result = await submitExpense(expenseData);

      return {
        content: [
          {
            type: 'text',
            text: `経費精算を提出しました。ID: ${result.id}`,
          },
        ],
      };
    }
  }
);

Zod の safeParse メソッドを使うことで、例外をスローせずに検証結果を取得できます。 これにより、エラーメッセージを LLM に返すことができ、LLM が状況を理解して適切に対処できるようになるのです。

セマンティック検証の追加

JSON Schema による型検証だけでなく、ビジネスルールに基づく検証も重要です。

ビジネスルール検証の実装

typescriptasync function validateBusinessRules(
  expenseData: z.infer<typeof ExpenseReportSchema>
): Promise<{ valid: boolean; error?: string }> {
  // ユーザーの存在確認
  const userExists = await checkUserExists(
    expenseData.userId
  );
  if (!userExists) {
    return {
      valid: false,
      error: '指定されたユーザーが見つかりません',
    };
  }

  // カテゴリー別の金額上限チェック
  const limits = {
    travel: 500000,
    meals: 10000,
    equipment: 300000,
    other: 50000,
  };

  if (expenseData.amount > limits[expenseData.category]) {
    return {
      valid: false,
      error: `${
        expenseData.category
      } カテゴリーの上限額は ${
        limits[expenseData.category]
      } 円です`,
    };
  }

  // 重複申請のチェック
  const isDuplicate = await checkDuplicateExpense(
    expenseData.userId,
    expenseData.amount,
    expenseData.description
  );

  if (isDuplicate) {
    return {
      valid: false,
      error: '同じ内容の経費精算が既に存在します',
    };
  }

  return { valid: true };
}

このように、型の検証とビジネスロジックの検証を分離することで、コードの保守性が高まります。

SQL インジェクション対策

データベースクエリを実行する際は、必ずパラメータ化クエリを使用します。

非推奨:文字列結合によるクエリ(脆弱)

typescript// ⚠️ 絶対に使用しないでください
async function getUserByIdUnsafe(userId: string) {
  const query = `SELECT * FROM users WHERE id = '${userId}'`;
  return await db.query(query);
}

推奨:パラメータ化クエリ(安全)

typescriptasync function getUserByIdSafe(userId: string) {
  // プレースホルダーを使用
  const query = `SELECT * FROM users WHERE id = ?`;
  return await db.query(query, [userId]);
}

ORM の使用(より安全)

typescriptimport { eq } from 'drizzle-orm';
import { users } from './schema';

async function getUserWithORM(userId: string) {
  return await db
    .select()
    .from(users)
    .where(eq(users.id, userId))
    .limit(1);
}

ORM(Object-Relational Mapping)を使用すると、SQL インジェクションのリスクをさらに減らせますね。

コマンドインジェクション対策

シェルコマンドの実行は、可能な限り避けるべきです。 どうしても必要な場合は、以下のような対策を講じます。

シェルコマンド実行の制限

typescriptimport { exec } from 'child_process';
import { promisify } from 'util';

const execAsync = promisify(exec);

// 許可されたコマンドのホワイトリスト
const ALLOWED_COMMANDS = new Set([
  'git status',
  'git log',
  'ls',
]);

安全なコマンド実行

typescriptasync function executeCommand(
  command: string
): Promise<string> {
  // ホワイトリストチェック
  if (!ALLOWED_COMMANDS.has(command)) {
    throw new Error(
      `コマンド '${command}' は許可されていません`
    );
  }

  // シェルメタ文字のチェック
  if (/[;&|`$(){}[\]<>]/.test(command)) {
    throw new Error('不正な文字が含まれています');
  }

  try {
    const { stdout, stderr } = await execAsync(command, {
      timeout: 5000, // タイムアウト設定
      maxBuffer: 1024 * 1024, // 最大出力サイズ
    });

    return stdout || stderr;
  } catch (error) {
    throw new Error(`コマンド実行エラー: ${error.message}`);
  }
}

このように、ホワイトリスト方式とメタ文字のチェックを組み合わせることで、コマンドインジェクションを防げます。

最小権限の原則

各 MCP サーバーは、必要最小限の権限のみを持つべきです。

#原則実装方法効果
1ツールの最小化必要なツールのみを公開攻撃対象領域の削減
2スコープの限定ユーザーごとにアクセス可能なリソースを制限横断的アクセスの防止
3読み取り専用の優先可能な限り読み取り専用ツールを提供データ改ざんリスクの低減
4タイムアウトの設定すべての外部呼び出しにタイムアウトを設定リソース枯渇の防止

権限スコープの実装例

typescriptinterface ToolContext {
  userId: string;
  role: 'admin' | 'user' | 'readonly';
  allowedResources: string[];
}

権限チェック関数

typescriptfunction checkPermission(
  context: ToolContext,
  requiredRole: string,
  resourceId: string
): { allowed: boolean; error?: string } {
  // ロールチェック
  if (
    context.role === 'readonly' &&
    requiredRole !== 'readonly'
  ) {
    return {
      allowed: false,
      error:
        '読み取り専用ユーザーは変更操作を実行できません',
    };
  }

  // リソースアクセス権チェック
  if (!context.allowedResources.includes(resourceId)) {
    return {
      allowed: false,
      error: 'このリソースへのアクセス権がありません',
    };
  }

  return { allowed: true };
}

ツールハンドラでの権限チェック統合

typescriptserver.setRequestHandler(
  CallToolRequestSchema,
  async (request) => {
    // コンテキストの取得(実際は認証トークンから抽出)
    const context: ToolContext = {
      userId: 'user123',
      role: 'user',
      allowedResources: ['project-A', 'project-B'],
    };

    const { name, arguments: args } = request.params;

    if (name === 'update_project') {
      // 権限チェック
      const permission = checkPermission(
        context,
        'user',
        args.projectId
      );

      if (!permission.allowed) {
        return {
          content: [
            {
              type: 'text',
              text: permission.error,
            },
          ],
          isError: true,
        };
      }

      // 実際の処理
      // ...
    }
  }
);

権限チェックをツールの実行前に行うことで、不正なアクセスを事前に防げるのです。

スキーマ設計のベストプラクティス

詳細な JSON Schema の作成

LLM が正確にツールを呼び出すには、詳細なスキーマ定義が不可欠です。

基本的なスキーマ

typescriptconst BasicSchema = {
  type: 'object',
  properties: {
    name: { type: 'string' },
  },
  required: ['name'],
};

詳細なスキーマ(推奨)

typescriptconst DetailedSchema = {
  type: 'object',
  properties: {
    name: {
      type: 'string',
      description:
        'プロジェクトの名前(例: 新規 Web サイト構築)',
      minLength: 3,
      maxLength: 100,
      pattern: '^[a-zA-Z0-9\\s-_]+$',
    },
    priority: {
      type: 'string',
      description: 'プロジェクトの優先度',
      enum: ['high', 'medium', 'low'],
      default: 'medium',
    },
    dueDate: {
      type: 'string',
      description:
        '期限日(ISO 8601 形式、例: 2025-12-31)',
      format: 'date',
    },
    budget: {
      type: 'number',
      description: '予算(円単位)',
      minimum: 0,
      maximum: 100000000,
      multipleOf: 1000,
    },
    tags: {
      type: 'array',
      description: 'プロジェクトに関連するタグ',
      items: {
        type: 'string',
        minLength: 2,
        maxLength: 20,
      },
      minItems: 1,
      maxItems: 10,
      uniqueItems: true,
    },
  },
  required: ['name', 'priority', 'dueDate'],
};

詳細なスキーマには、以下の情報を含めるべきです。

#項目目的
1descriptionLLM への説明"ユーザーの一意な識別子"
2minLength / maxLength文字列長の制約3 ~ 100 文字
3minimum / maximum数値の範囲0 ~ 1,000,000
4pattern正規表現パターン^[A-Z]{3}[0-9]{4}$
5enum許可される値のリスト["high", "medium", "low"]
6formatデータ形式date, email, uri
7defaultデフォルト値"medium"

実用的なスキーマ例

実際のビジネスシーンで使用するスキーマの例を見てみましょう。

会議スケジュールツールのスキーマ

typescriptconst ScheduleMeetingSchema = {
  type: 'object',
  properties: {
    title: {
      type: 'string',
      description: '会議のタイトル',
      minLength: 5,
      maxLength: 200,
    },
    attendees: {
      type: 'array',
      description: '参加者のメールアドレスリスト',
      items: {
        type: 'string',
        format: 'email',
        description: '参加者のメールアドレス',
      },
      minItems: 1,
      maxItems: 50,
      uniqueItems: true,
    },
    startTime: {
      type: 'string',
      description:
        '会議開始時刻(ISO 8601 形式、例: 2025-10-19T14:00:00+09:00)',
      format: 'date-time',
    },
    durationMinutes: {
      type: 'integer',
      description: '会議の所要時間(分単位)',
      minimum: 15,
      maximum: 480,
      multipleOf: 15,
      default: 60,
    },
    location: {
      type: 'object',
      description: '会議の場所',
      properties: {
        type: {
          type: 'string',
          enum: ['physical', 'online', 'hybrid'],
          description: '会議の形式',
        },
        address: {
          type: 'string',
          description:
            '物理的な住所(type が physical または hybrid の場合)',
        },
        meetingUrl: {
          type: 'string',
          format: 'uri',
          description:
            'オンライン会議の URL(type が online または hybrid の場合)',
        },
      },
      required: ['type'],
    },
    reminder: {
      type: 'object',
      description: 'リマインダー設定',
      properties: {
        enabled: {
          type: 'boolean',
          default: true,
        },
        minutesBefore: {
          type: 'integer',
          description: '会議の何分前にリマインドするか',
          enum: [5, 10, 15, 30, 60],
          default: 15,
        },
      },
    },
  },
  required: ['title', 'attendees', 'startTime'],
};

このスキーマでは、ネストされたオブジェクトや条件付き必須フィールドも表現できています。 location.type に応じて、address または meetingUrl が必要になるというビジネスロジックを、スキーマで表現しているのです。

条件付きスキーマの活用

JSON Schema の if-then-else を使うと、より複雑な条件を表現できます。

条件付きスキーマの例

typescriptconst ConditionalLocationSchema = {
  type: 'object',
  properties: {
    type: {
      type: 'string',
      enum: ['physical', 'online', 'hybrid'],
    },
    address: { type: 'string' },
    meetingUrl: {
      type: 'string',
      format: 'uri',
    },
  },
  required: ['type'],
  // 条件付きバリデーション
  if: {
    properties: {
      type: { const: 'physical' },
    },
  },
  then: {
    required: ['address'],
  },
  else: {
    if: {
      properties: {
        type: { const: 'online' },
      },
    },
    then: {
      required: ['meetingUrl'],
    },
    else: {
      // type が "hybrid" の場合
      required: ['address', 'meetingUrl'],
    },
  },
};

このように、if-then-else を使うことで、「物理的な会議の場合は住所が必須、オンラインの場合は URL が必須」という条件を表現できますね。

エラーハンドリングのベストプラクティス

エラーは、MCP プロトコルレベルのエラーではなく、ツールの結果として返すべきです。

エラー分類の定義

typescriptenum ErrorCategory {
  CLIENT_ERROR = 'CLIENT_ERROR', // クライアント側のエラー(4xx 相当)
  SERVER_ERROR = 'SERVER_ERROR', // サーバー側のエラー(5xx 相当)
  VALIDATION_ERROR = 'VALIDATION_ERROR', // 入力検証エラー
  PERMISSION_ERROR = 'PERMISSION_ERROR', // 権限エラー
  NOT_FOUND = 'NOT_FOUND', // リソースが見つからない
}

エラーレスポンスの構造

typescriptinterface ErrorResponse {
  category: ErrorCategory;
  code: string;
  message: string;
  details?: Record<string, unknown>;
  retryable?: boolean;
}

エラーハンドリングの実装

typescriptserver.setRequestHandler(
  CallToolRequestSchema,
  async (request) => {
    try {
      const { name, arguments: args } = request.params;

      if (name === 'get_user_profile') {
        // 入力検証
        const validation = UserIdSchema.safeParse(
          args.userId
        );
        if (!validation.success) {
          return {
            content: [
              {
                type: 'text',
                text: JSON.stringify({
                  category: ErrorCategory.VALIDATION_ERROR,
                  code: 'INVALID_USER_ID',
                  message: 'ユーザー ID が無効です',
                  details: validation.error.flatten(),
                }),
              },
            ],
            isError: true,
          };
        }

        // ユーザー取得
        const user = await getUserById(validation.data);

        if (!user) {
          return {
            content: [
              {
                type: 'text',
                text: JSON.stringify({
                  category: ErrorCategory.NOT_FOUND,
                  code: 'USER_NOT_FOUND',
                  message:
                    '指定されたユーザーが見つかりません',
                  details: { userId: validation.data },
                }),
              },
            ],
            isError: true,
          };
        }

        // 成功レスポンス
        return {
          content: [
            {
              type: 'text',
              text: JSON.stringify(user),
            },
          ],
        };
      }
    } catch (error) {
      // 予期しないエラー
      return {
        content: [
          {
            type: 'text',
            text: JSON.stringify({
              category: ErrorCategory.SERVER_ERROR,
              code: 'INTERNAL_ERROR',
              message: '内部エラーが発生しました',
              retryable: true,
            }),
          },
        ],
        isError: true,
      };
    }
  }
);

エラーを isError: true と共に返すことで、LLM はエラーの内容を理解し、ユーザーに適切な説明をしたり、代替手段を提案したりできます。

以下の図は、エラーハンドリングのフローを示しています。

mermaidflowchart TB
    start["ツール呼び出し"] --> validate["入力検証"]
    validate -->|検証失敗| validation_error["VALIDATION_ERROR を返却"]
    validate -->|検証成功| permission["権限チェック"]
    permission -->|権限なし| permission_error["PERMISSION_ERROR を返却"]
    permission -->|権限あり| execute["ツール実行"]
    execute -->|リソース未発見| not_found["NOT_FOUND を返却"]
    execute -->|サーバーエラー| server_error["SERVER_ERROR を返却"]
    execute -->|成功| success["成功レスポンス返却"]

    validation_error --> llm["LLM にエラー通知"]
    permission_error --> llm
    not_found --> llm
    server_error --> llm
    success --> llm

    style validation_error fill:#fff3cd
    style permission_error fill:#ffcccc
    style not_found fill:#cce5ff
    style server_error fill:#ffcccc
    style success fill:#d4edda

図で理解できる要点

  • すべてのエラーは LLM に返却され、プロトコルエラーとしてスローされません
  • エラーカテゴリーごとに色分けされ、視覚的に識別できます
  • LLM はエラー内容を理解し、適切な対応を判断できます

具体例

実践的な MCP サーバーの実装

ここでは、プロジェクト管理システムと連携する MCP サーバーの実装例を示します。 このサーバーは、タスクの作成、検索、更新、完了の機能を提供します。

プロジェクト構成

まず、プロジェクトの全体構成を見ていきましょう。

mermaidflowchart TB
    subgraph client["MCP クライアント(LLM)"]
        llm["Claude / GPT-4"]
    end

    subgraph server["MCP サーバー"]
        tools["ツール層<br/>(create_task, search_tasks など)"]
        validation["検証層<br/>(Zod スキーマ)"]
        business["ビジネスロジック層<br/>(権限チェック、ドメインルール)"]
        data["データアクセス層<br/>(DB / API 呼び出し)"]
    end

    subgraph external["外部システム"]
        db[("プロジェクト DB")]
        api["プロジェクト管理 API"]
    end

    llm -->|JSON-RPC リクエスト| tools
    tools --> validation
    validation --> business
    business --> data
    data --> db
    data --> api

図で理解できる要点

  • MCP サーバーは 4 つの層に分かれており、関心の分離が実現されています
  • 各層は独立しており、テストや変更が容易です
  • 外部システムへのアクセスはデータアクセス層に集約されています

依存パッケージのインストール

bashyarn add @modelcontextprotocol/sdk zod drizzle-orm
yarn add -D @types/node typescript

このコマンドで、MCP SDK、Zod(スキーマ検証)、Drizzle ORM(データベースアクセス)をインストールします。

スキーマ定義

タスクのスキーマ定義

typescriptimport { z } from 'zod';

// タスク ID のスキーマ
export const TaskIdSchema = z
  .string()
  .uuid('タスク ID は UUID 形式である必要があります');

タスク作成のスキーマ

typescriptexport const CreateTaskSchema = z.object({
  title: z
    .string()
    .min(5, 'タイトルは 5 文字以上必要です')
    .max(
      200,
      'タイトルは 200 文字以内である必要があります'
    ),

  description: z
    .string()
    .min(10, '説明は 10 文字以上必要です')
    .max(2000, '説明は 2000 文字以内である必要があります')
    .optional(),

  priority: z.enum(['low', 'medium', 'high', 'critical'], {
    errorMap: () => ({
      message:
        '優先度は low, medium, high, critical のいずれかである必要があります',
    }),
  }),

  assigneeId: z
    .string()
    .uuid('担当者 ID は UUID 形式である必要があります')
    .optional(),

  dueDate: z
    .string()
    .datetime(
      '期限は ISO 8601 形式の日時である必要があります'
    )
    .optional(),

  tags: z
    .array(z.string())
    .max(10, 'タグは 10 個まで設定できます')
    .optional(),
});

タスク検索のスキーマ

typescriptexport const SearchTasksSchema = z.object({
  query: z
    .string()
    .min(1, '検索クエリは 1 文字以上必要です')
    .optional(),

  status: z
    .enum(['todo', 'in_progress', 'done', 'archived'])
    .optional(),

  priority: z
    .enum(['low', 'medium', 'high', 'critical'])
    .optional(),

  assigneeId: z.string().uuid().optional(),

  limit: z
    .number()
    .int('制限数は整数である必要があります')
    .min(1, '制限数は 1 以上である必要があります')
    .max(100, '制限数は 100 以下である必要があります')
    .default(10),
});

このように、Zod を使うことで、TypeScript の型安全性と実行時の検証を同時に実現できます。

ツール定義の実装

ツールメタデータの定義

typescriptimport { McpServer } from '@modelcontextprotocol/sdk';
import {
  ListToolsRequestSchema,
  CallToolRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';

const server = new McpServer({
  name: 'project-management-server',
  version: '1.0.0',
});

ツールリストの登録

typescriptserver.setRequestHandler(
  ListToolsRequestSchema,
  async () => ({
    tools: [
      {
        name: 'create_task',
        description:
          '新しいタスクをプロジェクトに追加します。タスクのタイトル、説明、優先度などを指定できます。',
        inputSchema: {
          type: 'object',
          properties: {
            title: {
              type: 'string',
              description:
                'タスクのタイトル(例: バグ修正:ログイン画面のレイアウト崩れ)',
            },
            description: {
              type: 'string',
              description: 'タスクの詳細な説明',
            },
            priority: {
              type: 'string',
              enum: ['low', 'medium', 'high', 'critical'],
              description: 'タスクの優先度',
            },
            assigneeId: {
              type: 'string',
              description: '担当者の UUID(省略可)',
            },
            dueDate: {
              type: 'string',
              description:
                '期限日時(ISO 8601 形式、例: 2025-12-31T23:59:59+09:00)',
            },
            tags: {
              type: 'array',
              items: { type: 'string' },
              description: 'タスクに関連するタグ',
            },
          },
          required: ['title', 'priority'],
        },
      },
      {
        name: 'search_tasks',
        description:
          '条件を指定してタスクを検索します。キーワード検索、ステータス、優先度、担当者でフィルタリングできます。',
        inputSchema: {
          type: 'object',
          properties: {
            query: {
              type: 'string',
              description:
                '検索キーワード(タスクのタイトルと説明を対象)',
            },
            status: {
              type: 'string',
              enum: [
                'todo',
                'in_progress',
                'done',
                'archived',
              ],
              description: 'タスクのステータス',
            },
            priority: {
              type: 'string',
              enum: ['low', 'medium', 'high', 'critical'],
              description: 'タスクの優先度',
            },
            assigneeId: {
              type: 'string',
              description: '担当者の UUID',
            },
            limit: {
              type: 'number',
              description:
                '取得する最大件数(デフォルト: 10、最大: 100)',
            },
          },
        },
      },
    ],
  })
);

各ツールには、詳細な descriptioninputSchema を設定しています。 これにより、LLM はツールの目的と使い方を正確に理解できるのです。

ツール実行ハンドラの実装

ツール呼び出しのルーティング

typescriptserver.setRequestHandler(
  CallToolRequestSchema,
  async (request) => {
    const { name, arguments: args } = request.params;

    // コンテキストの取得(実際は認証トークンから抽出)
    const context = {
      userId: 'user-uuid-123',
      role: 'user' as const,
      allowedProjects: ['project-A', 'project-B'],
    };

    try {
      switch (name) {
        case 'create_task':
          return await handleCreateTask(args, context);

        case 'search_tasks':
          return await handleSearchTasks(args, context);

        case 'update_task':
          return await handleUpdateTask(args, context);

        case 'complete_task':
          return await handleCompleteTask(args, context);

        default:
          return createErrorResponse(
            ErrorCategory.CLIENT_ERROR,
            'UNKNOWN_TOOL',
            `ツール '${name}' は存在しません`
          );
      }
    } catch (error) {
      return createErrorResponse(
        ErrorCategory.SERVER_ERROR,
        'INTERNAL_ERROR',
        '予期しないエラーが発生しました',
        { error: error.message }
      );
    }
  }
);

タスク作成ハンドラの実装

typescriptasync function handleCreateTask(
  args: unknown,
  context: ToolContext
) {
  // 1. 入力検証
  const validation = CreateTaskSchema.safeParse(args);
  if (!validation.success) {
    return createErrorResponse(
      ErrorCategory.VALIDATION_ERROR,
      'INVALID_INPUT',
      '入力データが無効です',
      validation.error.flatten()
    );
  }

  const taskData = validation.data;

  // 2. ビジネスルール検証
  if (taskData.assigneeId) {
    const assigneeExists = await checkUserExists(
      taskData.assigneeId
    );
    if (!assigneeExists) {
      return createErrorResponse(
        ErrorCategory.CLIENT_ERROR,
        'ASSIGNEE_NOT_FOUND',
        '指定された担当者が見つかりません',
        { assigneeId: taskData.assigneeId }
      );
    }
  }

  // 3. タスク作成
  const task = await createTask({
    ...taskData,
    createdBy: context.userId,
    status: 'todo',
    createdAt: new Date().toISOString(),
  });

  // 4. 成功レスポンス
  return {
    content: [
      {
        type: 'text',
        text: JSON.stringify({
          success: true,
          task: {
            id: task.id,
            title: task.title,
            status: task.status,
            priority: task.priority,
            createdAt: task.createdAt,
          },
          message: `タスク「${task.title}」を作成しました`,
        }),
      },
    ],
  };
}

このハンドラでは、入力検証、ビジネスルール検証、実際のデータ操作、レスポンス生成の 4 つのステップを明確に分離しています。

タスク検索ハンドラの実装

typescriptasync function handleSearchTasks(
  args: unknown,
  context: ToolContext
) {
  // 1. 入力検証
  const validation = SearchTasksSchema.safeParse(args);
  if (!validation.success) {
    return createErrorResponse(
      ErrorCategory.VALIDATION_ERROR,
      'INVALID_INPUT',
      '検索条件が無効です',
      validation.error.flatten()
    );
  }

  const searchParams = validation.data;

  // 2. 権限チェック(ユーザーは自分に関連するタスクのみ検索可能)
  const filters = {
    ...searchParams,
    // ユーザーが担当者または作成者のタスクのみ
    visibleToUser: context.userId,
  };

  // 3. タスク検索
  const tasks = await searchTasks(filters);

  // 4. レスポンス整形
  return {
    content: [
      {
        type: 'text',
        text: JSON.stringify({
          success: true,
          count: tasks.length,
          tasks: tasks.map((task) => ({
            id: task.id,
            title: task.title,
            status: task.status,
            priority: task.priority,
            assignee: task.assignee?.name,
            dueDate: task.dueDate,
            tags: task.tags,
          })),
        }),
      },
    ],
  };
}

検索ハンドラでは、権限チェックを行い、ユーザーが閲覧可能なタスクのみを返すようにしています。

ユーティリティ関数

エラーレスポンス作成関数

typescriptfunction createErrorResponse(
  category: ErrorCategory,
  code: string,
  message: string,
  details?: unknown
) {
  return {
    content: [
      {
        type: 'text',
        text: JSON.stringify({
          category,
          code,
          message,
          details,
        }),
      },
    ],
    isError: true,
  };
}

データアクセス関数(例)

typescriptimport { eq, and, like } from 'drizzle-orm';
import { db } from './db';
import { tasks, users } from './schema';

async function createTask(data: {
  title: string;
  description?: string;
  priority: string;
  assigneeId?: string;
  dueDate?: string;
  tags?: string[];
  createdBy: string;
  status: string;
  createdAt: string;
}) {
  const [task] = await db
    .insert(tasks)
    .values(data)
    .returning();

  return task;
}

タスク検索関数

typescriptasync function searchTasks(filters: {
  query?: string;
  status?: string;
  priority?: string;
  assigneeId?: string;
  visibleToUser: string;
  limit: number;
}) {
  const conditions = [];

  // ユーザーに可視なタスクのみ
  conditions.push(
    or(
      eq(tasks.createdBy, filters.visibleToUser),
      eq(tasks.assigneeId, filters.visibleToUser)
    )
  );

  // ステータスフィルタ
  if (filters.status) {
    conditions.push(eq(tasks.status, filters.status));
  }

  // 優先度フィルタ
  if (filters.priority) {
    conditions.push(eq(tasks.priority, filters.priority));
  }

  // 担当者フィルタ
  if (filters.assigneeId) {
    conditions.push(
      eq(tasks.assigneeId, filters.assigneeId)
    );
  }

  // キーワード検索
  if (filters.query) {
    conditions.push(
      or(
        like(tasks.title, `%${filters.query}%`),
        like(tasks.description, `%${filters.query}%`)
      )
    );
  }

  return await db
    .select()
    .from(tasks)
    .leftJoin(users, eq(tasks.assigneeId, users.id))
    .where(and(...conditions))
    .limit(filters.limit);
}

Drizzle ORM を使うことで、型安全かつ SQL インジェクションのリスクがないクエリを構築できます。

サーバーの起動

メイン関数の実装

typescriptimport { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';

async function main() {
  const transport = new StdioServerTransport();

  await server.connect(transport);

  console.error('Project Management MCP Server started');
}

エラーハンドリング付きの起動

typescriptmain().catch((error) => {
  console.error('Fatal error:', error);
  process.exit(1);
});

console.error を使うのは、stdout が MCP プロトコルの通信に使われるためです。 ログは必ず stderr に出力する必要があります。

MCP サーバーの設定

MCP サーバーをクライアントに登録するには、設定ファイルを編集します。

Claude Desktop の設定例(macOS)

json{
  "mcpServers": {
    "project-management": {
      "command": "node",
      "args": ["/path/to/your/project/build/index.js"],
      "env": {
        "DATABASE_URL": "postgresql://user:password@localhost:5432/projects",
        "LOG_LEVEL": "info"
      }
    }
  }
}

設定ファイルのパスは、以下の通りです。

#OS設定ファイルパス
1macOS~​/​Library​/​Application Support​/​Claude​/​claude_desktop_config.json
2Windows%APPDATA%\Claude\claude_desktop_config.json
3Linux~​/​.config​/​Claude​/​claude_desktop_config.json

テストの実装

MCP サーバーの品質を保つには、包括的なテストが必要です。

ユニットテストの例(Vitest)

typescriptimport { describe, it, expect, beforeEach } from 'vitest';
import { CreateTaskSchema } from './schemas';

describe('CreateTaskSchema', () => {
  it('有効なタスクデータを受け入れる', () => {
    const validData = {
      title: '新しいタスク',
      priority: 'high',
      description: 'これは有効なタスクの説明です',
    };

    const result = CreateTaskSchema.safeParse(validData);
    expect(result.success).toBe(true);
  });

  it('短すぎるタイトルを拒否する', () => {
    const invalidData = {
      title: '短い',
      priority: 'high',
    };

    const result = CreateTaskSchema.safeParse(invalidData);
    expect(result.success).toBe(false);
    if (!result.success) {
      expect(result.error.issues[0].message).toContain(
        '5 文字以上'
      );
    }
  });

  it('無効な優先度を拒否する', () => {
    const invalidData = {
      title: '有効なタイトル',
      priority: 'invalid',
    };

    const result = CreateTaskSchema.safeParse(invalidData);
    expect(result.success).toBe(false);
  });
});

統合テストの例

typescriptimport { describe, it, expect, beforeEach } from 'vitest';
import { handleCreateTask } from './handlers';

describe('handleCreateTask', () => {
  const mockContext = {
    userId: 'test-user-id',
    role: 'user' as const,
    allowedProjects: ['project-1'],
  };

  it('タスクを正常に作成する', async () => {
    const args = {
      title: 'テストタスク',
      priority: 'high',
      description: 'これはテストです',
    };

    const response = await handleCreateTask(
      args,
      mockContext
    );

    expect(response.isError).toBeUndefined();
    const content = JSON.parse(response.content[0].text);
    expect(content.success).toBe(true);
    expect(content.task.title).toBe('テストタスク');
  });

  it('無効な入力でエラーを返す', async () => {
    const args = {
      title: '短', // 短すぎる
      priority: 'high',
    };

    const response = await handleCreateTask(
      args,
      mockContext
    );

    expect(response.isError).toBe(true);
    const content = JSON.parse(response.content[0].text);
    expect(content.category).toBe('VALIDATION_ERROR');
  });
});

テストを書くことで、リファクタリングや機能追加の際に既存の機能が壊れていないことを確認できます。

まとめ

MCP サーバーの設計におけるベストプラクティスを、ツール定義、権限分離、スキーマ設計の 3 つの観点から解説しました。

ツール定義のポイント

MCP のツールは、単純な CRUD 操作ではなく、ドメイン固有のアクションとして定義すべきです。 create_record ではなく submit_expense_report のように、ビジネスの文脈を反映した名前にすることで、LLM が適切にツールを選択・連携できるようになります。

また、詳細な descriptioninputSchema を提供することで、LLM がツールの使い方を正確に理解できます。

権限分離とセキュリティのポイント

**多層防御(Defense in Depth)**の原則に基づき、ネットワーク分離、認証・認可、入力検証、出力サニタイゼーション、監査ログの 5 つの層でセキュリティを確保します。

特に重要なのが、入力検証とパラメータ化クエリの使用です。 Zod による型検証とビジネスルール検証を組み合わせ、SQL インジェクションやコマンドインジェクションを防ぐことが不可欠ですね。

また、最小権限の原則に従い、各クライアントには必要最小限のツールとリソースのみを公開すべきです。

スキーマ設計のポイント

JSON Schema は、単に型を定義するだけでなく、descriptionminLengthmaxLengthpatternenum などを活用して、LLM が理解しやすいスキーマを作成します。

条件付きスキーマ(if-then-else)を使うことで、「物理的な会議の場合は住所が必須」といった複雑なビジネスルールも表現できます。

エラーハンドリングでは、MCP プロトコルエラーではなく、ツールの結果として isError: true と共にエラー情報を返すことで、LLM がエラーを理解し、適切に対処できるようになります。

今後の展望

MCP は、AI アプリケーションのエコシステムを統一する可能性を秘めています。 標準化されたプロトコルにより、開発者は再利用可能なツールを共有でき、AI アプリケーションの開発効率が大幅に向上するでしょう。

セキュリティ、保守性、拡張性を意識した MCP サーバー設計により、安全で信頼性の高い AI システムを構築できますね。

関連リンク