UNPKG

docfs

Version:

MCP server for accessing local file system content with intelligent search and listing tools

249 lines 8.88 kB
/** * Filesystem utilities for safe file operations within allowed directories */ import { promises as fs } from 'node:fs'; import { join, normalize, resolve, extname, basename, sep, relative } from 'node:path'; import ignore from 'ignore'; import { LRUCache } from './cache.js'; const metadataCacheSize = Number(process.env.DOCFS_METADATA_CACHE_SIZE ?? '1000'); const metadataCacheTtl = Number(process.env.DOCFS_METADATA_CACHE_TTL ?? '30000'); const metadataCache = new LRUCache(metadataCacheSize, metadataCacheTtl); async function loadIgnoreMatcher(rootPath) { const ig = ignore(); try { const content = await fs.readFile(join(rootPath, '.gitignore'), 'utf-8'); ig.add(content); } catch { // no .gitignore found } return ig; } /** * Validates if a path is within the allowed root directories */ export function validatePathAccess(targetPath, allowedRoots) { const normalized = normalize(resolve(targetPath)); for (const root of allowedRoots) { const normalizedRoot = normalize(resolve(root)); if (normalized.startsWith(normalizedRoot + sep) || normalized === normalizedRoot) { return normalized; } } throw new Error(`Path '${targetPath}' is outside allowed directories`); } /** * Checks if a file or directory exists */ export async function pathExists(path) { try { await fs.access(path); return true; } catch { return false; } } /** * Gets file information with error handling */ export async function getFileInfo(path) { const cached = metadataCache.get(path); if (cached) { return cached; } try { const stats = await fs.stat(path); const fileName = basename(path); const fileExt = stats.isFile() ? extname(path).slice(1) : undefined; const info = { path, name: fileName, size: stats.size, modified: stats.mtime.toISOString(), isDirectory: stats.isDirectory(), extension: fileExt, }; metadataCache.set(path, info); return info; } catch (error) { throw new Error(`Failed to get info for '${path}': ${error.message}`); } } export function clearFileInfoCache() { metadataCache.clear(); } /** * Lists files in a directory with filtering options */ export async function listFiles(rootPath, options = {}) { const { recursive = true, maxDepth = 10, includeHidden = false, pattern } = options; const results = []; const matcher = await loadIgnoreMatcher(rootPath); await walkDirectory(rootPath, results, 0, maxDepth, recursive, includeHidden, pattern, matcher, rootPath); return results.sort((a, b) => { if (a.isDirectory && !b.isDirectory) return -1; if (!a.isDirectory && b.isDirectory) return 1; return a.name.localeCompare(b.name); }); } /** * Recursively walks a directory tree */ async function walkDirectory(dirPath, results, currentDepth, maxDepth, recursive, includeHidden, pattern, matcher, basePath) { if (currentDepth > maxDepth) return; try { const entries = await fs.readdir(dirPath); for (const entry of entries) { if (!includeHidden && entry.startsWith('.')) continue; const fullPath = join(dirPath, entry); const relPath = relative(basePath, fullPath); if (matcher.ignores(relPath)) continue; const fileInfo = await getFileInfo(fullPath); if (fileInfo.isDirectory) { // Always include directories and continue traversal regardless of pattern, // so we don't prematurely stop exploring nested files that may match. results.push(fileInfo); if (recursive) { await walkDirectory(fullPath, results, currentDepth + 1, maxDepth, recursive, includeHidden, pattern, matcher, basePath); } continue; } // For files, apply pattern filtering (if provided) if (pattern && !matchesPattern(fileInfo.name, pattern)) continue; results.push(fileInfo); } } catch (error) { console.warn(`Failed to read directory '${dirPath}': ${error.message}`); } } /** * Simple pattern matching for file names */ function matchesPattern(fileName, pattern) { const regex = new RegExp(pattern.replace(/\./g, '\\.').replace(/\*/g, '.*').replace(/\?/g, '.'), 'i'); return regex.test(fileName); } /** * Builds a nested directory tree structure */ export async function getDirectoryTree(rootPath, options = {}, currentDepth = 0, matcher, basePath = rootPath) { const { maxDepth = 10, includeHidden = false } = options; const ignoreMatcher = matcher ?? (await loadIgnoreMatcher(basePath)); const info = await getFileInfo(rootPath); if (!info.isDirectory || currentDepth >= maxDepth) { return info; } try { const entries = await fs.readdir(rootPath); const children = []; for (const entry of entries) { if (!includeHidden && entry.startsWith('.')) continue; const fullPath = join(rootPath, entry); const relPath = relative(basePath, fullPath); if (ignoreMatcher.ignores(relPath)) continue; const child = await getDirectoryTree(fullPath, options, currentDepth + 1, ignoreMatcher, basePath); children.push(child); } children.sort((a, b) => { if (a.isDirectory && !b.isDirectory) return -1; if (!a.isDirectory && b.isDirectory) return 1; return a.name.localeCompare(b.name); }); return { ...info, children }; } catch (error) { throw new Error(`Failed to read directory '${rootPath}': ${error.message}`); } } /** * Searches for text content within files */ export async function searchInFiles(rootPaths, options) { const { query, filePattern, caseSensitive = false, wholeWord = false, contextLines = 2, maxDepth, } = options; const results = []; for (const rootPath of rootPaths) { await searchInDirectory(rootPath, query, caseSensitive, wholeWord, contextLines, filePattern, maxDepth, results); } return results; } /** * Searches for text in a single directory */ async function searchInDirectory(dirPath, query, caseSensitive, wholeWord, contextLines, filePattern, maxDepth, results) { try { const files = await listFiles(dirPath, { pattern: filePattern, maxDepth }); for (const file of files) { if (file.isDirectory) continue; try { await searchInFile(file.path, query, caseSensitive, wholeWord, contextLines, results); } catch (error) { console.warn(`Failed to search in file '${file.path}': ${error.message}`); } } } catch (error) { console.warn(`Failed to search in directory '${dirPath}': ${error.message}`); } } /** * Searches for text in a single file */ async function searchInFile(filePath, query, caseSensitive, wholeWord, contextLines, results) { const content = await fs.readFile(filePath, 'utf-8'); const lines = content.split('\n'); const flags = caseSensitive ? 'g' : 'gi'; const pattern = wholeWord ? `\\b${escapeRegExp(query)}\\b` : escapeRegExp(query); const regex = new RegExp(pattern, flags); for (let i = 0; i < lines.length; i++) { if (regex.test(lines[i])) { const before = lines.slice(Math.max(0, i - contextLines), i); const after = lines.slice(i + 1, i + 1 + contextLines); results.push({ file: filePath, line: i + 1, content: lines[i], context: { before, after }, }); } } } /** * Escapes special regex characters */ function escapeRegExp(string) { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } /** * Reads file content with optional line range */ export async function readFileContent(filePath, startLine, endLine, encoding = 'utf-8') { try { const content = await fs.readFile(filePath, encoding); if (startLine === undefined && endLine === undefined) { return content; } const lines = content.split('\n'); const start = Math.max(0, (startLine ?? 1) - 1); const end = Math.min(lines.length, endLine ?? lines.length); return lines.slice(start, end).join('\n'); } catch (error) { throw new Error(`Failed to read file '${filePath}': ${error.message}`); } } //# sourceMappingURL=filesystem.js.map