0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Model Context Protocol完全解説 30日間シリーズ - Day 29【MCP総集編 #29】実践プロジェクト:社内文書検索システムをMCPで構築してみよう

Posted at

はじめに

この記事は、QiitaのModel Context Protocol(MCP)解説シリーズの第29回です。いよいよ最終章、これまでの知識を総動員して、一つの実践的なプロジェクトを構築してみましょう。

今回は、**「社内文書検索システム」**をテーマに、MCPサーバー、ファイルシステム、そしてLLMを連携させる方法を解説します。

🎯 プロジェクトのゴール

このプロジェクトのゴールは、Claudeに以下のようなタスクを自然な言葉で依頼できるようにすることです。

  • 「マーケティングチームの最新の議事録を探して。」
  • 「2024年Q3の売上レポートの主要なポイントを教えて。」
  • 「新製品Aの仕様書にある『セキュリティ』に関する記述を要約して。」

これを実現するために、以下の3つの要素を組み合わせます。

  1. リソース(Resources): 社内文書(PDF, DOCX, TXTなど)へのアクセス経路を提供
  2. ツール(Tools): ファイル探索、検索、内容分析機能を提供
  3. LLM: ユーザーの指示を理解し、適切なツールとリソースを組み合わせてタスクを解決

📁 ステップ1:プロジェクト構造の準備

ディレクトリ構造の設計

まず、検索対象となる社内文書を模したディレクトリとファイルを作成します。意味のあるフォルダ構造がLLMの探索行動を効率化します。

document-search-mcp/
├── package.json
├── tsconfig.json
├── src/
│   ├── server.ts
│   └── utils/
│       ├── file-processor.ts
│       └── search-engine.ts
├── corporate_docs/
│   ├── reports/
│   │   ├── sales/
│   │   │   ├── Q1_2024_report.pdf
│   │   │   ├── Q2_2024_report.pdf
│   │   │   └── Q3_2024_report.pdf
│   │   └── marketing/
│   │       ├── 2024_campaign_analysis.docx
│   │       ├── social_media_strategy.pdf
│   │       └── customer_feedback_analysis.txt
│   ├── manuals/
│   │   ├── product_a/
│   │   │   ├── spec_sheet_a.txt
│   │   │   └── security_requirements.md
│   │   ├── product_b/
│   │   │   └── user_guide.pdf
│   │   └── general/
│   │       ├── company_guidelines.txt
│   │       └── coding_standards.md
│   └── meeting_minutes/
│       ├── dev_team/
│       │   ├── 20240915_dev_meeting.txt
│       │   └── 20240920_sprint_review.md
│       └── marketing_team/
│           ├── 20240910_marketing_minutes.txt
│           └── 20240925_campaign_planning.md
└── tests/
    └── server.test.ts

プロジェクトの初期化

# プロジェクトディレクトリの作成
mkdir document-search-mcp
cd document-search-mcp

# package.jsonの初期化
npm init -y

# 必要な依存関係をインストール
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node ts-node nodemon

# TypeScript設定
npx tsc --init

package.jsonの設定

{
  "name": "document-search-mcp",
  "version": "1.0.0",
  "description": "MCP-based document search system",
  "main": "dist/server.js",
  "scripts": {
    "build": "tsc",
    "dev": "nodemon --exec ts-node src/server.ts",
    "start": "node dist/server.js",
    "test": "jest"
  },
  "dependencies": {
    "@modelcontextprotocol/sdk": "^0.1.0",
    "zod": "^3.22.0"
  },
  "devDependencies": {
    "@types/node": "^20.0.0",
    "nodemon": "^3.0.0",
    "ts-node": "^10.9.0",
    "typescript": "^5.0.0"
  }
}

🔧 ステップ2:MCPサーバーの実装

ファイル処理ユーティリティの実装

まず、ファイル処理機能を別モジュールとして実装します。

src/utils/file-processor.ts:

import { readFileSync, readdirSync, statSync } from 'fs';
import { join, extname, relative } from 'path';

export interface FileInfo {
  name: string;
  path: string;
  relativePath: string;
  size: number;
  extension: string;
  lastModified: Date;
  isDirectory: boolean;
}

