index.ts•14 kB
#!/usr/bin/env node
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
  CallToolRequestSchema,
  ListToolsRequestSchema,
  Tool,
  TextContent
} from '@modelcontextprotocol/sdk/types.js';
import { loadConfig } from './config/config.js';
import { DocumentLoader } from './services/DocumentLoader.js';
import { DocumentRepository } from './services/DocumentRepository.js';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
// 전역 변수
let repository: DocumentRepository | null = null;
let config: any = null;
let isInitialized = false;
let isInitializing = false;
console.error(`[DEBUG] Module loaded at ${new Date().toISOString()}`);
// PID 파일 경로
const PID_FILE = path.join(os.tmpdir(), 'mcp-knowledge-retrieval.pid');
/**
 * 프로세스 잠금 확인 및 설정
 */
function checkAndSetProcessLock(): void {
  try {
    if (fs.existsSync(PID_FILE)) {
      const existingPid = fs.readFileSync(PID_FILE, 'utf8').trim();
      
      // 기존 프로세스가 실행 중인지 확인
      try {
        process.kill(parseInt(existingPid), 0); // 시그널 0으로 프로세스 존재 확인
        console.error(`[ERROR] MCP server already running with PID ${existingPid}`);
        console.error(`[ERROR] Kill existing process first: kill ${existingPid}`);
        process.exit(1);
      } catch (e) {
        // 프로세스가 존재하지 않으면 PID 파일 제거
        console.error(`[DEBUG] Stale PID file found, removing...`);
        fs.unlinkSync(PID_FILE);
      }
    }
    
    // 현재 프로세스 PID 저장
    fs.writeFileSync(PID_FILE, process.pid.toString());
    console.error(`[DEBUG] Process lock acquired, PID: ${process.pid}`);
    
    // 프로세스 종료 시 PID 파일 정리
    process.on('exit', () => {
      try {
        if (fs.existsSync(PID_FILE)) {
          fs.unlinkSync(PID_FILE);
        }
      } catch (e) {
        // 무시
      }
    });
    
    process.on('SIGINT', () => {
      console.error(`[DEBUG] Received SIGINT, cleaning up...`);
      process.exit(0);
    });
    
    process.on('SIGTERM', () => {
      console.error(`[DEBUG] Received SIGTERM, cleaning up...`);
      process.exit(0);
    });
    
  } catch (error) {
    console.error(`[ERROR] Failed to set process lock: ${error}`);
    process.exit(1);
  }
}
/**
 * MCP 서버 초기화
 */
async function initializeServer() {
  console.error(`[DEBUG] initializeServer() started, isInitialized=${isInitialized}, isInitializing=${isInitializing}`);
  
  if (isInitialized && repository?.isInitialized()) {
    console.error(`[DEBUG] Already initialized, skipping. Repository has ${repository.getStatistics().totalDocuments} documents`);
    return;
  }
  
  if (isInitializing) {
    console.error(`[DEBUG] Already initializing, waiting...`);
    // 초기화 완료까지 대기
    while (isInitializing && !isInitialized) {
      await new Promise(resolve => setTimeout(resolve, 100));
    }
    return;
  }
  
  try {
    isInitializing = true;
    
    // 설정 로드
    console.error(`[DEBUG] Loading config...`);
    config = await loadConfig();
    console.error(`[DEBUG] Config loaded`);
    console.error(`[DEBUG] config.documentSource.basePath=${config.documentSource.basePath}`);
    console.error(`[DEBUG] config.documentSource.domains length=${config.documentSource.domains.length}`);
    config.documentSource.domains.forEach((d: {name: string; path: string; category?: string})=>
      console.error(`[DEBUG] domain-> name:${d.name}, path:${d.path}`)
    );
    // 문서 로드
    console.error(`[DEBUG] Creating DocumentLoader...`);
    const loader = new DocumentLoader(config.documentSource);
    console.error(`[DEBUG] Loading documents...`);
    const documents = await loader.loadAllDocuments();
    console.error(`[DEBUG] Documents loaded: ${documents.length} documents`);
    // 저장소 생성 및 초기화 (기존 repository가 있으면 재사용하지 않고 새로 생성)
    console.error(`[DEBUG] Creating and initializing DocumentRepository...`);
    if (!repository) {
      repository = new DocumentRepository();
    }
    
    // repository가 이미 초기화되어 있다면 재초기화하지 않음
    if (!repository.isInitialized()) {
      await repository.initialize(documents); // 비동기 초기화 대기
    } else {
      console.error(`[DEBUG] Repository already initialized, skipping re-initialization`);
    }
    
    const stats = repository.getStatistics();
    console.error(`[DEBUG] Repository fully initialized`);
    console.error(`[DEBUG] Repository instance: ${repository ? 'EXISTS' : 'NULL'}`);
    console.error(`[DEBUG] Repository stats: ${JSON.stringify(stats)}`);
    
    isInitialized = true;
    console.error(`[DEBUG] initializeServer() completed successfully, isInitialized=${isInitialized}`);
  } catch (error) {
    console.error(`[DEBUG] Error in initializeServer(): ${error}`);
    isInitialized = false;
    throw error;
  } finally {
    isInitializing = false;
  }
}
/**
 * 서버가 준비될 때까지 대기
 */
