@mtorange/mcp-local-file-search
Version:
Local file indexing and search MCP server with BM25 algorithm and multilingual support
506 lines (436 loc) • 14.2 kB
JavaScript
const fs = require('fs-extra');
const path = require('path');
const Searcher = require('./searcher');
const Indexer = require('./indexer');
const i18n = require('./i18n');
class MCPServer {
constructor(targetDir, debugFile = null) {
this.targetDir = targetDir;
this.debugFile = debugFile;
this.searcher = new Searcher(targetDir, debugFile);
this.indexer = new Indexer(targetDir, debugFile);
// MCP 프로토콜 상태
this.requestId = 0;
this.isRunning = false;
this.initialized = false;
this.clientInitialized = false; // 클라이언트 초기화 상태 추가
// 언어 정보 로그
this.debug(`Language info: ${JSON.stringify(i18n.getLocaleInfo(), null, 2)}`);
}
// 디버그 로그 출력
debug(message) {
if (this.debugFile) {
const timestamp = new Date().toISOString();
const logMessage = `[${timestamp}] ${message}\n`;
fs.appendFileSync(this.debugFile, logMessage);
}
}
// JSON-RPC 메시지 파싱
parseMessage(line) {
try {
const message = JSON.parse(line);
this.debug(`메시지 파싱 성공: ${JSON.stringify(message)}`);
return message;
} catch (error) {
this.debug(`메시지 파싱 오류: ${error.message}, 입력: ${line}`);
return null;
}
}
// JSON-RPC 응답 전송
sendResponse(id, result = null, error = null) {
// id가 null 또는 undefined인 경우 처리
if (id === null || id === undefined) {
this.debug('경고: 응답 ID가 null 또는 undefined입니다.');
return;
}
const response = {
jsonrpc: '2.0',
id: id
};
if (error) {
response.error = {
code: error.code || -32000,
message: error.message || String(error)
};
} else {
response.result = result || {};
}
const responseStr = JSON.stringify(response);
this.debug(`응답 전송: ${responseStr}`);
process.stdout.write(responseStr + '\n');
}
// 알림 전송
sendNotification(method, params = null) {
const notification = {
jsonrpc: '2.0',
method: method
};
if (params !== null) {
notification.params = params;
}
const notificationStr = JSON.stringify(notification);
this.debug(`알림 전송: ${notificationStr}`);
process.stdout.write(notificationStr + '\n');
}
// 초기화 처리
async handleInitialize(params) {
this.debug('초기화 요청 처리');
this.initialized = true;
return {
protocolVersion: '2024-11-05',
capabilities: {
tools: {
listChanged: true
}
},
serverInfo: {
name: 'local-file',
version: '1.0.0'
}
};
}
// notifications/initialized 처리
async handleInitializedNotification() {
this.debug('클라이언트 초기화 알림 수신');
this.clientInitialized = true;
// 초기 인덱싱 시작
try {
const indexPath = path.join(this.targetDir, '.local-file-index.json');
if (!await fs.pathExists(indexPath)) {
this.debug(i18n.t('indexing.start'));
await this.indexer.index(false);
}
} catch (error) {
this.debug(`초기 인덱싱 오류: ${error.message}`);
}
}
// 리소스 목록 제공 (빈 리스트 반환)
async handleListResources() {
this.debug('리소스 목록 요청 처리 (빈 리스트 반환)');
return {
resources: []
};
}
// 프롬프트 목록 제공 (빈 리스트 반환)
async handleListPrompts() {
this.debug('프롬프트 목록 요청 처리 (빈 리스트 반환)');
return {
prompts: []
};
}
// 도구 목록 제공
async handleListTools() {
this.debug('도구 목록 요청 처리');
return {
tools: [
{
name: 'search-local',
description: i18n.t('tool.search_local.description'),
inputSchema: {
type: 'object',
properties: {
query: {
type: 'string',
description: i18n.t('tool.search_local.query')
},
limit: {
type: 'integer',
description: i18n.t('tool.search_local.limit'),
default: 10
}
},
required: ['query']
}
},
{
name: 'search-in-file',
description: i18n.t('tool.search_in_file.description'),
inputSchema: {
type: 'object',
properties: {
filePath: {
type: 'string',
description: i18n.t('tool.search_in_file.file_path')
},
query: {
type: 'string',
description: i18n.t('tool.search_in_file.query')
}
},
required: ['filePath', 'query']
}
},
{
name: 'get-index-stats',
description: i18n.t('tool.get_index_stats.description'),
inputSchema: {
type: 'object',
properties: {
random_string: {
type: 'string',
description: 'Dummy parameter for no-parameter tools'
}
},
required: ['random_string']
}
},
{
name: 'find-similar-files',
description: i18n.t('tool.find_similar_files.description'),
inputSchema: {
type: 'object',
properties: {
filePath: {
type: 'string',
description: i18n.t('tool.find_similar_files.file_path')
},
limit: {
type: 'integer',
description: i18n.t('tool.find_similar_files.limit'),
default: 5
}
},
required: ['filePath']
}
},
{
name: 'reindex',
description: i18n.t('tool.reindex.description'),
inputSchema: {
type: 'object',
properties: {
force: {
type: 'boolean',
description: i18n.t('tool.reindex.force'),
default: false
}
}
}
}
]
};
}
// 도구 실행
async handleCallTool(params) {
const { name, arguments: args } = params;
this.debug(`도구 실행: ${name}, 인수: ${JSON.stringify(args)}`);
try {
switch (name) {
case 'search-local':
return await this.handleSearchLocal(args);
case 'search-in-file':
return await this.handleSearchInFile(args);
case 'get-index-stats':
return await this.handleGetIndexStats(args);
case 'find-similar-files':
return await this.handleFindSimilarFiles(args);
case 'reindex':
return await this.handleReindex(args);
default:
throw new Error(`알 수 없는 도구: ${name}`);
}
} catch (error) {
this.debug(`도구 실행 오류: ${error.message}`);
throw error;
}
}
// 로컬 검색 처리
async handleSearchLocal(args) {
const { query, limit = 10 } = args;
if (!query) {
throw new Error('검색어가 필요합니다.');
}
const results = await this.searcher.search(query, limit);
return {
content: [
{
type: 'text',
text: `검색 결과 (${results.length}개):\n\n` +
results.map((result, index) =>
`${index + 1}. **${result.file}** (점수: ${result.score.toFixed(4)})\n` +
` 확장자: ${result.extension}, 크기: ${result.size}바이트\n` +
` 수정일: ${result.mtime}\n` +
` 내용: ${result.content}\n`
).join('\n')
}
]
};
}
// 파일 내 검색 처리
async handleSearchInFile(args) {
const { filePath, query } = args;
if (!filePath || !query) {
throw new Error('파일 경로와 검색어가 필요합니다.');
}
const result = await this.searcher.searchInFile(filePath, query);
if (result) {
return {
content: [
{
type: 'text',
text: `파일 "${result.file}"에서 검색 결과:\n\n` +
`점수: ${result.score.toFixed(4)}\n` +
`확장자: ${result.extension}, 크기: ${result.size}바이트\n` +
`수정일: ${result.mtime}\n\n` +
`내용:\n${result.content}`
}
]
};
} else {
return {
content: [
{
type: 'text',
text: `파일 "${filePath}"에서 검색 결과를 찾을 수 없습니다.`
}
]
};
}
}
// 인덱스 통계 조회 처리
async handleGetIndexStats(args = {}) {
// random_string 파라미터는 무시 (더미 파라미터)
const stats = await this.searcher.getIndexStats();
return {
content: [
{
type: 'text',
text: `인덱스 통계:\n\n` +
`총 파일 수: ${stats.totalFiles}\n` +
`총 용어 수: ${stats.totalTerms}\n` +
`인덱스 크기: ${(stats.indexSize / 1024).toFixed(2)}KB\n` +
`마지막 업데이트: ${stats.lastUpdated}`
}
]
};
}
// 유사 파일 찾기 처리
async handleFindSimilarFiles(args) {
const { filePath, limit = 5 } = args;
if (!filePath) {
throw new Error('파일 경로가 필요합니다.');
}
const results = await this.searcher.findSimilarFiles(filePath, limit);
return {
content: [
{
type: 'text',
text: `"${filePath}"와 유사한 파일들 (${results.length}개):\n\n` +
results.map((result, index) =>
`${index + 1}. **${result.file}** (유사도: ${result.score.toFixed(4)})\n` +
` 확장자: ${result.extension}, 크기: ${result.size}바이트\n`
).join('\n')
}
]
};
}
// 재인덱싱 처리
async handleReindex(args) {
const { force = false } = args;
const result = await this.indexer.index(force);
return {
content: [
{
type: 'text',
text: `인덱싱 완료:\n\n` +
`총 파일 수: ${result.totalFiles}\n` +
`인덱싱된 파일 수: ${result.indexedFiles}\n` +
`제거된 파일 수: ${result.removedFiles}\n` +
`총 용어 수: ${result.totalTerms}`
}
]
};
}
// 요청 처리
async handleRequest(message) {
// 메시지 유효성 검사
if (!message || typeof message !== 'object') {
this.debug('유효하지 않은 메시지 형식');
return;
}
const { id, method, params } = message;
// 알림 메시지 처리 (id가 null 또는 undefined인 경우만)
if ((id === null || id === undefined) && method) {
this.debug(`알림 처리: ${method}`);
if (method === 'notifications/initialized') {
await this.handleInitializedNotification();
} else if (method === 'notifications/cancelled') {
this.debug(`요청 취소 알림: ${JSON.stringify(params)}`);
} else {
this.debug(`알 수 없는 알림: ${method}`);
}
return;
}
// 요청 메시지 처리 (id가 존재하는 경우)
if (!method) {
this.debug('method 필드가 없는 요청');
this.sendResponse(id, null, { code: -32600, message: 'Invalid Request: method 필드가 필요합니다.' });
return;
}
this.debug(`요청 처리: ${method}, ID: ${id}`);
// initialize 요청이 아닌 경우 초기화 상태 확인
if (method !== 'initialize' && !this.initialized) {
this.debug('초기화되지 않은 상태에서 요청 거부');
this.sendResponse(id, null, { code: -32002, message: '서버가 초기화되지 않았습니다. 먼저 initialize를 호출하세요.' });
return;
}
try {
let result;
switch (method) {
case 'initialize':
result = await this.handleInitialize(params);
break;
case 'tools/list':
result = await this.handleListTools();
break;
case 'tools/call':
result = await this.handleCallTool(params);
break;
case 'resources/list':
result = await this.handleListResources();
break;
case 'prompts/list':
result = await this.handleListPrompts();
break;
default:
throw new Error(`지원하지 않는 메서드: ${method}`);
}
this.sendResponse(id, result);
} catch (error) {
this.debug(`요청 처리 오류: ${error.message}`);
this.sendResponse(id, null, { code: -32000, message: error.message });
}
}
// 서버 시작
async start() {
this.debug('MCP 서버 시작');
this.isRunning = true;
// stdio 입력 처리
process.stdin.setEncoding('utf8');
process.stdin.on('data', (data) => {
const lines = data.trim().split('\n');
lines.forEach(line => {
if (line.trim()) {
const message = this.parseMessage(line);
if (message) {
this.handleRequest(message);
}
}
});
});
process.stdin.on('end', () => {
this.debug('MCP 서버 종료');
this.isRunning = false;
});
process.on('SIGINT', () => {
this.debug('SIGINT 수신, 서버 종료');
process.exit(0);
});
process.on('SIGTERM', () => {
this.debug('SIGTERM 수신, 서버 종료');
process.exit(0);
});
this.debug('MCP 서버 준비 완료, 클라이언트의 initialize 요청 대기 중');
}
}
module.exports = MCPServer;