vercel/mcp-handlerを用いたMCPサーバーでのZodバリデーション完全ガイド

mcpzodtypescriptvalidationai
By sko X opus 4.19/19/202520 min read

ZodはMCPサーバーにおける型安全なバリデーションの中核を担っています。vercel/mcp-handlerを使って開発する際、ZodスキーマはAIモデルとツール間の契約を定義し、データの整合性を保証し、豊富なTypeScriptの型推論を提供します。このガイドでは、実践的なMCP固有の例とともに、必要なZodの全機能を網羅します。

MCPサーバーにおけるZodの核心概念

MCPにおけるZodスキーマをAI用のAPIコントラクトとして考えてください。公開する各ツールには明確な入力バリデーションが必要です。これはRESTエンドポイントでリクエストバリデーションが必要なのと同様ですが、AIモデルが動的に入力を生成するという複雑さが加わります。

// 従来のAPI: 人間の開発者がドキュメントを読み、正しいデータを送信
// MCPツール: AIモデルがスキーマを読み、マッチするデータを生成
// Zodは契約の両側面が確実に守られることを保証

プリミティブバリデーションパターン

ビジネスルール付き文字列バリデーション

import { createMcpHandler } from "mcp-handler";
import { z } from "zod";

const handler = createMcpHandler((server) => {
  server.tool(
    "user_registration",
    "バリデーション済み入力で新規ユーザーを登録",
    {
      // カスタムドメインバリデーション付きメール
      email: z
        .string()
        .email("無効なメール形式です")
        .refine(
          (email) => email.endsWith("@company.com") || email.endsWith("@partner.org"),
          "メールは company.com または partner.org ドメインである必要があります"
        ),

      // 複雑な要件を持つユーザー名
      username: z
        .string()
        .min(3, "ユーザー名は最低3文字必要です")
        .max(20, "ユーザー名は20文字を超えることはできません")
        .regex(/^[a-zA-Z0-9_-]+$/, "ユーザー名は英数字、アンダースコア、ハイフンのみ使用可能です")
        .transform((val) => val.toLowerCase()), // 常に小文字

      // セキュリティ要件付きパスワード
      password: z
        .string()
        .min(8, "パスワードは最低8文字必要です")
        .regex(/[A-Z]/, "パスワードには大文字を最低1文字含める必要があります")
        .regex(/[a-z]/, "パスワードには小文字を最低1文字含める必要があります")
        .regex(/[0-9]/, "パスワードには数字を最低1文字含める必要があります")
        .regex(/[^A-Za-z0-9]/, "パスワードには特殊文字を最低1文字含める必要があります"),

      // 長さ制約付きオプショナルな自己紹介
      bio: z
        .string()
        .max(500, "自己紹介は500文字を超えることはできません")
        .optional()
        .transform((val) => val?.trim()), // 提供された場合は空白を削除
    },
    async ({ email, username, password, bio }) => {
      // すべての入力は検証済みで型付けされています
      const user = await createUser({ email, username, password, bio });
      return {
        content: [{
          type: "text",
          text: `ユーザー ${username} がID: ${user.id} で正常に作成されました`
        }],
      };
    }
  );
});

範囲付き数値バリデーション

server.tool(
  "financial_transaction",
  "厳格なバリデーション付き金融取引処理",
  {
    amount: z
      .number()
      .positive("金額は正の値である必要があります")
      .multipleOf(0.01, "金額は小数点以下2桁までにしてください")
      .max(1000000, "単一取引の限度額を超えています"),

    quantity: z
      .number()
      .int("数量は整数である必要があります")
      .min(1, "数量は最低1以上である必要があります")
      .max(100, "最大数量は100です"),

    percentage: z
      .number()
      .min(0, "パーセンテージは負の値にできません")
      .max(100, "パーセンテージは100を超えることはできません")
      .transform((val) => val / 100), // 小数に変換

    temperature: z
      .number()
      .finite("温度は有限数である必要があります")
      .refine(
        (val) => val >= -273.15,
        "温度は絶対零度を下回ることはできません"
      ),
  },
  async (params) => {
    // 検証済み取引を処理
    return { content: [{ type: "text", text: "取引が処理されました" }] };
  }
);

ブール値と日付のバリデーション

