はじめに
Model Context Protocol (MCP) は、AIエージェントとデータソース間の標準化された通信プロトコルです。MCPアプリケーションを効率的に構築するには、どのデータをMCPサーバー側で管理し、どのデータをクライアント(AIエージェント)側で処理するかを適切に設計することが重要です。
本記事では、MCPにおける実践的なデータ管理戦略と、パフォーマンスと保守性を両立させる設計パターンを解説します。
1. MCPのデータ管理における2つのレイヤー
MCPアーキテクチャでは、データ管理が2つの明確なレイヤーに分かれています:
┌────────────────────────────────────────┐
│ クライアント側 (AIエージェント) │
│ ┌──────────────────────────────────┐ │
│ │ セッションコンテキスト │ │
│ │ - 会話履歴 │ │
│ │ - 一時的なステート │ │
│ │ - クライアントキャッシュ │ │
│ └──────────────────────────────────┘ │
└────────────┬───────────────────────────┘
│ MCP Protocol (JSON-RPC)
▼
┌────────────────────────────────────────┐
│ サーバー側 (MCPサーバー) │
│ ┌──────────────────────────────────┐ │
│ │ 永続データ管理 │ │
│ │ - リソースのインデックス │ │
│ │ - プロンプトテンプレート │ │
│ │ - ツールの実装とステート │ │
│ │ - データソース接続 │ │
│ └──────────────────────────────────┘ │
└────────────┬───────────────────────────┘
│
▼
┌────────────────────────────────────────┐
│ 実データソース │
│ (Database, API, FileSystem, etc.) │
└────────────────────────────────────────┘
2. サーバー側データ管理:永続性と信頼性
2.1. サーバー側で管理すべきデータ
MCPサーバーは以下のようなデータを管理する責任があります:
| データタイプ | 役割と目的 | 実装例 |
|---|---|---|
| リソース定義 | 利用可能なデータリソースのメタデータとアクセス方法 | データベーステーブル定義、APIエンドポイント情報 |
| プロンプトテンプレート | 再利用可能なプロンプトの定義とパラメータ | テンプレートエンジンによる動的生成 |
| ツールの実装 | AIエージェントが呼び出せる機能の実装 | ビジネスロジック、外部API連携 |
| 認証情報 | データソースへのアクセスに必要な認証情報 | 暗号化されたAPIキー、データベース接続情報 |
| アクセス制御 | リソースやツールへのアクセス権限 | ユーザーロール、リソースレベルのパーミッション |
| 監査ログ | リソースアクセスやツール呼び出しの履歴 | 構造化ログ、タイムスタンプ付きイベント |
2.2. サーバー側実装パターン
例: リソース管理の実装
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { z } from "zod";
class ResourceManager {
private resources: Map<string, ResourceDefinition> = new Map();
private accessLog: AccessLog[] = [];
// リソース定義を登録
registerResource(definition: ResourceDefinition) {
this.resources.set(definition.uri, definition);
}
// リソースの一覧を取得
listResources(filter?: ResourceFilter): ResourceInfo[] {
return Array.from(this.resources.values())
.filter(r => this.matchesFilter(r, filter))
.map(r => ({
uri: r.uri,
name: r.name,
mimeType: r.mimeType,
description: r.description,
}));
}
// リソースの内容を取得(アクセス制御付き)
async readResource(
uri: string,
context: AccessContext
): Promise<ResourceContent> {
const resource = this.resources.get(uri);
if (!resource) {
throw new Error(`Resource not found: ${uri}`);
}
// アクセス制御チェック
if (!this.checkAccess(resource, context)) {
throw new Error(`Access denied to resource: ${uri}`);
}
// 監査ログに記録
this.logAccess({
uri,
timestamp: Date.now(),
user: context.userId,
operation: "read",
});
// リソースの内容を取得
return await resource.fetch();
}
private checkAccess(
resource: ResourceDefinition,
context: AccessContext
): boolean {
// アクセス制御ロジック
if (resource.permissions.public) {
return true;
}
return resource.permissions.allowedUsers?.includes(context.userId)
?? false;
}
private logAccess(log: AccessLog) {
this.accessLog.push(log);
// 永続化(データベースへの書き込み等)
this.persistLog(log);
}
}
2.3. ステートフルなツールの管理
サーバー側でステートを管理する必要があるツールの例:
class StatefulToolManager {
private sessions: Map<string, ToolSession> = new Map();
async executeTool(
toolName: string,
args: any,
sessionId: string
): Promise<any> {
// セッションの取得または作成
let session = this.sessions.get(sessionId);
if (!session) {
session = this.createSession(sessionId);
this.sessions.set(sessionId, session);
}
// ツールの実行(セッションステートを利用)
const result = await this.executeWithState(
toolName,
args,
session
);
// セッションステートを更新
session.lastAccess = Date.now();
return result;
}
private createSession(sessionId: string): ToolSession {
return {
id: sessionId,
createdAt: Date.now(),
lastAccess: Date.now(),
state: {},
};
}
// 例: データベーストランザクションの管理
private async executeWithState(
toolName: string,
args: any,
session: ToolSession
): Promise<any> {
switch (toolName) {
case "db_transaction_begin":
session.state.transaction = await this.db.beginTransaction();
return { success: true };
case "db_transaction_commit":
if (session.state.transaction) {
await session.state.transaction.commit();
delete session.state.transaction;
}
return { success: true };
case "db_query":
// トランザクション内でクエリを実行
const tx = session.state.transaction || this.db;
return await tx.query(args.sql, args.params);
default:
throw new Error(`Unknown tool: ${toolName}`);
}
}
// セッションのクリーンアップ
cleanupSessions(maxAge: number = 3600000) {
const now = Date.now();
for (const [id, session] of this.sessions.entries()) {
if (now - session.lastAccess > maxAge) {
this.sessions.delete(id);
}
}
}
}
3. クライアント側データ管理:効率性と応答性
3.1. クライアント側で管理すべきデータ
AIエージェント(クライアント)側では、以下のデータを管理します:
| データタイプ | 役割と目的 | 実装の考慮点 |
|---|---|---|
| 会話コンテキスト | 現在の会話の履歴とステート | メモリ効率、コンテキストウィンドウの制限 |
| 一時キャッシュ | 頻繁にアクセスされるリソースのキャッシュ | TTL、キャッシュ無効化戦略 |
| ユーザー設定 | ユーザー固有の設定や好み | ローカルストレージ、プライバシー保護 |
| UIステート | アプリケーションのUI状態 | リアクティビティ、状態管理 |
3.2. クライアント側実装パターン
例: リソースのクライアント側キャッシング
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
class CachedMCPClient {
private client: Client;
private cache: Map<string, CacheEntry> = new Map();
private cacheTTL: number = 300000; // 5分
constructor(client: Client) {
this.client = client;
}
async readResource(uri: string, useCache: boolean = true): Promise<any> {
// キャッシュチェック
if (useCache) {
const cached = this.cache.get(uri);
if (cached && Date.now() - cached.timestamp < this.cacheTTL) {
return cached.data;
}
}
// サーバーから取得
const response = await this.client.request({
method: "resources/read",
params: { uri },
});
// キャッシュに保存
this.cache.set(uri, {
data: response.contents,
timestamp: Date.now(),
});
return response.contents;
}
// キャッシュの無効化
invalidateCache(uri?: string) {
if (uri) {
this.cache.delete(uri);
} else {
this.cache.clear();
}
}
// プリフェッチング
async prefetchResources(uris: string[]) {
await Promise.all(
uris.map(uri => this.readResource(uri, false))
);
}
}
4. ハイブリッド戦略による最適なデータフロー
効率的なMCPアプリケーションは、両レイヤーの責任を明確に分離しながら、適切に連携させます。
4.1. シナリオ別のデータフロー
シナリオ1: 頻繁にアクセスされるリソース
1. クライアント: キャッシュをチェック
├─ ヒット → キャッシュから返す
└─ ミス → サーバーにリクエスト
2. サーバー: リソースを取得(アクセス制御あり)
3. サーバー: アクセスログを記録
4. クライアント: レスポンスをキャッシュ
シナリオ2: ステートフルなツール実行
1. クライアント: ツール呼び出しをリクエスト
2. サーバー: セッションを取得/作成
3. サーバー: セッションステートを使用してツール実行
4. サーバー: セッションステートを更新
5. サーバー: 結果を返す
6. クライアント: UIを更新
シナリオ3: 大量データの処理
1. クライアント: 処理をサーバーに委譲
2. サーバー: バックグラウンドジョブを開始
3. サーバー: 進捗をイベントで通知(サブスクリプション)
4. クライアント: 進捗をUIに表示
5. サーバー: 完了時に結果を通知
6. クライアント: 結果を取得・表示
4.2. 実装例: リアルタイム進捗通知
// サーバー側
class LongRunningTaskServer {
async handleToolCall(
toolName: string,
args: any,
sessionId: string,
progressCallback: (progress: number) => void
): Promise<any> {
if (toolName === "process_large_dataset") {
const totalItems = args.items.length;
const results = [];
for (let i = 0; i < totalItems; i++) {
// 処理
const result = await this.processItem(args.items[i]);
results.push(result);
// 進捗を通知
const progress = ((i + 1) / totalItems) * 100;
progressCallback(progress);
}
return { results };
}
}
}
// クライアント側
class ProgressTrackingClient {
async executeWithProgress(
toolName: string,
args: any,
onProgress: (progress: number) => void
): Promise<any> {
// WebSocketやServer-Sent Eventsで進捗を受信
const progressStream = this.subscribeToProgress();
progressStream.on("progress", (data) => {
onProgress(data.progress);
});
const result = await this.client.request({
method: "tools/call",
params: { name: toolName, arguments: args },
});
progressStream.close();
return result;
}
}
5. パフォーマンス最適化戦略
5.1. キャッシング戦略の比較
| 戦略 | 適用場面 | メリット | デメリット |
|---|---|---|---|
| クライアント側キャッシュ | 頻繁にアクセスされる小さなリソース | レイテンシ最小、サーバー負荷軽減 | 一貫性の管理が必要 |
| サーバー側キャッシュ | 計算コストの高いクエリ | 複数クライアント間で共有可能 | キャッシュ無効化の複雑さ |
| CDNキャッシュ | 静的な公開リソース | グローバルな高速配信 | 更新の遅延 |
5.2. レイテンシ削減のテクニック
class OptimizedMCPClient {
// バッチリクエスト
async batchRead(uris: string[]): Promise<Map<string, any>> {
// 複数のリソースを1回のRPCで取得
const responses = await this.client.request({
method: "resources/read_batch",
params: { uris },
});
return new Map(
responses.results.map((r: any) => [r.uri, r.contents])
);
}
// 投機的プリフェッチング
async speculativePrefetch(context: ConversationContext) {
// 会話の文脈から次に必要になりそうなリソースを予測
const likelyUris = this.predictNextResources(context);
// バックグラウンドでプリフェッチ
this.prefetchResources(likelyUris);
}
// 並列リクエスト
async parallelToolCalls(tools: ToolCall[]): Promise<any[]> {
return await Promise.all(
tools.map(tool =>
this.client.request({
method: "tools/call",
params: tool,
})
)
);
}
}
6. セキュリティとプライバシーの考慮
6.1. データの保護レイヤー
| レイヤー | 保護対象 | 実装方法 |
|---|---|---|
| サーバー側 | 認証情報、アクセス制御、監査ログ | 暗号化、RBAC、セキュアなストレージ |
| 通信層 | MCP通信の内容 | TLS/SSL、トークンベース認証 |
| クライアント側 | ユーザー設定、キャッシュデータ | ローカル暗号化、メモリ保護 |
6.2. 実装例: セキュアなリソースアクセス
class SecureMCPServer {
async handleResourceRead(
uri: string,
authToken: string
): Promise<any> {
// 認証トークンの検証
const user = await this.authenticateToken(authToken);
if (!user) {
throw new Error("Authentication failed");
}
// アクセス権限の確認
const hasAccess = await this.checkPermission(user, uri, "read");
if (!hasAccess) {
// 監査ログに記録
await this.logAccessDenied(user, uri);
throw new Error("Access denied");
}
// リソースの取得
const content = await this.fetchResource(uri);
// 成功を監査ログに記録
await this.logAccessSuccess(user, uri);
return content;
}
}
7. ベストプラクティスまとめ
サーバー側の責任
- ✅ 永続データの管理と整合性の保証
- ✅ アクセス制御と認証の実装
- ✅ 監査ログの記録
- ✅ ビジネスロジックの実装
クライアント側の責任
- ✅ ユーザーエクスペリエンスの最適化
- ✅ 一時的なキャッシングによる高速化
- ✅ UIステートの管理
- ✅ エラーハンドリングとリトライ
データフローの設計
- ✅ 頻繁にアクセスされるデータはキャッシュ
- ✅ 重い処理はサーバー側で実行
- ✅ セキュリティが重要なデータはサーバー側で保護
- ✅ レスポンシブな体験のための非同期処理
まとめ
MCPにおける効率的なデータ管理は、サーバー側とクライアント側の責任を適切に分離し、それぞれの強みを活かすことで実現されます。
- サーバー側: 信頼性、一貫性、セキュリティを担保
- クライアント側: 応答性、ユーザー体験を最適化
この戦略により、スケーラブルで保守性の高いMCPアプリケーションを構築できます。
注意: MCPはAnthropicが開発した比較的新しいプロトコルです。最新の情報については、公式ドキュメントを参照してください。