export interface SearchResult {
  file: FileInfo;
  matches: Array<{
    lineNumber: number;
    line: string;
    context: string;
  }>;
  score: number;
}

export class FileProcessor {
  constructor(private baseDirectory: string) {}

  /**
   * 指定されたディレクトリ内のファイル一覧を取得
   */
  listFiles(directoryPath: string = ''): FileInfo[] {
    const fullPath = join(this.baseDirectory, directoryPath);
    
    try {
      const items = readdirSync(fullPath);
      return items.map(item => {
        const itemPath = join(fullPath, item);
        const stats = statSync(itemPath);
        const relativePath = relative(this.baseDirectory, itemPath);
        
        return {
          name: item,
          path: itemPath,
          relativePath,
          size: stats.size,
          extension: extname(item),
          lastModified: stats.mtime,
          isDirectory: stats.isDirectory()
        };
      });
    } catch (error) {
      throw new Error(`ディレクトリの読み取りに失敗しました: ${directoryPath}`);
    }
  }

  /**
   * ファイル内容を読み取り
   */
  readFile(filePath: string): string {
    const fullPath = join(this.baseDirectory, filePath);
    
    try {
      const stats = statSync(fullPath);
      if (stats.isDirectory()) {
        throw new Error('指定されたパスはディレクトリです');
      }
      
      // ファイルサイズ制限(10MB)
      if (stats.size > 10 * 1024 * 1024) {
        throw new Error('ファイルサイズが大きすぎます(制限: 10MB)');
      }
      
      return readFileSync(fullPath, 'utf8');
    } catch (error) {
      if (error instanceof Error) {
        throw error;
      }
      throw new Error(`ファイルの読み取りに失敗しました: ${filePath}`);
    }
  }

  /**
   * ディレクトリを再帰的に探索してファイルを検索
   */
  searchFiles(
    keyword: string,
    directoryPath: string = '',
    options: {
      caseSensitive?: boolean;
      fileExtensions?: string[];
      maxDepth?: number;
    } = {}
  ): SearchResult[] {
    const {
      caseSensitive = false,
      fileExtensions = ['.txt', '.md', '.json'],
      maxDepth = 5
    } = options;

    const results: SearchResult[] = [];
    const searchTerm = caseSensitive ? keyword : keyword.toLowerCase();

    this._searchRecursive(directoryPath, searchTerm, results, 0, maxDepth, {
      caseSensitive,
      fileExtensions
    });

    // スコア順にソート
    return results.sort((a, b) => b.score - a.score);
  }

  private _searchRecursive(
    currentPath: string,
    searchTerm: string,
    results: SearchResult[],
    currentDepth: number,
    maxDepth: number,
    options: { caseSensitive: boolean; fileExtensions: string[] }
  ): void {
    if (currentDepth >= maxDepth) return;

    try {
      const files = this.listFiles(currentPath);

      for (const file of files) {
        if (file.isDirectory) {
          this._searchRecursive(
            file.relativePath,
            searchTerm,
            results,
            currentDepth + 1,
            maxDepth,
            options
          );
        } else if (options.fileExtensions.includes(file.extension)) {
          const matches = this._searchInFile(file, searchTerm, options.caseSensitive);
          if (matches.length > 0) {
            results.push({
              file,
              matches,
              score: this._calculateScore(matches, searchTerm)
            });
          }
        }
      }
    } catch (error) {
      console.warn(`検索中にエラーが発生しました: ${currentPath}`, error);
    }
  }

  private _searchInFile(
    file: FileInfo,
    searchTerm: string,
    caseSensitive: boolean
  ): Array<{ lineNumber: number; line: string; context: string }> {
    try {
      const content = this.readFile(file.relativePath);
      const lines = content.split('\n');
      const matches = [];

      for (let i = 0; i < lines.length; i++) {
        const line = lines[i];
        const searchLine = caseSensitive ? line : line.toLowerCase();

        if (searchLine.includes(searchTerm)) {
          // 前後2行をコンテキストとして含める
          const contextStart = Math.max(0, i - 2);
          const contextEnd = Math.min(lines.length - 1, i + 2);
          const context = lines.slice(contextStart, contextEnd + 1).join('\n');

          matches.push({
            lineNumber: i + 1,
            line: line.trim(),
            context
          });
        }
      }

      return matches;
    } catch (error) {
      return [];
    }
  }

