Skip to main content
Glama

MCP Smart Filesystem Server

by lofcz
ripgrep.ts10.7 kB
/** * Ripgrep integration for fast code searching */ import { spawn } from 'child_process'; import path from 'path'; import { validatePath } from './lib.js'; export interface RipgrepSearchOptions { pattern: string; path?: string; filePattern?: string; // -g flag caseInsensitive?: boolean; // -i flag contextLines?: number; // -C flag maxResults?: number; // Results per page page?: number; // Page number (1-based) literalString?: boolean; // -F flag wordBoundary?: boolean; // -w flag } export interface RipgrepMatch { file: string; line: number; column: number; matchedText: string; contextBefore: string[]; contextAfter: string[]; } export interface RipgrepResult { query: { pattern: string; searchedPaths: string[]; options?: any; }; summary: { totalMatches: number; filesWithMatches: number; searchTimeMs: number; }; pagination?: { page: number; pageSize: number; totalPages: number; hasMore: boolean; }; matches: RipgrepMatch[]; ripgrepDetails: { commandUsed: string; explanation: string; }; suggestions?: string[]; } /** * Execute ripgrep search with the given options */ export async function ripgrepSearch( options: RipgrepSearchOptions, allowedDirs: string[] ): Promise<RipgrepResult> { const startTime = Date.now(); const args: string[] = ['--json', '--no-config']; // Context lines if (options.contextLines !== undefined && options.contextLines > 0) { args.push('-C', options.contextLines.toString()); } // Case sensitivity if (options.caseInsensitive) { args.push('-i'); } // Literal string (not regex) if (options.literalString) { args.push('-F'); } // Word boundary if (options.wordBoundary) { args.push('-w'); } // File pattern (glob) if (options.filePattern) { // Support multiple patterns separated by comma const patterns = options.filePattern.split(',').map(p => p.trim()); patterns.forEach(pattern => { args.push('-g', pattern); }); } // Note: We don't use ripgrep's -m flag for pagination // Instead, we collect ALL matches and paginate in memory // This allows proper pagination across all results // Pattern args.push(options.pattern); // Search paths (validated) let searchPaths: string[]; if (options.path) { try { const validated = await validatePath(path.join(allowedDirs[0], options.path)); searchPaths = [validated]; } catch (error) { throw new Error(`Invalid search path: ${error instanceof Error ? error.message : String(error)}`); } } else { searchPaths = allowedDirs; } args.push(...searchPaths); const result = await executeRipgrep(args, options.pattern, searchPaths); result.summary.searchTimeMs = Date.now() - startTime; // Apply pagination const page = options.page || 1; const pageSize = options.maxResults || 100; const totalMatches = result.matches.length; const startIdx = (page - 1) * pageSize; const endIdx = startIdx + pageSize; // Paginate matches const paginatedMatches = result.matches.slice(startIdx, endIdx); const totalPages = Math.ceil(totalMatches / pageSize); result.matches = paginatedMatches; result.pagination = { page, pageSize, totalPages, hasMore: endIdx < totalMatches }; // Update summary to reflect total (not paginated) result.summary.totalMatches = totalMatches; // Add helpful suggestions if (totalMatches === 0) { result.suggestions = [ 'No matches found. Try:', '- Using case-insensitive search (caseInsensitive: true)', '- Simplifying the pattern', '- Checking if the file pattern is correct' ]; } else if (result.pagination.hasMore) { result.suggestions = [ `Showing page ${page} of ${totalPages} (${paginatedMatches.length} of ${totalMatches} total matches)`, `To see more results, request page ${page + 1}`, 'Or narrow your search with:', '- More specific pattern', '- File pattern filter (filePattern)', '- Path restriction (path parameter)' ]; } else if (page > 1) { result.suggestions = [ `Showing final page ${page} of ${totalPages} (${paginatedMatches.length} matches on this page, ${totalMatches} total)` ]; } return result; } /** * Search within a specific file using ripgrep */ export async function ripgrepSearchInFile( filePath: string, pattern: string, options: { caseInsensitive?: boolean; contextLines?: number; literalString?: boolean; wordBoundary?: boolean; } = {} ): Promise<RipgrepResult> { const args: string[] = ['--json', '--no-config']; if (options.contextLines !== undefined && options.contextLines > 0) { args.push('-C', options.contextLines.toString()); } if (options.caseInsensitive) { args.push('-i'); } if (options.literalString) { args.push('-F'); } if (options.wordBoundary) { args.push('-w'); } args.push(pattern); args.push(filePath); const startTime = Date.now(); const result = await executeRipgrep(args, pattern, [filePath]); result.summary.searchTimeMs = Date.now() - startTime; return result; } /** * Find files by name pattern using ripgrep */ export async function ripgrepFindFiles( pattern: string, searchPath: string | undefined, allowedDirs: string[] ): Promise<string[]> { const args = ['--files', '--no-config']; let searchPaths: string[]; if (searchPath) { try { const validated = await validatePath(path.join(allowedDirs[0], searchPath)); searchPaths = [validated]; } catch (error) { throw new Error(`Invalid search path: ${error instanceof Error ? error.message : String(error)}`); } } else { searchPaths = allowedDirs; } args.push(...searchPaths); return new Promise((resolve, reject) => { const rg = spawn('rg', args); let stdout = ''; let stderr = ''; rg.stdout.on('data', (data) => stdout += data); rg.stderr.on('data', (data) => stderr += data); rg.on('close', (code) => { if (code === 0 || code === 1) { const allFiles = stdout.trim().split('\n').filter(f => f); // Filter by pattern (simple glob matching) const matching = allFiles.filter(file => { const filename = path.basename(file); return matchPattern(filename, pattern); }); resolve(matching); } else { reject(new Error(`ripgrep failed: ${stderr}`)); } }); rg.on('error', (error) => { reject(new Error(`Failed to spawn ripgrep: ${error.message}`)); }); }); } /** * Execute ripgrep command and parse output */ async function executeRipgrep(args: string[], pattern: string, searchPaths: string[]): Promise<RipgrepResult> { return new Promise((resolve, reject) => { const rg = spawn('rg', args); let stdout = ''; let stderr = ''; rg.stdout.on('data', (data) => stdout += data); rg.stderr.on('data', (data) => stderr += data); rg.on('close', (code) => { if (code === 0 || code === 1) { // 0=found, 1=not found try { const result = parseRipgrepJsonOutput(stdout, pattern, args, searchPaths); resolve(result); } catch (err) { reject(new Error(`Failed to parse ripgrep output: ${err instanceof Error ? err.message : String(err)}`)); } } else { reject(new Error(`ripgrep error (code ${code}): ${stderr}`)); } }); rg.on('error', (error) => { reject(new Error(`Failed to spawn ripgrep: ${error.message}. Make sure ripgrep is installed.`)); }); }); } /** * Parse ripgrep JSON output */ function parseRipgrepJsonOutput(jsonOutput: string, pattern: string, args: string[], searchPaths: string[]): RipgrepResult { const lines = jsonOutput.trim().split('\n').filter(l => l); const matches: RipgrepMatch[] = []; let currentFile = ''; let contextBefore: string[] = []; for (const line of lines) { try { const entry = JSON.parse(line); if (entry.type === 'begin') { currentFile = entry.data.path.text; contextBefore = []; } else if (entry.type === 'match') { const matchedLine = entry.data.line_number; matches.push({ file: currentFile, line: matchedLine, column: entry.data.submatches[0]?.start || 0, matchedText: entry.data.lines.text.trimEnd(), contextBefore: [...contextBefore], contextAfter: [] }); contextBefore = []; } else if (entry.type === 'context') { const contextLine = entry.data.line_number; const contextText = entry.data.lines.text.trimEnd(); if (matches.length === 0) { // Context before first match contextBefore.push(contextText); } else { const lastMatch = matches[matches.length - 1]; if (contextLine < lastMatch.line) { // Context before this match (shouldn't happen with current logic) contextBefore.push(contextText); } else { // Context after last match lastMatch.contextAfter.push(contextText); } } } } catch { // Skip malformed lines } } const filesWithMatches = new Set(matches.map(m => m.file)).size; return { query: { pattern, searchedPaths: searchPaths }, summary: { totalMatches: matches.length, filesWithMatches, searchTimeMs: 0 // Will be set by caller }, matches, ripgrepDetails: { commandUsed: `rg ${args.join(' ')}`, explanation: `Searched for pattern: ${pattern}` } }; } /** * Match filename against glob pattern */ function matchPattern(filename: string, pattern: string): boolean { // If pattern doesn't contain wildcards, add implicit wildcards for partial matching const hasWildcards = pattern.includes('*') || pattern.includes('?') || pattern.includes('{'); if (!hasWildcards) { // For patterns without wildcards, match if the pattern appears anywhere in the filename return filename.toLowerCase().includes(pattern.toLowerCase()); } // Convert glob pattern to regex const regex = new RegExp( '^' + pattern .replace(/\./g, '\\.') .replace(/\*/g, '.*') .replace(/\?/g, '.') .replace(/\{([^}]+)\}/g, (_, alternatives) => `(${alternatives.replace(/,/g, '|')})`) + '$', 'i' ); return regex.test(filename); }

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/lofcz/mcp-filesystem-smart'

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