Skip to main content
Glama
filesystem-tools.js68.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.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), caseSensitive ? 'g' : 'gi' ); // Search in each file for (const file of files) { try { const filePath = join(validation.resolvedPath, file.path.replace(directoryPath + '/', '')); const content = await readFile(filePath, 'utf8'); const lines = content.split('\n'); const fileMatches = []; lines.forEach((line, index) => { const lineMatches = [...line.matchAll(searchRegex)]; if (lineMatches.length > 0) { const match = { lineNumber: index + 1, lineContent: line.trim(), occurrences: lineMatches.length, }; // Add context lines if requested if (contextLines > 0) { match.context = { before: [], after: [] }; // Get lines before for (let i = Math.max(0, index - contextLines); i < index; i++) { match.context.before.push({ lineNumber: i + 1, content: lines[i] }); } // Get lines after for (let i = index + 1; i < Math.min(lines.length, index + 1 + contextLines); i++) { match.context.after.push({ lineNumber: i + 1, content: lines[i] }); } } fileMatches.push(match); } }); if (fileMatches.length > 0) { allResults.push({ filePath: file.path, fileSize: file.size, lastModified: file.lastModified, totalOccurrences: fileMatches.reduce((sum, m) => sum + m.occurrences, 0), matchCount: fileMatches.length, matches: includeLineNumbers ? fileMatches : fileMatches.map(m => ({ lineContent: m.lineContent, occurrences: m.occurrences, context: m.context })) }); } } catch (error) { // Skip files that can't be read } } // Sort results const sortedResults = this.sortSearchResults(allResults, sortBy, sortOrder); // Apply pagination const totalMatches = sortedResults.length; const paginatedResults = sortedResults.slice(offset, offset + limit); const hasMore = offset + limit < totalMatches; const response = { searchText, searchPath: directoryPath, filePattern, totalFiles: totalMatches, returnedFiles: paginatedResults.length, totalOccurrences: allResults.reduce((sum, r) => sum + r.totalOccurrences, 0), limit, offset, hasMore, sortBy, sortOrder, results: paginatedResults }; if (hasMore) { response.nextOffset = offset + limit; } return { content: [{ type: 'text', text: JSON.stringify(response, null, 2), }], }; } catch (error) { return { content: [{ type: 'text', text: `❌ Error searching in files: ${error.message}`, }], }; } } async analyzeProject(args) { const { rootPath = '.', includeEmbeddings = false, maxDepth = 5, filePattern = '*', offset = 0, limit = 50, sortBy = 'name', sortOrder = 'asc', includeHidden = false, minSize, maxSize } = args; try { const validation = await this.validator.validatePath(rootPath); if (!validation.valid) { return { content: [{ type: 'text', text: `❌ Invalid root path: ${validation.error}`, }], }; } // Lazy load metadata extractor if (!this.metadataExtractor) { const { FileMetadataExtractor } = await import('./metadata-extractor.js'); this.metadataExtractor = new FileMetadataExtractor(); } const structure = await this.analyzeDirectoryStructure( validation.resolvedPath, rootPath, 0, maxDepth, filePattern, includeEmbeddings, { offset, limit, sortBy, sortOrder, includeHidden, minSize, maxSize } ); return { content: [{ type: 'text', text: JSON.stringify({ rootPath, timestamp: new Date().toISOString(), queryOptions: { sortBy, sortOrder, includeHidden, filePattern, maxDepth, minSize, maxSize }, pagination: { offset, limit, totalItems: structure.pagination?.totalChildren || 0, hasMore: structure.pagination?.hasMore || false, nextOffset: structure.pagination?.nextOffset }, structure, }, null, 2), }], }; } catch (error) { return { content: [{ type: 'text', text: `❌ Error analyzing project: ${error.message}`, }], }; } } async analyzeDirectoryStructure(dirPath, relativePath, currentDepth, maxDepth, filePattern, includeEmbeddings, options = {}) { const { offset = 0, limit = 50, sortBy = 'name', sortOrder = 'asc', includeHidden = false, minSize, maxSize } = options; if (currentDepth > maxDepth) { return { truncated: true }; } const structure = { name: basename(relativePath), path: relativePath, type: 'directory', children: [], stats: { fileCount: 0, directoryCount: 0, totalSize: 0, }, pagination: { offset, limit, totalChildren: 0, hasMore: false, }, }; try { const items = await readdir(dirPath, { withFileTypes: true }); const allChildren = []; // First pass: collect all items with stats for (const item of items) { // Skip hidden files unless requested if (!includeHidden && item.name.startsWith('.')) continue; const fullPath = join(dirPath, item.name); const itemRelativePath = join(relativePath, item.name); if (item.isDirectory()) { structure.stats.directoryCount++; const subStructure = await this.analyzeDirectoryStructure( fullPath, itemRelativePath, currentDepth + 1, maxDepth, filePattern, includeEmbeddings, options ); const directoryItem = { ...subStructure, depth: currentDepth + 1, lastModified: (await stat(fullPath)).mtime, sortKey: this.getSortKey(subStructure, sortBy) }; allChildren.push(directoryItem); structure.stats.fileCount += subStructure.stats?.fileCount || 0; structure.stats.totalSize += subStructure.stats?.totalSize || 0; } else if (item.isFile()) { // Check if file matches pattern const regexPattern = '^' + filePattern .replace(/\./g, '\\.') .replace(/\*/g, '.*') .replace(/\?/g, '.') + '$'; const regex = new RegExp(regexPattern, 'i'); if (regex.test(item.name)) { const stats = await stat(fullPath); // Apply size filters if (minSize !== undefined && stats.size < minSize) continue; if (maxSize !== undefined && stats.size > maxSize) continue; structure.stats.fileCount++; structure.stats.totalSize += stats.size; const fileInfo = { name: item.name, path: itemRelativePath, type: 'file', size: stats.size, lastModified: stats.mtime, lastModifiedISO: stats.mtime.toISOString(), extension: extname(item.name), depth: currentDepth + 1, }; // Add metadata for code files if (this.metadataExtractor.supportedExtensions.includes(fileInfo.extension)) { try { const content = await readFile(fullPath, 'utf8'); fileInfo.metadata = await this.metadataExtractor.extractMetadata(content, itemRelativePath); // Add embeddings if requested if (includeEmbeddings) { if (!this.embeddingManager) { const { LocalEmbeddingManager } = await import('./embedding-manager.js'); this.embeddingManager = new LocalEmbeddingManager(); await this.embeddingManager.initialize(); } const embedding = await this.embeddingManager.generateEmbedding(content, itemRelativePath); fileInfo.embedding = embedding.vector; } } catch (error) { fileInfo.metadataError = error.message; } } // Add sort key for this file fileInfo.sortKey = this.getSortKey(fileInfo, sortBy); allChildren.push(fileInfo); } } } // Sort all children this.sortItems(allChildren, sortBy, sortOrder); // Apply pagination structure.pagination.totalChildren = allChildren.length; structure.pagination.hasMore = offset + limit < allChildren.length; structure.pagination.nextOffset = structure.pagination.hasMore ? offset + limit : null; // Clean up sort keys and lastModified objects before return const children = allChildren.slice(offset, offset + limit).map(child => { const cleanChild = { ...child }; delete cleanChild.sortKey; if (cleanChild.lastModified && cleanChild.lastModifiedISO) { cleanChild.lastModified = cleanChild.lastModifiedISO; delete cleanChild.lastModifiedISO; } return cleanChild; }); structure.children = children; } catch (error) { structure.error = error.message; } return structure; } /** * Get sort key for an item based on sort field */ getSortKey(item, sortBy) { switch (sortBy) { case 'name': return item.name ? item.name.toLowerCase() : ''; case 'size': return item.size || 0; case 'lastModified': return item.lastModified ? new Date(item.lastModified).getTime() : 0; case 'type': return item.type === 'directory' ? 0 : 1; // Directories first case 'depth': return item.depth || 0; default: return item.name ? item.name.toLowerCase() : ''; } } /** * Sort items array in place */ sortItems(items, sortBy, sortOrder) { const multiplier = sortOrder === 'desc' ? -1 : 1; items.sort((a, b) => { const aKey = a.sortKey; const bKey = b.sortKey; if (typeof aKey === 'string' && typeof bKey === 'string') { return aKey.localeCompare(bKey) * multiplier; } if (aKey < bKey) return -1 * multiplier; if (aKey > bKey) return 1 * multiplier; return 0; }); } async findSimilarFiles(args) { const { referencePath, searchPath = '.', topK = 10, threshold = 0.7 } = args; try { // Validate reference file const refValidation = await this.validator.validateOperation('read', referencePath); if (!refValidation.allowed) { return { content: [{ type: 'text', text: `❌ Cannot read reference file: ${refValidation.reason}`, }], }; } // Validate search path const searchValidation = await this.validator.validatePath(searchPath); if (!searchValidation.valid) { return { content: [{ type: 'text', text: `❌ Invalid search path: ${searchValidation.error}`, }], }; } // Initialize embedding manager if (!this.embeddingManager) { const { LocalEmbeddingManager } = await import('./embedding-manager.js'); this.embeddingManager = new LocalEmbeddingManager(); await this.embeddingManager.initialize(); } // Generate embedding for reference file const refContent = await readFile(refValidation.resolvedPath, 'utf8'); const refEmbedding = await this.embeddingManager.generateEmbedding(refContent, referencePath); // Find all code files in search path const files = await this.searchFilesRecursive( searchValidation.resolvedPath, '*', true, false, searchPath ); // Filter to supported file types const codeFiles = files.filter(file => { const ext = extname(file.name); return ['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs'].includes(ext); }); // Generate embeddings for all files const fileEmbeddings = []; for (const file of codeFiles) { if (file.path === referencePath) continue; // Skip reference file try { const filePath = join(searchValidation.resolvedPath, file.path.replace(searchPath + '/', '')); const content = await readFile(filePath, 'utf8'); const embedding = await this.embeddingManager.generateEmbedding(content, file.path); fileEmbeddings.push({ path: file.path, vector: embedding.vector, }); } catch (error) { // Skip files that can't be read } } // Find similar files using high-performance vector operations const similarFiles = await this.embeddingManager.findSimilar( refEmbedding.vector, fileEmbeddings, topK, threshold ); return { content: [{ type: 'text', text: JSON.stringify({ referenceFile: referencePath, searchPath, similarityThreshold: threshold, foundCount: similarFiles.length, similarFiles, }, null, 2), }], }; } catch (error) { return { content: [{ type: 'text', text: `❌ Error finding similar files: ${error.message}`, }], }; } } /** * Extract snippet with safety checks */ async extractSnippet(args) { try { // Validate file path const validation = await this.validator.validateOperation('read', args.filePath); if (!validation.allowed) { return { content: [{ type: 'text', text: `❌ Cannot read file: ${validation.reason}`, }], }; } // Lazy load snippet manager if (!this.snippetManager) { const { SnippetManager } = await import('./snippet-manager.js'); this.snippetManager = new SnippetManager(); } // Extract snippet const result = await this.snippetManager.extractSnippet({ ...args, filePath: validation.resolvedPath, }); // Format response if (result.requiresConfirmation) { return { content: [{ type: 'text', text: JSON.stringify(result, null, 2), }], }; } return { content: [{ type: 'text', text: JSON.stringify({ success: true, snippet: result.snippet, metadata: result.metadata, safetyCheck: result.safetyCheck, auditInfo: { sharedAt: new Date().toISOString(), sharingLevel: args.sharingLevel, confirmed: true, }, }, null, 2), }], }; } catch (error) { return { content: [{ type: 'text', text: `❌ Error extracting snippet: ${error.message}`, }], }; } } /** * Request editing help with smart escalation */ async requestEditingHelp(args) { try { // Validate file path const validation = await this.validator.validateOperation('read', args.filePath); if (!validation.allowed) { return { content: [{ type: 'text', text: `❌ Cannot read file: ${validation.reason}`, }], }; } // Lazy load snippet manager if (!this.snippetManager) { const { SnippetManager } = await import('./snippet-manager.js'); this.snippetManager = new SnippetManager(); } // Request help const result = await this.snippetManager.requestEditingHelp({ ...args, filePath: validation.resolvedPath, }); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2), }], }; } catch (error) { return { content: [{ type: 'text', text: `❌ Error requesting help: ${error.message}`, }], }; } } /** * Register all tools with the MCP server * @param {Object} server - MCP server instance */ registerWithServer(server) { // This will be called from server.js to register all tools return this.tools; } /** * Get all tool definitions * @returns {Array} Array of tool definitions */ getTools() { return this.tools; } /** * Handle tool call * @param {string} toolName - Name of the tool to call * @param {Object} args - Tool arguments * @returns {Promise<Object>} Tool response */ async handleToolCall(toolName, args) { const tool = this.tools.find(t => t.name === toolName); if (!tool) { throw new Error(`Unknown tool: ${toolName}`); } return await tool.handler(args); } }

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/moikas-code/moidvk'

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