server.tool(
  "schedule_meeting",
  "日時バリデーション付きミーティングスケジュール",
  {
    title: z.string().min(1, "タイトルは必須です"),

    // ビジネスロジック付き日付バリデーション
    startDate: z
      .string()
      .datetime({ message: "無効な日時形式です" })
      .refine((date) => {
        const d = new Date(date);
        return d > new Date();
      }, "開始日は未来の日時である必要があります")
      .transform((str) => new Date(str)),

    // 分単位の期間
    duration: z
      .number()
      .int()
      .min(15, "最短ミーティング時間は15分です")
      .max(480, "最長ミーティング時間は8時間です"),

    // デフォルト値付きブールフラグ
    isRecurring: z.boolean().default(false),
    sendReminders: z.boolean().default(true),
    requiresApproval: z.boolean().optional(),

    // 文字列をブールに変換(フォームデータに有用)
    isPublic: z
      .string()
      .transform((val) => val === "true" || val === "1")
      .or(z.boolean())
      .default(false),
  },
  async (meeting) => {
    // meeting.startDateはDateオブジェクトになっています
    const scheduled = await scheduleMeeting(meeting);
    return {
      content: [{
        type: "text",
        text: `ミーティングが ${meeting.startDate.toISOString()} にスケジュールされました`
      }],
    };
  }
);

高度なオブジェクトスキーマ

バリデーション付きネストオブジェクト

server.tool(
  "create_project",
  "複雑なネスト構造でプロジェクトを作成",
  {
    project: z.object({
      name: z.string().min(1).max(100),
      description: z.string().optional(),

      // 設定用ネストオブジェクト
      settings: z.object({
        visibility: z.enum(["public", "private", "team"]),
        autoArchive: z.boolean().default(false),
        maxMembers: z.number().int().min(1).max(1000).default(50),
      }),

      // バリデーション依存関係付きネストオブジェクト
      budget: z.object({
        amount: z.number().positive(),
        currency: z.enum(["USD", "EUR", "GBP"]),
        allocated: z.number().default(0),
      }).refine(
        (budget) => budget.allocated <= budget.amount,
        "割り当て予算は総予算を超えることはできません"
      ),

      // ネストオブジェクトの配列
      milestones: z.array(
        z.object({
          title: z.string(),
          dueDate: z.string().datetime(),
          status: z.enum(["pending", "in_progress", "completed"]).default("pending"),
        })
      ).min(1, "最低1つのマイルストーンが必要です")
       .max(20, "最大20個のマイルストーンまで許可されています"),
    }),
  },
  async ({ project }) => {
    const created = await createProject(project);
    return {
      content: [{
        type: "text",
        text: `プロジェクト「${project.name}」が ${project.milestones.length} のマイルストーンで作成されました`
      }],
    };
  }
);

レコードを使った動的オブジェクトキー

server.tool(
  "update_metadata",
  "動的なキー値ペアでメタデータを更新",
  {
    entityId: z.string().uuid("無効なエンティティID形式です"),

    // 文字列キーと様々な値タイプのレコード
    metadata: z.record(
      z.string().regex(/^[a-z_][a-z0-9_]*$/i, "無効なメタデータキー形式です"),
      z.union([
        z.string(),
        z.number(),
        z.boolean(),
        z.null(),
      ])
    ),

    // enum キー付きレコード
    permissions: z.record(
      z.enum(["read", "write", "delete", "admin"]),
      z.boolean()
    ),

    // 複雑な構造用ネストレコード
    translations: z.record(
      z.string().length(2, "言語コードは2文字である必要があります"), // 例: "en", "fr"
      z.object({
        title: z.string(),
        description: z.string().optional(),
      })
    ),
  },
  async ({ entityId, metadata, permissions, translations }) => {
    // 動的キーへの型安全アクセス
    const result = await updateEntity(entityId, { metadata, permissions, translations });
    return {
      content: [{
        type: "text",
        text: `エンティティ ${entityId} を ${Object.keys(metadata).length} のメタデータフィールドで更新しました`
      }],
    };
  }
);

配列とタプル

制約付き配列バリデーション

