UNPKG

@moikas/moidvk

Version:

The Ultimate DevKit - MCP server for development best practices

1,738 lines (1,595 loc) 68.4 kB
import { readFile, writeFile, unlink, rename, copyFile, mkdir, rmdir, readdir, stat } from 'fs/promises'; import { createReadStream, createWriteStream } from 'fs'; import { join, dirname, basename, extname } from 'path'; import { pipeline } from 'stream/promises'; import { SecurityValidator } from './security-validator.js'; /** * Main filesystem toolsuite for MCP server * Provides privacy-first file operations with optional embedding support */ export class FilesystemToolsuite { constructor(options = {}) { this.validator = new SecurityValidator(options); this.embeddingManager = null; // Will be lazy-loaded this.metadataExtractor = null; // Will be lazy-loaded this.snippetManager = null; // Will be lazy-loaded this.tools = []; this.initializeTools(); } /** * Initialize all filesystem tools */ initializeTools() { // Core file operations this.tools.push({ name: 'create_file', description: 'Create a new file with content. Requires explicit path within workspace.', inputSchema: { type: 'object', properties: { filePath: { type: 'string', description: 'Path to the file to create (relative to workspace)', }, content: { type: 'string', description: 'Content to write to the file', }, encoding: { type: 'string', description: 'File encoding (default: utf8)', default: 'utf8', }, }, required: ['filePath', 'content'], }, handler: this.createFile.bind(this), }); this.tools.push({ name: 'read_file', description: 'Read a file with optional embedding generation for AI context. When forAI is true, returns embeddings instead of content.', inputSchema: { type: 'object', properties: { filePath: { type: 'string', description: 'Path to the file to read (relative to workspace)', }, forAI: { type: 'boolean', description: 'If true, return embeddings and metadata instead of raw content', default: false, }, encoding: { type: 'string', description: 'File encoding (default: utf8)', default: 'utf8', }, }, required: ['filePath'], }, handler: this.readFile.bind(this), }); this.tools.push({ name: 'update_file', description: 'Update an existing file with automatic backup creation.', inputSchema: { type: 'object', properties: { filePath: { type: 'string', description: 'Path to the file to update (relative to workspace)', }, content: { type: 'string', description: 'New content for the file', }, createBackup: { type: 'boolean', description: 'Create a backup before updating (default: true)', default: true, }, encoding: { type: 'string', description: 'File encoding (default: utf8)', default: 'utf8', }, }, required: ['filePath', 'content'], }, handler: this.updateFile.bind(this), }); this.tools.push({ name: 'delete_file', description: 'Delete a file with confirmation required.', inputSchema: { type: 'object', properties: { filePath: { type: 'string', description: 'Path to the file to delete (relative to workspace)', }, confirmed: { type: 'boolean', description: 'Explicit confirmation required to delete', default: false, }, }, required: ['filePath'], }, handler: this.deleteFile.bind(this), }); this.tools.push({ name: 'move_file', description: 'Move or rename a file.', inputSchema: { type: 'object', properties: { sourcePath: { type: 'string', description: 'Current path of the file (relative to workspace)', }, destinationPath: { type: 'string', description: 'New path for the file (relative to workspace)', }, overwrite: { type: 'boolean', description: 'Overwrite destination if it exists (default: false)', default: false, }, }, required: ['sourcePath', 'destinationPath'], }, handler: this.moveFile.bind(this), }); this.tools.push({ name: 'copy_file', description: 'Copy a file to a new location.', inputSchema: { type: 'object', properties: { sourcePath: { type: 'string', description: 'Path of the file to copy (relative to workspace)', }, destinationPath: { type: 'string', description: 'Destination path for the copy (relative to workspace)', }, overwrite: { type: 'boolean', description: 'Overwrite destination if it exists (default: false)', default: false, }, }, required: ['sourcePath', 'destinationPath'], }, handler: this.copyFile.bind(this), }); // Directory operations this.tools.push({ name: 'list_directory', description: 'List directory contents with metadata, pagination, and filtering.', inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'Path to the directory (relative to workspace)', default: '.', }, includeHidden: { type: 'boolean', description: 'Include hidden files (default: false)', default: false, }, recursive: { type: 'boolean', description: 'List recursively (default: false)', default: false, }, // Pagination parameters limit: { type: 'number', description: 'Maximum number of entries to return (default: 100, max: 1000)', default: 100, minimum: 1, maximum: 1000, }, offset: { type: 'number', description: 'Starting index for pagination (default: 0)', default: 0, minimum: 0, }, // Sorting parameters sortBy: { type: 'string', description: 'Field to sort by', enum: ['name', 'size', 'lastModified', 'type'], default: 'name', }, sortOrder: { type: 'string', description: 'Sort order', enum: ['asc', 'desc'], default: 'asc', }, // Filtering parameters minSize: { type: 'number', description: 'Minimum file size in bytes', minimum: 0, }, maxSize: { type: 'number', description: 'Maximum file size in bytes', minimum: 0, }, modifiedAfter: { type: 'string', description: 'Filter entries modified after this date (ISO 8601 format)', }, modifiedBefore: { type: 'string', description: 'Filter entries modified before this date (ISO 8601 format)', }, type: { type: 'string', description: 'Filter by entry type', enum: ['file', 'directory'], }, fileExtensions: { type: 'array', description: 'Filter by file extensions (e.g., [".js", ".ts"])', items: { type: 'string', }, }, }, required: [], }, handler: this.listDirectory.bind(this), }); this.tools.push({ name: 'create_directory', description: 'Create a new directory with parent creation support.', inputSchema: { type: 'object', properties: { directoryPath: { type: 'string', description: 'Path to the directory to create (relative to workspace)', }, recursive: { type: 'boolean', description: 'Create parent directories if needed (default: true)', default: true, }, }, required: ['directoryPath'], }, handler: this.createDirectory.bind(this), }); this.tools.push({ name: 'delete_directory', description: 'Delete a directory with confirmation required.', inputSchema: { type: 'object', properties: { directoryPath: { type: 'string', description: 'Path to the directory to delete (relative to workspace)', }, recursive: { type: 'boolean', description: 'Delete recursively (default: false)', default: false, }, confirmed: { type: 'boolean', description: 'Explicit confirmation required to delete', default: false, }, }, required: ['directoryPath'], }, handler: this.deleteDirectory.bind(this), }); this.tools.push({ name: 'move_directory', description: 'Move an entire directory to a new location.', inputSchema: { type: 'object', properties: { sourcePath: { type: 'string', description: 'Current path of the directory (relative to workspace)', }, destinationPath: { type: 'string', description: 'New path for the directory (relative to workspace)', }, overwrite: { type: 'boolean', description: 'Overwrite destination if it exists (default: false)', default: false, }, }, required: ['sourcePath', 'destinationPath'], }, handler: this.moveDirectory.bind(this), }); // Search and analysis tools this.tools.push({ name: 'search_files', description: 'Search for files by name pattern in directories.', inputSchema: { type: 'object', properties: { pattern: { type: 'string', description: 'Search pattern (supports wildcards * and ?)', }, directoryPath: { type: 'string', description: 'Directory to search in (default: workspace root)', default: '.', }, recursive: { type: 'boolean', description: 'Search recursively in subdirectories (default: true)', default: true, }, caseSensitive: { type: 'boolean', description: 'Case sensitive search (default: false)', default: false, }, // Pagination parameters limit: { type: 'number', description: 'Maximum number of results to return (default: 100, max: 1000)', default: 100, minimum: 1, maximum: 1000, }, offset: { type: 'number', description: 'Starting index for pagination (default: 0)', default: 0, minimum: 0, }, // Sorting parameters sortBy: { type: 'string', description: 'Field to sort by', enum: ['path', 'name', 'size', 'lastModified'], default: 'path', }, sortOrder: { type: 'string', description: 'Sort order', enum: ['asc', 'desc'], default: 'asc', }, // Filtering parameters minSize: { type: 'number', description: 'Minimum file size in bytes', minimum: 0, }, maxSize: { type: 'number', description: 'Maximum file size in bytes', minimum: 0, }, modifiedAfter: { type: 'string', description: 'Filter files modified after this date (ISO 8601 format)', }, modifiedBefore: { type: 'string', description: 'Filter files modified before this date (ISO 8601 format)', }, excludePatterns: { type: 'array', description: 'Patterns to exclude from results', items: { type: 'string', }, }, }, required: ['pattern'], }, handler: this.searchFiles.bind(this), }); this.tools.push({ name: 'search_in_files', description: 'Search for text content within files with pagination and filtering.', inputSchema: { type: 'object', properties: { searchText: { type: 'string', description: 'Text to search for', }, directoryPath: { type: 'string', description: 'Directory to search in (default: workspace root)', default: '.', }, filePattern: { type: 'string', description: 'File pattern to search in (e.g., "*.js")', default: '*', }, caseSensitive: { type: 'boolean', description: 'Case sensitive search (default: false)', default: false, }, // Pagination parameters limit: { type: 'number', description: 'Maximum number of results to return (default: 100, max: 1000)', default: 100, minimum: 1, maximum: 1000, }, offset: { type: 'number', description: 'Starting index for pagination (default: 0)', default: 0, minimum: 0, }, // Sorting parameters sortBy: { type: 'string', description: 'Field to sort by', enum: ['relevance', 'filePath', 'lineNumber', 'occurrences'], default: 'relevance', }, sortOrder: { type: 'string', description: 'Sort order', enum: ['asc', 'desc'], default: 'desc', }, // Filtering parameters modifiedAfter: { type: 'string', description: 'Only search in files modified after this date (ISO 8601 format)', }, modifiedBefore: { type: 'string', description: 'Only search in files modified before this date (ISO 8601 format)', }, minFileSize: { type: 'number', description: 'Minimum file size in bytes', minimum: 0, }, maxFileSize: { type: 'number', description: 'Maximum file size in bytes', minimum: 0, }, includeLineNumbers: { type: 'boolean', description: 'Include line numbers in results (default: true)', default: true, }, contextLines: { type: 'number', description: 'Number of context lines before/after matches (default: 0)', default: 0, minimum: 0, maximum: 5, }, }, required: ['searchText'], }, handler: this.searchInFiles.bind(this), }); this.tools.push({ name: 'analyze_project', description: 'Generate project structure overview with optional embeddings.', inputSchema: { type: 'object', properties: { rootPath: { type: 'string', description: 'Root directory to analyze (default: workspace root)', default: '.', }, includeEmbeddings: { type: 'boolean', description: 'Generate embeddings for code files (default: false)', default: false, }, maxDepth: { type: 'number', description: 'Maximum directory depth to analyze (default: 5)', default: 5, }, filePattern: { type: 'string', description: 'File pattern to include (default: all files)', default: '*', }, offset: { type: 'number', description: 'Offset for pagination (default: 0)', default: 0, }, limit: { type: 'number', description: 'Limit for pagination (default: 50)', default: 50, }, sortBy: { type: 'string', description: 'Field to sort by', enum: ['name', 'size', 'lastModified', 'type', 'depth'], default: 'name', }, sortOrder: { type: 'string', description: 'Sort order', enum: ['asc', 'desc'], default: 'asc', }, includeHidden: { type: 'boolean', description: 'Include hidden files and directories (default: false)', default: false, }, minSize: { type: 'number', description: 'Minimum file size in bytes', minimum: 0, }, maxSize: { type: 'number', description: 'Maximum file size in bytes', minimum: 0, }, }, required: [], }, handler: this.analyzeProject.bind(this), }); this.tools.push({ name: 'find_similar_files', description: 'Find semantically similar files using embeddings.', inputSchema: { type: 'object', properties: { referencePath: { type: 'string', description: 'Reference file to find similar files for', }, searchPath: { type: 'string', description: 'Directory to search in (default: workspace root)', default: '.', }, topK: { type: 'number', description: 'Number of similar files to return (default: 10)', default: 10, }, threshold: { type: 'number', description: 'Similarity threshold 0-1 (default: 0.7)', default: 0.7, }, }, required: ['referencePath'], }, handler: this.findSimilarFiles.bind(this), }); // Snippet sharing tools this.tools.push({ name: 'extract_snippet', description: 'Extract a code snippet from a file with safety checks and consent requirement.', inputSchema: { type: 'object', properties: { filePath: { type: 'string', description: 'Path to the file to extract from', }, startLine: { type: 'number', description: 'Starting line number (1-based)', }, endLine: { type: 'number', description: 'Ending line number (inclusive)', }, purpose: { type: 'string', description: 'Purpose of the snippet extraction', }, sharingLevel: { type: 'string', description: 'Sharing level: micro (10 lines), function (50 lines), component (200 lines)', enum: ['micro', 'function', 'component'], default: 'micro', }, autoDetectBoundaries: { type: 'boolean', description: 'Auto-detect code boundaries (functions, classes)', default: true, }, sanitize: { type: 'boolean', description: 'Sanitize sensitive data like API keys', default: true, }, confirmed: { type: 'boolean', description: 'Explicit confirmation required for sharing', default: false, }, }, required: ['filePath', 'startLine', 'endLine', 'purpose'], }, handler: this.extractSnippet.bind(this), }); this.tools.push({ name: 'request_editing_help', description: 'Request help for a coding task with smart escalation from embeddings to snippets.', inputSchema: { type: 'object', properties: { task: { type: 'string', description: 'Description of the task needing help', }, filePath: { type: 'string', description: 'Path to the file needing help', }, sharingLevel: { type: 'string', description: 'Maximum sharing level allowed', enum: ['micro', 'function', 'component'], default: 'function', }, preferEmbeddings: { type: 'boolean', description: 'Try embeddings first before snippets', default: true, }, }, required: ['task', 'filePath'], }, handler: this.requestEditingHelp.bind(this), }); } /** * Core file operation handlers */ async createFile(args) { const { filePath, content, encoding = 'utf8' } = args; try { const validation = await this.validator.validateOperation('create', filePath); if (!validation.allowed) { return { content: [{ type: 'text', text: `❌ Cannot create file: ${validation.reason}`, }], }; } // Ensure parent directory exists const dir = dirname(validation.resolvedPath); await mkdir(dir, { recursive: true }); // Write the file await writeFile(validation.resolvedPath, content, encoding); return { content: [{ type: 'text', text: `✅ File created successfully: ${filePath}`, }], }; } catch (error) { return { content: [{ type: 'text', text: `❌ Error creating file: ${error.message}`, }], }; } } async readFile(args) { const { filePath, forAI = false, encoding = 'utf8' } = args; try { const validation = await this.validator.validateOperation('read', filePath); if (!validation.allowed) { return { content: [{ type: 'text', text: `❌ Cannot read file: ${validation.reason}`, }], }; } // Check file size const sizeCheck = await this.validator.validateFileSize(validation.resolvedPath); if (!sizeCheck.valid) { return { content: [{ type: 'text', text: `❌ ${sizeCheck.error}`, }], }; } // If AI mode is requested, return embeddings and metadata if (forAI) { // Lazy load embedding manager if (!this.embeddingManager) { const { LocalEmbeddingManager } = await import('./embedding-manager.js'); this.embeddingManager = new LocalEmbeddingManager(); await this.embeddingManager.initialize(); } // Lazy load metadata extractor if (!this.metadataExtractor) { const { FileMetadataExtractor } = await import('./metadata-extractor.js'); this.metadataExtractor = new FileMetadataExtractor(); } const content = await readFile(validation.resolvedPath, encoding); const embedding = await this.embeddingManager.generateEmbedding(content, validation.resolvedPath); const metadata = await this.metadataExtractor.extractMetadata(content, filePath); return { content: [{ type: 'text', text: JSON.stringify({ embedding: embedding.vector, metadata, fileInfo: { path: filePath, size: sizeCheck.size, lastModified: (await stat(validation.resolvedPath)).mtime.toISOString(), }, }, null, 2), }], }; } // Regular read - return file content const content = await readFile(validation.resolvedPath, encoding); return { content: [{ type: 'text', text: content, }], }; } catch (error) { return { content: [{ type: 'text', text: `❌ Error reading file: ${error.message}`, }], }; } } async updateFile(args) { const { filePath, content, createBackup = true, encoding = 'utf8' } = args; try { const validation = await this.validator.validateOperation('update', filePath); if (!validation.allowed) { return { content: [{ type: 'text', text: `❌ Cannot update file: ${validation.reason}`, }], }; } // Create backup if requested let backupPath = null; if (createBackup) { backupPath = this.validator.createBackupPath(validation.resolvedPath); await copyFile(validation.resolvedPath, backupPath); } // Update the file await writeFile(validation.resolvedPath, content, encoding); return { content: [{ type: 'text', text: `✅ File updated successfully: ${filePath}${backupPath ? `\n📋 Backup created: ${basename(backupPath)}` : ''}`, }], }; } catch (error) { return { content: [{ type: 'text', text: `❌ Error updating file: ${error.message}`, }], }; } } async deleteFile(args) { const { filePath, confirmed = false } = args; try { const validation = await this.validator.validateOperation('delete', filePath); if (!validation.allowed) { return { content: [{ type: 'text', text: `❌ Cannot delete file: ${validation.reason}`, }], }; } if (!confirmed) { const stats = await stat(validation.resolvedPath); return { content: [{ type: 'text', text: `⚠️ Confirmation required to delete file:\n📄 ${filePath}\n📊 Size: ${stats.size} bytes\n\nSet "confirmed": true to proceed with deletion.`, }], }; } await unlink(validation.resolvedPath); return { content: [{ type: 'text', text: `✅ File deleted successfully: ${filePath}`, }], }; } catch (error) { return { content: [{ type: 'text', text: `❌ Error deleting file: ${error.message}`, }], }; } } async moveFile(args) { const { sourcePath, destinationPath, overwrite = false } = args; try { const sourceValidation = await this.validator.validateOperation('move', sourcePath); if (!sourceValidation.allowed) { return { content: [{ type: 'text', text: `❌ Cannot move source file: ${sourceValidation.reason}`, }], }; } const destValidation = await this.validator.validatePath(destinationPath); if (!destValidation.valid) { return { content: [{ type: 'text', text: `❌ Invalid destination path: ${destValidation.error}`, }], }; } // Check if destination exists const destExists = await this.validator.pathExists(destValidation.resolvedPath); if (destExists && !overwrite) { return { content: [{ type: 'text', text: '❌ Destination already exists. Set "overwrite": true to replace.', }], }; } // Ensure destination directory exists const destDir = dirname(destValidation.resolvedPath); await mkdir(destDir, { recursive: true }); await rename(sourceValidation.resolvedPath, destValidation.resolvedPath); return { content: [{ type: 'text', text: `✅ File moved successfully:\n📤 From: ${sourcePath}\n📥 To: ${destinationPath}`, }], }; } catch (error) { return { content: [{ type: 'text', text: `❌ Error moving file: ${error.message}`, }], }; } } async copyFile(args) { const { sourcePath, destinationPath, overwrite = false } = args; try { const sourceValidation = await this.validator.validateOperation('read', sourcePath); if (!sourceValidation.allowed) { return { content: [{ type: 'text', text: `❌ Cannot read source file: ${sourceValidation.reason}`, }], }; } const destValidation = await this.validator.validatePath(destinationPath); if (!destValidation.valid) { return { content: [{ type: 'text', text: `❌ Invalid destination path: ${destValidation.error}`, }], }; } // Check if destination exists const destExists = await this.validator.pathExists(destValidation.resolvedPath); if (destExists && !overwrite) { return { content: [{ type: 'text', text: '❌ Destination already exists. Set "overwrite": true to replace.', }], }; } // Ensure destination directory exists const destDir = dirname(destValidation.resolvedPath); await mkdir(destDir, { recursive: true }); // Check file size for streaming decision const sizeCheck = await this.validator.validateFileSize(sourceValidation.resolvedPath); if (!sizeCheck.valid) { return { content: [{ type: 'text', text: `❌ ${sizeCheck.error}`, }], }; } // Use streaming for larger files if (sizeCheck.size > 1024 * 1024) { // 1MB const readStream = createReadStream(sourceValidation.resolvedPath); const writeStream = createWriteStream(destValidation.resolvedPath); await pipeline(readStream, writeStream); } else { await copyFile(sourceValidation.resolvedPath, destValidation.resolvedPath); } return { content: [{ type: 'text', text: `✅ File copied successfully:\n📤 From: ${sourcePath}\n📥 To: ${destinationPath}`, }], }; } catch (error) { return { content: [{ type: 'text', text: `❌ Error copying file: ${error.message}`, }], }; } } /** * Directory operation handlers */ async listDirectory(args) { const { path = '.', includeHidden = false, recursive = false, limit = 100, offset = 0, sortBy = 'name', sortOrder = 'asc', minSize, maxSize, modifiedAfter, modifiedBefore, type, fileExtensions } = args; try { const validation = await this.validator.validatePath(path); if (!validation.valid) { return { content: [{ type: 'text', text: `❌ Invalid directory path: ${validation.error}`, }], }; } // Get all entries const allEntries = await this.listDirectoryRecursive( validation.resolvedPath, includeHidden, recursive, path ); // Flatten entries for filtering and sorting const flatEntries = this.flattenDirectoryEntries(allEntries); // Apply filters let filteredEntries = this.filterDirectoryEntries(flatEntries, { minSize, maxSize, modifiedAfter, modifiedBefore, type, fileExtensions }); // Sort entries filteredEntries = this.sortDirectoryEntries(filteredEntries, sortBy, sortOrder); // Apply pagination const totalMatches = filteredEntries.length; const paginatedEntries = filteredEntries.slice(offset, offset + limit); const hasMore = offset + limit < totalMatches; const response = { entries: paginatedEntries, metadata: { totalMatches, returnedMatches: paginatedEntries.length, limit, offset, hasMore, sortBy, sortOrder, } }; if (hasMore) { response.metadata.nextOffset = offset + limit; } return { content: [{ type: 'text', text: JSON.stringify(response, null, 2), }], }; } catch (error) { return { content: [{ type: 'text', text: `❌ Error listing directory: ${error.message}`, }], }; } } async listDirectoryRecursive(dirPath, includeHidden, recursive, relativeTo) { const entries = []; const items = await readdir(dirPath, { withFileTypes: true }); for (const item of items) { if (!includeHidden && item.name.startsWith('.')) { continue; } const fullPath = join(dirPath, item.name); const stats = await stat(fullPath); const relativePath = join(relativeTo, item.name); const entry = { name: item.name, path: relativePath, type: item.isDirectory() ? 'directory' : 'file', size: stats.size, lastModified: stats.mtime.toISOString(), }; if (item.isFile()) { entry.extension = extname(item.name); } entries.push(entry); if (recursive && item.isDirectory()) { entry.children = await this.listDirectoryRecursive( fullPath, includeHidden, recursive, relativePath ); } } return entries; } async createDirectory(args) { const { directoryPath, recursive = true } = args; try { const validation = await this.validator.validatePath(directoryPath); if (!validation.valid) { return { content: [{ type: 'text', text: `❌ Invalid directory path: ${validation.error}`, }], }; } await mkdir(validation.resolvedPath, { recursive }); return { content: [{ type: 'text', text: `✅ Directory created successfully: ${directoryPath}`, }], }; } catch (error) { if (error.code === 'EEXIST') { return { content: [{ type: 'text', text: `⚠️ Directory already exists: ${directoryPath}`, }], }; } return { content: [{ type: 'text', text: `❌ Error creating directory: ${error.message}`, }], }; } } async deleteDirectory(args) { const { directoryPath, recursive = false, confirmed = false } = args; try { const validation = await this.validator.validateOperation('delete', directoryPath); if (!validation.allowed) { return { content: [{ type: 'text', text: `❌ Cannot delete directory: ${validation.reason}`, }], }; } if (!confirmed) { const entries = await readdir(validation.resolvedPath); return { content: [{ type: 'text', text: `⚠️ Confirmation required to delete directory:\n📁 ${directoryPath}\n📊 Contains ${entries.length} items\n\nSet "confirmed": true to proceed with deletion.`, }], }; } if (recursive) { // Recursive delete using fs promises await rmdir(validation.resolvedPath, { recursive: true }); } else { await rmdir(validation.resolvedPath); } return { content: [{ type: 'text', text: `✅ Directory deleted successfully: ${directoryPath}`, }], }; } catch (error) { return { content: [{ type: 'text', text: `❌ Error deleting directory: ${error.message}`, }], }; } } async moveDirectory(args) { const { sourcePath, destinationPath, overwrite = false } = args; try { const sourceValidation = await this.validator.validateOperation('move', sourcePath); if (!sourceValidation.allowed) { return { content: [{ type: 'text', text: `❌ Cannot move source directory: ${sourceValidation.reason}`, }], }; } const destValidation = await this.validator.validatePath(destinationPath); if (!destValidation.valid) { return { content: [{ type: 'text', text: `❌ Invalid destination path: ${destValidation.error}`, }], }; } // Check if destination exists const destExists = await this.validator.pathExists(destValidation.resolvedPath); if (destExists && !overwrite) { return { content: [{ type: 'text', text: '❌ Destination already exists. Set "overwrite": true to replace.', }], }; } // Ensure destination parent directory exists const destDir = dirname(destValidation.resolvedPath); await mkdir(destDir, { recursive: true }); await rename(sourceValidation.resolvedPath, destValidation.resolvedPath); return { content: [{ type: 'text', text: `✅ Directory moved successfully:\n📤 From: ${sourcePath}\n📥 To: ${destinationPath}`, }], }; } catch (error) { return { content: [{ type: 'text', text: `❌ Error moving directory: ${error.message}`, }], }; } } /** * Search tool implementations */ async searchFiles(args) { const { pattern, directoryPath = '.', recursive = true, caseSensitive = false, // Pagination limit = 100, offset = 0, // Sorting sortBy = 'path', sortOrder = 'asc', // Filtering minSize, maxSize, modifiedAfter, modifiedBefore, excludePatterns = [] } = args; try { const validation = await this.validator.validatePath(directoryPath); if (!validation.valid) { return { content: [{ type: 'text', text: `❌ Invalid directory path: ${validation.error}`, }], }; } // Validate limit const finalLimit = Math.min(Math.max(1, limit), 1000); const finalOffset = Math.max(0, offset); // Collect all results first const allResults = await this.searchFilesRecursive( validation.resolvedPath, pattern, recursive, caseSensitive, directoryPath, { minSize, maxSize, modifiedAfter: modifiedAfter ? new Date(modifiedAfter) : null, modifiedBefore: modifiedBefore ? new Date(modifiedBefore) : null, excludePatterns } ); // Sort results const sortedResults = this.sortFileResults(allResults, sortBy, sortOrder); // Apply pagination const totalMatches = sortedResults.length; const paginatedResults = sortedResults.slice(finalOffset, finalOffset + finalLimit); const hasMore = (finalOffset + finalLimit) < totalMatches; const nextOffset = hasMore ? finalOffset + finalLimit : null; return { content: [{ type: 'text', text: JSON.stringify({ pattern, searchPath: directoryPath, totalMatches, returnedMatches: paginatedResults.length, offset: finalOffset, limit: finalLimit, hasMore, nextOffset, sortBy, sortOrder, matches: paginatedResults, }, null, 2), }], }; } catch (error) { return { content: [{ type: 'text', text: `❌ Error searching files: ${error.message}`, }], }; } } async searchFilesRecursive(dirPath, pattern, recursive, caseSensitive, baseDir, filters = {}) { const results = []; const MAX_RESULTS = 10000; // Stop after finding 10k files to prevent memory issues try { const items = await readdir(dirPath, { withFileTypes: true }); // Convert pattern to regex const regexPattern = pattern .replace(/\./g, '\\.') .replace(/\*/g, '.*') .replace(/\?/g, '.'); const regex = new RegExp(regexPattern, caseSensitive ? '' : 'i'); // Convert exclude patterns to regexes const excludeRegexes = (filters.excludePatterns || []).map(excludePattern => { const excludeRegexPattern = excludePattern .replace(/\./g, '\\.') .replace(/\*/g, '.*') .replace(/\?/g, '.'); return new RegExp(excludeRegexPattern, 'i'); }); for (const item of items) { const fullPath = join(dirPath, item.name); const relativePath = join(baseDir, item.name); // Check if path matches any exclude pattern const isExcluded = excludeRegexes.some(excludeRegex => excludeRegex.test(relativePath)); if (isExcluded) continue; if (item.isFile() && regex.test(item.name)) { const stats = await stat(fullPath); // Apply filters if (filters.minSize !== undefined && stats.size < filters.minSize) continue; if (filters.maxSize !== undefined && stats.size > filters.maxSize) continue; if (filters.modifiedAfter && stats.mtime < filters.modifiedAfter) continue; if (filters.modifiedBefore && stats.mtime > filters.modifiedBefore) continue; results.push({ path: relativePath, name: item.name, size: stats.size, lastModified: stats.mtime.toISOString(), }); // Stop if we've found too many results if (results.length >= MAX_RESULTS) { return results; } } if (recursive && item.isDirectory() && !item.name.startsWith('.')) { const subResults = await this.searchFilesRecursive( fullPath, pattern, recursive, caseSensitive, relativePath, filters ); results.push(...subResults); // Stop if we've found too many results if (results.length >= MAX_RESULTS) { return results.slice(0, MAX_RESULTS); } } } } catch (error) { // Ignore permission errors } return results; } sortFileResults(results, sortBy, sortOrder) { const sorted = [...results]; sorted.sort((a, b) => { let aValue, bValue; switch (sortBy) { case 'path': aValue = a.path.toLowerCase(); bValue = b.path.toLowerCase(); break; case 'name': aValue = a.name.toLowerCase(); bValue = b.name.toLowerCase(); break; case 'size': aValue = a.size; bValue = b.size; break; case 'lastModified': aValue = new Date(a.lastModified); bValue = new Date(b.lastModified); break; default: aValue = a.path.toLowerCase(); bValue = b.path.toLowerCase(); } if (sortBy === 'size' || sortBy === 'lastModified') { // Numeric/date comparison if (sortOrder === 'asc') { return aValue < bValue ? -1 : aValue > bValue ? 1 : 0; } else { return aValue > bValue ? -1 : aValue < bValue ? 1 : 0; } } else { // String comparison if (sortOrder === 'asc') { return aValue.localeCompare(bValue); } else { return bValue.localeCompare(aValue); } } }); return sorted; } /** * Directory listing helper methods */ flattenDirectoryEntries(entries, parentPath = '') { const flattened = []; for (const entry of entries) { const fullPath = parentPath ? `${parentPath}/${entry.name}` : entry.name; const flatEntry = { ...entry, fullPath, }; flattened.push(flatEntry); if (entry.children && entry.children.length > 0) { flattened.push(...this.flattenDirectoryEntries(entry.children, entry.path)); } } return flattened; } filterDirectoryEntries(entries, filters) { return entries.filter(entry => { // Type filter if (filters.type && entry.type !== filters.type) { return false; } // Size filters (only for files) if (entry.type === 'file') { if (filters.minSize !== undefined && entry.size < filters.minSize) { return false; } if (filters.maxSize !== undefined && entry.size > filters.maxSize) { return false; } } // Date filters if (filters.modifiedAfter) { const modifiedDate = new Date(entry.lastModified); const afterDate = new Date(filters.modifiedAfter); if (modifiedDate < afterDate) { return false; } } if (filters.modifiedBefore) { const modifiedDate = new Date(entry.lastModified); const beforeDate = new Date(filters.modifiedBefore); if (modifiedDate > beforeDate) { return false; } } // File extension filter if (filters.fileExtensions && filters.fileExtensions.length > 0 && entry.type === 'file') { const hasValidExtension = filters.fileExtensions.some(ext => { const normalizedExt = ext.startsWith('.') ? ext : `.${ext}`; return entry.extension === normalizedExt; }); if (!hasValidExtension) { return false; } } return true; }); } sortDirectoryEntries(entries, sortBy, sortOrder) { const sorted = [...entries]; sorted.sort((a, b) => { let aValue, bValue; switch (sortBy) { case 'name': aValue = a.name.toLowerCase(); bValue = b.name.toLowerCase(); break; case 'size': aValue = a.size; bValue = b.size; break; case 'lastModified': aValue = new Date(a.lastModified); bValue = new Date(b.lastModified); break; case 'type': aValue = a.type; bValue = b.type; break; default: aValue = a.name.toLowerCase(); bValue = b.name.toLowerCase(); } if (sortBy === 'size' || sortBy === 'lastModified') { // Numeric/date comparison if (sortOrder === 'asc') { return aValue < bValue ? -1 : aValue > bValue ? 1 : 0; } else { return aValue > bValue ? -1 : aValue < bValue ? 1 : 0; } } else { // String comparison if (sortOrder === 'asc') { return aValue.localeCompare(bValue); } else { return bValue.localeCompare(aValue); } } }); return sorted; } sortSearchResults(results, sortBy, sortOrder) { const sorted = [...results]; sorted.sort((a, b) => { let aValue, bValue; switch (sortBy) { case 'relevance': // Sort by total occurrences (most relevant = most occurrences) aValue = a.totalOccurrences; bValue = b.totalOccurrences; break; case 'filePath': aValue = a.filePath.toLowerCase(); bValue = b.filePath.toLowerCase(); break; case 'lineNumber': // Sort by first match line number aValue = a.matches[0]?.lineNumber || 0; bValue = b.matches[0]?.lineNumber || 0; break; case 'occurrences': aValue = a.totalOccurrences; bValue = b.totalOccurrences; break; default: aValue = a.totalOccurrences; bValue = b.totalOccurrences; } if (sortBy === 'relevance' || sortBy === 'occurrences' || sortBy === 'lineNumber') { // Numeric comparison if (sortOrder === 'asc') { return aValue < bValue ? -1 : aValue > bValue ? 1 : 0; } else { return aValue > bValue ? -1 : aValue < bValue ? 1 : 0; } } else { // String comparison if (sortOrder === 'asc') { return aValue.localeCompare(bValue); } else { return bValue.localeCompare(aValue); } } }); return sorted; } async searchInFiles(args) { const { searchText, directoryPath = '.', filePattern = '*', caseSensitive = false, limit = 100, offset = 0, sortBy = 'relevance', sortOrder = 'desc', modifiedAfter, modifiedBefore, minFileSize, maxFileSize, includeLineNumbers = true, contextLines = 0 } = args; try { const validation = await this.validator.validatePath(directoryPath); if (!validation.valid) { return { content: [{ type: 'text', text: `❌ Invalid directory path: ${validation.error}`, }], }; } // First, find all matching files with filters const filters = { minSize: minFileSize, maxSize: maxFileSize, modifiedAfter, modifiedBefore }; const files = await this.searchFilesRecursive( validation.resolvedPath, filePattern, true, false, directoryPath, filters ); const allResults = []; const searchRegex = new RegExp( searchText.