Skip to main content
Glama

Claude Desktop Commander MCP

filesystem.ts40.7 kB
import fs from "fs/promises"; import path from "path"; import os from 'os'; import fetch from 'cross-fetch'; import { createReadStream } from 'fs'; import { createInterface } from 'readline'; import { isBinaryFile } from 'isbinaryfile'; import {capture} from '../utils/capture.js'; import {withTimeout} from '../utils/withTimeout.js'; import {configManager} from '../config-manager.js'; // CONSTANTS SECTION - Consolidate all timeouts and thresholds const FILE_OPERATION_TIMEOUTS = { PATH_VALIDATION: 10000, // 10 seconds URL_FETCH: 30000, // 30 seconds FILE_READ: 30000, // 30 seconds } as const; const FILE_SIZE_LIMITS = { LARGE_FILE_THRESHOLD: 10 * 1024 * 1024, // 10MB LINE_COUNT_LIMIT: 10 * 1024 * 1024, // 10MB for line counting } as const; const READ_PERFORMANCE_THRESHOLDS = { SMALL_READ_THRESHOLD: 100, // For very small reads DEEP_OFFSET_THRESHOLD: 1000, // For byte estimation SAMPLE_SIZE: 10000, // Sample size for estimation CHUNK_SIZE: 8192, // 8KB chunks for reverse reading } as const; // UTILITY FUNCTIONS - Eliminate duplication /** * Count lines in text content efficiently * @param content Text content to count lines in * @returns Number of lines */ function countLines(content: string): number { return content.split('\n').length; } /** * Count lines in a file efficiently (for files under size limit) * @param filePath Path to the file * @returns Line count or undefined if file too large/can't read */ async function getFileLineCount(filePath: string): Promise<number | undefined> { try { const stats = await fs.stat(filePath); // Only count lines for reasonably sized files to avoid performance issues if (stats.size < FILE_SIZE_LIMITS.LINE_COUNT_LIMIT) { const content = await fs.readFile(filePath, 'utf8'); return countLines(content); } } catch (error) { // If we can't read the file, just return undefined } return undefined; } /** * Get MIME type information for a file * @param filePath Path to the file * @returns Object with mimeType and isImage properties */ async function getMimeTypeInfo(filePath: string): Promise<{ mimeType: string; isImage: boolean }> { const { getMimeType, isImageFile } = await import('./mime-types.js'); const mimeType = getMimeType(filePath); const isImage = isImageFile(mimeType); return { mimeType, isImage }; } /** * Get file extension for telemetry purposes * @param filePath Path to the file * @returns Lowercase file extension */ function getFileExtension(filePath: string): string { return path.extname(filePath).toLowerCase(); } /** * Get default read length from configuration * @returns Default number of lines to read */ async function getDefaultReadLength(): Promise<number> { const config = await configManager.getConfig(); return config.fileReadLineLimit ?? 1000; // Default to 1000 lines if not set } /** * Generate instructions for handling binary files * @param filePath Path to the binary file * @param mimeType MIME type of the file * @returns Instruction message for the LLM */ function getBinaryFileInstructions(filePath: string, mimeType: string): string { const fileName = path.basename(filePath); return `Cannot read binary file as text: ${fileName} (${mimeType}) Use start_process + interact_with_process to analyze binary files with appropriate tools (Node.js or Python libraries, command-line utilities, etc.). The read_file tool only handles text files and images.`; } // Initialize allowed directories from configuration async function getAllowedDirs(): Promise<string[]> { try { let allowedDirectories; const config = await configManager.getConfig(); if (config.allowedDirectories && Array.isArray(config.allowedDirectories)) { allowedDirectories = config.allowedDirectories; } else { // Fall back to default directories if not configured allowedDirectories = [ os.homedir() // User's home directory ]; // Update config with default await configManager.setValue('allowedDirectories', allowedDirectories); } return allowedDirectories; } catch (error) { console.error('Failed to initialize allowed directories:', error); // Keep the default permissive path } return []; } // Normalize all paths consistently function normalizePath(p: string): string { return path.normalize(expandHome(p)).toLowerCase(); } function expandHome(filepath: string): string { if (filepath.startsWith('~/') || filepath === '~') { return path.join(os.homedir(), filepath.slice(1)); } return filepath; } /** * Recursively validates parent directories until it finds a valid one * This function handles the case where we need to create nested directories * and we need to check if any of the parent directories exist * * @param directoryPath The path to validate * @returns Promise<boolean> True if a valid parent directory was found */ async function validateParentDirectories(directoryPath: string): Promise<boolean> { const parentDir = path.dirname(directoryPath); // Base case: we've reached the root or the same directory (shouldn't happen normally) if (parentDir === directoryPath || parentDir === path.dirname(parentDir)) { return false; } try { // Check if the parent directory exists await fs.realpath(parentDir); return true; } catch { // Parent doesn't exist, recursively check its parent return validateParentDirectories(parentDir); } } /** * Checks if a path is within any of the allowed directories * * @param pathToCheck Path to check * @returns boolean True if path is allowed */ async function isPathAllowed(pathToCheck: string): Promise<boolean> { // If root directory is allowed, all paths are allowed const allowedDirectories = await getAllowedDirs(); if (allowedDirectories.includes('/') || allowedDirectories.length === 0) { return true; } let normalizedPathToCheck = normalizePath(pathToCheck); if(normalizedPathToCheck.slice(-1) === path.sep) { normalizedPathToCheck = normalizedPathToCheck.slice(0, -1); } // Check if the path is within any allowed directory const isAllowed = allowedDirectories.some(allowedDir => { let normalizedAllowedDir = normalizePath(allowedDir); if(normalizedAllowedDir.slice(-1) === path.sep) { normalizedAllowedDir = normalizedAllowedDir.slice(0, -1); } // Check if path is exactly the allowed directory if (normalizedPathToCheck === normalizedAllowedDir) { return true; } // Check if path is a subdirectory of the allowed directory // Make sure to add a separator to prevent partial directory name matches // e.g. /home/user vs /home/username const subdirCheck = normalizedPathToCheck.startsWith(normalizedAllowedDir + path.sep); if (subdirCheck) { return true; } // If allowed directory is the root (C:\ on Windows), allow access to the entire drive if (normalizedAllowedDir === 'c:' && process.platform === 'win32') { return normalizedPathToCheck.startsWith('c:'); } return false; }); return isAllowed; } /** * Validates a path to ensure it can be accessed or created. * For existing paths, returns the real path (resolving symlinks). * For non-existent paths, validates parent directories to ensure they exist. * * @param requestedPath The path to validate * @returns Promise<string> The validated path * @throws Error if the path or its parent directories don't exist or if the path is not allowed */ export async function validatePath(requestedPath: string): Promise<string> { const validationOperation = async (): Promise<string> => { // Expand home directory if present const expandedPath = expandHome(requestedPath); // Convert to absolute path const absolute = path.isAbsolute(expandedPath) ? path.resolve(expandedPath) : path.resolve(process.cwd(), expandedPath); // Check if path is allowed if (!(await isPathAllowed(absolute))) { capture('server_path_validation_error', { error: 'Path not allowed', allowedDirsCount: (await getAllowedDirs()).length }); throw new Error(`Path not allowed: ${requestedPath}. Must be within one of these directories: ${(await getAllowedDirs()).join(', ')}`); } // Check if path exists try { const stats = await fs.stat(absolute); // If path exists, resolve any symlinks return await fs.realpath(absolute); } catch (error) { // Path doesn't exist - validate parent directories if (await validateParentDirectories(absolute)) { // Return the path if a valid parent exists // This will be used for folder creation and many other file operations return absolute; } // If no valid parent found, return the absolute path anyway return absolute; } }; // Execute with timeout const result = await withTimeout( validationOperation(), FILE_OPERATION_TIMEOUTS.PATH_VALIDATION, `Path validation operation`, // Generic name for telemetry null ); if (result === null) { // Keep original path in error for AI but a generic message for telemetry capture('server_path_validation_timeout', { timeoutMs: FILE_OPERATION_TIMEOUTS.PATH_VALIDATION }); throw new Error(`Path validation failed for path: ${requestedPath}`); } return result; } // File operation tools export interface FileResult { content: string; mimeType: string; isImage: boolean; } /** * Read file content from a URL * @param url URL to fetch content from * @returns File content or file result with metadata */ export async function readFileFromUrl(url: string): Promise<FileResult> { // Import the MIME type utilities const { isImageFile } = await import('./mime-types.js'); // Set up fetch with timeout const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH); try { const response = await fetch(url, { signal: controller.signal }); // Clear the timeout since fetch completed clearTimeout(timeoutId); if (!response.ok) { throw new Error(`HTTP error! Status: ${response.status}`); } // Get MIME type from Content-Type header const contentType = response.headers.get('content-type') || 'text/plain'; const isImage = isImageFile(contentType); if (isImage) { // For images, convert to base64 const buffer = await response.arrayBuffer(); const content = Buffer.from(buffer).toString('base64'); return { content, mimeType: contentType, isImage }; } else { // For text content const content = await response.text(); return { content, mimeType: contentType, isImage }; } } catch (error) { // Clear the timeout to prevent memory leaks clearTimeout(timeoutId); // Return error information instead of throwing const errorMessage = error instanceof DOMException && error.name === 'AbortError' ? `URL fetch timed out after ${FILE_OPERATION_TIMEOUTS.URL_FETCH}ms: ${url}` : `Failed to fetch URL: ${error instanceof Error ? error.message : String(error)}`; throw new Error(errorMessage); } } /** * Generate enhanced status message with total and remaining line information * @param readLines Number of lines actually read * @param offset Starting offset (line number) * @param totalLines Total lines in the file (if available) * @param isNegativeOffset Whether this is a tail operation * @returns Enhanced status message string */ function generateEnhancedStatusMessage( readLines: number, offset: number, totalLines?: number, isNegativeOffset: boolean = false ): string { if (isNegativeOffset) { // For tail operations (negative offset) if (totalLines !== undefined) { return `[Reading last ${readLines} lines (total: ${totalLines} lines)]`; } else { return `[Reading last ${readLines} lines]`; } } else { // For normal reads (positive offset) if (totalLines !== undefined) { const endLine = offset + readLines; const remainingLines = Math.max(0, totalLines - endLine); if (offset === 0) { return `[Reading ${readLines} lines from start (total: ${totalLines} lines, ${remainingLines} remaining)]`; } else { return `[Reading ${readLines} lines from line ${offset} (total: ${totalLines} lines, ${remainingLines} remaining)]`; } } else { // Fallback when total lines unknown if (offset === 0) { return `[Reading ${readLines} lines from start]`; } else { return `[Reading ${readLines} lines from line ${offset}]`; } } } } /** * Read file content using smart positioning for optimal performance * @param filePath Path to the file (already validated) * @param offset Starting line number (negative for tail behavior) * @param length Maximum number of lines to read * @param mimeType MIME type of the file * @param includeStatusMessage Whether to include status headers (default: true) * @returns File result with content */ async function readFileWithSmartPositioning(filePath: string, offset: number, length: number, mimeType: string, includeStatusMessage: boolean = true): Promise<FileResult> { const stats = await fs.stat(filePath); const fileSize = stats.size; // Check if the file is binary (but allow images to pass through) const { isImage } = await getMimeTypeInfo(filePath); if (!isImage) { const isBinary = await isBinaryFile(filePath); if (isBinary) { // Return instructions instead of trying to read binary content const instructions = getBinaryFileInstructions(filePath, mimeType); throw new Error(instructions); } } // Get total line count for enhanced status messages (only for smaller files) const totalLines = await getFileLineCount(filePath); // For negative offsets (tail behavior), use reverse reading if (offset < 0) { const requestedLines = Math.abs(offset); if (fileSize > FILE_SIZE_LIMITS.LARGE_FILE_THRESHOLD && requestedLines <= READ_PERFORMANCE_THRESHOLDS.SMALL_READ_THRESHOLD) { // Use efficient reverse reading for large files with small tail requests return await readLastNLinesReverse(filePath, requestedLines, mimeType, includeStatusMessage, totalLines); } else { // Use readline circular buffer for other cases return await readFromEndWithReadline(filePath, requestedLines, mimeType, includeStatusMessage, totalLines); } } // For positive offsets else { // For small files or reading from start, use simple readline if (fileSize < FILE_SIZE_LIMITS.LARGE_FILE_THRESHOLD || offset === 0) { return await readFromStartWithReadline(filePath, offset, length, mimeType, includeStatusMessage, totalLines); } // For large files with middle/end reads, try to estimate position else { // If seeking deep into file, try byte estimation if (offset > READ_PERFORMANCE_THRESHOLDS.DEEP_OFFSET_THRESHOLD) { return await readFromEstimatedPosition(filePath, offset, length, mimeType, includeStatusMessage, totalLines); } else { return await readFromStartWithReadline(filePath, offset, length, mimeType, includeStatusMessage, totalLines); } } } } /** * Read last N lines efficiently by reading file backwards in chunks */ async function readLastNLinesReverse(filePath: string, n: number, mimeType: string, includeStatusMessage: boolean = true, fileTotalLines?: number): Promise<FileResult> { const fd = await fs.open(filePath, 'r'); try { const stats = await fd.stat(); const fileSize = stats.size; let position = fileSize; let lines: string[] = []; let partialLine = ''; while (position > 0 && lines.length < n) { const readSize = Math.min(READ_PERFORMANCE_THRESHOLDS.CHUNK_SIZE, position); position -= readSize; const buffer = Buffer.alloc(readSize); await fd.read(buffer, 0, readSize, position); const chunk = buffer.toString('utf-8'); const text = chunk + partialLine; const chunkLines = text.split('\n'); partialLine = chunkLines.shift() || ''; lines = chunkLines.concat(lines); } // Add the remaining partial line if we reached the beginning if (position === 0 && partialLine) { lines.unshift(partialLine); } const result = lines.slice(-n); // Get exactly n lines const content = includeStatusMessage ? `${generateEnhancedStatusMessage(result.length, -n, fileTotalLines, true)}\n\n${result.join('\n')}` : result.join('\n'); return { content, mimeType, isImage: false }; } finally { await fd.close(); } } /** * Read from end using readline with circular buffer */ async function readFromEndWithReadline(filePath: string, requestedLines: number, mimeType: string, includeStatusMessage: boolean = true, fileTotalLines?: number): Promise<FileResult> { const rl = createInterface({ input: createReadStream(filePath), crlfDelay: Infinity }); const buffer: string[] = new Array(requestedLines); let bufferIndex = 0; let totalLines = 0; for await (const line of rl) { buffer[bufferIndex] = line; bufferIndex = (bufferIndex + 1) % requestedLines; totalLines++; } rl.close(); // Extract lines in correct order let result: string[]; if (totalLines >= requestedLines) { result = [ ...buffer.slice(bufferIndex), ...buffer.slice(0, bufferIndex) ].filter(line => line !== undefined); } else { result = buffer.slice(0, totalLines); } const content = includeStatusMessage ? `${generateEnhancedStatusMessage(result.length, -requestedLines, fileTotalLines, true)}\n\n${result.join('\n')}` : result.join('\n'); return { content, mimeType, isImage: false }; } /** * Read from start/middle using readline */ async function readFromStartWithReadline(filePath: string, offset: number, length: number, mimeType: string, includeStatusMessage: boolean = true, fileTotalLines?: number): Promise<FileResult> { const rl = createInterface({ input: createReadStream(filePath), crlfDelay: Infinity }); const result: string[] = []; let lineNumber = 0; for await (const line of rl) { if (lineNumber >= offset && result.length < length) { result.push(line); } if (result.length >= length) break; // Early exit optimization lineNumber++; } rl.close(); if (includeStatusMessage) { const statusMessage = generateEnhancedStatusMessage(result.length, offset, fileTotalLines, false); const content = `${statusMessage}\n\n${result.join('\n')}`; return { content, mimeType, isImage: false }; } else { const content = result.join('\n'); return { content, mimeType, isImage: false }; } } /** * Read from estimated byte position for very large files */ async function readFromEstimatedPosition(filePath: string, offset: number, length: number, mimeType: string, includeStatusMessage: boolean = true, fileTotalLines?: number): Promise<FileResult> { // First, do a quick scan to estimate lines per byte const rl = createInterface({ input: createReadStream(filePath), crlfDelay: Infinity }); let sampleLines = 0; let bytesRead = 0; for await (const line of rl) { bytesRead += Buffer.byteLength(line, 'utf-8') + 1; // +1 for newline sampleLines++; if (bytesRead >= READ_PERFORMANCE_THRESHOLDS.SAMPLE_SIZE) break; } rl.close(); if (sampleLines === 0) { // Fallback to simple read return await readFromStartWithReadline(filePath, offset, length, mimeType, includeStatusMessage, fileTotalLines); } // Estimate average line length and seek position const avgLineLength = bytesRead / sampleLines; const estimatedBytePosition = Math.floor(offset * avgLineLength); // Create a new stream starting from estimated position const fd = await fs.open(filePath, 'r'); try { const stats = await fd.stat(); const startPosition = Math.min(estimatedBytePosition, stats.size); const stream = createReadStream(filePath, { start: startPosition }); const rl2 = createInterface({ input: stream, crlfDelay: Infinity }); const result: string[] = []; let lineCount = 0; let firstLineSkipped = false; for await (const line of rl2) { // Skip first potentially partial line if we didn't start at beginning if (!firstLineSkipped && startPosition > 0) { firstLineSkipped = true; continue; } if (result.length < length) { result.push(line); } else { break; } lineCount++; } rl2.close(); const content = includeStatusMessage ? `${generateEnhancedStatusMessage(result.length, offset, fileTotalLines, false)}\n\n${result.join('\n')}` : result.join('\n'); return { content, mimeType, isImage: false }; } finally { await fd.close(); } } /** * Read file content from the local filesystem * @param filePath Path to the file * @param offset Starting line number to read from (default: 0) * @param length Maximum number of lines to read (default: from config or 1000) * @returns File content or file result with metadata */ export async function readFileFromDisk(filePath: string, offset: number = 0, length?: number): Promise<FileResult> { // Add validation for required parameters if (!filePath || typeof filePath !== 'string') { throw new Error('Invalid file path provided'); } // Get default length from config if not provided if (length === undefined) { length = await getDefaultReadLength(); } const validPath = await validatePath(filePath); // Get file extension for telemetry using path module consistently const fileExtension = getFileExtension(validPath); // Check file size before attempting to read try { const stats = await fs.stat(validPath); // Capture file extension in telemetry without capturing the file path capture('server_read_file', { fileExtension: fileExtension, offset: offset, length: length, fileSize: stats.size }); } catch (error) { console.error('error catch ' + error); const errorMessage = error instanceof Error ? error.message : String(error); capture('server_read_file_error', {error: errorMessage, fileExtension: fileExtension}); // If we can't stat the file, continue anyway and let the read operation handle errors } // Detect the MIME type based on file extension const { mimeType, isImage } = await getMimeTypeInfo(validPath); // Use withTimeout to handle potential hangs const readOperation = async () => { if (isImage) { // For image files, read as Buffer and convert to base64 // Images are always read in full, ignoring offset and length const buffer = await fs.readFile(validPath); const content = buffer.toString('base64'); return { content, mimeType, isImage }; } else { // For all other files, use smart positioning approach try { return await readFileWithSmartPositioning(validPath, offset, length, mimeType, true); } catch (error) { // If it's our binary file instruction error, return it as content if (error instanceof Error && error.message.includes('Cannot read binary file as text:')) { return { content: error.message, mimeType: 'text/plain', isImage: false }; } // If UTF-8 reading fails for other reasons, also check if it's binary const isBinary = await isBinaryFile(validPath); if (isBinary) { const instructions = getBinaryFileInstructions(validPath, mimeType); return { content: instructions, mimeType: 'text/plain', isImage: false }; } // Only if it's truly not binary, then we have a real UTF-8 reading error throw error; } } }; // Execute with timeout const result = await withTimeout( readOperation(), FILE_OPERATION_TIMEOUTS.FILE_READ, `Read file operation for ${filePath}`, null ); if (result == null) { // Handles the impossible case where withTimeout resolves to null instead of throwing throw new Error('Failed to read the file'); } return result; } /** * Read a file from either the local filesystem or a URL * @param filePath Path to the file or URL * @param isUrl Whether the path is a URL * @param offset Starting line number to read from (default: 0) * @param length Maximum number of lines to read (default: from config or 1000) * @returns File content or file result with metadata */ export async function readFile(filePath: string, isUrl?: boolean, offset?: number, length?: number): Promise<FileResult> { return isUrl ? readFileFromUrl(filePath) : readFileFromDisk(filePath, offset, length); } /** * Read file content without status messages for internal operations * This function preserves exact file content including original line endings, * which is essential for edit operations that need to maintain file formatting. * @param filePath Path to the file * @param offset Starting line number to read from (default: 0) * @param length Maximum number of lines to read (default: from config or 1000) * @returns File content without status headers, with preserved line endings */ export async function readFileInternal(filePath: string, offset: number = 0, length?: number): Promise<string> { // Get default length from config if not provided if (length === undefined) { length = await getDefaultReadLength(); } const validPath = await validatePath(filePath); // Get file extension and MIME type const fileExtension = getFileExtension(validPath); const { mimeType, isImage } = await getMimeTypeInfo(validPath); if (isImage) { throw new Error('Cannot read image files as text for internal operations'); } // IMPORTANT: For internal operations (especially edit operations), we must // preserve exact file content including original line endings. // We cannot use readline-based reading as it strips line endings. // Read entire file content preserving line endings const content = await fs.readFile(validPath, 'utf8'); // If we need to apply offset/length, do it while preserving line endings if (offset === 0 && length >= Number.MAX_SAFE_INTEGER) { // Most common case for edit operations: read entire file return content; } // Handle offset/length by splitting on line boundaries while preserving line endings const lines = splitLinesPreservingEndings(content); // Apply offset and length const selectedLines = lines.slice(offset, offset + length); // Join back together (this preserves the original line endings) return selectedLines.join(''); } /** * Split text into lines while preserving original line endings with each line * @param content The text content to split * @returns Array of lines, each including its original line ending */ function splitLinesPreservingEndings(content: string): string[] { if (!content) return ['']; const lines: string[] = []; let currentLine = ''; for (let i = 0; i < content.length; i++) { const char = content[i]; currentLine += char; // Check for line ending patterns if (char === '\n') { // LF or end of CRLF lines.push(currentLine); currentLine = ''; } else if (char === '\r') { // Could be CR or start of CRLF if (i + 1 < content.length && content[i + 1] === '\n') { // It's CRLF, include the \n as well currentLine += content[i + 1]; i++; // Skip the \n in next iteration } // Either way, we have a complete line lines.push(currentLine); currentLine = ''; } } // Handle any remaining content (file not ending with line ending) if (currentLine) { lines.push(currentLine); } return lines; } export async function writeFile(filePath: string, content: string, mode: 'rewrite' | 'append' = 'rewrite'): Promise<void> { const validPath = await validatePath(filePath); // Get file extension for telemetry const fileExtension = getFileExtension(validPath); // Calculate content metrics const contentBytes = Buffer.from(content).length; const lineCount = countLines(content); // Capture file extension and operation details in telemetry without capturing the file path capture('server_write_file', { fileExtension: fileExtension, mode: mode, contentBytes: contentBytes, lineCount: lineCount }); // Use different fs methods based on mode if (mode === 'append') { await fs.appendFile(validPath, content); } else { await fs.writeFile(validPath, content); } } export interface MultiFileResult { path: string; content?: string; mimeType?: string; isImage?: boolean; error?: string; } export async function readMultipleFiles(paths: string[]): Promise<MultiFileResult[]> { return Promise.all( paths.map(async (filePath: string) => { try { const validPath = await validatePath(filePath); const fileResult = await readFile(validPath); return { path: filePath, content: typeof fileResult === 'string' ? fileResult : fileResult.content, mimeType: typeof fileResult === 'string' ? "text/plain" : fileResult.mimeType, isImage: typeof fileResult === 'string' ? false : fileResult.isImage }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { path: filePath, error: errorMessage }; } }), ); } export async function createDirectory(dirPath: string): Promise<void> { const validPath = await validatePath(dirPath); await fs.mkdir(validPath, { recursive: true }); } export async function listDirectory(dirPath: string, depth: number = 2): Promise<string[]> { const validPath = await validatePath(dirPath); const results: string[] = []; const MAX_NESTED_ITEMS = 100; // Maximum items to show per nested directory async function listRecursive(currentPath: string, currentDepth: number, relativePath: string = '', isTopLevel: boolean = true): Promise<void> { if (currentDepth <= 0) return; let entries; try { entries = await fs.readdir(currentPath, { withFileTypes: true }); } catch (error) { // If we can't read this directory (permission denied), show as denied const displayPath = relativePath || path.basename(currentPath); results.push(`[DENIED] ${displayPath}`); return; } // Apply filtering for nested directories (not top level) const totalEntries = entries.length; let entriesToShow = entries; let filteredCount = 0; if (!isTopLevel && totalEntries > MAX_NESTED_ITEMS) { entriesToShow = entries.slice(0, MAX_NESTED_ITEMS); filteredCount = totalEntries - MAX_NESTED_ITEMS; } for (const entry of entriesToShow) { const fullPath = path.join(currentPath, entry.name); const displayPath = relativePath ? path.join(relativePath, entry.name) : entry.name; // Add this entry to results results.push(`${entry.isDirectory() ? "[DIR]" : "[FILE]"} ${displayPath}`); // If it's a directory and we have depth remaining, recurse if (entry.isDirectory() && currentDepth > 1) { try { // Validate the path before recursing await validatePath(fullPath); await listRecursive(fullPath, currentDepth - 1, displayPath, false); } catch (error) { // If validation fails or we can't access it, it will be marked as denied // when we try to read it in the recursive call continue; } } } // Add warning message if items were filtered if (filteredCount > 0) { const displayPath = relativePath || path.basename(currentPath); results.push(`[WARNING] ${displayPath}: ${filteredCount} items hidden (showing first ${MAX_NESTED_ITEMS} of ${totalEntries} total)`); } } await listRecursive(validPath, depth, '', true); return results; } export async function moveFile(sourcePath: string, destinationPath: string): Promise<void> { const validSourcePath = await validatePath(sourcePath); const validDestPath = await validatePath(destinationPath); await fs.rename(validSourcePath, validDestPath); } export async function searchFiles(rootPath: string, pattern: string): Promise<string[]> { // Use the new search manager for better performance // This provides a temporary compatibility layer until we fully migrate to search sessions const { searchManager } = await import('../search-manager.js'); try { const result = await searchManager.startSearch({ rootPath, pattern, searchType: 'files', ignoreCase: true, maxResults: 5000, // Higher limit for compatibility earlyTermination: true, // Use early termination for better performance }); const sessionId = result.sessionId; // Poll for results until complete let allResults: string[] = []; let isComplete = result.isComplete; let startTime = Date.now(); // Add initial results for (const searchResult of result.results) { if (searchResult.type === 'file') { allResults.push(searchResult.file); } } while (!isComplete) { await new Promise(resolve => setTimeout(resolve, 100)); // Wait 100ms const results = searchManager.readSearchResults(sessionId); isComplete = results.isComplete; // Add new file paths to results for (const searchResult of results.results) { if (searchResult.file !== '__LAST_READ_MARKER__' && searchResult.type === 'file') { allResults.push(searchResult.file); } } // Safety check to prevent infinite loops (30 second timeout) if (Date.now() - startTime > 30000) { searchManager.terminateSearch(sessionId); break; } } // Log only the count of found files, not their paths capture('server_search_files_complete', { resultsCount: allResults.length, patternLength: pattern.length, usedRipgrep: true }); return allResults; } catch (error) { // Fallback to original Node.js implementation if ripgrep fails capture('server_search_files_ripgrep_fallback', { error: error instanceof Error ? error.message : 'Unknown error' }); return await searchFilesNodeJS(rootPath, pattern); } } // Keep the original Node.js implementation as fallback async function searchFilesNodeJS(rootPath: string, pattern: string): Promise<string[]> { const results: string[] = []; async function search(currentPath: string): Promise<void> { let entries; try { entries = await fs.readdir(currentPath, { withFileTypes: true }); } catch (error) { return; // Skip this directory on error } for (const entry of entries) { const fullPath = path.join(currentPath, entry.name); try { await validatePath(fullPath); if (entry.name.toLowerCase().includes(pattern.toLowerCase())) { results.push(fullPath); } if (entry.isDirectory()) { await search(fullPath); } } catch (error) { continue; } } } try { // Validate root path before starting search const validPath = await validatePath(rootPath); await search(validPath); // Log only the count of found files, not their paths capture('server_search_files_complete', { resultsCount: results.length, patternLength: pattern.length, usedRipgrep: false }); return results; } catch (error) { // For telemetry only - sanitize error info capture('server_search_files_error', { errorType: error instanceof Error ? error.name : 'Unknown', error: 'Error with root path', isRootPathError: true }); // Re-throw the original error for the caller throw error; } } export async function getFileInfo(filePath: string): Promise<Record<string, any>> { const validPath = await validatePath(filePath); const stats = await fs.stat(validPath); // Basic file info const info: Record<string, any> = { size: stats.size, created: stats.birthtime, modified: stats.mtime, accessed: stats.atime, isDirectory: stats.isDirectory(), isFile: stats.isFile(), permissions: stats.mode.toString(8).slice(-3), }; // For text files that aren't too large, also count lines if (stats.isFile() && stats.size < FILE_SIZE_LIMITS.LINE_COUNT_LIMIT) { try { // Get MIME type information const { mimeType, isImage } = await getMimeTypeInfo(validPath); // Only count lines for non-image, likely text files if (!isImage) { const content = await fs.readFile(validPath, 'utf8'); const lineCount = countLines(content); info.lineCount = lineCount; info.lastLine = lineCount - 1; // Zero-indexed last line info.appendPosition = lineCount; // Position to append at end } } catch (error) { // If reading fails, just skip the line count // This could happen for binary files or very large files } } return info; }

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/wonderwhy-er/DesktopCommanderMCP'

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