  private _calculateScore(
    matches: Array<{ lineNumber: number; line: string; context: string }>,
    searchTerm: string
  ): number {
    // 単純なスコアリング:マッチ数 + 検索語の出現頻度
    let score = matches.length * 10;
    
    for (const match of matches) {
      const occurrences = (match.line.toLowerCase().match(new RegExp(searchTerm.toLowerCase(), 'g')) || []).length;
      score += occurrences * 5;
    }

    return score;
  }
}

メインサーバーの実装

src/server.ts:

import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { z } from 'zod';
import { FileProcessor, SearchResult } from './utils/file-processor.js';
import { join } from 'path';

class DocumentSearchServer {
  private server: Server;
  private fileProcessor: FileProcessor;

  constructor() {
    this.server = new Server(
      {
        name: 'document-search-server',
        version: '1.0.0',
      },
      {
        capabilities: {
          resources: {},
          tools: {},
        },
      }
    );

    this.fileProcessor = new FileProcessor('./corporate_docs');
    this.setupHandlers();
  }

  private setupHandlers() {
    // Tools リストの提供
    this.server.setRequestHandler(ListToolsRequestSchema, async () => {
      return {
        tools: [
          {
            name: 'list_files',
            description: '指定されたディレクトリ内のファイルとフォルダの一覧を取得します。',
            inputSchema: {
              type: 'object',
              properties: {
                directory_path: {
                  type: 'string',
                  description: '一覧を取得するディレクトリのパス(空文字の場合はルートディレクトリ)',
                  default: ''
                }
              }
            }
          },
          {
            name: 'read_file',
            description: 'ファイルの内容を読み取ります。',
            inputSchema: {
              type: 'object',
              properties: {
                file_path: {
                  type: 'string',
                  description: '読み取るファイルのパス'
                }
              },
              required: ['file_path']
            }
          },
          {
            name: 'search_files',
            description: 'キーワードに基づいてファイル内容を検索します。',
            inputSchema: {
              type: 'object',
              properties: {
                keyword: {
                  type: 'string',
                  description: '検索するキーワード'
                },
                directory_path: {
                  type: 'string',
                  description: '検索を開始するディレクトリのパス',
                  default: ''
                },
                case_sensitive: {
                  type: 'boolean',
                  description: '大文字小文字を区別するかどうか',
                  default: false
                },
                file_extensions: {
                  type: 'array',
                  items: { type: 'string' },
                  description: '検索対象のファイル拡張子',
                  default: ['.txt', '.md', '.json']
                }
              },
              required: ['keyword']
            }
          },
          {
            name: 'analyze_document',
            description: 'ドキュメントの内容を分析し、構造化された情報を提供します。',
            inputSchema: {
              type: 'object',
              properties: {
                file_path: {
                  type: 'string',
                  description: '分析するファイルのパス'
                },
                analysis_type: {
                  type: 'string',
                  enum: ['summary', 'keywords', 'structure', 'full'],
                  description: '分析の種類',
                  default: 'summary'
                }
              },
              required: ['file_path']
            }
          }
        ]
      };
    });

    // Tool 実行ハンドラー
    this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
      const { name, arguments: args } = request.params;

      try {
        switch (name) {
          case 'list_files':
            return await this.handleListFiles(args);
          case 'read_file':
            return await this.handleReadFile(args);
          case 'search_files':
            return await this.handleSearchFiles(args);
          case 'analyze_document':
            return await this.handleAnalyzeDocument(args);
          default:
            throw new Error(`未知のツール: ${name}`);
        }
      } catch (error) {
        return {
          content: [
            {
              type: 'text',
              text: `エラーが発生しました: ${error instanceof Error ? error.message : String(error)}`
            }
          ],
          isError: true
        };
      }
    });

    // Resources リストの提供
    this.server.setRequestHandler(ListResourcesRequestSchema, async () => {
      return {
        resources: [
          {
            uri: 'file://corporate_docs',
            mimeType: 'application/vnd.directory',
            name: '社内文書ディレクトリ',
            description: '社内の各種文書が格納されているディレクトリ'
          }
        ]
      };
    });

    // Resource 読み取りハンドラー
    this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
      const { uri } = request.params;

      if (uri === 'file://corporate_docs') {
        const files = this.fileProcessor.listFiles();
        const structure = this.buildDirectoryStructure(files);
        
        return {
          contents: [
            {
              uri,
              mimeType: 'text/plain',
              text: JSON.stringify(structure, null, 2)
            }
          ]
        };
      }

      throw new Error(`未知のリソース: ${uri}`);
    });
  }

  private async handleListFiles(args: any) {
    const directoryPath = args?.directory_path || '';
    const files = this.fileProcessor.listFiles(directoryPath);

    const fileList = files.map(file => ({
      name: file.name,
      type: file.isDirectory ? 'directory' : 'file',
      size: file.size,
      extension: file.extension,
      lastModified: file.lastModified.toISOString(),
      relativePath: file.relativePath
    }));

    return {
      content: [
        {
          type: 'text',
          text: JSON.stringify({
            directory: directoryPath || 'ルートディレクトリ',
            files: fileList,
            total: fileList.length
          }, null, 2)
        }
      ]
    };
  }

  private async handleReadFile(args: any) {
    const filePath = args?.file_path;
    if (!filePath) {
      throw new Error('file_pathが指定されていません');
    }

    const content = this.fileProcessor.readFile(filePath);

    return {
      content: [
        {
          type: 'text',
          text: content
        }
      ]
    };
  }

  private async handleSearchFiles(args: any) {
    const {
      keyword,
      directory_path = '',
      case_sensitive = false,
      file_extensions = ['.txt', '.md', '.json']
    } = args;

    if (!keyword) {
      throw new Error('keywordが指定されていません');
    }

    const results = this.fileProcessor.searchFiles(keyword, directory_path, {
      caseSensitive: case_sensitive,
      fileExtensions: file_extensions
    });

    const formattedResults = results.map(result => ({
      file: {
        name: result.file.name,
        path: result.file.relativePath,
        size: result.file.size,
        lastModified: result.file.lastModified.toISOString()
      },
      matches: result.matches.map(match => ({
        lineNumber: match.lineNumber,
        line: match.line,
        context: match.context
      })),
      score: result.score
    }));

    return {
      content: [
        {
          type: 'text',
          text: JSON.stringify({
            keyword,
            searchPath: directory_path || 'ルートディレクトリ',
            totalResults: formattedResults.length,
            results: formattedResults.slice(0, 20) // 上位20件のみ返す
          }, null, 2)
        }
      ]
    };
  }

  private async handleAnalyzeDocument(args: any) {
    const { file_path, analysis_type = 'summary' } = args;
    
    if (!file_path) {
      throw new Error('file_pathが指定されていません');
    }

    const content = this.fileProcessor.readFile(file_path);
    const lines = content.split('\n');
    
    let analysis: any = {};

    switch (analysis_type) {
      case 'summary':
        analysis = {
          lineCount: lines.length,
          characterCount: content.length,
          wordCount: content.split(/\s+/).length,
          preview: content.substring(0, 500) + (content.length > 500 ? '...' : '')
        };
        break;
      
      case 'keywords':
        const words = content.toLowerCase().match(/\b\w+\b/g) || [];
        const frequency = words.reduce((acc, word) => {
          if (word.length > 3) { // 3文字以上の単語のみ
            acc[word] = (acc[word] || 0) + 1;
          }
          return acc;
        }, {} as Record<string, number>);
        
        analysis = {
          topKeywords: Object.entries(frequency)
            .sort(([,a], [,b]) => b - a)
            .slice(0, 20)
            .map(([word, count]) => ({ word, count }))
        };
        break;
      
      case 'structure':
        const headers = lines.filter(line => line.trim().startsWith('#'));
        const sections = lines.filter(line => line.trim().match(/^(第|Chapter|Section|\d+\.)/));
        
        analysis = {
          headers: headers.map((header, index) => ({
            level: header.match(/^#+/)?.[0]?.length || 0,
            text: header.replace(/^#+\s*/, ''),
            lineNumber: lines.indexOf(header) + 1
          })),
          sections: sections.map((section, index) => ({
            text: section.trim(),
            lineNumber: lines.indexOf(section) + 1
          }))
        };
        break;
      
      case 'full':
        analysis = {
          summary: {
            lineCount: lines.length,
            characterCount: content.length,
            wordCount: content.split(/\s+/).length
          },
          structure: {
            headers: lines.filter(line => line.trim().startsWith('#')),
            sections: lines.filter(line => line.trim().match(/^(第|Chapter|Section|\d+\.)/))
          },
          content: content
        };
        break;
    }

    return {
      content: [
        {
          type: 'text',
          text: JSON.stringify({
            file: file_path,
            analysisType: analysis_type,
            analysis
          }, null, 2)
        }
      ]
    };
  }

  private buildDirectoryStructure(files: any[]): any {
    const structure: any = { directories: {}, files: [] };

    for (const file of files) {
      if (file.isDirectory) {
        structure.directories[file.name] = {
          name: file.name,
          path: file.relativePath,
          lastModified: file.lastModified
        };
      } else {
        structure.files.push({
          name: file.name,
          path: file.relativePath,
          size: file.size,
          extension: file.extension,
          lastModified: file.lastModified
        });
      }
    }

    return structure;
  }

  async start() {
    const transport = new StdioServerTransport();
    await this.server.connect(transport);
    console.error('Document Search MCP Server started');
  }
}

// サーバーの起動
const server = new DocumentSearchServer();
server.start().catch(console.error);

🚀 ステップ3:サンプルデータの準備

実際にテストできるよう、サンプル文書を作成しましょう。

corporate_docs/reports/sales/Q3_2024_report.txt:

Q3 2024 売上レポート

概要:
第3四半期の売上は前年同期比15%増となり、目標を上回る結果となりました。

主要数値:
- 売上高: 1,500万円
- 利益率: 22%
- 新規顧客数: 450件

主要成果:
新製品Aのセキュリティ機能が市場で高く評価され、企業向け販売が好調でした。
マーケティング施策の効果で認知度が大幅に向上しました。

課題:
競合他社との価格競争が激化しており、来期の戦略見直しが必要です。

corporate_docs/manuals/product_a/security_requirements.md:

# 製品A セキュリティ要件書

## 概要
製品Aは企業向けソフトウェアとして、高度なセキュリティ機能を実装しています。

## セキュリティ機能
### 認証・認可
- 多要素認証 (MFA) 対応
- RBAC (Role-Based Access Control)
- SSO (Single Sign-On) 対応

### データ保護
- AES-256暗号化
- TLS 1.3 通信暗号化
- 個人情報保護機能

### 監査・ログ
- 全操作のログ記録
- 不正アクセス検知
- レポート自動生成機能

🔧 ステップ4:Claude Desktopとの連携

Claude Desktop設定

claude_desktop_config.jsonに以下の設定を追加:

{
  "mcpServers": {
    "document-search": {
      "command": "node",
      "args": ["dist/server.js"],
      "cwd": "/path/to/document-search-mcp"
    }
  }
}

サーバーの起動とテスト

# TypeScriptのビルド
npm run build

# 開発モードでの起動(ホットリロード付き)
npm run dev

# 本番モードでの起動
npm start

🎯 使用例とテストケース

Claude Desktopから以下の質問をテストしてみてください:

基本的なファイル探索

質問: 「reports フォルダにはどんなファイルがありますか?」

  • LLMは list_files ツールを使用してディレクトリ構造を探索

キーワード検索

質問: 「セキュリティに関する文書を全て探して」

  • LLMは search_files ツールで "セキュリティ" をキーワードに検索
  • 関連ファイルとマッチした行を文脈付きで表示

文書分析

質問: 「Q3の売上レポートの主要なポイントを教えて」

  • LLMは適切なファイルを read_file で読み込み
  • 内容を要約して回答

高度な検索と分析

質問: 「製品Aの仕様書から、セキュリティ機能の一覧を抽出して」

  • 複数のツールを組み合わせて使用
  • ファイル検索 → 読み込み → 分析 → 要約

📊 パフォーマンスと監視

ログ機能の追加

src/utils/logger.ts:

export class Logger {
  static info(message: string, data?: any) {
    console.error(`[INFO] ${new Date().toISOString()} - ${message}`, data || '');
  }

  static warn(message: string, data?: any) {
    console.error(`[WARN] ${new Date().toISOString()} - ${message}`, data || '');
  }

  static error(message: string, error?: any) {
    console.error(`[ERROR] ${new Date().toISOString()} - ${message}`, error || '');
  }
}

メトリクス収集

ツール呼び出し回数、検索時間、エラー率などの基本的なメトリクスを収集することで、システムのパフォーマンスを監視できます。

🔐 セキュリティ考慮事項

ファイルアクセス制限

private validatePath(requestedPath: string): boolean {
  const normalizedPath = path.normalize(requestedPath);
  const basePath = path.normalize(this.baseDirectory);
  
  // ディレクトリトラバーサル攻撃を防ぐ
  return normalizedPath.startsWith(basePath);
}

ファイルサイズ制限

大きなファイルによるメモリ枯渇を防ぐため、ファイルサイズの制限を実装。

機密情報の除外

特定のファイル拡張子や パターンを除外するフィルタリング機能を実装。

🚀 今後の拡張可能性

1. 高度な検索機能

  • 全文検索エンジン(Elasticsearch等)との連携
  • 意味検索(ベクトル検索)の実装
  • OCR機能によるPDFや画像内テキストの検索

2. AI機能の強化

  • 文書分類・タグ付け自動化
  • 要約生成の改善
  • 関連文書の推薦機能

3. 多形式対応

  • Office文書(Word, Excel, PowerPoint)の解析
  • PDF内容の抽出・検索
  • 画像内テキストのOCR処理

4. リアルタイム機能

  • ファイル変更の監視
  • リアルタイム検索結果の更新
  • WebSocketによるリアルタイム通知

5. コラボレーション機能

  • ユーザー別のアクセス権限管理
  • 文書へのコメント・注釈機能
  • バージョン管理との連携

🎯 まとめ

このプロジェクトは、これまでのシリーズで学んだ以下の要素を統合したものです:

  • Resourcesによる外部データの構造化された公開
  • Toolsによる柔軟で強力な検索・分析機能の提供
  • エラーハンドリングによる堅牢なシステム構築
  • モジュラー設計による保守性と拡張性の確保

学習のポイント

  1. 実用的なMCPサーバー設計: 単純な機能から始めて段階的に複雑な機能を追加
  2. ファイルシステムとの効率的な連携: セキュリティを考慮した安全なファイルアクセス
  3. LLMフレンドリーなAPI設計: 明確な説明とスキーマ定義による使いやすさの向上
  4. エラー処理とログ: 運用時の問題を迅速に特定・解決できる仕組み

応用可能な領域

このパターンは以下のような様々なドメインで応用できます:

  • コードベース質問応答: GitHubリポジトリとの連携
  • データ分析プラットフォーム: CSV/JSONファイルの分析と可視化
  • ログ解析システム: アプリケーションログの検索と異常検知
  • ナレッジベース: Wiki/Confluence的な知識管理システム
  • 法務文書管理: 契約書や規約の検索と分析

🎓 次のステップ

これで基本的な文書検索システムが完成しました。さらなる学習のために:

  1. パフォーマンス最適化: 大量ファイルでの動作検証
  2. セキュリティ強化: より細かいアクセス制御の実装
  3. UI/UX改善: 検索結果の見やすい表示
  4. テストの充実: 単体テストとE2Eテストの追加

次回、いよいよ最終回です。Day 30では、MCPエコシステムの将来展望と、コミュニティでの継続学習について解説します。お楽しみに!


📚 関連リソース

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?