server.tool(
  "batch_process",
  "配列バリデーション付きバッチ操作処理",
  {
    // 長さ制約付きシンプル配列
    ids: z
      .array(z.string().uuid())
      .min(1, "最低1つのIDが必要です")
      .max(100, "バッチあたり最大100項目までです"),

    // 一意値の配列
    tags: z
      .array(z.string())
      .refine(
        (tags) => new Set(tags).size === tags.length,
        "タグは一意である必要があります"
      ),

    // 複雑なバリデーション付きオブジェクトの配列
    operations: z
      .array(
        z.object({
          type: z.enum(["create", "update", "delete"]),
          data: z.record(z.any()),
          priority: z.number().int().min(1).max(10).default(5),
        })
      )
      .transform((ops) =>
        // 優先度でソート
        ops.sort((a, b) => b.priority - a.priority)
      ),

    // 固定長配列用タプル
    coordinates: z.tuple([
      z.number().min(-90).max(90),  // 緯度
      z.number().min(-180).max(180), // 経度
    ]),

    // タプルでの残余要素
    version: z.tuple([
      z.number().int(), // メジャー
      z.number().int(), // マイナー
      z.number().int(), // パッチ
    ]).rest(z.string()), // 追加の文字列要素を持てます
  },
  async ({ ids, tags, operations, coordinates, version }) => {
    // coordinatesは[number, number]として型付けされます
    // versionは[number, number, number, ...string[]]として型付けされます
    const results = await processBatch({ ids, tags, operations });
    return {
      content: [{
        type: "text",
        text: `座標 ${coordinates.join(", ")} で ${operations.length} の操作を処理しました`
      }],
    };
  }
);

列挙型と判別共用体

列挙型パターン

// 再利用のためにenum値をconstとして定義
const TaskStatus = {
  PENDING: "pending",
  IN_PROGRESS: "in_progress",
  COMPLETED: "completed",
  CANCELLED: "cancelled",
} as const;

const TaskPriority = ["low", "medium", "high", "urgent"] as const;

server.tool(
  "task_management",
  "enumバリデーション付きタスク管理",
  {
    action: z.enum(["create", "update", "delete", "archive"]),

    // オブジェクト値をenumとして使用
    status: z.enum(Object.values(TaskStatus) as [string, ...string[]]),

    // 配列をenumとして使用
    priority: z.enum(TaskPriority),

    // デフォルト付きオプショナルenum
    assigneeRole: z
      .enum(["developer", "designer", "manager", "qa"])
      .optional()
      .default("developer"),

    // enum値の変換
    visibility: z
      .enum(["0", "1", "2"]) // 文字列として受信
      .transform((val) => {
        const map = { "0": "private", "1": "team", "2": "public" };
        return map[val as keyof typeof map];
      }),
  },
  async (params) => {
    const result = await manageTask(params);
    return {
      content: [{
        type: "text",
        text: `タスクが ${params.action} されました。ステータス: ${params.status}`
      }],
    };
  }
);

複雑な型用判別共用体

server.tool(
  "process_payment",
  "判別共用体で異なる決済タイプを処理",
  {
    payment: z.discriminatedUnion("type", [
      z.object({
        type: z.literal("credit_card"),
        cardNumber: z.string().regex(/^\d{16}$/, "カード番号は16桁である必要があります"),
        cvv: z.string().regex(/^\d{3,4}$/, "CVVは3-4桁である必要があります"),
        expiryMonth: z.number().int().min(1).max(12),
        expiryYear: z.number().int().min(new Date().getFullYear()),
      }),
      z.object({
        type: z.literal("bank_transfer"),
        accountNumber: z.string(),
        routingNumber: z.string(),
        accountType: z.enum(["checking", "savings"]),
      }),
      z.object({
        type: z.literal("crypto"),
        walletAddress: z.string().regex(/^0x[a-fA-F0-9]{40}$/, "無効なウォレットアドレスです"),
        network: z.enum(["ethereum", "polygon", "arbitrum"]),
        tokenAddress: z.string().optional(),
      }),
      z.object({
        type: z.literal("paypal"),
        email: z.string().email(),
        paypalId: z.string().optional(),
      }),
    ]),

    amount: z.number().positive(),
    currency: z.string().length(3, "通貨コードは3文字である必要があります"),
  },
  async ({ payment, amount, currency }) => {
    // TypeScriptはpayment.typeに基づいて正確な形状を認識します
    if (payment.type === "credit_card") {
      // ここでpayment.cardNumberが利用可能です
      return await processCreditCard(payment, amount, currency);
    } else if (payment.type === "crypto") {
      // ここでpayment.walletAddressが利用可能です
      return await processCrypto(payment, amount, currency);
    }
    // ... 他の決済タイプを処理

    return {
      content: [{
        type: "text",
        text: `${payment.type} で ${amount} ${currency} の決済が処理されました`
      }],
    };
  }
);

変換と前処理

データ変換パイプライン

