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 つの主要コンポーネントで構成されます。
# | コンポーネント | 役割 | 具体例 |
---|---|---|---|
1 | MCP ホスト | ユーザーインターフェース層 | Claude Desktop、エディタ拡張 |
2 | MCP クライアント | LLM とサーバーの仲介層 | プロトコル変換、認証管理 |
3 | MCP サーバー | ツール・リソース提供層 | データベースアクセス、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 の理解しやすさ |
---|---|---|---|
1 | submit_expense_report | 経費精算を提出 | ★★★★★ 明確 |
2 | approve_leave_request | 休暇申請を承認 | ★★★★★ 明確 |
3 | schedule_meeting | 会議をスケジュール | ★★★★★ 明確 |
非推奨例:CRUD 操作
# | ツール名 | 目的 | LLM の理解しやすさ |
---|---|---|---|
1 | create_record | レコードを作成 | ★★☆☆☆ 曖昧 |
2 | update_data | データを更新 | ★☆☆☆☆ 非常に曖昧 |
3 | delete_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'],
};
詳細なスキーマには、以下の情報を含めるべきです。
# | 項目 | 目的 | 例 |
---|---|---|---|
1 | description | LLM への説明 | "ユーザーの一意な識別子" |
2 | minLength / maxLength | 文字列長の制約 | 3 ~ 100 文字 |
3 | minimum / maximum | 数値の範囲 | 0 ~ 1,000,000 |
4 | pattern | 正規表現パターン | ^[A-Z]{3}[0-9]{4}$ |
5 | enum | 許可される値のリスト | ["high", "medium", "low"] |
6 | format | データ形式 | date , email , uri |
7 | default | デフォルト値 | "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)',
},
},
},
},
],
})
);
各ツールには、詳細な description
と inputSchema
を設定しています。
これにより、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 | 設定ファイルパス |
---|---|---|
1 | macOS | ~/Library/Application Support/Claude/claude_desktop_config.json |
2 | Windows | %APPDATA%\Claude\claude_desktop_config.json |
3 | Linux | ~/.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 が適切にツールを選択・連携できるようになります。
また、詳細な description
と inputSchema
を提供することで、LLM がツールの使い方を正確に理解できます。
権限分離とセキュリティのポイント
**多層防御(Defense in Depth)**の原則に基づき、ネットワーク分離、認証・認可、入力検証、出力サニタイゼーション、監査ログの 5 つの層でセキュリティを確保します。
特に重要なのが、入力検証とパラメータ化クエリの使用です。 Zod による型検証とビジネスルール検証を組み合わせ、SQL インジェクションやコマンドインジェクションを防ぐことが不可欠ですね。
また、最小権限の原則に従い、各クライアントには必要最小限のツールとリソースのみを公開すべきです。
スキーマ設計のポイント
JSON Schema は、単に型を定義するだけでなく、description
、minLength
、maxLength
、pattern
、enum
などを活用して、LLM が理解しやすいスキーマを作成します。
条件付きスキーマ(if-then-else
)を使うことで、「物理的な会議の場合は住所が必須」といった複雑なビジネスルールも表現できます。
エラーハンドリングでは、MCP プロトコルエラーではなく、ツールの結果として isError: true
と共にエラー情報を返すことで、LLM がエラーを理解し、適切に対処できるようになります。
今後の展望
MCP は、AI アプリケーションのエコシステムを統一する可能性を秘めています。 標準化されたプロトコルにより、開発者は再利用可能なツールを共有でき、AI アプリケーションの開発効率が大幅に向上するでしょう。
セキュリティ、保守性、拡張性を意識した MCP サーバー設計により、安全で信頼性の高い AI システムを構築できますね。
関連リンク
- article
MCP サーバー 設計ベストプラクティス:ツール定義、権限分離、スキーマ設計の要点まとめ
- article
MCP サーバー セットアップ完全ガイド:インストール・環境変数・ポート/証明書設定の最短手順
- article
MCP サーバー とは?Model Context Protocol の基礎・仕組み・活用メリットを徹底解説
- article
Playwright MCP で複数プロジェクトのテストを一元管理
- article
Playwright MCP 活用事例集:現場で効く業務効率化テクニック
- article
Playwright MCP とローカル実行のベンチマーク徹底比較
- article
Convex で Presence(在席)機能を実装:ユーザーステータスのリアルタイム同期
- article
Next.js の RSC 境界設計:Client Components を最小化する責務分離戦略
- article
Mermaid 矢印・接続子チートシート:線種・方向・注釈の一覧早見
- article
Codex とは何か?AI コーディングの基礎・仕組み・適用範囲をやさしく解説
- article
MCP サーバー 設計ベストプラクティス:ツール定義、権限分離、スキーマ設計の要点まとめ
- article
Astro で動的 OG 画像を生成する:Satori/Canvas 連携の実装レシピ
- blog
iPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
- blog
Googleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
- blog
【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
- blog
Googleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
- blog
Pixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
- blog
フロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
- review
今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
- review
ついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
- review
愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
- review
週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
- review
新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
- review
科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来