vercel/mcp-handler로 MCP 서버에서 Zod 검증 마스터하기

mcpzodtypescriptvalidationai
By sko X opus 4.19/19/202514 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]/, "비밀번호는 최소 하나의 대문자를 포함해야 합니다")
        .regex(/[a-z]/, "비밀번호는 최소 하나의 소문자를 포함해야 합니다")
        .regex(/[0-9]/, "비밀번호는 최소 하나의 숫자를 포함해야 합니다")
        .regex(/[^A-Za-z0-9]/, "비밀번호는 최소 하나의 특수문자를 포함해야 합니다"),

      // 길이 제한이 있는 선택적 소개
      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, "금액은 소수점 둘째 자리까지여야 합니다")
      .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, "최소 하나의 마일스톤이 필요합니다")
       .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(),
      ])
    ),

    // 열거형 키가 있는 레코드
    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, "최소 하나의 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}개의 작업을 처리했습니다`
      }],
    };
  }
);

열거형과 구별된 유니온

열거형 패턴

// 재사용을 위해 열거형 값을 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",
  "열거형 검증으로 작업 관리",
  {
    action: z.enum(["create", "update", "delete", "archive"]),

    // 객체 값을 열거형으로 사용
    status: z.enum(Object.values(TaskStatus) as [string, ...string[]]),

    // 배열을 열거형으로 사용
    priority: z.enum(TaskPriority),

    // 기본값이 있는 선택적 열거형
    assigneeRole: z
      .enum(["developer", "designer", "manager", "qa"])
      .optional()
      .default("developer"),

    // 열거형 값 변환
    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, "최소 하나의 파일이 필요합니다")
     .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: `${operation} 작업으로 ${files.length}개의 파일을 처리했습니다. 출력: ${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. 데이터 정규화를 위해 Transform 사용

// ✅ 좋음: 일찍 데이터 정규화
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. Optional 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 서버에서 모든 검증 시나리오를 처리할 수 있는 능력을 갖추게 됩니다.