server.tool(
  "import_data",
  "前処理付きデータインポートと変換",
  {
    // 文字列を数値に強制変換
    userId: z.string().transform((val) => parseInt(val, 10)),

    // JSON文字列をパース
    config: z
      .string()
      .transform((str) => {
        try {
          return JSON.parse(str);
        } catch {
          throw new Error("無効なJSON設定です");
        }
      })
      .pipe(
        z.object({
          enabled: z.boolean(),
          threshold: z.number(),
        })
      ),

    // データのクリーンアップと正規化
    phone: z
      .string()
      .transform((phone) => phone.replace(/\D/g, "")) // 数字以外を削除
      .refine((phone) => phone.length === 10, "電話番号は10桁である必要があります")
      .transform((phone) => `+1${phone}`), // 国番号を追加

    // カンマ区切り値のパース
    tags: z
      .string()
      .transform((str) => str.split(",").map(s => s.trim()))
      .pipe(z.array(z.string().min(1))),

    // バリデーション付き複雑な変換
    dateRange: z
      .string()
      .transform((str) => {
        const [start, end] = str.split(" to ").map(d => new Date(d.trim()));
        return { start, end };
      })
      .refine(
        ({ start, end }) => start < end,
        "開始日は終了日より前である必要があります"
      ),
  },
  async (data) => {
    // すべてのデータは変換済みで検証済みです
    const result = await importData(data);
    return {
      content: [{
        type: "text",
        text: `ユーザー ${data.userId} のデータを ${data.tags.length} のタグでインポートしました`
      }],
    };
  }
);

正規化のための前処理

server.tool(
  "search_products",
  "入力前処理付き商品検索",
  {
    // 様々な入力形式を処理する前処理
    query: z.preprocess(
      (val) => {
        if (typeof val === "string") return val.trim().toLowerCase();
        if (typeof val === "number") return String(val);
        return val;
      },
      z.string().min(1, "検索クエリが必要です")
    ),

    // 価格範囲の強制変換とバリデーション
    minPrice: z.preprocess(
      (val) => (val === "" || val === null ? undefined : Number(val)),
      z.number().min(0).optional()
    ),

    maxPrice: z.preprocess(
      (val) => (val === "" || val === null ? undefined : Number(val)),
      z.number().positive().optional()
    ),

    // ブール値様の値の処理
    inStock: z.preprocess(
      (val) => {
        if (typeof val === "boolean") return val;
        if (val === "true" || val === "1" || val === 1) return true;
        if (val === "false" || val === "0" || val === 0) return false;
        return undefined;
      },
      z.boolean().optional()
    ),

    // 日付文字列のパースとバリデーション
    availableAfter: z.preprocess(
      (val) => {
        if (!val) return undefined;
        const date = new Date(String(val));
        return isNaN(date.getTime()) ? undefined : date;
      },
      z.date().optional()
    ),
  },
  async (params) => {
    const products = await searchProducts(params);
    return {
      content: [{
        type: "text",
        text: `「${params.query}」にマッチする ${products.length} の商品を見つけました`
      }],
    };
  }
);

精密化とカスタムバリデーション

基本的な精密化

server.tool(
  "create_event",
  "複雑なバリデーションルール付きイベント作成",
  {
    name: z.string(),
    startTime: z.string().datetime(),
    endTime: z.string().datetime(),
    maxAttendees: z.number().int().positive(),
    currentAttendees: z.number().int().min(0).default(0),

    // オブジェクトレベルの精密化
    location: z.object({
      type: z.enum(["physical", "virtual", "hybrid"]),
      address: z.string().optional(),
      url: z.string().url().optional(),
      capacity: z.number().int().positive().optional(),
    }).refine(
      (loc) => {
        if (loc.type === "physical" && !loc.address) {
          return false;
        }
        if (loc.type === "virtual" && !loc.url) {
          return false;
        }
        return true;
      },
      {
        message: "物理イベントには住所、バーチャルイベントにはURLが必要です",
      }
    ),
  },
  async (eventData) => {
    const event = await createEvent(eventData);
    return {
      content: [{
        type: "text",
        text: `イベント「${eventData.name}」が正常に作成されました`
      }],
    };
  }
);

複数バリデーション用SuperRefine

server.tool(
  "configure_deployment",
  "相互依存バリデーション付きデプロイメント設定",
  {
    environment: z.enum(["development", "staging", "production"]),
    replicas: z.number().int().positive(),
    memory: z.number().positive(), // GB単位
    cpu: z.number().positive(), // コア数
    autoScaling: z.boolean(),
    minReplicas: z.number().int().positive().optional(),
    maxReplicas: z.number().int().positive().optional(),
    customDomain: z.string().optional(),
    ssl: z.boolean().optional(),
  },
  async (config) => {
    // 入力はすべてのsuperRefineルールに対してバリデーション済みです
    const deployment = await configureDeployment(config);
    return {
      content: [{
        type: "text",
        text: `${config.environment} 環境で ${config.replicas} レプリカのデプロイメントが設定されました`
      }],
    };
  }
);

