UNPKG

fast-filesystem-mcp

Version:

Fast Filesystem MCP Server - Advanced file operations with Auto-Chunking, Sequential Reading, complex file operations (copy, move, delete, batch, compress), optimized for Claude Desktop

518 lines 22.2 kB
import { promises as fs } from 'fs'; import path from 'path'; import { ResponseSizeMonitor, AutoChunkingHelper, createChunkedResponse, globalTokenManager } from './auto-chunking.js'; import { safePath, formatSize, shouldExcludePath, truncateContent, CLAUDE_MAX_CHUNK_SIZE, CLAUDE_MAX_LINES, CLAUDE_MAX_DIR_ITEMS } from './utils.js'; // handleReadFile 함수 (자동 청킹 지원) export async function handleReadFileWithAutoChunking(args) { const { path: filePath, start_offset = 0, max_size, line_start, line_count, encoding = 'utf-8', continuation_token, auto_chunk = true } = args; const monitor = new ResponseSizeMonitor(0.9); // 900KB 제한 let actualLineStart = line_start || 0; let actualStartOffset = start_offset || 0; // Continuation token 처리 if (continuation_token) { const token = globalTokenManager.getToken(continuation_token); if (token && token.type === 'read_file' && token.path === filePath) { actualLineStart = token.line_start || actualLineStart; actualStartOffset = token.byte_offset || actualStartOffset; } } const safePath_resolved = safePath(filePath); const stats = await fs.stat(safePath_resolved); if (!stats.isFile()) { throw new Error('Path is not a file'); } const maxReadSize = max_size ? Math.min(max_size, CLAUDE_MAX_CHUNK_SIZE) : CLAUDE_MAX_CHUNK_SIZE; // 라인 모드 - 자동 청킹 지원 if (line_start !== undefined || continuation_token) { const linesToRead = line_count ? Math.min(line_count, CLAUDE_MAX_LINES) : CLAUDE_MAX_LINES; // 파일 읽기 (전체 또는 스트리밍) let fileContent; if (stats.size > 10 * 1024 * 1024) { // 10MB 이상은 스트리밍 // 스트리밍 읽기 로직 (기존 코드 유지) const fileHandle = await fs.open(safePath_resolved, 'r'); const stream = fileHandle.createReadStream({ encoding: encoding }); let currentLine = 0; let buffer = ''; const lines = []; for await (const chunk of stream) { buffer += chunk; const chunkLines = buffer.split('\n'); buffer = chunkLines.pop() || ''; for (const line of chunkLines) { if (currentLine >= actualLineStart && lines.length < linesToRead) { lines.push(line); } currentLine++; if (lines.length >= linesToRead) { break; } } if (lines.length >= linesToRead) { break; } } if (buffer && currentLine >= actualLineStart && lines.length < linesToRead) { lines.push(buffer); } await fileHandle.close(); fileContent = lines.join('\n'); } else { // 작은 파일은 전체 읽기 fileContent = await fs.readFile(safePath_resolved, encoding); } // 자동 청킹 적용 if (auto_chunk) { const chunkResult = AutoChunkingHelper.chunkTextByLines(fileContent, monitor, actualLineStart); let continuationTokenId; if (chunkResult.hasMore) { continuationTokenId = globalTokenManager.generateToken('read_file', filePath, { ...args, line_start: chunkResult.nextStartLine, encoding }); globalTokenManager.updateToken(continuationTokenId, { line_start: chunkResult.nextStartLine, path: filePath }); } const response = createChunkedResponse({ content: chunkResult.content, mode: 'lines', start_line: actualLineStart, lines_read: chunkResult.content.split('\n').length, file_size: stats.size, file_size_readable: formatSize(stats.size), encoding: encoding, path: safePath_resolved, auto_chunked: true }, chunkResult.hasMore, monitor, continuationTokenId); return response; } else { // 기존 방식 (청킹 없음) const allLines = fileContent.split('\n'); const selectedLines = allLines.slice(actualLineStart, actualLineStart + linesToRead); return { content: selectedLines.join('\n'), mode: 'lines', start_line: actualLineStart, lines_read: selectedLines.length, file_size: stats.size, file_size_readable: formatSize(stats.size), encoding: encoding, has_more: actualLineStart + selectedLines.length < allLines.length, path: safePath_resolved, auto_chunked: false }; } } // 바이트 모드 - 자동 청킹 지원 const fileHandle = await fs.open(safePath_resolved, 'r'); if (auto_chunk) { // 전체 파일 읽기 (메모리가 허용하는 경우) let content; if (stats.size < CLAUDE_MAX_CHUNK_SIZE) { const buffer = Buffer.alloc(Math.min(stats.size, maxReadSize)); const { bytesRead } = await fileHandle.read(buffer, 0, buffer.length, actualStartOffset); content = buffer.subarray(0, bytesRead).toString(encoding); await fileHandle.close(); // 청킹 적용 const chunkResult = AutoChunkingHelper.chunkTextByLines(content, monitor, 0); let continuationTokenId; if (chunkResult.hasMore) { const nextOffset = actualStartOffset + Buffer.byteLength(chunkResult.content, encoding); continuationTokenId = globalTokenManager.generateToken('read_file', filePath, { ...args, start_offset: nextOffset, encoding }); globalTokenManager.updateToken(continuationTokenId, { byte_offset: nextOffset, path: filePath }); } const response = createChunkedResponse({ content: chunkResult.content, mode: 'bytes', start_offset: actualStartOffset, bytes_read: Buffer.byteLength(chunkResult.content, encoding), file_size: stats.size, file_size_readable: formatSize(stats.size), encoding: encoding, path: safePath_resolved, auto_chunked: true }, chunkResult.hasMore, monitor, continuationTokenId); return response; } } // 기존 바이트 모드 (대용량 파일이거나 auto_chunk=false) const buffer = Buffer.alloc(maxReadSize); const { bytesRead } = await fileHandle.read(buffer, 0, maxReadSize, actualStartOffset); await fileHandle.close(); const content = buffer.subarray(0, bytesRead).toString(encoding); const result = truncateContent(content); return { content: result.content, mode: 'bytes', start_offset: actualStartOffset, bytes_read: bytesRead, file_size: stats.size, file_size_readable: formatSize(stats.size), encoding: encoding, truncated: result.truncated, has_more: actualStartOffset + bytesRead < stats.size, path: safePath_resolved, auto_chunked: false }; } // handleListDirectory 함수 (자동 청킹 지원) export async function handleListDirectoryWithAutoChunking(args) { const { path: dirPath, page = 1, page_size, pattern, show_hidden = false, sort_by = 'name', reverse = false, continuation_token, auto_chunk = true } = args; const monitor = new ResponseSizeMonitor(0.9); // 900KB 제한 let actualPage = page; // Continuation token 처리 if (continuation_token) { const token = globalTokenManager.getToken(continuation_token); if (token && token.type === 'list_directory' && token.path === dirPath) { actualPage = token.page || actualPage; } } const safePath_resolved = safePath(dirPath); const stats = await fs.stat(safePath_resolved); if (!stats.isDirectory()) { throw new Error('Path is not a directory'); } const pageSize = page_size ? Math.min(page_size, CLAUDE_MAX_DIR_ITEMS) : 50; const entries = await fs.readdir(safePath_resolved, { withFileTypes: true }); let filteredEntries = entries.filter(entry => { if (!show_hidden && entry.name.startsWith('.')) return false; if (shouldExcludePath(path.join(safePath_resolved, entry.name))) return false; if (pattern) { return entry.name.toLowerCase().includes(pattern.toLowerCase()); } return true; }); // 정렬 filteredEntries.sort((a, b) => { let comparison = 0; switch (sort_by) { case 'name': comparison = a.name.localeCompare(b.name); break; case 'type': const aType = a.isDirectory() ? 'directory' : 'file'; const bType = b.isDirectory() ? 'directory' : 'file'; comparison = aType.localeCompare(bType); break; default: comparison = a.name.localeCompare(b.name); } return reverse ? -comparison : comparison; }); const startIdx = (actualPage - 1) * pageSize; const endIdx = startIdx + pageSize; // 자동 청킹 지원 if (auto_chunk) { const allItems = []; let currentIdx = startIdx; // 항목들을 하나씩 처리하면서 응답 크기 모니터링 while (currentIdx < Math.min(endIdx, filteredEntries.length)) { const entry = filteredEntries[currentIdx]; try { const fullPath = path.join(safePath_resolved, entry.name); const itemStats = await fs.stat(fullPath); const item = { name: entry.name, type: entry.isDirectory() ? 'directory' : 'file', size: entry.isFile() ? itemStats.size : null, size_readable: entry.isFile() ? formatSize(itemStats.size) : null, modified: itemStats.mtime.toISOString(), created: itemStats.birthtime.toISOString(), permissions: itemStats.mode, path: fullPath }; // 응답 크기 확인 if (!monitor.canAddContent(item)) { break; } monitor.addContent(item); allItems.push(item); currentIdx++; } catch { // 권한 오류 등으로 stat 실패한 경우 const fallbackItem = { name: entry.name, type: entry.isDirectory() ? 'directory' : 'file', size: null, size_readable: null, modified: null, created: null, permissions: null, path: path.join(safePath_resolved, entry.name) }; if (monitor.canAddContent(fallbackItem)) { monitor.addContent(fallbackItem); allItems.push(fallbackItem); currentIdx++; } else { break; } } } const hasMore = currentIdx < filteredEntries.length; let continuationTokenId; if (hasMore) { const nextPage = Math.floor(currentIdx / pageSize) + 1; continuationTokenId = globalTokenManager.generateToken('list_directory', dirPath, { ...args, page: nextPage }); globalTokenManager.updateToken(continuationTokenId, { page: nextPage, path: dirPath }); } const response = createChunkedResponse({ path: safePath_resolved, items: allItems, page: actualPage, page_size: pageSize, total_count: filteredEntries.length, total_pages: Math.ceil(filteredEntries.length / pageSize), items_in_response: allItems.length, sort_by: sort_by, reverse: reverse, auto_chunked: true, timestamp: new Date().toISOString() }, hasMore, monitor, continuationTokenId); return response; } else { // 기존 방식 (청킹 없음) const pageEntries = filteredEntries.slice(startIdx, endIdx); const items = await Promise.all(pageEntries.map(async (entry) => { try { const fullPath = path.join(safePath_resolved, entry.name); const itemStats = await fs.stat(fullPath); return { name: entry.name, type: entry.isDirectory() ? 'directory' : 'file', size: entry.isFile() ? itemStats.size : null, size_readable: entry.isFile() ? formatSize(itemStats.size) : null, modified: itemStats.mtime.toISOString(), created: itemStats.birthtime.toISOString(), permissions: itemStats.mode, path: fullPath }; } catch { return { name: entry.name, type: entry.isDirectory() ? 'directory' : 'file', size: null, size_readable: null, modified: null, created: null, permissions: null, path: path.join(safePath_resolved, entry.name) }; } })); return { path: safePath_resolved, items: items, page: actualPage, page_size: pageSize, total_count: filteredEntries.length, total_pages: Math.ceil(filteredEntries.length / pageSize), has_more: endIdx < filteredEntries.length, sort_by: sort_by, reverse: reverse, auto_chunked: false, timestamp: new Date().toISOString() }; } } // handleSearchFiles 함수 (자동 청킹 지원) export async function handleSearchFilesWithAutoChunking(args) { const { path: searchPath, pattern, content_search = false, case_sensitive = false, max_results = 100, context_lines = 0, file_pattern = '', include_binary = false, continuation_token, auto_chunk = true } = args; const monitor = new ResponseSizeMonitor(0.9); // 900KB 제한 let fileIndex = 0; let lastFile = ''; // Continuation token 처리 if (continuation_token) { const token = globalTokenManager.getToken(continuation_token); if (token && token.type === 'search_files' && token.path === searchPath) { fileIndex = token.file_index || 0; lastFile = token.last_file || ''; } } const safePath_resolved = safePath(searchPath); const maxResults = Math.min(max_results, 200); const results = []; const searchPattern = case_sensitive ? pattern : pattern.toLowerCase(); // 정규표현식 패턴 지원 let regexPattern = null; try { regexPattern = new RegExp(pattern, case_sensitive ? 'g' : 'gi'); } catch { // 정규표현식이 아닌 경우 문자열 검색으로 처리 } // 파일 패턴 필터 let fileRegex = null; if (file_pattern) { try { const regexStr = file_pattern .replace(/\./g, '\\.') .replace(/\*/g, '.*') .replace(/\?/g, '.'); fileRegex = new RegExp(`^${regexStr}$`, 'i'); } catch { // 정규표현식 변환 실패시 단순 문자열 포함 검사 } } let currentFileIndex = 0; let shouldStartProcessing = !continuation_token; // 토큰이 없으면 처음부터 async function searchDirectory(dirPath) { if (results.length >= maxResults) return false; try { const entries = await fs.readdir(dirPath, { withFileTypes: true }); for (const entry of entries) { if (results.length >= maxResults) break; const fullPath = path.join(dirPath, entry.name); if (shouldExcludePath(fullPath)) continue; if (entry.isFile()) { // continuation 지점 확인 if (!shouldStartProcessing) { if (currentFileIndex >= fileIndex && fullPath >= lastFile) { shouldStartProcessing = true; } else { currentFileIndex++; continue; } } // 파일 패턴 필터링 if (fileRegex && !fileRegex.test(entry.name)) { currentFileIndex++; continue; } const searchName = case_sensitive ? entry.name : entry.name.toLowerCase(); let matched = false; let matchType = ''; let matchedLines = []; // 파일명 검색 if (regexPattern ? regexPattern.test(entry.name) : searchName.includes(searchPattern)) { matched = true; matchType = 'filename'; } if (matched) { const stats = await fs.stat(fullPath); const result = { path: fullPath, name: entry.name, match_type: matchType, size: stats.size, size_readable: formatSize(stats.size), modified: stats.mtime.toISOString(), created: stats.birthtime.toISOString(), extension: path.extname(fullPath), permissions: stats.mode, is_binary: false, file_index: currentFileIndex }; // 자동 청킹 확인 if (auto_chunk && !monitor.canAddContent(result)) { // 응답 크기 한계 도달 return false; } if (auto_chunk) { monitor.addContent(result); } results.push(result); } currentFileIndex++; } else if (entry.isDirectory()) { const canContinue = await searchDirectory(fullPath); if (!canContinue) return false; } } } catch (error) { console.warn(`Failed to search directory ${dirPath}: ${error instanceof Error ? error.message : 'Unknown error'}`); } return true; } const startTime = Date.now(); const canContinue = await searchDirectory(safePath_resolved); const searchTime = Date.now() - startTime; const hasMore = !canContinue || (results.length >= maxResults && currentFileIndex < Number.MAX_SAFE_INTEGER); let continuationTokenId; if (hasMore && auto_chunk) { const lastResult = results[results.length - 1]; continuationTokenId = globalTokenManager.generateToken('search_files', searchPath, { ...args, file_index: currentFileIndex, last_file: lastResult?.path || lastFile }); globalTokenManager.updateToken(continuationTokenId, { file_index: currentFileIndex, last_file: lastResult?.path || lastFile, path: searchPath }); } if (auto_chunk) { const response = createChunkedResponse({ results: results, total_found: results.length, search_pattern: pattern, search_path: safePath_resolved, content_search: content_search, case_sensitive: case_sensitive, context_lines: context_lines, file_pattern: file_pattern, include_binary: include_binary, max_results_reached: results.length >= maxResults, search_time_ms: searchTime, regex_used: regexPattern !== null, ripgrep_enhanced: true, auto_chunked: true, current_file_index: currentFileIndex, timestamp: new Date().toISOString() }, hasMore, monitor, continuationTokenId); return response; } else { // 기존 방식 return { results: results, total_found: results.length, search_pattern: pattern, search_path: safePath_resolved, content_search: content_search, case_sensitive: case_sensitive, context_lines: context_lines, file_pattern: file_pattern, include_binary: include_binary, max_results_reached: results.length >= maxResults, search_time_ms: searchTime, regex_used: regexPattern !== null, ripgrep_enhanced: true, auto_chunked: false, timestamp: new Date().toISOString() }; } } //# sourceMappingURL=enhanced-handlers.js.map