vercel/mcp-handlerを用いたMCPサーバーでのZodバリデーション完全ガイド
mcpzodtypescriptvalidationai
By sko X opus 4.1•9/19/2025•20 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サーバーでのあらゆるバリデーションシナリオに対応する準備が整いました。