// 複雑なマルチフィールドバリデーション用にスキーマにsuperRefineを追加
const deploymentSchema = z
  .object({
    environment: z.enum(["development", "staging", "production"]),
    replicas: z.number().int().positive(),
    memory: z.number().positive(),
    cpu: z.number().positive(),
    autoScaling: z.boolean(),
    minReplicas: z.number().int().positive().optional(),
    maxReplicas: z.number().int().positive().optional(),
    customDomain: z.string().optional(),
    ssl: z.boolean().optional(),
  })
  .superRefine((data, ctx) => {
    // 本番環境固有のバリデーション
    if (data.environment === "production") {
      if (data.replicas < 2) {
        ctx.addIssue({
          code: z.ZodIssueCode.custom,
          message: "本番環境は最低2つのレプリカが必要です",
          path: ["replicas"],
        });
      }

      if (data.memory < 4) {
        ctx.addIssue({
          code: z.ZodIssueCode.too_small,
          message: "本番環境は最低4GBのメモリが必要です",
          path: ["memory"],
          minimum: 4,
          inclusive: true,
          type: "number",
        });
      }
    }

    // オートスケーリングのバリデーション
    if (data.autoScaling) {
      if (!data.minReplicas || !data.maxReplicas) {
        ctx.addIssue({
          code: z.ZodIssueCode.custom,
          message: "オートスケーリングにはminReplicasとmaxReplicasの両方が必要です",
        });
      }

      if (data.minReplicas && data.maxReplicas && data.minReplicas > data.maxReplicas) {
        ctx.addIssue({
          code: z.ZodIssueCode.custom,
          message: "minReplicasはmaxReplicasより大きくできません",
          path: ["minReplicas"],
        });
      }
    }

    // カスタムドメインにはSSLが必要
    if (data.customDomain && !data.ssl) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "カスタムドメインにはSSLの有効化が必要です",
        path: ["ssl"],
      });
    }

    // リソース比率のバリデーション
    const cpuMemoryRatio = data.cpu / data.memory;
    if (cpuMemoryRatio > 1) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "CPUコア数はメモリ(GB)を超えるべきではありません",
      });
    }
  });

非同期精密化

server.tool(
  "register_domain",
  "非同期バリデーション付きドメイン登録",
  {
    domain: z
      .string()
      .regex(/^[a-z0-9-]+\.[a-z]{2,}$/, "無効なドメイン形式です")
      .refine(async (domain) => {
        // ドメインの可用性チェック(非同期操作)
        const available = await checkDomainAvailability(domain);
        return available;
      }, "ドメインは利用できません"),

    owner: z.object({
      email: z
        .string()
        .email()
        .refine(async (email) => {
          // メールがブラックリストにないか確認
          const blacklisted = await checkEmailBlacklist(email);
          return !blacklisted;
        }, "メールがブラックリストに登録されています"),

      organizationId: z
        .string()
        .uuid()
        .refine(async (id) => {
          // 組織の存在確認
          const exists = await organizationExists(id);
          return exists;
        }, "組織が見つかりません"),
    }),

    duration: z.number().int().min(1).max(10), // 年数
  },
  async ({ domain, owner, duration }) => {
    const registration = await registerDomain({ domain, owner, duration });
    return {
      content: [{
        type: "text",
        text: `ドメイン ${domain} が ${duration} 年間で登録されました`
      }],
    };
  }
);

共用体とオプショナル処理

柔軟な入力タイプ

