Skip to main content
Glama

fast-filesystem-mcp

index.ts168 kB
#!/usr/bin/env node /* * Copyright 2025 efforthye * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js'; import { promises as fs } from 'fs'; import path from 'path'; import { exec, spawn } from 'child_process'; import { promisify } from 'util'; import { ResponseSizeMonitor, ContinuationTokenManager, AutoChunkingHelper, createChunkedResponse, globalTokenManager, type ContinuationToken, type ChunkedResponse } from './auto-chunking.js'; import { handleReadFileWithAutoChunking, handleListDirectoryWithAutoChunking, handleSearchFilesWithAutoChunking } from './enhanced-handlers.js'; import { isPathAllowed as coreIsPathAllowed, safePath as coreSafePath, getAllowedDirectories, addAllowedDirectories } from './utils.js'; // Import safe logger to prevent JSON parsing errors import { logger, initializeSafeLogging } from './logger/index.js'; // import { searchCode, SearchResult } from './search.js'; const execAsync = promisify(exec); // Claude 최적화 설정 const CLAUDE_MAX_RESPONSE_SIZE = 5 * 1024 * 1024; // 5MB const CLAUDE_MAX_CHUNK_SIZE = 2 * 1024 * 1024; // 2MB const CLAUDE_MAX_LINES = 2000; // 최대 2000줄 const CLAUDE_MAX_DIR_ITEMS = 1000; // 디렉토리 항목 최대 1000개 // --- Allowed directories are managed centrally in utils.ts. // Parse CLI flags "--allow <dir>" to extend allowed set at runtime. // Parse CLI flags "--disable-tools" for tool filtering. const argv = process.argv.slice(2); const allowArgs: string[] = []; const disabledTools: string[] = []; for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a === '--allow' && i + 1 < argv.length) { allowArgs.push(argv[i + 1]); i++; } else if ((a === '--disable-tools' || a === '-dt') && i + 1 < argv.length) { const tools = argv[i + 1].split(',').map(t => t.trim()).filter(t => t); disabledTools.push(...tools); i++; } } if (allowArgs.length > 0) { const res = addAllowedDirectories(allowArgs); if (res.skipped.length > 0) { logger.warn('Some --allow paths were skipped:', res.skipped); } } // Tool category mapping for --disable-tools (smart prefix matching) function getToolsByCategory(category: string, allToolNames: string[]): string[] { const prefixes: Record<string, string[]> = { read: ['fast_read_', 'fast_list_', 'fast_get_', 'fast_extract_lines'], write: ['fast_write_', 'fast_edit_', 'fast_create_', 'fast_copy_', 'fast_move_', 'fast_compress_', 'fast_extract_archive', 'fast_sync_', 'fast_batch_'], delete: ['fast_delete_'], search: ['fast_search_', 'fast_find_'] }; const categoryPrefixes = prefixes[category] || []; return allToolNames.filter(tool => categoryPrefixes.some(prefix => tool.startsWith(prefix) || tool === prefix) ); } // Expand disabled tools to include tools from categories function expandDisabledTools(disabledTools: string[], allToolNames: string[]): Set<string> { const expandedSet = new Set<string>(); for (const item of disabledTools) { // First check if it's an exact tool name if (item.startsWith('fast_')) { expandedSet.add(item); } else { // Fallback: check if it's a category const categoryTools = getToolsByCategory(item, allToolNames); for (const tool of categoryTools) { expandedSet.add(tool); } } } return expandedSet; } // Initialize expandedDisabledTools immediately after CLI parsing let actualExpandedDisabledTools: Set<string> = new Set(); // Pre-computed filtered tools for ListTools handler optimization let cachedFilteredTools: any[] = []; // 백업 파일 설정 (환경변수나 설정으로 제어) const CREATE_BACKUP_FILES = process.env.CREATE_BACKUP_FILES === 'true'; // 기본값: false, true로 설정시만 활성화 // 기본 제외 패턴 (보안 및 성능) const DEFAULT_EXCLUDE_PATTERNS = [ '.venv', 'venv', 'node_modules', '.git', '.svn', '.hg', '__pycache__', '.pytest_cache', '.mypy_cache', '.coverage', 'dist', 'build', 'target', 'bin', 'obj', '.vs', '.vscode', '*.pyc', '*.pyo', '*.pyd', '.DS_Store', 'Thumbs.db' ]; // 이모지 감지 함수 (제거하지 않고 경고만) function detectEmojis(text: string): { hasEmojis: boolean; count: number; positions: number[] } { const emojiRegex = /[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F700}-\u{1F77F}]|[\u{1F780}-\u{1F7FF}]|[\u{1F800}-\u{1F8FF}]|[\u{1F900}-\u{1F9FF}]|[\u{1FA00}-\u{1FA6F}]|[\u{1FA70}-\u{1FAFF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{1F004}]|[\u{1F0CF}]|[\u{1F170}-\u{1F251}]/gu; const matches = Array.from(text.matchAll(emojiRegex)); const positions = matches.map(match => match.index || 0); return { hasEmojis: matches.length > 0, count: matches.length, positions: positions }; } // 이모지 제거 함수 (선택적) function removeEmojis(text: string): string { const emojiRegex = /[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F700}-\u{1F77F}]|[\u{1F780}-\u{1F7FF}]|[\u{1F800}-\u{1F8FF}]|[\u{1F900}-\u{1F9FF}]|[\u{1FA00}-\u{1FA6F}]|[\u{1FA70}-\u{1FAFF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{1F004}]|[\u{1F0CF}]|[\u{1F170}-\u{1F251}]/gu; return text.replace(emojiRegex, ''); } // 파일 타입별 이모지 가이드라인 function getEmojiGuideline(filePath: string): { shouldAvoidEmojis: boolean; reason: string; fileType: string } { const ext = path.extname(filePath).toLowerCase(); const fileName = path.basename(filePath).toLowerCase(); // 코드 파일들 const codeExtensions = ['.js', '.ts', '.jsx', '.tsx', '.py', '.java', '.cpp', '.c', '.h', '.cs', '.php', '.rb', '.go', '.rs', '.swift', '.kt']; // 설정 파일들 const configExtensions = ['.json', '.yml', '.yaml', '.toml', '.ini', '.cfg', '.conf']; const configFiles = ['package.json', 'tsconfig.json', 'webpack.config.js', 'dockerfile', 'makefile']; // 문서 파일들 const docExtensions = ['.md', '.txt', '.rst', '.adoc']; if (codeExtensions.includes(ext)) { return { shouldAvoidEmojis: true, reason: 'Emojis not recommended in code files', fileType: 'code' }; } if (configExtensions.includes(ext) || configFiles.includes(fileName)) { return { shouldAvoidEmojis: true, reason: 'Emojis not recommended in config files', fileType: 'config' }; } if (docExtensions.includes(ext)) { return { shouldAvoidEmojis: true, reason: 'Emojis not recommended in files', fileType: 'documentation' }; } return { shouldAvoidEmojis: true, reason: 'Emojis not recommended in files', fileType: 'general' }; } // 유틸리티 함수들 function isPathAllowed(targetPath: string): boolean { return coreIsPathAllowed(targetPath); } function safePath(inputPath: string): string { return coreSafePath(inputPath); } function formatSize(bytes: number): string { const units = ['B', 'KB', 'MB', 'GB', 'TB']; let size = bytes; let unitIndex = 0; while (size >= 1024 && unitIndex < units.length - 1) { size /= 1024; unitIndex++; } return `${size.toFixed(2)} ${units[unitIndex]}`; } function shouldExcludePath(targetPath: string, excludePatterns: string[] = []): boolean { const patterns = [...DEFAULT_EXCLUDE_PATTERNS, ...excludePatterns]; const pathName = path.basename(targetPath).toLowerCase(); const pathParts = targetPath.split(path.sep); return patterns.some(pattern => { const patternLower = pattern.toLowerCase(); if (pattern.includes('*') || pattern.includes('?')) { const regex = new RegExp(pattern.replace(/\\*/g, '.*').replace(/\\?/g, '.')); return regex.test(pathName); } return pathParts.some(part => part.toLowerCase() === patternLower) || pathName === patternLower; }); } function truncateContent(content: string, maxSize: number = CLAUDE_MAX_RESPONSE_SIZE) { const contentBytes = Buffer.byteLength(content, 'utf8'); if (contentBytes <= maxSize) { return { content, truncated: false }; } let truncated = content; while (Buffer.byteLength(truncated, 'utf8') > maxSize) { truncated = truncated.slice(0, -1); } return { content: truncated, truncated: true, original_size: contentBytes, truncated_size: Buffer.byteLength(truncated, 'utf8') }; } // 대용량 파일 작성을 위한 유틸리티 함수들 async function writeFileWithRetry( filePath: string, content: string, encoding: BufferEncoding, chunkSize: number, maxRetries: number, append: boolean ): Promise<{ retryCount: number; totalTime: number }> { let retryCount = 0; const startTime = Date.now(); while (retryCount <= maxRetries) { try { await writeFileStreaming(filePath, content, encoding, chunkSize, append); return { retryCount, totalTime: Date.now() - startTime }; } catch (error) { retryCount++; if (retryCount > maxRetries) { throw error; } const delay = Math.min(1000 * Math.pow(2, retryCount - 1), 10000); await new Promise(resolve => setTimeout(resolve, delay)); } } throw new Error('Max retry attempts exceeded'); } async function writeFileStreaming( filePath: string, content: string, encoding: BufferEncoding, chunkSize: number, append: boolean ): Promise<void> { const buffer = Buffer.from(content, encoding); const fileHandle = await fs.open(filePath, append ? 'a' : 'w'); try { let position = 0; while (position < buffer.length) { const end = Math.min(position + chunkSize, buffer.length); const chunk = buffer.subarray(position, end); await fileHandle.write(chunk); position = end; // 메모리 압박 방지를 위한 이벤트 루프 양보 if (position % (chunkSize * 10) === 0) { await new Promise(resolve => setImmediate(resolve)); } } await fileHandle.sync(); // 디스크에 강제 동기화 } finally { await fileHandle.close(); } } async function checkDiskSpace(dirPath: string, requiredBytes: number): Promise<void> { try { const { stdout } = await execAsync(`df -B1 "${dirPath}" | tail -1 | awk '{print $4}'`); const availableBytes = parseInt(stdout.trim()); if (availableBytes < requiredBytes * 1.5) { throw new Error( `Insufficient disk space. Required: ${formatSize(requiredBytes)}, ` + `Available: ${formatSize(availableBytes)}` ); } } catch (error) { logger.warn('Could not check disk space:', error); } } async function getOriginalFileSize(filePath: string): Promise<number> { try { const stats = await fs.stat(filePath); return stats.size; } catch { return 0; } } // MCP 서버 생성 const server = new Server( { name: 'fast-filesystem', version: '3.4.0', }, { capabilities: { tools: {}, }, } ); // Tool disabling will be initialized after tool definitions are available // 툴 목록 정의 server.setRequestHandler(ListToolsRequestSchema, async () => { const allTools = [ { name: 'fast_list_allowed_directories', description: 'Lists the allowed directories', inputSchema: { type: 'object', properties: {}, required: [] } }, { name: 'fast_read_file', description: 'Reads a file (with auto-chunking support)', inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'File path to read' }, start_offset: { type: 'number', description: 'Starting byte offset' }, max_size: { type: 'number', description: 'Maximum size to read' }, line_start: { type: 'number', description: 'Starting line number' }, line_count: { type: 'number', description: 'Number of lines to read' }, encoding: { type: 'string', description: 'Text encoding', default: 'utf-8' }, continuation_token: { type: 'string', description: 'Continuation token from a previous call' }, auto_chunk: { type: 'boolean', description: 'Enable auto-chunking', default: true } }, required: ['path'] } }, { name: 'fast_read_multiple_files', description: 'Reads the content of multiple files simultaneously (supports sequential reading)', inputSchema: { type: 'object', properties: { paths: { type: 'array', items: { type: 'string' }, description: 'File paths to read' }, continuation_tokens: { type: 'object', description: 'Per-file continuation token (value returned from a previous call)' }, auto_continue: { type: 'boolean', description: 'Automatically read the entire file (default: true)', default: true }, chunk_size: { type: 'number', description: 'Chunk size (bytes, default: 1MB)', default: 1048576 } }, required: ['paths'] } }, { name: 'fast_write_file', description: 'Writes or modifies a file (provides emoji guidelines)', inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'File path' }, content: { type: 'string', description: 'File content' }, encoding: { type: 'string', description: 'Text encoding', default: 'utf-8' }, create_dirs: { type: 'boolean', description: 'Automatically create directories', default: true }, append: { type: 'boolean', description: 'Append mode', default: false }, force_remove_emojis: { type: 'boolean', description: 'Force remove emojis (default: false)', default: false } }, required: ['path', 'content'] } }, { name: 'fast_large_write_file', description: 'Reliably writes large files (with streaming, retry, backup, and verification features)', inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'File path' }, content: { type: 'string', description: 'File content' }, encoding: { type: 'string', description: 'Text encoding', default: 'utf-8' }, create_dirs: { type: 'boolean', description: 'Automatically create directories', default: true }, append: { type: 'boolean', description: 'Append mode', default: false }, chunk_size: { type: 'number', description: 'Chunk size (bytes)', default: 65536 }, backup: { type: 'boolean', description: 'Create a backup of the existing file', default: true }, retry_attempts: { type: 'number', description: 'Number of retry attempts', default: 3 }, verify_write: { type: 'boolean', description: 'Verify after writing', default: true }, force_remove_emojis: { type: 'boolean', description: 'Force remove emojis (default: false)', default: false } }, required: ['path', 'content'] } }, { name: 'fast_list_directory', description: 'Lists the contents of a directory (with auto-chunking and pagination support)', inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'Directory path' }, page: { type: 'number', description: 'Page number', default: 1 }, page_size: { type: 'number', description: 'Number of items per page' }, pattern: { type: 'string', description: 'Filename filter pattern' }, show_hidden: { type: 'boolean', description: 'Show hidden files', default: false }, sort_by: { type: 'string', description: 'Sort by', enum: ['name', 'size', 'modified', 'type'], default: 'name' }, reverse: { type: 'boolean', description: 'Reverse sort order', default: false }, continuation_token: { type: 'string', description: 'Continuation token from a previous call' }, auto_chunk: { type: 'boolean', description: 'Enable auto-chunking', default: true } }, required: ['path'] } }, { name: 'fast_get_file_info', description: 'Gets detailed information about a file or directory', inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'Path to get info for' } }, required: ['path'] } }, { name: 'fast_create_directory', description: 'Creates a directory', inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'Path of the directory to create' }, recursive: { type: 'boolean', description: 'Create parent directories if they do not exist', default: true } }, required: ['path'] } }, { name: 'fast_search_files', description: 'Searches for files (by name/content) - supports auto-chunking, regex, context, and line numbers', inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'Directory to search in' }, pattern: { type: 'string', description: 'Search pattern (regex supported)' }, content_search: { type: 'boolean', description: 'Search file content', default: false }, case_sensitive: { type: 'boolean', description: 'Case-sensitive search', default: false }, max_results: { type: 'number', description: 'Maximum number of results', default: 100 }, context_lines: { type: 'number', description: 'Number of context lines around a match', default: 0 }, file_pattern: { type: 'string', description: 'Filename filter pattern (e.g., *.js, *.txt)', default: '' }, include_binary: { type: 'boolean', description: 'Include binary files in search', default: false }, continuation_token: { type: 'string', description: 'Continuation token from a previous call' }, auto_chunk: { type: 'boolean', description: 'Enable auto-chunking', default: true } }, required: ['path', 'pattern'] } }, { name: 'fast_search_code', description: 'Searches for code (ripgrep-style) - provides auto-chunking, line numbers, and context', inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'Directory to search in' }, pattern: { type: 'string', description: 'Search pattern (regex supported)' }, file_pattern: { type: 'string', description: 'File extension filter (e.g., *.js, *.ts)', default: '' }, context_lines: { type: 'number', description: 'Number of context lines around a match', default: 2 }, max_results: { type: 'number', description: 'Maximum number of results', default: 50 }, case_sensitive: { type: 'boolean', description: 'Case-sensitive search', default: false }, include_hidden: { type: 'boolean', description: 'Include hidden files', default: false }, max_file_size: { type: 'number', description: 'Maximum file size to search (in MB)', default: 10 }, continuation_token: { type: 'string', description: 'Continuation token from a previous call' }, auto_chunk: { type: 'boolean', description: 'Enable auto-chunking', default: true } }, required: ['path', 'pattern'] } }, { name: 'fast_get_directory_tree', description: 'Gets the directory tree structure', inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'Root directory path' }, max_depth: { type: 'number', description: 'Maximum depth', default: 3 }, show_hidden: { type: 'boolean', description: 'Show hidden files', default: false }, include_files: { type: 'boolean', description: 'Include files in the tree', default: true } }, required: ['path'] } }, { name: 'fast_get_disk_usage', description: 'Gets disk usage information', inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'Path to check', default: '/' } } } }, { name: 'fast_find_large_files', description: 'Finds large files', inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'Directory to search in' }, min_size: { type: 'string', description: 'Minimum size (e.g., 100MB, 1GB)', default: '100MB' }, max_results: { type: 'number', description: 'Maximum number of results', default: 50 } }, required: ['path'] } }, { name: 'fast_edit_block', description: 'Precise block editing: safely replace exact matches (desktop-commander style)', inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'Path of the file to edit' }, old_text: { type: 'string', description: 'Exact existing text to match (include minimal context)' }, new_text: { type: 'string', description: 'Replacement text' }, expected_replacements: { type: 'number', description: 'Expected number of replacements (safety guard)', default: 1 }, backup: { type: 'boolean', description: 'Create a backup', default: true }, word_boundary: { type: 'boolean', description: 'Enforce word boundaries (prevents partial matches)', default: false }, preview_only: { type: 'boolean', description: 'Preview only (don’t modify the file)', default: false }, case_sensitive: { type: 'boolean', description: 'Match case sensitively', default: true } }, required: ['path', 'old_text', 'new_text'] } }, { name: 'fast_safe_edit', description: 'Safe smart editing: Detects risks and provides interactive confirmation', inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'Path of the file to edit' }, old_text: { type: 'string', description: 'Text to be replaced' }, new_text: { type: 'string', description: 'The new text' }, safety_level: { type: 'string', enum: ['strict', 'moderate', 'flexible'], default: 'moderate', description: 'Safety level (strict: very safe, moderate: balanced, flexible: lenient)' }, auto_add_context: { type: 'boolean', description: 'Automatically add context', default: true }, require_confirmation: { type: 'boolean', description: 'Require confirmation on high risk', default: true } }, required: ['path', 'old_text', 'new_text'] } }, { name: 'fast_edit_multiple_blocks', description: 'Edits multiple parts of a file at once', inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'Path of the file to edit' }, edits: { type: 'array', description: 'List of edit operations', items: { type: 'object', properties: { old_text: { type: 'string', description: 'Existing text to find' }, new_text: { type: 'string', description: 'The new text' }, line_number: { type: 'number', description: 'Line number' }, mode: { type: 'string', enum: ['replace', 'insert_before', 'insert_after', 'delete_line'], default: 'replace' } } } }, backup: { type: 'boolean', description: 'Create a backup', default: true } }, required: ['path', 'edits'] } }, { name: 'fast_edit_blocks', description: 'Processes multiple precise block edits at once (array of fast_edit_block)', inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'Path of the file to edit' }, edits: { type: 'array', description: 'List of precise block edits', items: { type: 'object', properties: { old_text: { type: 'string', description: 'The exact existing text to match' }, new_text: { type: 'string', description: 'The new text' }, expected_replacements: { type: 'number', description: 'Expected number of replacements', default: 1 } }, required: ['old_text', 'new_text'] } }, backup: { type: 'boolean', description: 'Create a backup', default: true } }, required: ['path', 'edits'] } }, { name: 'fast_extract_lines', description: 'Extracts specific lines from a file', inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'File path' }, line_numbers: { type: 'array', items: { type: 'number' }, description: 'Line numbers to extract' }, start_line: { type: 'number', description: 'Start line (for range extraction)' }, end_line: { type: 'number', description: 'End line (for range extraction)' }, pattern: { type: 'string', description: 'Extract lines by pattern' }, context_lines: { type: 'number', description: 'Number of context lines before and after a pattern match', default: 0 } }, required: ['path'] } }, { name: 'fast_copy_file', description: 'Copies a file or directory', inputSchema: { type: 'object', properties: { source: { type: 'string', description: 'Source file/directory path' }, destination: { type: 'string', description: 'Destination path' }, overwrite: { type: 'boolean', description: 'Overwrite existing file', default: false }, preserve_timestamps: { type: 'boolean', description: 'Preserve timestamps', default: true }, recursive: { type: 'boolean', description: 'Recursively copy directory', default: true }, create_dirs: { type: 'boolean', description: 'Automatically create destination directories', default: true } }, required: ['source', 'destination'] } }, { name: 'fast_move_file', description: 'Moves or renames a file or directory', inputSchema: { type: 'object', properties: { source: { type: 'string', description: 'Source file/directory path' }, destination: { type: 'string', description: 'Destination path' }, overwrite: { type: 'boolean', description: 'Overwrite existing file', default: false }, create_dirs: { type: 'boolean', description: 'Automatically create destination directories', default: true }, backup_if_exists: { type: 'boolean', description: 'Create a backup if the destination file exists', default: false } }, required: ['source', 'destination'] } }, { name: 'fast_delete_file', description: 'Deletes a file or directory', inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'Path of the file/directory to delete' }, recursive: { type: 'boolean', description: 'Recursively delete directory', default: false }, force: { type: 'boolean', description: 'Force deletion', default: false }, backup_before_delete: { type: 'boolean', description: 'Create a backup before deleting', default: false }, confirm_delete: { type: 'boolean', description: 'Confirm deletion (safety measure)', default: true } }, required: ['path'] } }, { name: 'fast_batch_file_operations', description: 'Performs batch operations on multiple files', inputSchema: { type: 'object', properties: { operations: { type: 'array', description: 'List of batch operations', items: { type: 'object', properties: { operation: { type: 'string', enum: ['copy', 'move', 'delete', 'rename'], description: 'Operation type' }, source: { type: 'string', description: 'Source path' }, destination: { type: 'string', description: 'Destination path (for copy, move, rename)' }, overwrite: { type: 'boolean', description: 'Allow overwrite', default: false } }, required: ['operation', 'source'] } }, stop_on_error: { type: 'boolean', description: 'Stop on error', default: true }, dry_run: { type: 'boolean', description: 'Preview without actual execution', default: false }, create_backup: { type: 'boolean', description: 'Create backup before changes', default: false } }, required: ['operations'] } }, { name: 'fast_compress_files', description: 'Compresses files or directories', inputSchema: { type: 'object', properties: { paths: { type: 'array', items: { type: 'string' }, description: 'Paths of files/directories to compress' }, output_path: { type: 'string', description: 'Output archive file path' }, format: { type: 'string', enum: ['zip', 'tar', 'tar.gz', 'tar.bz2'], default: 'zip', description: 'Archive format' }, compression_level: { type: 'number', minimum: 0, maximum: 9, default: 6, description: 'Compression level (0=store, 9=max)' }, exclude_patterns: { type: 'array', items: { type: 'string' }, description: 'Patterns to exclude (e.g., *.log, node_modules)', default: [] } }, required: ['paths', 'output_path'] } }, { name: 'fast_extract_archive', description: 'Extracts an archive file', inputSchema: { type: 'object', properties: { archive_path: { type: 'string', description: 'Archive file path' }, extract_to: { type: 'string', description: 'Directory to extract to', default: '.' }, overwrite: { type: 'boolean', description: 'Overwrite existing files', default: false }, create_dirs: { type: 'boolean', description: 'Automatically create directories', default: true }, preserve_permissions: { type: 'boolean', 'description': 'Preserve permissions', default: true }, extract_specific: { type: 'array', items: { type: 'string' }, description: 'Extract only specific files (optional)' } }, required: ['archive_path'] } }, { name: 'fast_sync_directories', description: 'Synchronizes two directories', inputSchema: { type: 'object', properties: { source_dir: { type: 'string', description: 'Source directory' }, target_dir: { type: 'string', description: 'Target directory' }, sync_mode: { type: 'string', enum: ['mirror', 'update', 'merge'], default: 'update', description: 'Synchronization mode' }, delete_extra: { type: 'boolean', description: 'Delete files that only exist in the target', default: false }, preserve_newer: { type: 'boolean', description: 'Preserve newer files', default: true }, dry_run: { type: 'boolean', description: 'Preview without actual execution', default: false }, exclude_patterns: { type: 'array', items: { type: 'string' }, description: 'Patterns to exclude', default: ['.git', 'node_modules', '.DS_Store'] } }, required: ['source_dir', 'target_dir'] } }, ]; // Get all tool names for dynamic tool disabling initialization const allToolNames = allTools.map(tool => tool.name); // Initialize disabled tools immediately to prevent bypass (moved from server creation) actualExpandedDisabledTools = expandDisabledTools(disabledTools, allToolNames); // Log disabled tools if any if (actualExpandedDisabledTools.size > 0 && disabledTools.length > 0) { logger.info(`Disabled tools: ${Array.from(actualExpandedDisabledTools).join(', ')}`); } // Pre-compute filtered tools at startup to optimize ListTools requests cachedFilteredTools = allTools.filter(tool => !actualExpandedDisabledTools.has(tool.name)); return { tools: cachedFilteredTools }; }); // Add environment variable to control error suppression const SILENT_ERRORS = process.env.MCP_SILENT_ERRORS === 'true' || process.env.SILENT_ERRORS === 'true'; // 툴 호출 핸들러 server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; // Silent mode: return friendly error response instead of throwing const handleError = (error: any) => { const errorMessage = error instanceof Error ? error.message : String(error); if (SILENT_ERRORS) { // Return a normal response with error info embedded // This prevents Claude Desktop from showing error snackbars return { content: [{ type: 'text', text: JSON.stringify({ status: 'error', error: errorMessage, tool: name, message: 'The operation could not be completed', timestamp: new Date().toISOString(), silent_mode: true }, null, 2) }] }; } // Normal error behavior when not in silent mode throw new Error(errorMessage); }; // Check if tool is disabled if (actualExpandedDisabledTools.has(name)) { return handleError(new Error(`Tool '${name}' is disabled via --disable-tools flag`)); } try { let result; switch (name) { case 'fast_list_allowed_directories': result = await handleListAllowedDirectories(); break; case 'fast_read_file': result = await handleReadFileWithAutoChunking(args); break; case 'fast_read_multiple_files': result = await handleReadMultipleFiles(args); break; case 'fast_write_file': result = await handleWriteFile(args); break; case 'fast_large_write_file': result = await handleLargeWriteFile(args); break; case 'fast_list_directory': result = await handleListDirectoryWithAutoChunking(args); break; case 'fast_get_file_info': result = await handleGetFileInfo(args); break; case 'fast_create_directory': result = await handleCreateDirectory(args); break; case 'fast_search_files': result = await handleSearchFiles(args); break; case 'fast_search_code': result = await handleSearchCode(args); break; case 'fast_get_directory_tree': result = await handleGetDirectoryTree(args); break; case 'fast_get_disk_usage': result = await handleGetDiskUsage(args); break; case 'fast_find_large_files': result = await handleFindLargeFiles(args); break; case 'fast_edit_block': result = await handleEditBlockSafe(args); break; case 'fast_edit_blocks': result = await handleEditBlocks(args); break; case 'fast_edit_multiple_blocks': result = await handleEditMultipleBlocks(args); break; case 'fast_safe_edit': result = await handleSafeEdit(args); break; case 'fast_extract_lines': result = await handleExtractLines(args); break; case 'fast_copy_file': result = await handleCopyFile(args); break; case 'fast_move_file': result = await handleMoveFile(args); break; case 'fast_delete_file': result = await handleDeleteFile(args); break; case 'fast_batch_file_operations': result = await handleBatchFileOperations(args); break; case 'fast_compress_files': result = await handleCompressFiles(args); break; case 'fast_extract_archive': result = await handleExtractArchive(args); break; case 'fast_sync_directories': result = await handleSyncDirectories(args); break; default: throw new Error(`Tool not implemented: ${name}`); } return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; } catch (error) { // Use the handleError function for consistent error handling return handleError(error); } }); // 툴 핸들러 함수들 async function handleListAllowedDirectories() { return { allowed_directories: getAllowedDirectories(), current_working_directory: process.cwd(), exclude_patterns: DEFAULT_EXCLUDE_PATTERNS, claude_limits: { max_response_size_mb: CLAUDE_MAX_RESPONSE_SIZE / (1024 ** 2), max_chunk_size_mb: CLAUDE_MAX_CHUNK_SIZE / (1024 ** 2), max_lines_per_read: CLAUDE_MAX_LINES, max_dir_items: CLAUDE_MAX_DIR_ITEMS }, server_info: { name: 'fast-filesystem', version: '3.4.0', features: ['emoji-guidelines', 'large-file-writing', 'smart-recommendations', 'configurable-backup'], emoji_policy: 'Emojis not recommended in all file types', backup_enabled: CREATE_BACKUP_FILES, backup_env_var: 'MCP_CREATE_BACKUP_FILES', timestamp: new Date().toISOString() } }; } async function handleReadFile(args: any) { const { path: filePath, start_offset = 0, max_size, line_start, line_count, encoding = 'utf-8', continuation_token, auto_chunk = true } = args; 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; // 라인 모드 - Python 방식으로 스트리밍 읽기 if (line_start !== undefined) { const linesToRead = line_count ? Math.min(line_count, CLAUDE_MAX_LINES) : CLAUDE_MAX_LINES; const lines: string[] = []; // 큰 파일은 스트리밍으로 처리 if (stats.size > 10 * 1024 * 1024) { // 10MB 이상 const fileHandle = await fs.open(safePath_resolved, 'r'); const stream = fileHandle.createReadStream({ encoding: encoding as BufferEncoding }); let currentLine = 0; let buffer = ''; for await (const chunk of stream) { buffer += chunk; const chunkLines = buffer.split('\n'); buffer = chunkLines.pop() || ''; // 마지막 불완전한 라인은 보관 for (const line of chunkLines) { if (currentLine >= line_start && lines.length < linesToRead) { lines.push(line); } currentLine++; if (lines.length >= linesToRead) { break; } } if (lines.length >= linesToRead) { break; } } // 버퍼에 남은 마지막 라인 처리 if (buffer && currentLine >= line_start && lines.length < linesToRead) { lines.push(buffer); } await fileHandle.close(); } else { // 작은 파일은 기존 방식 (하지만 전체 라인 수는 세지 않음) const fileContent = await fs.readFile(safePath_resolved, encoding as BufferEncoding); const allLines = fileContent.split('\n'); const selectedLines = allLines.slice(line_start, line_start + linesToRead); lines.push(...selectedLines); } return { content: lines.join('\n'), mode: 'lines', start_line: line_start, lines_read: lines.length, file_size: stats.size, file_size_readable: formatSize(stats.size), encoding: encoding, has_more: lines.length >= linesToRead, // 요청한 만큼 읽었다면 더 있을 가능성 path: safePath_resolved }; } // 바이트 모드 - 기존 방식 유지 const fileHandle = await fs.open(safePath_resolved, 'r'); const buffer = Buffer.alloc(maxReadSize); const { bytesRead } = await fileHandle.read(buffer, 0, maxReadSize, start_offset); await fileHandle.close(); const content = buffer.subarray(0, bytesRead).toString(encoding as BufferEncoding); const result = truncateContent(content); return { content: result.content, mode: 'bytes', start_offset: start_offset, bytes_read: bytesRead, file_size: stats.size, file_size_readable: formatSize(stats.size), encoding: encoding, truncated: result.truncated, has_more: start_offset + bytesRead < stats.size, path: safePath_resolved }; } // 여러 파일을 한번에 읽는 핸들러 (순차적 읽기 지원) async function handleReadMultipleFiles(args: any) { const { paths = [], continuation_tokens = {}, // 파일별 continuation token auto_continue = true, // 자동으로 전체 파일 읽기 chunk_size = 1024 * 1024 // 1MB 청크 크기 } = args; if (!Array.isArray(paths) || paths.length === 0) { throw new Error('paths parameter must be a non-empty array'); } const results: any[] = []; const errors: any[] = []; const continuationData: any = {}; let totalSuccessful = 0; let totalErrors = 0; // 각 파일을 병렬로 읽기 const readPromises = paths.map(async (filePath: string, index: number) => { try { const safePath_resolved = safePath(filePath); const stats = await fs.stat(safePath_resolved); if (!stats.isFile()) { throw new Error('Path is not a file'); } // 이미지 파일 처리 const ext = path.extname(safePath_resolved).toLowerCase(); const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg']; if (imageExtensions.includes(ext)) { return { path: safePath_resolved, name: path.basename(safePath_resolved), type: 'image', content: '[IMAGE FILE - Content not displayed]', size: stats.size, size_readable: formatSize(stats.size), modified: stats.mtime.toISOString(), extension: ext, mime_type: getMimeType(safePath_resolved), encoding: 'binary', index: index }; } // 기존 continuation token 확인 const existingToken = continuation_tokens[safePath_resolved]; let startOffset = existingToken ? existingToken.next_offset : 0; // 텍스트 파일 읽기 (청킹 지원) let content = ''; let totalBytesRead = 0; let hasMore = false; let nextOffset = startOffset; if (auto_continue) { // 자동으로 전체 파일 읽기 (여러 청크) const fileHandle = await fs.open(safePath_resolved, 'r'); try { while (nextOffset < stats.size) { const remainingBytes = stats.size - nextOffset; const currentChunkSize = Math.min(chunk_size, remainingBytes); const buffer = Buffer.alloc(currentChunkSize); const { bytesRead } = await fileHandle.read(buffer, 0, currentChunkSize, nextOffset); if (bytesRead === 0) break; const chunkContent = buffer.subarray(0, bytesRead).toString('utf-8'); content += chunkContent; totalBytesRead += bytesRead; nextOffset += bytesRead; // 매우 큰 파일의 경우 일정 크기에서 중단 (5MB 제한) if (totalBytesRead >= 5 * 1024 * 1024) { hasMore = nextOffset < stats.size; break; } } } finally { await fileHandle.close(); } } else { // 단일 청크만 읽기 const fileHandle = await fs.open(safePath_resolved, 'r'); const buffer = Buffer.alloc(chunk_size); const { bytesRead } = await fileHandle.read(buffer, 0, chunk_size, startOffset); await fileHandle.close(); content = buffer.subarray(0, bytesRead).toString('utf-8'); totalBytesRead = bytesRead; nextOffset = startOffset + bytesRead; hasMore = nextOffset < stats.size; } // Continuation token 생성 (더 읽을 내용이 있는 경우) let continuationToken = null; if (hasMore) { continuationToken = { file_path: safePath_resolved, next_offset: nextOffset, total_size: stats.size, read_so_far: nextOffset, chunk_size: chunk_size, progress_percent: ((nextOffset / stats.size) * 100).toFixed(2) + '%' }; continuationData[safePath_resolved] = continuationToken; } return { path: safePath_resolved, name: path.basename(safePath_resolved), type: 'text', content: content, size: stats.size, size_readable: formatSize(stats.size), modified: stats.mtime.toISOString(), created: stats.birthtime.toISOString(), extension: ext, mime_type: getMimeType(safePath_resolved), encoding: 'utf-8', bytes_read: totalBytesRead, start_offset: startOffset, end_offset: nextOffset, is_complete: !hasMore, has_more: hasMore, continuation_token: continuationToken, auto_continued: auto_continue && startOffset === 0, index: index }; } catch (error) { return { path: filePath, name: path.basename(filePath), type: 'error', content: null, error: error instanceof Error ? error.message : 'Unknown error', index: index }; } }); // 모든 파일 읽기 완료 대기 const fileResults = await Promise.all(readPromises); // 결과 분류 fileResults.forEach(result => { if (result.type === 'error') { errors.push(result); totalErrors++; } else { results.push(result); totalSuccessful++; } }); // 결과를 원래 순서대로 정렬 results.sort((a, b) => a.index - b.index); errors.sort((a, b) => a.index - b.index); // 통계 계산 const incompleteFiles = results.filter(r => r.has_more); const completedFiles = results.filter(r => r.is_complete); // 응답 크기 체크 (100000 characters 제한) const MAX_RESPONSE_SIZE = 90000; // 여유있게 90000으로 설정 let estimatedSize = JSON.stringify({ message: 'Multiple files read completed', total_files: paths.length, successful: totalSuccessful, errors: totalErrors }).length; // 반환할 파일 결과 필터링 (순서대로) let filteredResults = []; let remainingPaths = []; for (let i = 0; i < results.length; i++) { const result = results[i]; const resultSize = JSON.stringify(result).length; if (estimatedSize + resultSize < MAX_RESPONSE_SIZE) { filteredResults.push(result); estimatedSize += resultSize; } else { // 나머지 파일들은 다음 요청을 위해 저장 for (let j = i; j < results.length; j++) { remainingPaths.push(results[j].path); } break; } } const hasMore = remainingPaths.length > 0; return { message: hasMore ? 'Multiple files read completed (partial response)' : 'Multiple files read completed', total_files: paths.length, successful: totalSuccessful, errors: totalErrors, completed_files: completedFiles.length, incomplete_files: incompleteFiles.length, files: filteredResults, files_in_response: filteredResults.length, failed_files: errors, has_more: hasMore, remaining_files: hasMore ? remainingPaths.length : 0, continuation_data: Object.keys(continuationData).length > 0 ? continuationData : null, continuation_guide: (incompleteFiles.length > 0 || hasMore) ? { message: hasMore ? "Response size limit reached - call again with remaining files" : "Some files were not fully read", next_request: hasMore ? { tool: "fast_read_multiple_files", paths: remainingPaths, continuation_tokens: continuationData, auto_continue: auto_continue } : { paths: incompleteFiles.map(f => f.path), continuation_tokens: continuationData, auto_continue: false }, tip: hasMore ? "Call fast_read_multiple_files again with the paths from next_request" : "Set auto_continue: false to read files in smaller chunks" } : null, performance: { parallel_read: true, chunk_size_mb: chunk_size / (1024 * 1024), auto_continue_enabled: auto_continue, max_file_size_limit_mb: auto_continue ? 5 : 1, response_size_limit_kb: MAX_RESPONSE_SIZE / 1024 }, timestamp: new Date().toISOString() }; } async function handleWriteFile(args: any) { const { path: filePath, content, encoding = 'utf-8', create_dirs = true, append = false, force_remove_emojis = false } = args; let targetPath: string; if (path.isAbsolute(filePath)) { targetPath = filePath; } else { targetPath = path.join(process.cwd(), filePath); } if (!isPathAllowed(targetPath)) { throw new Error(`Access denied to path: ${targetPath}`); } const resolvedPath = path.resolve(targetPath); // 백업 생성 (설정에 따라) let backupPath = null; if (CREATE_BACKUP_FILES && !append) { try { await fs.access(resolvedPath); backupPath = `${resolvedPath}.backup.${Date.now()}`; await fs.copyFile(resolvedPath, backupPath); } catch { // 원본 파일이 없으면 백업 생성 안함 } } if (create_dirs) { const dir = path.dirname(resolvedPath); await fs.mkdir(dir, { recursive: true }); } // 이모지 감지 및 가이드라인 제공 const emojiDetection = detectEmojis(content); const guideline = getEmojiGuideline(resolvedPath); // 최종 내용 결정 let finalContent = content; let emojiAction = 'none'; if (force_remove_emojis) { finalContent = removeEmojis(content); emojiAction = 'force_removed'; } else if (emojiDetection.hasEmojis && guideline.shouldAvoidEmojis) { // 권장사항 위반시 경고만 제공 (강제 제거 안함) emojiAction = 'warning_provided'; } if (append) { await fs.appendFile(resolvedPath, finalContent, encoding as BufferEncoding); } else { await fs.writeFile(resolvedPath, finalContent, encoding as BufferEncoding); } const stats = await fs.stat(resolvedPath); const result: any = { message: `File ${append ? 'appended' : 'written'} successfully`, path: resolvedPath, size: stats.size, size_readable: formatSize(stats.size), encoding: encoding, mode: append ? 'append' : 'write', backup_created: backupPath, backup_enabled: CREATE_BACKUP_FILES, timestamp: new Date().toISOString() }; // 이모지 관련 정보 추가 (간단하게) if (emojiDetection.hasEmojis) { result.emoji_info = { detected: true, guideline: guideline.reason }; } return result; } // 새로운 대용량 파일 작성 핸들러 async function handleLargeWriteFile(args: any) { const { path: filePath, content, encoding = 'utf-8', create_dirs = true, append = false, chunk_size = 64 * 1024, // 64KB 청크 backup = true, retry_attempts = 3, verify_write = true, force_remove_emojis = false } = args; let targetPath: string; if (path.isAbsolute(filePath)) { targetPath = filePath; } else { targetPath = path.join(process.cwd(), filePath); } if (!isPathAllowed(targetPath)) { throw new Error(`Access denied to path: ${targetPath}`); } const resolvedPath = path.resolve(targetPath); const tempPath = `${resolvedPath}.tmp.${Date.now()}`; const backupPath = `${resolvedPath}.backup.${Date.now()}`; try { // 이모지 감지 및 처리 const emojiDetection = detectEmojis(content); const guideline = getEmojiGuideline(resolvedPath); let finalContent = content; let emojiAction = 'none'; if (force_remove_emojis) { finalContent = removeEmojis(content); emojiAction = 'force_removed'; } else if (emojiDetection.hasEmojis && guideline.shouldAvoidEmojis) { emojiAction = 'warning_provided'; } // 1. 디렉토리 생성 if (create_dirs) { const dir = path.dirname(resolvedPath); await fs.mkdir(dir, { recursive: true }); } // 2. 디스크 공간 확인 const contentSize = Buffer.byteLength(finalContent, encoding as BufferEncoding); await checkDiskSpace(path.dirname(resolvedPath), contentSize); // 3. 기존 파일 백업 (덮어쓰기 모드이고 파일이 존재하며 백업이 활성화된 경우) let originalExists = false; let originalSize = 0; if (!append && backup && CREATE_BACKUP_FILES) { try { await fs.access(resolvedPath); originalExists = true; originalSize = await getOriginalFileSize(resolvedPath); await fs.copyFile(resolvedPath, backupPath); } catch { // 원본 파일이 없으면 무시 } } // 4. 스트리밍 방식으로 대용량 파일 작성 const result = await writeFileWithRetry( append ? resolvedPath : tempPath, finalContent, encoding as BufferEncoding, chunk_size, retry_attempts, append ); // 5. 원자적 이동 (append가 아닌 경우) if (!append) { await fs.rename(tempPath, resolvedPath); } // 6. 작성 검증 (옵션) if (verify_write) { const finalStats = await fs.stat(resolvedPath); if (!append) { // 새 파일인 경우 내용 크기와 일치해야 함 if (finalStats.size !== contentSize) { throw new Error(`File size verification failed. Expected: ${contentSize}, Actual: ${finalStats.size}`); } } else { // append 모드인 경우 최소한 내용 크기만큼은 증가해야 함 const expectedMinSize = originalSize + contentSize; if (finalStats.size < expectedMinSize) { throw new Error(`File size verification failed. Expected at least: ${expectedMinSize}, Actual: ${finalStats.size}`); } } } const finalStats = await fs.stat(resolvedPath); const response: any = { message: `Large file ${append ? 'appended' : 'written'} successfully`, path: resolvedPath, size: finalStats.size, size_readable: formatSize(finalStats.size), content_size: contentSize, content_size_readable: formatSize(contentSize), encoding: encoding, mode: append ? 'append' : 'write', chunks_written: Math.ceil(contentSize / chunk_size), chunk_size: chunk_size, retry_count: result.retryCount, backup_created: originalExists && backup && CREATE_BACKUP_FILES ? backupPath : null, backup_enabled: CREATE_BACKUP_FILES, timestamp: new Date().toISOString(), performance: { total_time_ms: result.totalTime, write_speed_mbps: (contentSize / (1024 * 1024)) / (result.totalTime / 1000) } }; // 이모지 관련 정보 추가 (간단하게) if (emojiDetection.hasEmojis) { response.emoji_info = { detected: true, guideline: guideline.reason }; } return response; } catch (error) { // 에러 복구 try { // 임시 파일 정리 await fs.unlink(tempPath).catch(() => { }); // 백업에서 복구 (실패한 경우) if (!append && backup && CREATE_BACKUP_FILES) { try { await fs.copyFile(backupPath, resolvedPath); } catch { // 복구도 실패 } } } catch { // 정리 실패는 무시 } throw error; } } async function handleListDirectory(args: any) { const { path: dirPath, page = 1, page_size, pattern, show_hidden = false, sort_by = 'name', reverse = false } = args; 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 = (page - 1) * pageSize; const endIdx = startIdx + pageSize; 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: page, 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, timestamp: new Date().toISOString() }; } async function handleGetFileInfo(args: any) { const { path: targetPath } = args; const safePath_resolved = safePath(targetPath); const stats = await fs.stat(safePath_resolved); const info = { path: safePath_resolved, name: path.basename(safePath_resolved), type: stats.isDirectory() ? 'directory' : 'file', size: stats.size, size_readable: formatSize(stats.size), created: stats.birthtime.toISOString(), modified: stats.mtime.toISOString(), accessed: stats.atime.toISOString(), permissions: stats.mode, is_readable: true, is_writable: true }; if (stats.isFile()) { (info as any).extension = path.extname(safePath_resolved); (info as any).mime_type = getMimeType(safePath_resolved); // 파일별 이모지 가이드라인 제거 (토큰 절약) if (stats.size > CLAUDE_MAX_CHUNK_SIZE) { (info as any).claude_guide = { message: 'File is large, consider using chunked reading', recommended_chunk_size: CLAUDE_MAX_CHUNK_SIZE, total_chunks: Math.ceil(stats.size / CLAUDE_MAX_CHUNK_SIZE) }; } } else if (stats.isDirectory()) { try { const entries = await fs.readdir(safePath_resolved); (info as any).item_count = entries.length; if (entries.length > CLAUDE_MAX_DIR_ITEMS) { (info as any).claude_guide = { message: 'Directory has many items, consider using pagination', recommended_page_size: CLAUDE_MAX_DIR_ITEMS, total_pages: Math.ceil(entries.length / CLAUDE_MAX_DIR_ITEMS) }; } } catch { (info as any).item_count = 'Unable to count'; } } return info; } async function handleCreateDirectory(args: any) { const { path: dirPath, recursive = true } = args; const safePath_resolved = safePath(dirPath); await fs.mkdir(safePath_resolved, { recursive }); return { message: 'Directory created successfully', path: safePath_resolved, recursive: recursive, timestamp: new Date().toISOString() }; } async function handleSearchFiles(args: any) { const { path: searchPath, pattern, content_search = false, case_sensitive = false, max_results = 100, context_lines = 0, // 새로 추가: 컨텍스트 라인 file_pattern = '', // 새로 추가: 파일 패턴 필터링 include_binary = false, // 새로 추가: 바이너리 파일 포함 여부 include_hidden = false // 새로 추가: 숨김 파일 포함 여부 } = args; const safePath_resolved = safePath(searchPath); const maxResults = Math.min(max_results, 200); const results: any[] = []; const searchPattern = case_sensitive ? pattern : pattern.toLowerCase(); // 정규표현식 패턴 지원 let regexPattern: RegExp | null = null; try { regexPattern = new RegExp(pattern, case_sensitive ? 'g' : 'gi'); } catch { // 정규표현식이 아닌 경우 문자열 검색으로 처리 } // 파일 패턴 필터 let fileRegex: RegExp | null = null; if (file_pattern) { try { // 와일드카드를 정규표현식으로 변환 const regexStr = file_pattern .replace(/\./g, '\\.') .replace(/\*/g, '.*') .replace(/\?/g, '.'); fileRegex = new RegExp(`^${regexStr}$`, 'i'); } catch { // 정규표현식 변환 실패시 단순 문자열 포함 검사 } } // 바이너리 파일 감지 함수 function isBinaryFile(buffer: Buffer): boolean { // 첫 1KB를 검사하여 null 바이트가 있으면 바이너리로 판단 const sample = buffer.slice(0, 1024); for (let i = 0; i < sample.length; i++) { if (sample[i] === 0) return true; } return false; } // 컨텍스트와 함께 매치된 라인들을 반환 function getMatchedLinesWithContext(content: string, pattern: string | RegExp, contextLines: number): Array<{ line_number: number; line_content: string; match_start?: number; match_end?: number; context_before?: string[]; context_after?: string[]; }> { const lines = content.split('\n'); const matches: Array<{ line_number: number; line_content: string; match_start?: number; match_end?: number; context_before?: string[]; context_after?: string[]; }> = []; for (let i = 0; i < lines.length; i++) { const line = lines[i]; const searchLine = case_sensitive ? line : line.toLowerCase(); let matched = false; let matchStart = -1; let matchEnd = -1; if (regexPattern) { const regexMatch = line.match(regexPattern); if (regexMatch) { matched = true; matchStart = regexMatch.index || 0; matchEnd = matchStart + regexMatch[0].length; } } else { const index = searchLine.indexOf(searchPattern); if (index !== -1) { matched = true; matchStart = index; matchEnd = index + searchPattern.length; } } if (matched) { const matchInfo: any = { line_number: i + 1, line_content: line, match_start: matchStart, match_end: matchEnd }; // 컨텍스트 라인 추가 if (contextLines > 0) { matchInfo.context_before = []; matchInfo.context_after = []; // 이전 라인들 for (let j = Math.max(0, i - contextLines); j < i; j++) { matchInfo.context_before.push(lines[j]); } // 이후 라인들 for (let j = i + 1; j <= Math.min(lines.length - 1, i + contextLines); j++) { matchInfo.context_after.push(lines[j]); } } matches.push(matchInfo); } } return matches; } async function searchDirectory(dirPath: string) { if (results.length >= maxResults) return; 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()) { // 파일 패턴 필터링 if (fileRegex && !fileRegex.test(entry.name)) continue; const searchName = case_sensitive ? entry.name : entry.name.toLowerCase(); let matched = false; let matchType = ''; let matchedLines: any[] = []; // 파일명 검색 if (regexPattern ? regexPattern.test(entry.name) : searchName.includes(searchPattern)) { matched = true; matchType = 'filename'; } // 내용 검색 - ripgrep 사용 if (!matched && content_search) { // NOTE: we now delegate bulk content searching to a single ripgrep call outside the per-file loop for performance. // The per-file loop will be skipped for content_search when a precomputed ripgrepResultsMap is present. const precomputed = (global as any).__precomputedRipgrepResults as Map<string, any[]> | undefined; if (precomputed) { // Try both the original path and normalized path for matching const normalizedPath = path.resolve(fullPath); let ripResults: any[] = []; if (precomputed.has(fullPath)) { ripResults = precomputed.get(fullPath) || []; } else if (precomputed.has(normalizedPath)) { ripResults = precomputed.get(normalizedPath) || []; } if (ripResults.length > 0) { matched = true; matchType = 'content'; matchedLines = ripResults.map((r: any) => ({ line_number: r.line, line_content: r.match, match_start: r.column || 0, match_end: (r.column || 0) + r.match.length, context_before: r.context_before || [], context_after: r.context_after || [] })); // Populate context_before/context_after from actual file content if requested if (context_lines > 0 && matchedLines.length > 0) { try { const stats = await fs.stat(fullPath); const maxBytes = 10 * 1024 * 1024; // 10MB limit if (stats.size <= maxBytes) { const content = await fs.readFile(fullPath, 'utf-8'); const lines = content.split('\n'); const N = context_lines; for (const m of matchedLines) { const idx = Math.max(0, (m.line_number || 1) - 1); m.context_before = []; m.context_after = []; for (let j = Math.max(0, idx - N); j < idx; j++) { m.context_before.push(lines[j] ?? ''); } for (let j = idx + 1; j <= Math.min(lines.length - 1, idx + N); j++) { m.context_after.push(lines[j] ?? ''); } } } } catch { // Ignore file read errors; leave context arrays empty } } } } else { try { const stats = await fs.stat(fullPath); if (stats.size < 10 * 1024 * 1024) { // 10MB 제한 // 바이너리 파일 체크 const buffer = await fs.readFile(fullPath); if (!include_binary && isBinaryFile(buffer)) { // 바이너리 파일은 건너뛰기 continue; } const content = buffer.toString('utf-8'); const searchContent = case_sensitive ? content : content.toLowerCase(); if (regexPattern ? regexPattern.test(content) : searchContent.includes(searchPattern)) { matched = true; matchType = 'content'; // 매치된 라인들과 컨텍스트 수집 matchedLines = getMatchedLinesWithContext(content, regexPattern || searchPattern, context_lines); } } } catch (error) { // 읽기 실패한 파일도 결과에 포함 (에러 정보와 함께) if (matched) { const stats = await fs.stat(fullPath); results.push({ path: fullPath, name: entry.name, match_type: matchType, size: stats.size, size_readable: formatSize(stats.size), modified: stats.mtime.toISOString(), extension: path.extname(fullPath), error: `Failed to read file: ${error instanceof Error ? error.message : 'Unknown error'}` }); } continue; } } } if (matched) { const stats = await fs.stat(fullPath); const result: any = { 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 }; // 내용 검색인 경우 매치된 라인 정보 추가 if (matchType === 'content' && matchedLines.length > 0) { result.matched_lines = matchedLines.slice(0, 20); // 최대 20개 라인만 result.total_matches = matchedLines.length; // 바이너리 파일 여부 표시 try { const buffer = await fs.readFile(fullPath); result.is_binary = isBinaryFile(buffer); } catch { // 파일 읽기 실패 } } results.push(result); } } else if (entry.isDirectory()) { await searchDirectory(fullPath); } } } catch (error) { // 권한 없는 디렉토리 등은 조용히 무시하지만, 로그에는 기록 // Silent: suppress warnings to prevent JSON parsing errors logger.warn(`Failed to search directory ${dirPath}: ${error instanceof Error ? error.message : 'Unknown error'}`); } } // If content_search is requested, run a single ripgrep search across the rootPath const startTime = Date.now(); if (content_search) { try { // Run ripgrep once for the root and cache results into a map for fast lookup const ripResults = await searchCodeWithRipgrep({ rootPath: safePath_resolved, pattern: pattern, filePattern: file_pattern, ignoreCase: !case_sensitive, maxResults: maxResults, includeHidden: include_hidden, contextLines: context_lines }); // Map results by normalized absolute file path to ensure consistency const resultMap = new Map<string, any[]>(); for (const r of ripResults) { // Normalize the file path to ensure consistent matching const normalizedPath = path.resolve(r.file); if (!resultMap.has(normalizedPath)) resultMap.set(normalizedPath, []); resultMap.get(normalizedPath)!.push(r); } logger.debug(`Ripgrep found ${ripResults.length} matches in ${resultMap.size} files`); logger.debug('Files with matches:', Array.from(resultMap.keys()).map(f => path.basename(f))); logger.debug('All ripgrep results:', ripResults.map(r => ({ file: r.file, line: r.line, match: r.match?.substring(0, 50) }))); // Expose map to per-file loop via global variable for this run (global as any).__precomputedRipgrepResults = resultMap; // Now run directory traversal which will consult the precomputed map await searchDirectory(safePath_resolved); // Clean up the global cache delete (global as any).__precomputedRipgrepResults; } catch (rgError) { logger.warn('Ripgrep bulk search failed, falling back to per-file checks:', rgError); logger.debug('Ripgrep error details:', rgError); await searchDirectory(safePath_resolved); } } else { await searchDirectory(safePath_resolved); } const searchTime = Date.now() - startTime; // Simple response size safety check const response: any = { 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, // ripgrep 통합 timestamp: new Date().toISOString() }; // Check response size and truncate if too large (800KB limit) const responseSize = Buffer.byteLength(JSON.stringify(response), 'utf8'); const maxResponseSize = 800 * 1024; // 800KB if (responseSize > maxResponseSize) { // Calculate how many results we can safely include const avgResultSize = responseSize / results.length; const safeResultCount = Math.floor(maxResponseSize / avgResultSize * 0.8); // 80% safety margin response.results = results.slice(0, safeResultCount); response.total_found = results.length; // Keep original count response.max_results_reached = true; response.size_limited = true; response.size_limit_info = { original_results: results.length, returned_results: safeResultCount, response_size_kb: Math.round(responseSize / 1024), limit_kb: 800, message: "Results truncated due to response size limit" }; } return response; } async function handleGetDirectoryTree(args: any) { const { path: rootPath, max_depth = 3, show_hidden = false, include_files = true } = args; const safePath_resolved = safePath(rootPath); async function buildTree(currentPath: string, currentDepth: number): Promise<any> { if (currentDepth > max_depth) return null; try { const stats = await fs.stat(currentPath); const name = path.basename(currentPath); if (!show_hidden && name.startsWith('.')) return null; if (shouldExcludePath(currentPath)) return null; const node: any = { name: name, path: currentPath, type: stats.isDirectory() ? 'directory' : 'file', size: stats.size, size_readable: formatSize(stats.size), modified: stats.mtime.toISOString() }; if (stats.isDirectory()) { node.children = []; try { const entries = await fs.readdir(currentPath, { withFileTypes: true }); for (const entry of entries) { const childPath = path.join(currentPath, entry.name); if (entry.isDirectory()) { const childNode = await buildTree(childPath, currentDepth + 1); if (childNode) node.children.push(childNode); } else if (include_files) { const childNode = await buildTree(childPath, currentDepth + 1); if (childNode) node.children.push(childNode); } } } catch { // 권한 없는 디렉토리 node.error = 'Access denied'; } } return node; } catch { return null; } } const tree = await buildTree(safePath_resolved, 0); return { tree: tree, root_path: safePath_resolved, max_depth: max_depth, show_hidden: show_hidden, include_files: include_files, timestamp: new Date().toISOString() }; } async function handleGetDiskUsage(args: any) { const { path: targetPath = '/' } = args; try { const { stdout } = await execAsync(`df -h "${targetPath}"`); const lines = stdout.split('\n').filter(line => line.trim()); if (lines.length > 1) { const data = lines[1].split(/\s+/); return { filesystem: data[0], total: data[1], used: data[2], available: data[3], use_percentage: data[4], mounted_on: data[5], path: targetPath, timestamp: new Date().toISOString() }; } } catch { // Fallback for systems without df command } return { error: 'Unable to get disk usage information', path: targetPath, timestamp: new Date().toISOString() }; } async function handleFindLargeFiles(args: any) { const { path: searchPath, min_size = '100MB', max_results = 50 } = args; const safePath_resolved = safePath(searchPath); const maxResults = Math.min(max_results, 100); // 크기 파싱 (예: 100MB -> bytes) const parseSize = (sizeStr: string): number => { const match = sizeStr.match(/^(\d+(\.\d+)?)\s*(B|KB|MB|GB|TB)?$/i); if (!match) return 100 * 1024 * 1024; // 기본값 100MB const value = parseFloat(match[1]); const unit = (match[3] || 'B').toUpperCase(); const units: { [key: string]: number } = { 'B': 1, 'KB': 1024, 'MB': 1024 * 1024, 'GB': 1024 * 1024 * 1024, 'TB': 1024 * 1024 * 1024 * 1024 }; return value * (units[unit] || 1); }; const minSizeBytes = parseSize(min_size); const results: any[] = []; async function findLargeFilesRecursive(dirPath: string) { if (results.length >= maxResults) return; 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()) { try { const stats = await fs.stat(fullPath); if (stats.size >= minSizeBytes) { results.push({ path: fullPath, name: entry.name, size: stats.size, size_readable: formatSize(stats.size), modified: stats.mtime.toISOString(), extension: path.extname(fullPath) }); } } catch { // 파일 접근 실패 무시 } } else if (entry.isDirectory()) { await findLargeFilesRecursive(fullPath); } } } catch { // 권한 없는 디렉토리 무시 } } await findLargeFilesRecursive(safePath_resolved); // 크기별로 정렬 (큰 것부터) results.sort((a, b) => b.size - a.size); return { results: results, total_found: results.length, search_path: safePath_resolved, min_size: min_size, min_size_bytes: minSizeBytes, max_results_reached: results.length >= maxResults, timestamp: new Date().toISOString() }; } // 검색 결과 타입 정의 interface SearchResult { file: string; line: number; match: string; column?: number; context_before?: string[]; context_after?: string[]; } // ripgrep을 사용한 고성능 검색 함수 async function searchCodeWithRipgrep(options: { rootPath: string, pattern: string, filePattern?: string, ignoreCase?: boolean, maxResults?: number, includeHidden?: boolean, contextLines?: number, timeout?: number, }): Promise<SearchResult[]> { const { rootPath, pattern, filePattern, ignoreCase = true, maxResults = 1000, includeHidden = false, contextLines = 0, timeout = 30000 } = options; // @vscode/ripgrep이 설치되지 않았을 경우 폴백 let rgPath: string; try { // 동적 require로 ripgrep 경로 가져오기 const createRequire = (await import('module')).createRequire; const require = createRequire(import.meta.url); const ripgrepModule = require('@vscode/ripgrep'); rgPath = ripgrepModule.rgPath; } catch (rgError) { // 폴백으로 시스템 rg 사용 시도 try { await execAsync(process.platform === 'win32' ? 'where rg' : 'which rg'); rgPath = 'rg'; } catch (systemRgError) { throw new Error('ripgrep not available'); } } // ripgrep 인수 구성 const args = [ '--json', '--line-number', '--column', '--no-heading', '--with-filename', ]; if (ignoreCase) args.push('-i'); // Remove -m flag to avoid limiting individual matches - we'll limit files instead // if (maxResults) args.push('-m', maxResults.toString()); if (includeHidden) args.push('--hidden'); if (contextLines > 0) args.push('-C', contextLines.toString()); if (filePattern) args.push('-g', filePattern); // Ensure pattern is not empty to avoid rg errors if (!pattern || pattern.trim().length === 0) { logger.debug('Empty pattern provided to ripgrep, returning empty results'); return Promise.resolve([]); } logger.debug(`Ripgrep search: pattern="${pattern}", rootPath="${rootPath}", maxResults=${maxResults}`); args.push('-e', pattern, rootPath); return new Promise((resolve, reject) => { const results: SearchResult[] = []; const rg = spawn(rgPath, args); let remainder = ''; let timeoutId: NodeJS.Timeout | undefined; let settled = false; function cleanupAndResolve() { if (settled) return; settled = true; if (timeoutId) clearTimeout(timeoutId); try { rg.stdout.removeAllListeners(); } catch { } try { rg.stderr.removeAllListeners(); } catch { } try { rg.removeAllListeners(); } catch { } try { if (!rg.killed) rg.kill(); } catch { } // Group results by file and limit the number of files instead of individual matches const fileGroups = new Map<string, SearchResult[]>(); for (const result of results) { const normalizedPath = path.resolve(result.file); if (!fileGroups.has(normalizedPath)) { fileGroups.set(normalizedPath, []); } fileGroups.get(normalizedPath)!.push(result); } // Limit by number of unique files, not individual matches const limitedFiles = Array.from(fileGroups.entries()).slice(0, Math.min(maxResults, 200)); const limitedResults: SearchResult[] = []; for (const [filePath, fileResults] of limitedFiles) { // Update file paths to use normalized paths for (const result of fileResults) { result.file = filePath; } limitedResults.push(...fileResults); } resolve(limitedResults); } function cleanupAndReject(err: any) { if (settled) return; settled = true; if (timeoutId) clearTimeout(timeoutId); try { rg.stdout.removeAllListeners(); } catch { } try { rg.stderr.removeAllListeners(); } catch { } try { rg.removeAllListeners(); } catch { } try { if (!rg.killed) rg.kill(); } catch { } reject(err instanceof Error ? err : new Error(String(err))); } if (timeout > 0) { timeoutId = setTimeout(() => { try { if (!rg.killed) rg.kill(); } catch { } cleanupAndReject(new Error(`Search timed out after ${timeout}ms`)); }, timeout); } rg.stdout.on('data', (data) => { remainder += data.toString(); let idx; while ((idx = remainder.indexOf('\n')) !== -1) { const line = remainder.slice(0, idx); remainder = remainder.slice(idx + 1); if (!line) continue; try { const parsed = JSON.parse(line); if (parsed.type === 'match') { parsed.data.submatches.forEach((submatch: any) => { results.push({ file: parsed.data.path.text, line: parsed.data.line_number, match: submatch.match.text, column: submatch.start }); }); } else if (parsed.type === 'context' && contextLines > 0) { // Ignore raw context events; we'll populate context_before/context_after later by rereading files } } catch (error) { // Silent: prevent error output that breaks JSON parsing logger.error(`Error parsing ripgrep output: ${error}`); } // Don't terminate early - let ripgrep finish processing all files // We'll limit results at the end } }); rg.stderr.on('data', (data) => { logger.error(`ripgrep error: ${data}`); }); rg.on('close', (code) => { if (settled) return; if (timeoutId) clearTimeout(timeoutId); logger.debug(`Ripgrep process closed with code ${code}, found ${results.length} results`); if (remainder) { const leftover = remainder.trim().split('\n'); for (const line of leftover) { if (!line) continue; try { const parsed = JSON.parse(line); if (parsed.type === 'match') { parsed.data.submatches.forEach((submatch: any) => { results.push({ file: parsed.data.path.text, line: parsed.data.line_number, match: submatch.match.text, column: submatch.start }); }); } else if (parsed.type === 'context' && contextLines > 0) { // Ignore raw context events; we'll populate context_before/context_after later by rereading files } } catch (error) { // Silent: prevent error output that breaks JSON parsing logger.error(`Error parsing ripgrep output: ${error}`); } } } if (code === 0 || code === 1) { cleanupAndResolve(); } else { cleanupAndReject(new Error(`ripgrep process exited with code ${code}`)); } }); rg.on('error', (error) => { if (timeoutId) clearTimeout(timeoutId); cleanupAndReject(error); }); }); } // 메인 검색 함수 (ripgrep 우선, 폴백으로 네이티브) async function searchCode(options: { rootPath: string, pattern: string, filePattern?: string, ignoreCase?: boolean, maxResults?: number, includeHidden?: boolean, contextLines?: number, timeout?: number, }): Promise<SearchResult[]> { try { return await searchCodeWithRipgrep(options); } catch (error) { logger.warn('Ripgrep failed, falling back to native search:', error); // 여기서는 간단한 폴백만 구현 (기존 로직 사용) return []; } } function getMimeType(filePath: string): string { const ext = path.extname(filePath).toLowerCase(); const mimeTypes: { [key: string]: string } = { '.txt': 'text/plain', '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript', '.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.pdf': 'application/pdf', '.zip': 'application/zip', '.md': 'text/markdown' }; return mimeTypes[ext] || 'application/octet-stream'; } // 서버 시작 async function main() { // Initialize safe logging first to prevent any JSON parsing errors initializeSafeLogging(); const transport = new StdioServerTransport(); await server.connect(transport); // Use logger's isDebugEnabled method to avoid duplication const debugMode = logger.isDebugEnabled(); const silentMode = SILENT_ERRORS; // Use logger instead of console.error for server startup message // This message should still go to stderr but in a controlled way process.stderr.write(`Fast Filesystem MCP Server v3.5.1 running on stdio\n`); process.stderr.write(`Options: Silent errors=${silentMode}, Debug=${debugMode}\n`); // Log to debug logger if enabled logger.info('Server started successfully', { silentMode, debugMode }); } // RegExp escape 함수 function escapeRegExp(string: string): string { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } // 정교한 블록 편집 핸들러 (desktop-commander 방식) async function handleEditBlock(args: any) { const { path: filePath, old_text, new_text, expected_replacements = 1, backup = true } = args; const safePath_resolved = safePath(filePath); // 파일 존재 확인 let fileExists = true; try { await fs.access(safePath_resolved); } catch { fileExists = false; throw new Error(`File does not exist: ${safePath_resolved}`); } const originalContent = await fs.readFile(safePath_resolved, 'utf-8'); const backupPath = backup && CREATE_BACKUP_FILES ? `${safePath_resolved}.backup.${Date.now()}` : null; // 백업 생성 (설정에 따라) if (backup && CREATE_BACKUP_FILES) { await fs.copyFile(safePath_resolved, backupPath!); } try { // 정확한 문자열 매칭 확인 const occurrences = (originalContent.match(new RegExp(escapeRegExp(old_text), 'g')) || []).length; if (occurrences === 0) { return { message: 'Text not found', path: safePath_resolved, old_text: old_text.length > 100 ? old_text.substring(0, 100) + '...' : old_text, expected_replacements: expected_replacements, actual_occurrences: 0, status: 'not_found', backup_created: backupPath, backup_enabled: CREATE_BACKUP_FILES, timestamp: new Date().toISOString() }; } if (expected_replacements !== occurrences) { return { message: 'Replacement count mismatch - operation cancelled for safety', path: safePath_resolved, old_text: old_text.length > 100 ? old_text.substring(0, 100) + '...' : old_text, expected_replacements: expected_replacements, actual_occurrences: occurrences, status: 'count_mismatch', safety_info: 'Use expected_replacements parameter to confirm the exact number of changes', backup_created: backupPath, backup_enabled: CREATE_BACKUP_FILES, timestamp: new Date().toISOString() }; } // 안전 확인 완료 - 편집 실행 const modifiedContent = originalContent.replace(new RegExp(escapeRegExp(old_text), 'g'), new_text); // 디렉토리 생성 const dir = path.dirname(safePath_resolved); await fs.mkdir(dir, { recursive: true }); // 수정된 내용 저장 await fs.writeFile(safePath_resolved, modifiedContent, 'utf-8'); const stats = await fs.stat(safePath_resolved); const originalLines = originalContent.split('\n').length; const newLines = modifiedContent.split('\n').length; // 변경된 위치 정보 제공 const beforeLines = originalContent.substring(0, originalContent.indexOf(old_text)).split('\n'); const changeStartLine = beforeLines.length; return { message: 'Block edited successfully with precise matching', path: safePath_resolved, changes_made: occurrences, expected_replacements: expected_replacements, actual_replacements: occurrences, change_start_line: changeStartLine, original_lines: originalLines, new_lines: newLines, old_text_preview: old_text.length > 100 ? old_text.substring(0, 100) + '...' : old_text, new_text_preview: new_text.length > 100 ? new_text.substring(0, 100) + '...' : new_text, status: 'success', backup_created: backupPath, backup_enabled: CREATE_BACKUP_FILES, size: stats.size, size_readable: formatSize(stats.size), timestamp: new Date().toISOString() }; } catch (error) { // 에러 시 백업에서 복구 if (backup && CREATE_BACKUP_FILES && backupPath) { try { await fs.copyFile(backupPath, safePath_resolved); } catch { // 복구 실패는 무시 } } throw error; } } // 여러개의 라인 기반 편집 (안전한 로직으로 업그레이드) async function handleEditMultipleBlocks(args: any) { const { path: filePath, edits, backup = true } = args; // path 매개변수 필수 검증 if (!filePath || typeof filePath !== 'string') { return { message: "Missing required parameter", error: "path_parameter_missing", details: "The 'path' parameter is required for multiple block editing operations.", example: { correct_usage: "fast_edit_multiple_blocks({ path: '/path/to/file.txt', edits: [...] })", missing_parameter: "path" }, suggestions: [ "Add the 'path' parameter with a valid file path", "Ensure the path is a string value", "Use an absolute path for better reliability" ], status: "parameter_error", timestamp: new Date().toISOString() }; } const safePath_resolved = safePath(filePath); // 파일 존재 확인 let fileExists = true; try { await fs.access(safePath_resolved); } catch { fileExists = false; throw new Error(`File does not exist: ${safePath_resolved}`); } const originalContent = await fs.readFile(safePath_resolved, 'utf-8'); const lines = originalContent.split('\n'); let modifiedLines = [...lines]; const backupPath = backup && CREATE_BACKUP_FILES ? `${safePath_resolved}.backup.${Date.now()}` : null; let totalChanges = 0; const editResults: any[] = []; // 백업 생성 (설정에 따라) if (backup && CREATE_BACKUP_FILES) { await fs.copyFile(safePath_resolved, backupPath!); } try { // 편집을 라인 번호 역순으로 정렬 (뒤에서부터 편집하여 인덱스 변화 방지) const sortedEdits = [...edits].sort((a, b) => { const lineA = a.line_number || 0; const lineB = b.line_number || 0; return lineB - lineA; }); for (let i = 0; i < sortedEdits.length; i++) { const edit = sortedEdits[i]; const { old_text, new_text, line_number, mode = 'replace', expected_replacements = 1, word_boundary = false, case_sensitive = true } = edit; let changesCount = 0; let editResult: any = { edit_index: i + 1, mode: mode, line_number: line_number, status: 'unknown' }; try { switch (mode) { case 'replace': if (old_text && new_text !== undefined) { // handleEditBlockSafe 스타일의 안전한 텍스트 교체 const currentContent = modifiedLines.join('\n'); // 위험 분석 const riskAnalysis = analyzeEditRisk(old_text, new_text, currentContent, { word_boundary, case_sensitive }); // 매칭 패턴 준비 let searchPattern = old_text; let flags = case_sensitive ? 'g' : 'gi'; if (word_boundary) { searchPattern = `\\b${escapeRegExp(old_text)}\\b`; } else { searchPattern = escapeRegExp(old_text); } const regex = new RegExp(searchPattern, flags); const occurrences = (currentContent.match(regex) || []).length; if (occurrences === 0) { editResult = { ...editResult, old_text: old_text.length > 100 ? old_text.substring(0, 100) + '...' : old_text, expected_replacements: expected_replacements, actual_occurrences: 0, status: 'not_found', risk_analysis: riskAnalysis }; } else if (expected_replacements !== occurrences) { editResult = { ...editResult, old_text: old_text.length > 100 ? old_text.substring(0, 100) + '...' : old_text, expected_replacements: expected_replacements, actual_occurrences: occurrences, status: 'count_mismatch', safety_info: 'Use expected_replacements parameter to confirm the exact number of changes', risk_analysis: riskAnalysis }; } else { // 안전 확인 완료 - 편집 실행 const modifiedContent = currentContent.replace(regex, new_text); modifiedLines = modifiedContent.split('\n'); changesCount = occurrences; editResult = { ...editResult, old_text_preview: old_text.length > 100 ? old_text.substring(0, 100) + '...' : old_text, new_text_preview: new_text.length > 100 ? new_text.substring(0, 100) + '...' : new_text, status: 'success', changes_made: occurrences, expected_replacements: expected_replacements, actual_replacements: occurrences, risk_analysis: riskAnalysis, word_boundary_used: word_boundary, case_sensitive_used: case_sensitive }; } } else if (line_number && new_text !== undefined) { // 라인 기반 교체 (안전 검사 포함) const idx = line_number - 1; if (idx >= 0 && idx < modifiedLines.length) { const originalLine = modifiedLines[idx]; modifiedLines[idx] = new_text; changesCount++; editResult = { ...editResult, original_line: originalLine, new_line: new_text, status: 'success', changes_made: 1 }; } else { editResult = { ...editResult, status: 'invalid_line_number', error: `Line number ${line_number} is out of range (1-${modifiedLines.length})` }; } } break; case 'insert_before': if (line_number && new_text !== undefined) { const idx = line_number - 1; if (idx >= 0 && idx <= modifiedLines.length) { modifiedLines.splice(idx, 0, new_text); changesCount++; editResult = { ...editResult, inserted_line: new_text, status: 'success', changes_made: 1 }; } else { editResult = { ...editResult, status: 'invalid_line_number', error: `Line number ${line_number} is out of range for insertion` }; } } break; case 'insert_after': if (line_number && new_text !== undefined) { const idx = line_number; if (idx >= 0 && idx <= modifiedLines.length) { modifiedLines.splice(idx, 0, new_text); changesCount++; editResult = { ...editResult, inserted_line: new_text, status: 'success', changes_made: 1 }; } else { editResult = { ...editResult, status: 'invalid_line_number', error: `Line number ${line_number} is out of range for insertion` }; } } break; case 'delete_line': if (line_number) { const idx = line_number - 1; if (idx >= 0 && idx < modifiedLines.length) { const deletedLine = modifiedLines[idx]; modifiedLines.splice(idx, 1); changesCount++; editResult = { ...editResult, deleted_line: deletedLine, status: 'success', changes_made: 1 }; } else { editResult = { ...editResult, status: 'invalid_line_number', error: `Line number ${line_number} is out of range for deletion` }; } } break; default: editResult = { ...editResult, status: 'unsupported_mode', error: `Unsupported edit mode: ${mode}` }; } totalChanges += changesCount; editResults.push(editResult); } catch (error) { editResults.push({ ...editResult, status: 'error', error: error instanceof Error ? error.message : 'Unknown error' }); } } // 수정된 내용 저장 (변경사항이 있는 경우에만) if (totalChanges > 0) { const newContent = modifiedLines.join('\n'); // 디렉토리 생성 const dir = path.dirname(safePath_resolved); await fs.mkdir(dir, { recursive: true }); await fs.writeFile(safePath_resolved, newContent, 'utf-8'); } const stats = await fs.stat(safePath_resolved); return { message: `Safe multiple blocks edited successfully`, path: safePath_resolved, total_edits: edits.length, successful_edits: editResults.filter(r => r.status === 'success').length, total_changes: totalChanges, original_lines: lines.length, new_lines: modifiedLines.length, edit_results: editResults, backup_created: backupPath, backup_enabled: CREATE_BACKUP_FILES, size: stats.size, size_readable: formatSize(stats.size), timestamp: new Date().toISOString() }; } catch (error) { // 에러 시 백업에서 복구 if (backup && CREATE_BACKUP_FILES && backupPath) { try { await fs.copyFile(backupPath, safePath_resolved); } catch { // 복구 실패 } } throw error; } } async function handleExtractLines(args: any) { const { path: filePath, line_numbers, start_line, end_line, pattern, context_lines = 0 } = args; const safePath_resolved = safePath(filePath); const content = await fs.readFile(safePath_resolved, 'utf-8'); const lines = content.split('\n'); let extractedLines: Array<{ line_number: number, content: string }> = []; if (line_numbers && Array.isArray(line_numbers)) { // 특정 라인 번호들 추출 for (const lineNum of line_numbers) { const idx = lineNum - 1; if (idx >= 0 && idx < lines.length) { extractedLines.push({ line_number: lineNum, content: lines[idx] }); } } } else if (start_line && end_line) { // 범위 추출 const startIdx = start_line - 1; const endIdx = end_line - 1; if (startIdx >= 0 && endIdx < lines.length && startIdx <= endIdx) { for (let i = startIdx; i <= endIdx; i++) { extractedLines.push({ line_number: i + 1, content: lines[i] }); } } } else if (pattern) { // 패턴 매칭으로 추출 const regex = new RegExp(pattern, 'gi'); for (let i = 0; i < lines.length; i++) { if (regex.test(lines[i])) { // 컨텍스트 라인 포함 const contextStart = Math.max(0, i - context_lines); const contextEnd = Math.min(lines.length - 1, i + context_lines); for (let j = contextStart; j <= contextEnd; j++) { const existing = extractedLines.find(el => el.line_number === j + 1); if (!existing) { extractedLines.push({ line_number: j + 1, content: lines[j] }); } } } } } // 라인 번호순 정렬 extractedLines.sort((a, b) => a.line_number - b.line_number); return { extracted_lines: extractedLines, total_lines_extracted: extractedLines.length, total_file_lines: lines.length, path: safePath_resolved, timestamp: new Date().toISOString() }; } // 여러개의 정교한 블록 편집을 한 번에 처리하는 핸들러 (handleEditBlockSafe와 동일한 로직 사용) async function handleEditBlocks(args: any) { const { path: filePath, edits, backup = true } = args; // path 매개변수 필수 검증 if (!filePath || typeof filePath !== 'string') { return { message: "Missing required parameter", error: "path_parameter_missing", details: "The 'path' parameter is required for batch editing operations.", example: { correct_usage: "fast_edit_blocks({ path: '/path/to/file.txt', edits: [...] })", missing_parameter: "path" }, suggestions: [ "Add the 'path' parameter with a valid file path", "Ensure the path is a string value", "Use an absolute path for better reliability" ], status: "parameter_error", timestamp: new Date().toISOString() }; } const safePath_resolved = safePath(filePath); // 파일 존재 확인 let fileExists = true; try { await fs.access(safePath_resolved); } catch { fileExists = false; throw new Error(`File does not exist: ${safePath_resolved}`); } const originalContent = await fs.readFile(safePath_resolved, 'utf-8'); const backupPath = backup && CREATE_BACKUP_FILES ? `${safePath_resolved}.backup.${Date.now()}` : null; // 백업 생성 (설정에 따라) if (backup && CREATE_BACKUP_FILES) { await fs.copyFile(safePath_resolved, backupPath!); } try { let modifiedContent = originalContent; let totalChanges = 0; const editResults: any[] = []; // 각 편집을 순차적으로 처리 (handleEditBlockSafe와 동일한 로직) for (let i = 0; i < edits.length; i++) { const edit = edits[i]; const { old_text, new_text, expected_replacements = 1, word_boundary = false, case_sensitive = true } = edit; if (!old_text || new_text === undefined) { editResults.push({ edit_index: i + 1, old_text: old_text?.substring(0, 50) || '', status: 'skipped - invalid parameters', occurrences: 0 }); continue; } // handleEditBlockSafe와 동일한 위험 분석 const riskAnalysis = analyzeEditRisk(old_text, new_text, modifiedContent, { word_boundary, case_sensitive }); // handleEditBlockSafe와 동일한 매칭 패턴 준비 let searchPattern = old_text; let flags = case_sensitive ? 'g' : 'gi'; if (word_boundary) { // 단어 경계 추가로 부분 매칭 방지 searchPattern = `\\b${escapeRegExp(old_text)}\\b`; } else { searchPattern = escapeRegExp(old_text); } const regex = new RegExp(searchPattern, flags); const occurrences = (modifiedContent.match(regex) || []).length; if (occurrences === 0) { editResults.push({ edit_index: i + 1, old_text: old_text.length > 100 ? old_text.substring(0, 100) + '...' : old_text, expected_replacements: expected_replacements, actual_occurrences: 0, status: 'not_found', risk_analysis: riskAnalysis }); continue; } if (expected_replacements !== occurrences) { editResults.push({ edit_index: i + 1, old_text: old_text.length > 100 ? old_text.substring(0, 100) + '...' : old_text, expected_replacements: expected_replacements, actual_occurrences: occurrences, status: 'count_mismatch', safety_info: 'Use expected_replacements parameter to confirm the exact number of changes', risk_analysis: riskAnalysis }); continue; } // 안전 확인 완료 - 편집 실행 (handleEditBlockSafe와 동일) modifiedContent = modifiedContent.replace(regex, new_text); totalChanges += occurrences; editResults.push({ edit_index: i + 1, old_text_preview: old_text.length > 100 ? old_text.substring(0, 100) + '...' : old_text, new_text_preview: new_text.length > 100 ? new_text.substring(0, 100) + '...' : new_text, status: 'success', changes_made: occurrences, expected_replacements: expected_replacements, actual_replacements: occurrences, risk_analysis: riskAnalysis, word_boundary_used: word_boundary, case_sensitive_used: case_sensitive }); } // 수정된 내용 저장 (변경사항이 있는 경우에만) if (totalChanges > 0) { // 디렉토리 생성 const dir = path.dirname(safePath_resolved); await fs.mkdir(dir, { recursive: true }); await fs.writeFile(safePath_resolved, modifiedContent, 'utf-8'); } const stats = await fs.stat(safePath_resolved); const originalLines = originalContent.split('\n').length; const newLines = modifiedContent.split('\n').length; return { message: `Safe multiple block edits processed successfully`, path: safePath_resolved, total_edits: edits.length, successful_edits: editResults.filter(r => r.status === 'success').length, total_changes: totalChanges, original_lines: originalLines, new_lines: newLines, edit_results: editResults, backup_created: backupPath, backup_enabled: CREATE_BACKUP_FILES, size: stats.size, size_readable: formatSize(stats.size), timestamp: new Date().toISOString() }; } catch (error) { // 에러 시 백업에서 복구 if (backup && CREATE_BACKUP_FILES && backupPath) { try { await fs.copyFile(backupPath, safePath_resolved); } catch { // 복구 실패는 무시 } } throw error; } } // 코드 검색 함수 (ripgrep 기반) async function handleSearchCode(args: any) { const { path: searchPath, pattern, file_pattern = '', context_lines = 2, max_results = 50, case_sensitive = false, include_hidden = false, max_file_size = 10, timeout = 30 } = args; const safePath_resolved = safePath(searchPath); try { // ripgrep을 사용한 고성능 검색 시도 const _searchStartMs = Date.now(); const searchResults = await searchCode({ rootPath: safePath_resolved, pattern: pattern, filePattern: file_pattern, ignoreCase: !case_sensitive, maxResults: Math.min(max_results, 200), includeHidden: include_hidden, contextLines: context_lines, timeout: timeout * 1000 // 초를 밀리초로 변환 }); // 결과를 기존 형식으로 변환 const results: any[] = []; const fileGroups: { [file: string]: any } = {}; for (const result of searchResults) { if (!fileGroups[result.file]) { fileGroups[result.file] = { file: result.file, matches: [], total_matches: 0 }; } fileGroups[result.file].matches.push({ line_number: result.line, line_content: result.match, column: result.column || 0, context_before: result.context_before || [], context_after: result.context_after || [] }); fileGroups[result.file].total_matches++; } // 파일별로 결과 정리 (+ populate contexts by rereading file if requested) for (const [filePath, fileData] of Object.entries(fileGroups)) { const limitedMatches = fileData.matches.slice(0, max_results); // Populate context_before/context_after from actual file content if requested if (context_lines > 0) { try { const stats = await fs.stat(filePath); const maxBytes = max_file_size * 1024 * 1024; if (stats.size <= maxBytes) { const content = await fs.readFile(filePath, 'utf-8'); const lines = content.split('\n'); const N = context_lines; for (const m of limitedMatches) { const idx = Math.max(0, (m.line_number || 1) - 1); m.context_before = []; m.context_after = []; for (let j = Math.max(0, idx - N); j < idx; j++) { m.context_before.push(lines[j] ?? ''); } for (let j = idx + 1; j <= Math.min(lines.length - 1, idx + N); j++) { m.context_after.push(lines[j] ?? ''); } } } // Skip context population for files larger than max_file_size } catch (error) { // Intentionally ignore file read errors; leave context arrays empty // This ensures graceful degradation when files can't be read logger.debug(`Failed to read context for ${filePath}:`, error); } } results.push({ file: filePath, file_name: path.basename(filePath), total_matches: fileData.total_matches, matches: limitedMatches // 파일당 최대 결과 제한 (with context populated if available) }); } // 통합 출력 생성 (desktop-commander 스타일) let combinedOutput = ''; let totalMatches = 0; results.forEach(fileResult => { combinedOutput += `\n=== ${fileResult.file} ===\n`; fileResult.matches.forEach((match: any) => { combinedOutput += `${match.line_number}: ${match.line_content}\n`; totalMatches++; }); }); return { results: results, total_files: results.length, total_matches: totalMatches, search_pattern: pattern, search_path: safePath_resolved, file_pattern: file_pattern, context_lines: context_lines, case_sensitive: case_sensitive, include_hidden: include_hidden, max_file_size_mb: max_file_size, ripgrep_used: true, search_time_ms: (Date.now() - _searchStartMs), formatted_output: combinedOutput.trim(), timestamp: new Date().toISOString() }; } catch (error) { // ripgrep 실패시 폴백 로직 logger.warn('Ripgrep search failed, using fallback:', error); // 기존 네이티브 검색으로 폴백 return handleSearchCodeFallback(args); } } // 폴백용 네이티브 검색 함수 async function handleSearchCodeFallback(args: any) { const { path: searchPath, pattern, file_pattern = '', context_lines = 2, max_results = 50, case_sensitive = false, include_hidden = false, max_file_size = 10 } = args; const safePath_resolved = safePath(searchPath); const maxResults = Math.min(max_results, 200); const maxFileSize = max_file_size * 1024 * 1024; // MB to bytes const results: any[] = []; // 정규표현식 패턴 지원 let regexPattern: RegExp | null = null; try { regexPattern = new RegExp(pattern, case_sensitive ? 'g' : 'gi'); } catch { // 정규표현식이 아닌 경우 문자열 검색으로 처리 } // 파일 패턴 필터 let fileRegex: RegExp | null = null; if (file_pattern) { try { // 와일드카드를 정규표현식으로 변환 const regexStr = file_pattern .replace(/\./g, '\\.') .replace(/\*/g, '.*') .replace(/\?/g, '.'); fileRegex = new RegExp(`^${regexStr}$`, 'i'); } catch { // 정규표현식 변환 실패시 단순 문자열 포함 검사 } } // 코드 파일 확장자 (기본값) const codeExtensions = new Set([ '.js', '.ts', '.jsx', '.tsx', '.py', '.java', '.c', '.cpp', '.h', '.hpp', '.cs', '.php', '.rb', '.go', '.rs', '.swift', '.kt', '.scala', '.clj', '.sh', '.bash', '.zsh', '.fish', '.ps1', '.cmd', '.bat', '.sql', '.html', '.css', '.scss', '.sass', '.less', '.vue', '.svelte', '.md', '.markdown', '.json', '.xml', '.yaml', '.yml', '.toml', '.ini', '.cfg', '.conf' ]); // 바이너리 파일 감지 함수 function isBinaryFile(buffer: Buffer): boolean { const sample = buffer.slice(0, 1024); for (let i = 0; i < sample.length; i++) { if (sample[i] === 0) return true; } return false; } // desktop-commander 스타일 출력 생성 function formatOutput(filePath: string, matches: Array<{ line_number: number; line_content: string; match_start: number; match_end: number; context_before: string[]; context_after: string[]; }>): string { let output = `${filePath}:\n`; for (const match of matches) { // 컨텍스트 이전 라인들 if (match.context_before && match.context_before.length > 0) { match.context_before.forEach((line, index) => { const lineNum = match.line_number - match.context_before.length + index; output += ` ${lineNum}: ${line}\n`; }); } // 매치된 라인 (하이라이트 표시) const line = match.line_content; const highlighted = line.substring(0, match.match_start) + '**' + line.substring(match.match_start, match.match_end) + '**' + line.substring(match.match_end); output += ` ${match.line_number}: ${highlighted}\n`; // 컨텍스트 이후 라인들 if (match.context_after && match.context_after.length > 0) { match.context_after.forEach((line, index) => { const lineNum = match.line_number + 1 + index; output += ` ${lineNum}: ${line}\n`; }); } output += '\n'; } return output; } async function searchDirectory(dirPath: string) { if (results.length >= maxResults) return; 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 (!include_hidden && entry.name.startsWith('.')) continue; if (shouldExcludePath(fullPath)) continue; if (entry.isFile()) { // 파일 패턴 필터링 if (fileRegex) { if (!fileRegex.test(entry.name)) continue; } else if (!file_pattern) { // 파일 패턴이 지정되지 않은 경우 코드 파일만 검색 const ext = path.extname(entry.name).toLowerCase(); if (!codeExtensions.has(ext)) continue; } try { const stats = await fs.stat(fullPath); // 파일 크기 제한 if (stats.size > maxFileSize) continue; const buffer = await fs.readFile(fullPath); // 바이너리 파일 스킵 if (isBinaryFile(buffer)) continue; const content = buffer.toString('utf-8'); const lines = content.split('\n'); const matches: Array<{ line_number: number; line_content: string; match_start: number; match_end: number; context_before: string[]; context_after: string[]; }> = []; // 각 라인에서 패턴 검색 for (let i = 0; i < lines.length; i++) { const line = lines[i]; const searchLine = case_sensitive ? line : line.toLowerCase(); const searchPattern = case_sensitive ? pattern : pattern.toLowerCase(); let matched = false; let matchStart = -1; let matchEnd = -1; if (regexPattern) { regexPattern.lastIndex = 0; // 정규표현식 인덱스 리셋 const regexMatch = regexPattern.exec(line); if (regexMatch) { matched = true; matchStart = regexMatch.index; matchEnd = matchStart + regexMatch[0].length; } } else { const index = searchLine.indexOf(searchPattern); if (index !== -1) { matched = true; matchStart = index; matchEnd = index + searchPattern.length; } } if (matched) { const matchInfo = { line_number: i + 1, line_content: line, match_start: matchStart, match_end: matchEnd, context_before: [] as string[], context_after: [] as string[] }; // 컨텍스트 라인 추가 if (context_lines > 0) { // 이전 라인들 for (let j = Math.max(0, i - context_lines); j < i; j++) { matchInfo.context_before.push(lines[j]); } // 이후 라인들 for (let j = i + 1; j <= Math.min(lines.length - 1, i + context_lines); j++) { matchInfo.context_after.push(lines[j]); } } matches.push(matchInfo); } } if (matches.length > 0) { results.push({ file: fullPath, relative_path: path.relative(safePath_resolved, fullPath), matches: matches, total_matches: matches.length, file_size: stats.size, file_size_readable: formatSize(stats.size), modified: stats.mtime.toISOString(), extension: path.extname(fullPath), formatted_output: formatOutput(fullPath, matches) }); } } catch (error) { // 읽기 실패한 파일은 조용히 건너뛰기 continue; } } else if (entry.isDirectory()) { await searchDirectory(fullPath); } } } catch (error) { // 권한 없는 디렉토리 등은 조용히 무시 // Silent: suppress warnings to prevent JSON parsing errors logger.warn(`Failed to search directory ${dirPath}: ${error instanceof Error ? error.message : 'Unknown error'}`); } } const startTime = Date.now(); await searchDirectory(safePath_resolved); const searchTime = Date.now() - startTime; // desktop-commander 스타일의 통합 출력 생성 let combinedOutput = ''; let totalMatches = 0; for (const result of results) { combinedOutput += result.formatted_output; totalMatches += result.total_matches; } return { results: results, total_files: results.length, total_matches: totalMatches, search_pattern: pattern, search_path: safePath_resolved, file_pattern: file_pattern, context_lines: context_lines, case_sensitive: case_sensitive, include_hidden: include_hidden, max_file_size_mb: max_file_size, regex_used: regexPattern !== null, search_time_ms: searchTime, formatted_output: combinedOutput, timestamp: new Date().toISOString() }; } main().catch((error) => { logger.error('Server failed to start:', error); process.exit(1); }); // 새로운 복잡한 파일 작업 핸들러들 async function handleCopyFile(args: any) { const { source, destination, overwrite = false, preserve_timestamps = true, recursive = true, create_dirs = true } = args; const sourcePath = safePath(source); const destPath = safePath(destination); try { const sourceStats = await fs.stat(sourcePath); // 대상 디렉토리 생성 if (create_dirs) { const destDir = path.dirname(destPath); await fs.mkdir(destDir, { recursive: true }); } // 덮어쓰기 검사 let destExists = false; try { await fs.access(destPath); destExists = true; if (!overwrite) { throw new Error(`Destination already exists: ${destPath}`); } } catch (error) { if ((error as any).code !== 'ENOENT') { throw error; } } if (sourceStats.isDirectory()) { if (!recursive) { throw new Error('Cannot copy directory without recursive option'); } await copyDirectoryRecursive(sourcePath, destPath, overwrite, preserve_timestamps); } else { await fs.copyFile(sourcePath, destPath); // 타임스탬프 보존 if (preserve_timestamps) { await fs.utimes(destPath, sourceStats.atime, sourceStats.mtime); } } const destStats = await fs.stat(destPath); return { message: `${sourceStats.isDirectory() ? 'Directory' : 'File'} copied successfully`, source: sourcePath, destination: destPath, source_size: sourceStats.size, destination_size: destStats.size, source_size_readable: formatSize(sourceStats.size), destination_size_readable: formatSize(destStats.size), overwritten: destExists, preserve_timestamps: preserve_timestamps, recursive: recursive && sourceStats.isDirectory(), timestamp: new Date().toISOString() }; } catch (error) { throw new Error(`Copy failed: ${error instanceof Error ? error.message : 'Unknown error'}`); } } async function copyDirectoryRecursive(source: string, destination: string, overwrite: boolean, preserveTimestamps: boolean) { await fs.mkdir(destination, { recursive: true }); const entries = await fs.readdir(source, { withFileTypes: true }); for (const entry of entries) { const sourcePath = path.join(source, entry.name); const destPath = path.join(destination, entry.name); if (shouldExcludePath(sourcePath)) continue; if (entry.isDirectory()) { await copyDirectoryRecursive(sourcePath, destPath, overwrite, preserveTimestamps); } else { // 덮어쓰기 검사 try { await fs.access(destPath); if (!overwrite) { logger.warn(`Skipping existing file: ${destPath}`); continue; } } catch { // 파일이 없으면 계속 진행 } await fs.copyFile(sourcePath, destPath); if (preserveTimestamps) { const sourceStats = await fs.stat(sourcePath); await fs.utimes(destPath, sourceStats.atime, sourceStats.mtime); } } } } async function handleMoveFile(args: any) { const { source, destination, overwrite = false, create_dirs = true, backup_if_exists = false } = args; const sourcePath = safePath(source); const destPath = safePath(destination); try { const sourceStats = await fs.stat(sourcePath); // 대상 디렉토리 생성 if (create_dirs) { const destDir = path.dirname(destPath); await fs.mkdir(destDir, { recursive: true }); } // 덮어쓰기 및 백업 처리 let destExists = false; let backupPath = null; try { const destStats = await fs.stat(destPath); destExists = true; if (backup_if_exists && CREATE_BACKUP_FILES) { backupPath = `${destPath}.backup.${Date.now()}`; await fs.copyFile(destPath, backupPath); } if (!overwrite && !backup_if_exists) { throw new Error(`Destination already exists: ${destPath}`); } } catch (error) { if ((error as any).code !== 'ENOENT') { throw error; } } // 같은 파티션에서는 rename, 다른 파티션에서는 copy + delete try { await fs.rename(sourcePath, destPath); } catch (error) { // Cross-device 에러인 경우 copy + delete로 처리 if ((error as any).code === 'EXDEV') { if (sourceStats.isDirectory()) { await copyDirectoryRecursive(sourcePath, destPath, overwrite, true); await fs.rm(sourcePath, { recursive: true, force: true }); } else { await fs.copyFile(sourcePath, destPath); await fs.unlink(sourcePath); } } else { throw error; } } const destStats = await fs.stat(destPath); return { message: `${sourceStats.isDirectory() ? 'Directory' : 'File'} moved successfully`, source: sourcePath, destination: destPath, size: destStats.size, size_readable: formatSize(destStats.size), overwritten: destExists, backup_created: backupPath, backup_enabled: CREATE_BACKUP_FILES, cross_device_move: false, // 실제 구현에서는 감지 가능 timestamp: new Date().toISOString() }; } catch (error) { throw new Error(`Move failed: ${error instanceof Error ? error.message : 'Unknown error'}`); } } async function handleDeleteFile(args: any) { const { path: targetPath, recursive = false, force = false, backup_before_delete = false, confirm_delete = true } = args; const resolvedPath = safePath(targetPath); try { const stats = await fs.stat(resolvedPath); // 안전장치: 중요한 디렉토리 보호 const protectedPaths = ['/Users', '/home', '/', '/System', '/usr', '/bin', '/sbin']; if (protectedPaths.some(protectedPath => resolvedPath.startsWith(protectedPath) && resolvedPath.split('/').length <= 3)) { throw new Error(`Cannot delete protected system path: ${resolvedPath}`); } // 확인 단계 if (confirm_delete && !force) { const itemType = stats.isDirectory() ? 'directory' : 'file'; const warningMessage = `WARNING: This will permanently delete the ${itemType}: ${resolvedPath}`; logger.warn(warningMessage); // 실제 구현에서는 대화형 확인이 어려우므로 force 플래그 요구 if (!force) { return { message: 'Deletion cancelled for safety', path: resolvedPath, item_type: itemType, size: stats.size, size_readable: formatSize(stats.size), warning: 'Use force: true to confirm deletion', backup_available: backup_before_delete && CREATE_BACKUP_FILES, timestamp: new Date().toISOString() }; } } // 백업 생성 let backupPath = null; if (backup_before_delete && CREATE_BACKUP_FILES) { backupPath = `${resolvedPath}.deleted_backup.${Date.now()}`; if (stats.isDirectory()) { await copyDirectoryRecursive(resolvedPath, backupPath, true, true); } else { await fs.copyFile(resolvedPath, backupPath); } } // 삭제 실행 if (stats.isDirectory()) { if (!recursive) { // 빈 디렉토리인지 확인 const entries = await fs.readdir(resolvedPath); if (entries.length > 0) { throw new Error('Directory is not empty. Use recursive: true to delete non-empty directories'); } await fs.rmdir(resolvedPath); } else { await fs.rm(resolvedPath, { recursive: true, force: force }); } } else { await fs.unlink(resolvedPath); } return { message: `${stats.isDirectory() ? 'Directory' : 'File'} deleted successfully`, path: resolvedPath, item_type: stats.isDirectory() ? 'directory' : 'file', size: stats.size, size_readable: formatSize(stats.size), recursive: recursive && stats.isDirectory(), backup_created: backupPath, backup_enabled: CREATE_BACKUP_FILES, force_used: force, timestamp: new Date().toISOString() }; } catch (error) { throw new Error(`Delete failed: ${error instanceof Error ? error.message : 'Unknown error'}`); } } async function handleBatchFileOperations(args: any) { const { operations = [], stop_on_error = true, dry_run = false, create_backup = false } = args; const results: any[] = []; let successCount = 0; let errorCount = 0; let backupDir = null; // 백업 디렉토리 생성 (실제 실행시에만) if (create_backup && !dry_run && CREATE_BACKUP_FILES) { backupDir = `/tmp/mcp_batch_backup_${Date.now()}`; await fs.mkdir(backupDir, { recursive: true }); } try { for (let i = 0; i < operations.length; i++) { const operation = operations[i]; const { operation: op, source, destination, overwrite = false } = operation; try { // 입력 검증 if (!source) { throw new Error('Source path is required'); } if (['copy', 'move', 'rename'].includes(op) && !destination) { throw new Error(`Destination path is required for ${op} operation`); } const sourcePath = safePath(source); let result: any = { operation: op, source: sourcePath }; if (dry_run) { // Dry run: 실제 실행 없이 검증만 const sourceStats = await fs.stat(sourcePath); result.dry_run = true; result.source_exists = true; result.source_type = sourceStats.isDirectory() ? 'directory' : 'file'; result.source_size = sourceStats.size; if (destination) { const destPath = safePath(destination); result.destination = destPath; try { await fs.access(destPath); result.destination_exists = true; result.will_overwrite = overwrite; } catch { result.destination_exists = false; } } result.status = 'would_execute'; } else { // 실제 실행 let backupPath = null; // 개별 백업 생성 if (create_backup && backupDir && CREATE_BACKUP_FILES) { const sourceStats = await fs.stat(sourcePath); backupPath = path.join(backupDir, `${path.basename(sourcePath)}_${i}`); if (sourceStats.isDirectory()) { await copyDirectoryRecursive(sourcePath, backupPath, true, true); } else { await fs.copyFile(sourcePath, backupPath); } } switch (op) { case 'copy': const copyResult = await handleCopyFile({ source: sourcePath, destination: destination, overwrite: overwrite, recursive: true, create_dirs: true }); result = { ...result, ...copyResult, backup_created: backupPath }; break; case 'move': case 'rename': const moveResult = await handleMoveFile({ source: sourcePath, destination: destination, overwrite: overwrite, create_dirs: true }); result = { ...result, ...moveResult, backup_created: backupPath }; break; case 'delete': const deleteResult = await handleDeleteFile({ path: sourcePath, recursive: true, force: true, backup_before_delete: false // 이미 위에서 백업했음 }); result = { ...result, ...deleteResult, backup_created: backupPath }; break; default: throw new Error(`Unsupported operation: ${op}`); } result.status = 'success'; } results.push(result); successCount++; } catch (error) { const errorResult = { operation: op, source: source, destination: destination, status: 'error', error: error instanceof Error ? error.message : 'Unknown error', dry_run: dry_run }; results.push(errorResult); errorCount++; if (stop_on_error) { break; } } } return { message: `Batch operations ${dry_run ? 'analyzed' : 'completed'}`, total_operations: operations.length, successful: successCount, errors: errorCount, results: results, dry_run: dry_run, backup_directory: backupDir, backup_enabled: CREATE_BACKUP_FILES, stopped_on_error: stop_on_error && errorCount > 0, timestamp: new Date().toISOString() }; } catch (error) { throw new Error(`Batch operations failed: ${error instanceof Error ? error.message : 'Unknown error'}`); } } async function handleCompressFiles(args: any) { const { paths = [], output_path, format = 'zip', compression_level = 6, exclude_patterns = [] } = args; // Node.js 내장 모듈로 간단한 압축 구현 // 실제 구현에서는 archiver 등의 라이브러리 사용 권장 const outputPath = safePath(output_path); const resolvedPaths = paths.map((p: string) => safePath(p)); try { // 압축할 파일들 수집 const filesToCompress: Array<{ source: string, archive_path: string }> = []; for (const inputPath of resolvedPaths) { const stats = await fs.stat(inputPath); if (stats.isFile()) { if (!shouldExcludeFromCompression(inputPath, exclude_patterns)) { filesToCompress.push({ source: inputPath, archive_path: path.basename(inputPath) }); } } else if (stats.isDirectory()) { await collectFilesForCompression(inputPath, '', filesToCompress, exclude_patterns); } } // 간단한 tar 압축 구현 (실제로는 외부 도구 사용) if (format.startsWith('tar')) { await createTarArchive(filesToCompress, outputPath, format, compression_level); } else { // ZIP은 더 복잡하므로 외부 도구 필요 throw new Error('ZIP compression requires additional dependencies. Use tar format instead.'); } const outputStats = await fs.stat(outputPath); const totalOriginalSize = filesToCompress.reduce(async (acc, file) => { const stats = await fs.stat(file.source); return (await acc) + stats.size; }, Promise.resolve(0)); return { message: 'Files compressed successfully', output_path: outputPath, format: format, compression_level: compression_level, files_compressed: filesToCompress.length, original_size: await totalOriginalSize, compressed_size: outputStats.size, original_size_readable: formatSize(await totalOriginalSize), compressed_size_readable: formatSize(outputStats.size), compression_ratio: ((await totalOriginalSize - outputStats.size) / await totalOriginalSize * 100).toFixed(2) + '%', exclude_patterns: exclude_patterns, timestamp: new Date().toISOString() }; } catch (error) { throw new Error(`Compression failed: ${error instanceof Error ? error.message : 'Unknown error'}`); } } function shouldExcludeFromCompression(filePath: string, excludePatterns: string[]): boolean { const fileName = path.basename(filePath); const allPatterns = [...DEFAULT_EXCLUDE_PATTERNS, ...excludePatterns]; return allPatterns.some(pattern => { if (pattern.includes('*') || pattern.includes('?')) { const regex = new RegExp(pattern.replace(/\*/g, '.*').replace(/\?/g, '.')); return regex.test(fileName); } return fileName.includes(pattern); }); } async function collectFilesForCompression( dirPath: string, archivePath: string, fileList: Array<{ source: string, archive_path: string }>, excludePatterns: string[] ) { const entries = await fs.readdir(dirPath, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dirPath, entry.name); const entryArchivePath = path.join(archivePath, entry.name); if (shouldExcludeFromCompression(fullPath, excludePatterns)) { continue; } if (entry.isFile()) { fileList.push({ source: fullPath, archive_path: entryArchivePath }); } else if (entry.isDirectory()) { await collectFilesForCompression(fullPath, entryArchivePath, fileList, excludePatterns); } } } async function createTarArchive( files: Array<{ source: string, archive_path: string }>, outputPath: string, format: string, compressionLevel: number ) { // 간단한 tar 명령어 사용 (실제 구현) const tempListFile = `/tmp/tar_list_${Date.now()}.txt`; try { // 파일 목록 작성 const fileListContent = files.map(f => f.source).join('\n'); await fs.writeFile(tempListFile, fileListContent); // tar 명령어 구성 let tarCommand = `tar -cf "${outputPath}"`; if (format === 'tar.gz') { tarCommand = `tar -czf "${outputPath}"`; } else if (format === 'tar.bz2') { tarCommand = `tar -cjf "${outputPath}"`; } tarCommand += ` -T "${tempListFile}"`; // tar 실행 const { stdout, stderr } = await execAsync(tarCommand); if (stderr) { logger.warn('Tar warnings:', stderr); } } finally { // 임시 파일 정리 try { await fs.unlink(tempListFile); } catch { // 정리 실패 무시 } } } async function handleExtractArchive(args: any) { const { archive_path, extract_to = '.', overwrite = false, create_dirs = true, preserve_permissions = true, extract_specific = [] } = args; const archivePath = safePath(archive_path); const extractPath = safePath(extract_to); try { const archiveStats = await fs.stat(archivePath); if (create_dirs) { await fs.mkdir(extractPath, { recursive: true }); } // 아카이브 형식 감지 const ext = path.extname(archivePath).toLowerCase(); let format = 'unknown'; if (ext === '.tar' || archivePath.endsWith('.tar.gz') || archivePath.endsWith('.tar.bz2')) { format = 'tar'; } else if (ext === '.zip') { format = 'zip'; } let extractedFiles: string[] = []; if (format === 'tar') { extractedFiles = await extractTarArchive(archivePath, extractPath, overwrite, extract_specific); } else if (format === 'zip') { throw new Error('ZIP extraction requires additional dependencies. Use tar format instead.'); } else { throw new Error(`Unsupported archive format: ${ext}`); } return { message: 'Archive extracted successfully', archive_path: archivePath, extract_to: extractPath, format: format, files_extracted: extractedFiles.length, extracted_files: extractedFiles.slice(0, 50), // 처음 50개만 표시 archive_size: archiveStats.size, archive_size_readable: formatSize(archiveStats.size), overwrite: overwrite, preserve_permissions: preserve_permissions, specific_files: extract_specific.length > 0 ? extract_specific : null, timestamp: new Date().toISOString() }; } catch (error) { throw new Error(`Archive extraction failed: ${error instanceof Error ? error.message : 'Unknown error'}`); } } async function extractTarArchive( archivePath: string, extractPath: string, overwrite: boolean, specificFiles: string[] ): Promise<string[]> { // tar 명령어로 압축 해제 let tarCommand = `tar -tf "${archivePath}"`; // 파일 목록 먼저 확인 try { const { stdout: fileList } = await execAsync(tarCommand); const files = fileList.trim().split('\n').filter(f => f.trim()); // 특정 파일만 추출하는 경우 const filesToExtract = specificFiles.length > 0 ? files.filter(f => specificFiles.some(sf => f.includes(sf))) : files; // 덮어쓰기 확인 if (!overwrite) { for (const file of filesToExtract) { const targetPath = path.join(extractPath, file); try { await fs.access(targetPath); throw new Error(`File already exists: ${targetPath}. Use overwrite: true to replace.`); } catch (error) { if ((error as any).code !== 'ENOENT') { throw error; } } } } // 실제 압축 해제 let extractCommand = `tar -xf "${archivePath}" -C "${extractPath}"`; if (specificFiles.length > 0) { const tempListFile = `/tmp/extract_list_${Date.now()}.txt`; await fs.writeFile(tempListFile, filesToExtract.join('\n')); extractCommand += ` -T "${tempListFile}"`; try { await execAsync(extractCommand); } finally { await fs.unlink(tempListFile).catch(() => { }); } } else { await execAsync(extractCommand); } return filesToExtract; } catch (error) { throw new Error(`Tar extraction failed: ${error instanceof Error ? error.message : 'Unknown error'}`); } } async function handleSyncDirectories(args: any) { const { source_dir, target_dir, sync_mode = 'update', delete_extra = false, preserve_newer = true, dry_run = false, exclude_patterns = ['.git', 'node_modules', '.DS_Store'] } = args; const sourcePath = safePath(source_dir); const targetPath = safePath(target_dir); try { const sourceStats = await fs.stat(sourcePath); if (!sourceStats.isDirectory()) { throw new Error('Source must be a directory'); } // 대상 디렉토리 생성 if (!dry_run) { await fs.mkdir(targetPath, { recursive: true }); } const syncResults = { copied: [] as string[], updated: [] as string[], deleted: [] as string[], skipped: [] as string[], errors: [] as string[] }; // 소스 디렉토리 스캔 await syncDirectoryRecursive(sourcePath, targetPath, '', syncResults, { sync_mode, delete_extra, preserve_newer, dry_run, exclude_patterns }); // 대상에만 있는 파일들 처리 (삭제) if (delete_extra) { await cleanupExtraFiles(sourcePath, targetPath, '', syncResults, { dry_run, exclude_patterns }); } const totalOperations = syncResults.copied.length + syncResults.updated.length + syncResults.deleted.length + syncResults.skipped.length; return { message: `Directory sync ${dry_run ? 'analyzed' : 'completed'}`, source_directory: sourcePath, target_directory: targetPath, sync_mode: sync_mode, total_operations: totalOperations, copied: syncResults.copied.length, updated: syncResults.updated.length, deleted: syncResults.deleted.length, skipped: syncResults.skipped.length, errors: syncResults.errors.length, results: { copied: syncResults.copied.slice(0, 20), updated: syncResults.updated.slice(0, 20), deleted: syncResults.deleted.slice(0, 20), skipped: syncResults.skipped.slice(0, 20), errors: syncResults.errors.slice(0, 10) }, dry_run: dry_run, delete_extra: delete_extra, preserve_newer: preserve_newer, exclude_patterns: exclude_patterns, timestamp: new Date().toISOString() }; } catch (error) { throw new Error(`Directory sync failed: ${error instanceof Error ? error.message : 'Unknown error'}`); } } async function syncDirectoryRecursive( sourcePath: string, targetPath: string, relativePath: string, results: any, options: any ) { try { const currentSourcePath = path.join(sourcePath, relativePath); const currentTargetPath = path.join(targetPath, relativePath); const entries = await fs.readdir(currentSourcePath, { withFileTypes: true }); for (const entry of entries) { const entryRelativePath = path.join(relativePath, entry.name); const entrySourcePath = path.join(sourcePath, entryRelativePath); const entryTargetPath = path.join(targetPath, entryRelativePath); // 제외 패턴 확인 if (shouldExcludeFromSync(entrySourcePath, options.exclude_patterns)) { results.skipped.push(entryRelativePath); continue; } try { if (entry.isDirectory()) { // 디렉토리 생성 if (!options.dry_run) { await fs.mkdir(entryTargetPath, { recursive: true }); } // 재귀적으로 처리 await syncDirectoryRecursive(sourcePath, targetPath, entryRelativePath, results, options); } else if (entry.isFile()) { const sourceStats = await fs.stat(entrySourcePath); let shouldCopy = false; let operation = ''; try { const targetStats = await fs.stat(entryTargetPath); // 동기화 모드에 따른 처리 switch (options.sync_mode) { case 'mirror': shouldCopy = true; operation = 'updated'; break; case 'update': if (sourceStats.mtime > targetStats.mtime) { shouldCopy = true; operation = 'updated'; } else if (options.preserve_newer && targetStats.mtime > sourceStats.mtime) { results.skipped.push(entryRelativePath); continue; } else { shouldCopy = true; operation = 'updated'; } break; case 'merge': if (sourceStats.mtime > targetStats.mtime) { shouldCopy = true; operation = 'updated'; } else { results.skipped.push(entryRelativePath); continue; } break; } } catch (error) { // 대상 파일이 없는 경우 if ((error as any).code === 'ENOENT') { shouldCopy = true; operation = 'copied'; } else { throw error; } } if (shouldCopy) { if (!options.dry_run) { await fs.copyFile(entrySourcePath, entryTargetPath); // 타임스탬프 보존 await fs.utimes(entryTargetPath, sourceStats.atime, sourceStats.mtime); } if (operation === 'copied') { results.copied.push(entryRelativePath); } else { results.updated.push(entryRelativePath); } } } } catch (error) { results.errors.push(`${entryRelativePath}: ${error instanceof Error ? error.message : 'Unknown error'}`); } } } catch (error) { results.errors.push(`${relativePath}: ${error instanceof Error ? error.message : 'Unknown error'}`); } } function shouldExcludeFromSync(filePath: string, excludePatterns: string[]): boolean { const pathParts = filePath.split(path.sep); return excludePatterns.some(pattern => { return pathParts.some(part => { if (pattern.includes('*') || pattern.includes('?')) { const regex = new RegExp(pattern.replace(/\*/g, '.*').replace(/\?/g, '.')); return regex.test(part); } return part === pattern; }); }); } async function cleanupExtraFiles( sourcePath: string, targetPath: string, relativePath: string, results: any, options: any ) { try { const currentTargetPath = path.join(targetPath, relativePath); const entries = await fs.readdir(currentTargetPath, { withFileTypes: true }); for (const entry of entries) { const entryRelativePath = path.join(relativePath, entry.name); const entrySourcePath = path.join(sourcePath, entryRelativePath); const entryTargetPath = path.join(targetPath, entryRelativePath); // 제외 패턴 확인 if (shouldExcludeFromSync(entryTargetPath, options.exclude_patterns)) { continue; } try { // 소스에 해당 파일/디렉토리가 있는지 확인 await fs.access(entrySourcePath); // 있으면 디렉토리인 경우 재귀적으로 처리 if (entry.isDirectory()) { await cleanupExtraFiles(sourcePath, targetPath, entryRelativePath, results, options); } } catch (error) { // 소스에 없는 파일/디렉토리 발견 -> 삭제 if ((error as any).code === 'ENOENT') { if (!options.dry_run) { if (entry.isDirectory()) { await fs.rm(entryTargetPath, { recursive: true, force: true }); } else { await fs.unlink(entryTargetPath); } } results.deleted.push(entryRelativePath); } } } } catch (error) { // 대상 디렉토리가 없는 경우 등은 무시 } } // 안전한 편집 핸들러 함수들 async function handleEditBlockSafe(args: any) { const { path: filePath, old_text, new_text, expected_replacements = 1, backup = true, word_boundary = false, preview_only = false, case_sensitive = true } = args; // path 매개변수 필수 검증 if (!filePath || typeof filePath !== 'string') { return { message: "Missing required parameter", error: "path_parameter_missing", details: "The 'path' parameter is required for file editing operations.", example: { correct_usage: "fast_edit_block({ path: '/path/to/file.txt', old_text: '...', new_text: '...' })", missing_parameter: "path" }, suggestions: [ "Add the 'path' parameter with a valid file path", "Ensure the path is a string value", "Use an absolute path for better reliability" ], status: "parameter_error", timestamp: new Date().toISOString() }; } const safePath_resolved = safePath(filePath); // 파일 존재 확인 let fileExists = true; try { await fs.access(safePath_resolved); } catch { fileExists = false; throw new Error(`File does not exist: ${safePath_resolved}`); } const originalContent = await fs.readFile(safePath_resolved, 'utf-8'); const backupPath = backup && CREATE_BACKUP_FILES ? `${safePath_resolved}.backup.${Date.now()}` : null; // 위험 분석 const riskAnalysis = analyzeEditRisk(old_text, new_text, originalContent, { word_boundary, case_sensitive }); // 백업 생성 (설정에 따라) if (backup && CREATE_BACKUP_FILES && !preview_only) { await fs.copyFile(safePath_resolved, backupPath!); } try { // 매칭 패턴 준비 let searchPattern = old_text; let flags = case_sensitive ? 'g' : 'gi'; if (word_boundary) { // 단어 경계 추가로 부분 매칭 방지 searchPattern = `\\b${escapeRegExp(old_text)}\\b`; } else { searchPattern = escapeRegExp(old_text); } const regex = new RegExp(searchPattern, flags); const occurrences = (originalContent.match(regex) || []).length; if (occurrences === 0) { return { message: 'Text not found', path: safePath_resolved, old_text: old_text.length > 100 ? old_text.substring(0, 100) + '...' : old_text, expected_replacements: expected_replacements, actual_occurrences: 0, status: 'not_found', risk_analysis: riskAnalysis, backup_created: backupPath, backup_enabled: CREATE_BACKUP_FILES, timestamp: new Date().toISOString() }; } if (expected_replacements !== occurrences) { return { message: 'Replacement count mismatch - operation cancelled for safety', path: safePath_resolved, old_text: old_text.length > 100 ? old_text.substring(0, 100) + '...' : old_text, expected_replacements: expected_replacements, actual_occurrences: occurrences, status: 'count_mismatch', safety_info: 'Use expected_replacements parameter to confirm the exact number of changes', risk_analysis: riskAnalysis, backup_created: backupPath, backup_enabled: CREATE_BACKUP_FILES, timestamp: new Date().toISOString() }; } // 미리보기 모드 if (preview_only) { const modifiedContent = originalContent.replace(regex, new_text); const changePreview = generateChangePreview(originalContent, modifiedContent, old_text); return { message: 'Preview completed - no changes made', path: safePath_resolved, changes_made: 0, expected_replacements: expected_replacements, actual_replacements: occurrences, preview_mode: true, change_preview: changePreview, risk_analysis: riskAnalysis, backup_created: null, backup_enabled: CREATE_BACKUP_FILES, timestamp: new Date().toISOString() }; } // 안전 확인 완료 - 편집 실행 const modifiedContent = originalContent.replace(regex, new_text); // 디렉토리 생성 const dir = path.dirname(safePath_resolved); await fs.mkdir(dir, { recursive: true }); // 수정된 내용 저장 await fs.writeFile(safePath_resolved, modifiedContent, 'utf-8'); const stats = await fs.stat(safePath_resolved); const originalLines = originalContent.split('\n').length; const newLines = modifiedContent.split('\n').length; // 변경된 위치 정보 제공 const beforeLines = originalContent.substring(0, originalContent.indexOf(old_text)).split('\n'); const changeStartLine = beforeLines.length; return { message: 'Safe block editing completed successfully', path: safePath_resolved, changes_made: occurrences, expected_replacements: expected_replacements, actual_replacements: occurrences, change_start_line: changeStartLine, original_lines: originalLines, new_lines: newLines, old_text_preview: old_text.length > 100 ? old_text.substring(0, 100) + '...' : old_text, new_text_preview: new_text.length > 100 ? new_text.substring(0, 100) + '...' : new_text, status: 'success', risk_analysis: riskAnalysis, word_boundary_used: word_boundary, case_sensitive_used: case_sensitive, backup_created: backupPath, backup_enabled: CREATE_BACKUP_FILES, size: stats.size, size_readable: formatSize(stats.size), timestamp: new Date().toISOString() }; } catch (error) { // 에러 시 백업에서 복구 if (backup && CREATE_BACKUP_FILES && backupPath && !preview_only) { try { await fs.copyFile(backupPath, safePath_resolved); } catch { // 복구 실패는 무시 } } throw error; } } // 위험 분석 함수 function analyzeEditRisk(oldText: string, newText: string, content: string, options: any) { const risks = []; const warnings = []; let riskLevel = 'low'; // 1. 짧은 텍스트 위험 (부분 매칭 가능성) if (oldText.length < 10 && !options.word_boundary) { risks.push('Short text pattern may cause unintended partial matches'); riskLevel = 'medium'; } // 2. 다중 매칭 위험 const occurrences = (content.match(new RegExp(escapeRegExp(oldText), options.case_sensitive ? 'g' : 'gi')) || []).length; if (occurrences > 3) { risks.push(`High number of matches (${occurrences}) increases risk of unintended changes`); riskLevel = 'high'; } // 3. 변수명/식별자 패턴 감지 const identifierPattern = /^[a-zA-Z_][a-zA-Z0-9_]*$/; if (identifierPattern.test(oldText.trim()) && !options.word_boundary) { warnings.push('Identifier pattern detected - consider using word_boundary option'); if (riskLevel === 'low') riskLevel = 'medium'; } // 4. 특수문자 포함 검사 const hasSpecialChars = /[^\w\s]/.test(oldText); if (!hasSpecialChars && oldText.includes(' ')) { warnings.push('Whitespace-only separation may cause unexpected matches'); } // 5. 대소문자 혼합 패턴 const hasMixedCase = /[a-z]/.test(oldText) && /[A-Z]/.test(oldText); if (hasMixedCase && !options.case_sensitive) { warnings.push('Mixed case pattern with case-insensitive matching may be risky'); } return { risk_level: riskLevel, risks: risks, warnings: warnings, recommendations: generateSafetyRecommendations(oldText, riskLevel, options) }; } // 안전성 권장사항 생성 function generateSafetyRecommendations(oldText: string, riskLevel: string, options: any) { const recommendations = []; if (riskLevel === 'high') { recommendations.push('Consider adding more context to the old_text'); recommendations.push('Use preview_only: true to verify changes first'); } if (oldText.length < 10) { recommendations.push('Add surrounding context to make the match more specific'); } if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(oldText.trim()) && !options.word_boundary) { recommendations.push('Use word_boundary: true for identifier replacements'); } recommendations.push('Always enable backup: true for important files'); return recommendations; } // 변경 미리보기 생성 function generateChangePreview(original: string, modified: string, pattern: string) { const originalLines = original.split('\n'); const modifiedLines = modified.split('\n'); const preview = []; // 변경된 라인들 찾기 for (let i = 0; i < Math.max(originalLines.length, modifiedLines.length); i++) { const origLine = originalLines[i] || ''; const modLine = modifiedLines[i] || ''; if (origLine !== modLine) { preview.push({ line_number: i + 1, original: origLine, modified: modLine, change_type: origLine.includes(pattern) ? 'replacement' : 'side_effect' }); if (preview.length >= 10) { // 최대 10개 라인만 표시 preview.push({ line_number: -1, original: '...', modified: '...', change_type: 'truncated' }); break; } } } return preview; } // 스마트 안전 편집 핸들러 async function handleSafeEdit(args: any) { const { path: filePath, old_text, new_text, safety_level = 'moderate', auto_add_context = true, require_confirmation = true } = args; const safePath_resolved = safePath(filePath); const originalContent = await fs.readFile(safePath_resolved, 'utf-8'); // 자동 컨텍스트 추가 let enhancedOldText = old_text; if (auto_add_context && old_text.length < 20) { enhancedOldText = addSmartContext(old_text, originalContent); } // 안전 수준에 따른 설정 const safetyConfig = getSafetyConfig(safety_level); // 위험 분석 const riskAnalysis = analyzeEditRisk(enhancedOldText, new_text, originalContent, safetyConfig); // 위험 수준에 따른 처리 if (riskAnalysis.risk_level === 'high' && require_confirmation) { return { message: 'High risk detected - confirmation required', path: safePath_resolved, risk_analysis: riskAnalysis, enhanced_old_text: enhancedOldText, original_old_text: old_text, auto_context_added: enhancedOldText !== old_text, status: 'confirmation_required', safety_level: safety_level, next_steps: [ 'Review the risk analysis above', 'Consider using enhanced_old_text for safer matching', 'Set require_confirmation: false to proceed anyway', 'Use preview_only: true to see changes first' ], timestamp: new Date().toISOString() }; } // 안전한 편집 실행 return await handleEditBlockSafe({ path: filePath, old_text: enhancedOldText, new_text: new_text, expected_replacements: 1, backup: true, word_boundary: safetyConfig.word_boundary, preview_only: false, case_sensitive: safetyConfig.case_sensitive }); } // 스마트 컨텍스트 추가 function addSmartContext(pattern: string, content: string): string { const lines = content.split('\n'); // 패턴이 포함된 라인 찾기 for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (line.includes(pattern)) { // 전체 라인을 컨텍스트로 사용 (단, 너무 길면 줄임) if (line.length <= 100) { return line.trim(); } else { // 패턴 주변 50자씩 추출 const index = line.indexOf(pattern); const start = Math.max(0, index - 25); const end = Math.min(line.length, index + pattern.length + 25); return line.substring(start, end).trim(); } } } return pattern; // 컨텍스트 추가 실패시 원본 반환 } // 안전 수준 설정 function getSafetyConfig(level: string) { switch (level) { case 'strict': return { word_boundary: true, case_sensitive: true, require_context: true, min_context_length: 20 }; case 'moderate': return { word_boundary: false, case_sensitive: true, require_context: false, min_context_length: 10 }; case 'flexible': return { word_boundary: false, case_sensitive: false, require_context: false, min_context_length: 5 }; default: return getSafetyConfig('moderate'); } }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/efforthye/fast-filesystem-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server