async function ensureServerReady() {
  // 이미 초기화된 경우 기존 repository 사용
  if (isInitialized && repository?.isInitialized()) {
    console.error(`[DEBUG] ensureServerReady: Already initialized, using existing repository with ${repository.getStatistics().totalDocuments} documents`);
    return;
  }
  
  if (!isInitialized && !isInitializing) {
    console.error(`[DEBUG] ensureServerReady: Starting initialization...`);
    await initializeServer();
  } else if (isInitializing) {
    console.error(`[DEBUG] ensureServerReady: Waiting for initialization to complete...`);
    while (isInitializing) {
      await new Promise(resolve => setTimeout(resolve, 100));
    }
  }
  
  // 초기화 후 상태 검증
  if (!repository || !repository.isInitialized()) {
    throw new Error('Repository initialization failed or corrupted');
  }
}
/**
 * MCP 서버 생성 및 시작
 */
async function main() {
  console.error(`[DEBUG] Starting main() function at ${new Date().toISOString()}`);
  
  // 프로세스 중복 실행 방지
  checkAndSetProcessLock();
  
  // 서버 초기화 (문서 로딩 및 인덱싱 완료까지 대기)
  console.error(`[DEBUG] About to call initializeServer()`);
  await initializeServer();
  console.error(`[DEBUG] initializeServer() completed, repository exists: ${!!repository}`);
  console.error(`[DEBUG] Repository initialized: ${repository?.isInitialized()}`);
  // MCP 서버 생성
  const server = new Server(
    {
      name: config.serverName,
      version: config.serverVersion,
    },
    {
      capabilities: {
        tools: {},
      },
    }
  );
  // 도구 목록 조회 핸들러
  server.setRequestHandler(ListToolsRequestSchema, async () => {
    // This is a dummy handler, tools are managed by the client
    if (!config) {
      try {
        await ensureServerReady();
      } catch(e) {
        console.error("Failed to initialize server for ListTools", e);
      }
    }
    const tools: Tool[] = [
        {
          name: 'search-documents',
          description: 'Search documents using BM25 algorithm. Takes keyword arrays and returns relevant document chunks.',
          inputSchema: {
            type: 'object',
            properties: {
              keywords: {
                type: 'array',
                items: { type: 'string' },
                description: 'Array of keywords to search for (e.g., ["payment", "API", "authentication"])'
              },
              domain: {
                type: 'string',
                description: 'Domain to search in (optional, e.g., "company", "customer")'
              },
              topN: {
                type: 'number',
                description: 'Maximum number of results to return (default: 10)',
                default: 10
              }
            },
            required: ['keywords']
          }
        },
        {
          name: 'get-document-by-id',
          description: 'Retrieve full document by ID.',
          inputSchema: {
            type: 'object',
            properties: {
              id: {
                type: 'number',
                description: 'Document ID to retrieve'
              }
            },
            required: ['id']
          }
        },
        {
          name: 'list-domains',
          description: 'List all available domains and their document counts.',
          inputSchema: {
            type: 'object',
            properties: {}
          }
        },
        {
          name: 'get-chunk-with-context',
          description: 'Get specific chunk with surrounding context.',
          inputSchema: {
            type: 'object',
            properties: {
              documentId: {
                type: 'number',
                description: 'Document ID'
              },
              chunkId: {
                type: 'number',
                description: 'Chunk ID'
              },
              windowSize: {
                type: 'number',
                description: 'Context window size (default: 1)',
                default: 1
              }
            },
            required: ['documentId', 'chunkId']
          }
        }
      ];
    
    return { tools };
  });
  // 도구 실행 핸들러
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
    const { name, arguments: args } = request.params;
    
    console.error(`[DEBUG] Tool called: ${name}`);
    console.error(`[DEBUG] Repository state: ${repository ? 'EXISTS' : 'NULL'}`);
    
    // 도구 호출 전에 반드시 서버 준비 상태 확인
    try {
      await ensureServerReady();
    } catch (error) {
      console.error('[FATAL] Server initialization failed during tool call:', error);
      throw new Error('Server is not initialized and failed to recover. Please check logs and restart.');
    }
    // 상태 로깅
    if (repository) {
      console.error(`[DEBUG] Repository instance ID: ${repository.getInstanceId()}`);
      console.error(`[DEBUG] Repository documents count: ${repository.getStatistics().totalDocuments}`);
    }
    // 이 시점에서 repository는 null이 아님을 보장
    if (!repository || !repository.isInitialized()) {
      throw new Error('Repository is not properly initialized');
    }
    switch (name) {
      case 'search-documents': {
        const { keywords, domain, topN } = args as {
          keywords: string[];
          domain?: string;
          topN?: number;
        };
        const results = await repository.searchDocuments(keywords, {
          domain,
          topN,
          contextWindow: config.chunk.contextWindowSize
        });
        const content: TextContent[] = [{
          type: 'text',
          text: results
        }];
        
        return { content };
      }
      case 'get-document-by-id': {
        const { id } = args as { id: number };
        const document = repository.getDocumentById(id);
        if (!document) {
          const content: TextContent[] = [{
            type: 'text',
            text: `Document with ID ${id} not found.`
          }];
          
          return { content };
        }
        const content: TextContent[] = [{
          type: 'text',
          text: `# ${document.title}\n\n${document.content}`
        }];
        
        return { content };
      }
      case 'list-domains': {
        console.error(`[DEBUG] list-domains called`);
        const domains = repository.listDomains();
        console.error(`[DEBUG] domains found: ${JSON.stringify(domains)}`);
        
        if (domains.length === 0) {
          const content: TextContent[] = [{
            type: 'text',
            text: `## Available Domains\n\nNo domains found. Check if documents are loaded properly.`
          }];
          return { content };
        }
        const domainList = domains
          .map(d => `- ${d.name}: ${d.documentCount} documents`)
          .join('\n');
        const content: TextContent[] = [{
          type: 'text',
          text: `## Available Domains\n\n${domainList}`
        }];
        
        return { content };
      }
      case 'get-chunk-with-context': {
        const { documentId, chunkId, windowSize } = args as {
          documentId: number;
          chunkId: number;
          windowSize?: number;
        };
        const document = repository.getDocumentById(documentId);
        if (!document) {
          const content: TextContent[] = [{
            type: 'text',
            text: `Document with ID ${documentId} not found.`
          }];
          
          return { content };
        }
        const chunks = document.getChunkWithWindow(chunkId, windowSize || 1);
        if (chunks.length === 0) {
          const content: TextContent[] = [{
            type: 'text',
            text: `Chunk with ID ${chunkId} not found.`
          }];
          
          return { content };
        }
        const content = chunks.map(chunk => chunk.text).join('\n\n---\n\n');
        const textContent: TextContent[] = [{
          type: 'text',
          text: content
        }];
        
        return { content: textContent };
      }
      default:
        throw new Error(`Unknown tool: ${name}`);
    }
  });
  // STDIO 전송 설정
  const transport = new StdioServerTransport();
  await server.connect(transport);
  // MCP server started successfully (silent for protocol)
}
// 에러 핸들링
process.on('uncaughtException', (error) => {
  console.error('Uncaught exception:', error);
  process.exit(1);
});
process.on('unhandledRejection', (reason, promise) => {
  console.error('Unhandled rejection at:', promise, 'reason:', reason);
  process.exit(1);
});
// 서버 시작
main().catch((error) => {
  console.error('Failed to start server:', error);
  process.exit(1);
});