server.tool(
  "flexible_query",
  "共用体で複数の入力形式を処理",
  {
    // 文字列または数値ID
    identifier: z.union([
      z.string().uuid(),
      z.number().int().positive(),
      z.string().regex(/^[A-Z]{2,4}-\d{6}$/), // カスタム形式
    ]),

    // 複数の日付形式
    date: z.union([
      z.string().datetime(),
      z.string().regex(/^\d{4}-\d{2}-\d{2}$/), // YYYY-MM-DD
      z.number(), // Unixタイムスタンプ
    ]).transform((val) => {
      if (typeof val === "number") return new Date(val * 1000);
      return new Date(val);
    }),

    // 複数タイプのオプショナル
    filter: z
      .union([
        z.string(),
        z.array(z.string()),
        z.object({
          include: z.array(z.string()).optional(),
          exclude: z.array(z.string()).optional(),
        }),
      ])
      .optional()
      .transform((filter) => {
        // 一貫した形式に正規化
        if (!filter) return { include: [], exclude: [] };
        if (typeof filter === "string") return { include: [filter], exclude: [] };
        if (Array.isArray(filter)) return { include: filter, exclude: [] };
        return filter;
      }),

    // Nullable vs optional
    description: z.string().nullable(), // nullになり得る
    metadata: z.record(z.any()).optional(), // undefinedになり得る
    notes: z.string().nullish(), // nullまたはundefinedになり得る
  },
  async (params) => {
    const result = await executeQuery(params);
    return {
      content: [{
        type: "text",
        text: `識別子: ${params.identifier} でクエリが実行されました`
      }],
    };
  }
);

エラーハンドリングとカスタムメッセージ

包括的なエラーメッセージ

const userInputSchema = z.object({
  username: z
    .string({
      required_error: "ユーザー名は必須です",
      invalid_type_error: "ユーザー名は文字列である必要があります",
    })
    .min(3, { message: "ユーザー名は最低3文字必要です" })
    .max(20, { message: "ユーザー名は20文字を超えることはできません" })
    .regex(/^[a-zA-Z0-9_]+$/, {
      message: "ユーザー名は英数字とアンダースコアのみ使用可能です"
    }),

  age: z
    .number({
      required_error: "年齢は必須です",
      invalid_type_error: "年齢は数値である必要があります",
    })
    .int({ message: "年齢は整数である必要があります" })
    .positive({ message: "年齢は正の値である必要があります" })
    .max(120, { message: "年齢は120を超えることはできません" }),

  email: z
    .string()
    .email({ message: "有効なメールアドレスを入力してください" })
    .refine(
      (email) => !email.includes("tempmail"),
      { message: "一時的なメールアドレスは許可されていません" }
    ),
});

server.tool(
  "validate_user",
  "詳細なエラーメッセージ付きユーザー入力バリデーション",
  userInputSchema.shape,
  async (userData) => {
    // ここに到達した場合、すべてのバリデーションが通過しています
    return {
      content: [{
        type: "text",
        text: `ユーザー ${userData.username} が正常にバリデーションされました`
      }],
    };
  }
);

エラーパス指定

const complexSchema = z
  .object({
    user: z.object({
      profile: z.object({
        firstName: z.string(),
        lastName: z.string(),
        age: z.number(),
      }),
      settings: z.object({
        notifications: z.boolean(),
        theme: z.enum(["light", "dark"]),
      }),
    }),
    permissions: z.array(z.string()),
  })
  .superRefine((data, ctx) => {
    if (data.user.profile.age < 18 && data.permissions.includes("admin")) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "18歳未満のユーザーは管理者権限を持てません",
        path: ["permissions"], // エラーの正確なパスを指定
      });
    }

    if (data.user.profile.firstName === data.user.profile.lastName) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "名前と姓は異なる必要があります",
        path: ["user", "profile", "lastName"], // ネストしたパス
      });
    }
  });

パフォーマンス最適化パターン

再帰構造用の遅延スキーマ

// 再帰型の定義(例:ツリー構造、ネストしたコメント)
type TreeNode = {
  id: string;
  value: any;
  children?: TreeNode[];
};

const treeNodeSchema: z.ZodType<TreeNode> = z.lazy(() =>
  z.object({
    id: z.string().uuid(),
    value: z.any(),
    children: z.array(treeNodeSchema).optional(),
  })
);

server.tool(
  "process_tree",
  "階層ツリー構造の処理",
  {
    tree: treeNodeSchema,
    maxDepth: z.number().int().positive().max(10).default(5),
  },
  async ({ tree, maxDepth }) => {
    const result = await processTreeStructure(tree, maxDepth);
    return {
      content: [{
        type: "text",
        text: `ルートID: ${tree.id} のツリーを処理しました`
      }],
    };
  }
);

スキーマ合成と再利用

// 再利用のためのベーススキーマ
const addressSchema = z.object({
  street: z.string(),
  city: z.string(),
  state: z.string().length(2),
  zipCode: z.string().regex(/^\d{5}(-\d{4})?$/),
  country: z.string().default("US"),
});

const contactSchema = z.object({
  phone: z.string().regex(/^\+?[\d\s-()]+$/),
  email: z.string().email(),
  preferredContact: z.enum(["phone", "email", "both"]).default("email"),
});

// 交差を使ったスキーマ合成
const personSchema = z.intersection(
  z.object({
    id: z.string().uuid(),
    firstName: z.string(),
    lastName: z.string(),
    dateOfBirth: z.string().date(),
  }),
  contactSchema
);

// 特定の用途向けスキーマ拡張
const employeeSchema = personSchema.extend({
  employeeId: z.string(),
  department: z.enum(["engineering", "sales", "hr", "finance"]),
  salary: z.number().positive(),
  address: addressSchema,
  emergencyContact: contactSchema.partial(), // すべてのフィールドをオプショナルに
});

const customerSchema = personSchema.extend({
  customerId: z.string(),
  loyaltyPoints: z.number().int().min(0).default(0),
  shippingAddress: addressSchema,
  billingAddress: addressSchema.optional(),
  preferences: z.object({
    newsletter: z.boolean().default(true),
    smsAlerts: z.boolean().default(false),
  }),
});

server.tool(
  "manage_person",
  "従業員または顧客レコードの管理",
  {
    type: z.enum(["employee", "customer"]),
    data: z.union([employeeSchema, customerSchema]),
    action: z.enum(["create", "update", "archive"]),
  },
  async ({ type, data, action }) => {
    // TypeScriptは判別共用体に基づいて型を絞り込みます
    if (type === "employee") {
      return await manageEmployee(data as z.infer<typeof employeeSchema>, action);
    } else {
      return await manageCustomer(data as z.infer<typeof customerSchema>, action);
    }
  }
);

実世界のMCPツール例

完全なファイル処理ツール

const FileFormat = z.enum(["csv", "json", "xml", "yaml", "txt"]);
const Operation = z.enum(["parse", "validate", "transform", "merge"]);

server.tool(
  "process_files",
  "複数操作付き高度ファイル処理",
  {
    files: z.array(
      z.object({
        name: z.string().regex(/^[\w\-. ]+$/, "無効なファイル名です"),
        format: FileFormat,
        content: z.string().max(10 * 1024 * 1024, "ファイルサイズが10MBを超えています"),
        encoding: z.enum(["utf8", "base64", "ascii"]).default("utf8"),
      })
    ).min(1, "最低1つのファイルが必要です")
     .max(10, "一度に最大10ファイルまでです"),

    operation: Operation,

    options: z.discriminatedUnion("operation", [
      z.object({
        operation: z.literal("parse"),
        delimiter: z.string().optional(),
        headers: z.boolean().default(true),
        skipRows: z.number().int().min(0).default(0),
      }),
      z.object({
        operation: z.literal("validate"),
        schema: z.record(z.any()), // バリデーション用JSONスキーマ
        strict: z.boolean().default(false),
      }),
      z.object({
        operation: z.literal("transform"),
        transformations: z.array(
          z.object({
            field: z.string(),
            operation: z.enum(["uppercase", "lowercase", "trim", "replace", "remove"]),
            value: z.string().optional(),
          })
        ),
      }),
      z.object({
        operation: z.literal("merge"),
        mergeKey: z.string(),
        conflictResolution: z.enum(["keep_first", "keep_last", "combine"]),
      }),
    ]),

    output: z.object({
      format: FileFormat,
      compression: z.enum(["none", "gzip", "zip"]).default("none"),
      splitSize: z.number().int().positive().optional(), // KB
    }),
  },
  async ({ files, operation, options, output }) => {
    // 操作タイプに基づいてファイルを処理
    let result;

    switch (operation) {
      case "parse":
        result = await parseFiles(files, options);
        break;
      case "validate":
        result = await validateFiles(files, options);
        break;
      case "transform":
        result = await transformFiles(files, options);
        break;
      case "merge":
        result = await mergeFiles(files, options);
        break;
    }

    // 出力をフォーマット
    const formatted = await formatOutput(result, output);

    return {
      content: [{
        type: "text",
        text: `${files.length} ファイルを ${operation} 操作で処理しました。出力: ${output.format}`
      }],
    };
  }
);

レート制限付きAPI統合ツール

server.tool(
  "api_request",
  "バリデーションとレート制限付きAPIリクエスト",
  {
    endpoint: z
      .string()
      .url("無効なURL形式です")
      .refine(
        (url) => {
          const allowedDomains = ["api.example.com", "api.partner.com"];
          const urlObj = new URL(url);
          return allowedDomains.includes(urlObj.hostname);
        },
        "APIドメインがホワイトリストに登録されていません"
      ),

    method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),

    headers: z
      .record(z.string())
      .default({})
      .transform((headers) => ({
        ...headers,
        "Content-Type": headers["Content-Type"] || "application/json",
        "User-Agent": "MCP-Server/1.0",
      })),

    body: z
      .any()
      .optional()
      .refine(
        (body, ctx) => {
          const method = ctx.path[0] === "POST" || ctx.path[0] === "PUT" || ctx.path[0] === "PATCH";
          if (method && !body) {
            return false;
          }
          return true;
        },
        "POST/PUT/PATCHリクエストにはボディが必要です"
      ),

    auth: z.discriminatedUnion("type", [
      z.object({
        type: z.literal("bearer"),
        token: z.string().min(1),
      }),
      z.object({
        type: z.literal("api_key"),
        key: z.string().min(1),
        location: z.enum(["header", "query"]),
      }),
      z.object({
        type: z.literal("basic"),
        username: z.string(),
        password: z.string(),
      }),
      z.object({
        type: z.literal("oauth2"),
        clientId: z.string(),
        clientSecret: z.string(),
        scope: z.array(z.string()).optional(),
      }),
    ]).optional(),

    retry: z.object({
      maxAttempts: z.number().int().min(1).max(5).default(3),
      backoffMs: z.number().int().min(100).max(60000).default(1000),
      retryOn: z.array(z.number().int()).default([429, 500, 502, 503, 504]),
    }).optional(),

    timeout: z.number().int().min(1000).max(300000).default(30000), // ms
  },
  async (params) => {
    // レート制限を適用
    await rateLimiter.checkLimit(params.endpoint);

    // 認証付きリクエストを構築
    const request = buildAuthenticatedRequest(params);

    // リトライロジックで実行
    const response = await executeWithRetry(request, params.retry);

    return {
      content: [{
        type: "text",
        text: `${params.endpoint} へのAPIリクエストがステータス ${response.status} で完了しました`
      }],
    };
  }
);

MCPでのZodスキーマのベストプラクティス

1. 記述的なエラーメッセージを使用

// ❌ 悪い例: 汎用的なメッセージ
z.string().min(3)

// ✅ 良い例: 具体的で実行可能なメッセージ
z.string().min(3, "ユーザー名は最低3文字必要です")

2. 合理的な場所でデフォルト値を提供

// ✅ 良い例: オプショナル設定のデフォルト
z.object({
  retryCount: z.number().int().default(3),
  timeout: z.number().default(30000),
  debug: z.boolean().default(false),
})

3. データ正規化に変換を使用

// ✅ 良い例: 早期にデータを正規化
z.object({
  email: z.string().email().transform(e => e.toLowerCase()),
  tags: z.string().transform(s => s.split(",").map(t => t.trim())),
})

4. 複雑な型には判別共用体を活用

// ✅ 良い例: 型安全な分岐
z.discriminatedUnion("type", [
  z.object({ type: z.literal("A"), fieldA: z.string() }),
  z.object({ type: z.literal("B"), fieldB: z.number() }),
])

5. ビジネスロジックには精密化を使用

// ✅ 良い例: ビジネスルールをカプセル化
z.object({
  startDate: z.date(),
  endDate: z.date(),
}).refine(
  data => data.startDate < data.endDate,
  "終了日は開始日より後である必要があります"
)

6. 再利用可能なスキーマコンポーネントを作成

// ✅ 良い例: DRY原則
const paginationSchema = z.object({
  page: z.number().int().positive().default(1),
  limit: z.number().int().min(1).max(100).default(20),
});

// 複数のツールで再利用
server.tool("list_users", "...", {
  ...paginationSchema.shape,
  filter: z.string().optional(),
});

7. オプショナル vs Nullable を適切に処理

// 違いを理解する:
z.string().optional()  // string | undefined
z.string().nullable()  // string | null
z.string().nullish()   // string | null | undefined

結論

Zodは、MCPサーバーのバリデーションにおいて強力で型安全な基盤を提供します。基本的なプリミティブから複雑な判別共用体、変換、精密化まで、その包括的なAPIを活用することで、AIモデルが送信する可能性のあるあらゆるデータ構造を処理できる堅牢なMCPツールを構築できます。

重要なのは、Zodスキーマを契約として考えることです。これらはツールが期待するものを正確に定義し、期待が満たされない場合には有用なエラーメッセージを提供し、データをビジネスロジックが必要とする正確な形状に変換します。このガイドのパターンと例により、MCPサーバーでのあらゆるバリデーションシナリオに対応する準備が整いました。