MCP Ripgrep Server

by mcollina
Verified
import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; import { promisify } from "util"; import { exec as execCallback } from "child_process"; const exec = promisify(execCallback); /** * Strip ANSI escape sequences from a string to make it human-readable. */ function stripAnsiEscapeCodes(input: string): string { // This regex matches ANSI escape sequences return input.replace(/\u001b\[[0-9;]*[a-zA-Z]/g, ''); } /** * Process the output based on whether colors are requested. * If colors are used, return the original output, otherwise strip ANSI codes. */ function processOutput(output: string, useColors: boolean): string { if (!output) return output; return useColors ? output : stripAnsiEscapeCodes(output); } // Create an MCP server const server = new Server( { name: "ripgrep-search", version: "1.0.0" }, { capabilities: { tools: {} // Enable tools capability } } ); /** * Safely escape a string for shell command execution. * This is a basic implementation and should be replaced with a more robust solution in production. */ function escapeShellArg(arg: string): string { // Replace all single quotes with the sequence: '"'"' // This ensures the argument is properly quoted in shell commands return `'${arg.replace(/'/g, "'\"'\"'")}'`; } // List available tools server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: "search", description: "Search files for patterns using ripgrep (rg)", inputSchema: { type: "object", properties: { pattern: { type: "string", description: "The search pattern (regex by default)" }, path: { type: "string", description: "Directory or file(s) to search." }, caseSensitive: { type: "boolean", description: "Use case sensitive search (default: auto)" }, filePattern: { type: "string", description: "Filter by file type or glob" }, maxResults: { type: "number", description: "Limit the number of matching lines" }, context: { type: "number", description: "Show N lines before and after each match" }, useColors: { type: "boolean", description: "Use colors in output (default: false)" } }, required: ["pattern", "path"] } }, { name: "advanced-search", description: "Advanced search with ripgrep with more options", inputSchema: { type: "object", properties: { pattern: { type: "string", description: "The search pattern (regex by default)" }, path: { type: "string", description: "Directory or file(s) to search." }, caseSensitive: { type: "boolean", description: "Use case sensitive search (default: auto)" }, fixedStrings: { type: "boolean", description: "Treat pattern as a literal string, not a regex" }, filePattern: { type: "string", description: "Filter by file type or glob" }, fileType: { type: "string", description: "Filter by file type (e.g., js, py)" }, maxResults: { type: "number", description: "Limit the number of matching lines" }, context: { type: "number", description: "Show N lines before and after each match" }, invertMatch: { type: "boolean", description: "Show lines that don't match the pattern" }, wordMatch: { type: "boolean", description: "Only show matches surrounded by word boundaries" }, includeHidden: { type: "boolean", description: "Search in hidden files and directories" }, followSymlinks: { type: "boolean", description: "Follow symbolic links" }, showFilenamesOnly: { type: "boolean", description: "Only show filenames of matches, not content" }, showLineNumbers: { type: "boolean", description: "Show line numbers" }, useColors: { type: "boolean", description: "Use colors in output (default: false)" } }, required: ["pattern", "path"] } }, { name: "count-matches", description: "Count matches in files using ripgrep", inputSchema: { type: "object", properties: { pattern: { type: "string", description: "The search pattern (regex by default)" }, path: { type: "string", description: "Directory or file(s) to search." }, caseSensitive: { type: "boolean", description: "Use case sensitive search (default: auto)" }, filePattern: { type: "string", description: "Filter by file type or glob" }, countLines: { type: "boolean", description: "Count matching lines instead of total matches" }, useColors: { type: "boolean", description: "Use colors in output (default: false)" } }, required: ["pattern", "path"] } }, { name: "list-files", description: "List files that would be searched by ripgrep without actually searching them", inputSchema: { type: "object", properties: { path: { type: "string", description: "Directory or file(s) to search." }, filePattern: { type: "string", description: "Filter by file type or glob" }, fileType: { type: "string", description: "Filter by file type (e.g., js, py)" }, includeHidden: { type: "boolean", description: "Include hidden files and directories" } }, required: ["path"] } }, { name: "list-file-types", description: "List all supported file types in ripgrep", inputSchema: { type: "object", properties: {} } } ] }; }); // Handle tool calls server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { const toolName = request.params.name; if (!["search", "advanced-search", "count-matches", "list-files", "list-file-types"].includes(toolName)) { // Return ServerResult.NEXT to allow the next handler to process the request return Object.create(null); } try { const args = request.params.arguments || {}; switch (toolName) { case "search": { const pattern = String(args.pattern || ""); const path = String(args.path); const caseSensitive = typeof args.caseSensitive === 'boolean' ? args.caseSensitive : undefined; const filePattern = args.filePattern ? String(args.filePattern) : undefined; const maxResults = typeof args.maxResults === 'number' ? args.maxResults : undefined; const context = typeof args.context === 'number' ? args.context : undefined; const useColors = typeof args.useColors === 'boolean' ? args.useColors : false; if (!pattern) { return { isError: true, content: [{ type: "text", text: "Error: Pattern is required" }] }; } // Build the rg command with flags let command = "rg"; // Add case sensitivity flag if specified if (caseSensitive === true) { command += " -s"; // Case sensitive } else if (caseSensitive === false) { command += " -i"; // Case insensitive } // Add file pattern if specified if (filePattern) { command += ` -g ${escapeShellArg(filePattern)}`; } // Add max results if specified if (maxResults !== undefined && maxResults > 0) { command += ` -m ${maxResults}`; } // Add context lines if specified if (context !== undefined && context > 0) { command += ` -C ${context}`; } // Add line numbers command += " -n"; // Add color setting command += useColors ? " --color always" : " --color never"; // Add pattern and path command += ` ${escapeShellArg(pattern)} ${escapeShellArg(path)}`; console.error(`Executing: ${command}`); const { stdout, stderr } = await exec(command); // If there's anything in stderr, log it for debugging if (stderr) { console.error(`ripgrep stderr: ${stderr}`); } return { content: [ { type: "text", text: processOutput(stdout, useColors) || "No matches found" } ] }; } case "advanced-search": { const pattern = String(args.pattern || ""); const path = String(args.path); const caseSensitive = typeof args.caseSensitive === 'boolean' ? args.caseSensitive : undefined; const fixedStrings = typeof args.fixedStrings === 'boolean' ? args.fixedStrings : undefined; const filePattern = args.filePattern ? String(args.filePattern) : undefined; const fileType = args.fileType ? String(args.fileType) : undefined; const maxResults = typeof args.maxResults === 'number' ? args.maxResults : undefined; const context = typeof args.context === 'number' ? args.context : undefined; const invertMatch = typeof args.invertMatch === 'boolean' ? args.invertMatch : undefined; const wordMatch = typeof args.wordMatch === 'boolean' ? args.wordMatch : undefined; const includeHidden = typeof args.includeHidden === 'boolean' ? args.includeHidden : undefined; const followSymlinks = typeof args.followSymlinks === 'boolean' ? args.followSymlinks : undefined; const showFilenamesOnly = typeof args.showFilenamesOnly === 'boolean' ? args.showFilenamesOnly : undefined; const showLineNumbers = typeof args.showLineNumbers === 'boolean' ? args.showLineNumbers : undefined; const useColors = typeof args.useColors === 'boolean' ? args.useColors : false; if (!pattern) { return { isError: true, content: [{ type: "text", text: "Error: Pattern is required" }] }; } // Build the rg command with flags let command = "rg"; // Add case sensitivity flag if specified if (caseSensitive === true) { command += " -s"; // Case sensitive } else if (caseSensitive === false) { command += " -i"; // Case insensitive } // Add fixed strings flag if specified if (fixedStrings === true) { command += " -F"; // Fixed strings } // Add file pattern if specified if (filePattern) { command += ` -g ${escapeShellArg(filePattern)}`; } // Add file type if specified if (fileType) { command += ` -t ${fileType}`; } // Add max results if specified if (maxResults !== undefined && maxResults > 0) { command += ` -m ${maxResults}`; } // Add context lines if specified if (context !== undefined && context > 0) { command += ` -C ${context}`; } // Add invert match if specified if (invertMatch === true) { command += " -v"; } // Add word match if specified if (wordMatch === true) { command += " -w"; } // Add hidden files flag if specified if (includeHidden === true) { command += " -." } // Add follow symlinks flag if specified if (followSymlinks === true) { command += " -L"; } // Add filenames only flag if specified if (showFilenamesOnly === true) { command += " -l"; } // Add line numbers flag if specified if (showLineNumbers === true) { command += " -n"; } else if (showLineNumbers === false) { command += " -N"; } else { // Default to showing line numbers command += " -n"; } // Add color setting command += useColors ? " --color always" : " --color never"; // Add pattern and path command += ` ${escapeShellArg(pattern)} ${escapeShellArg(path)}`; console.error(`Executing: ${command}`); const { stdout, stderr } = await exec(command); // If there's anything in stderr, log it for debugging if (stderr) { console.error(`ripgrep stderr: ${stderr}`); } return { content: [ { type: "text", text: processOutput(stdout, useColors) || "No matches found" } ] }; } case "count-matches": { const pattern = String(args.pattern || ""); const path = String(args.path); const caseSensitive = typeof args.caseSensitive === 'boolean' ? args.caseSensitive : undefined; const filePattern = args.filePattern ? String(args.filePattern) : undefined; const countLines = typeof args.countLines === 'boolean' ? args.countLines : true; const useColors = typeof args.useColors === 'boolean' ? args.useColors : false; if (!pattern) { return { isError: true, content: [{ type: "text", text: "Error: Pattern is required" }] }; } // Build the rg command with flags let command = "rg"; // Add case sensitivity flag if specified if (caseSensitive === true) { command += " -s"; // Case sensitive } else if (caseSensitive === false) { command += " -i"; // Case insensitive } // Add file pattern if specified if (filePattern) { command += ` -g ${escapeShellArg(filePattern)}`; } // Add count flag if (countLines) { command += " -c"; // Count lines } else { command += " --count-matches"; // Count total matches } // Add color setting command += useColors ? " --color always" : " --color never"; // Add pattern and path command += ` ${escapeShellArg(pattern)} ${escapeShellArg(path)}`; console.error(`Executing: ${command}`); const { stdout, stderr } = await exec(command); // If there's anything in stderr, log it for debugging if (stderr) { console.error(`ripgrep stderr: ${stderr}`); } return { content: [ { type: "text", text: processOutput(stdout, useColors) || "No matches found" } ] }; } case "list-files": { const path = String(args.path); const filePattern = args.filePattern ? String(args.filePattern) : undefined; const fileType = args.fileType ? String(args.fileType) : undefined; const includeHidden = typeof args.includeHidden === 'boolean' ? args.includeHidden : undefined; // Build the rg command with flags let command = "rg --files"; // Add file pattern if specified if (filePattern) { command += ` -g ${escapeShellArg(filePattern)}`; } // Add file type if specified if (fileType) { command += ` -t ${fileType}`; } // Add hidden files flag if specified if (includeHidden === true) { command += " -." } // No colors for file listing command += " --color never"; // Add path command += ` ${escapeShellArg(path)}`; console.error(`Executing: ${command}`); const { stdout, stderr } = await exec(command); // If there's anything in stderr, log it for debugging if (stderr) { console.error(`ripgrep stderr: ${stderr}`); } return { content: [ { type: "text", text: stripAnsiEscapeCodes(stdout) || "No files found" } ] }; } case "list-file-types": { // No colors for type listing const command = "rg --type-list --color never"; console.error(`Executing: ${command}`); const { stdout, stderr } = await exec(command); // If there's anything in stderr, log it for debugging if (stderr) { console.error(`ripgrep stderr: ${stderr}`); } return { content: [ { type: "text", text: stripAnsiEscapeCodes(stdout) || "Failed to get file types" } ] }; } default: // This shouldn't happen due to the initial check, but TypeScript doesn't know that return { isError: true, content: [{ type: "text", text: `Unknown tool: ${toolName}` }] }; } } catch (error: any) { // If the command exits with code 1, it means no matches were found for ripgrep if (error.code === 1 && !error.stderr) { return { content: [ { type: "text", text: "No matches found." } ] }; } // Otherwise, it's a real error return { isError: true, content: [ { type: "text", text: stripAnsiEscapeCodes(`Error: ${error.message}\n${error.stderr || ""}`) } ] }; } }); async function main() { // Start receiving messages on stdin and sending messages on stdout const transport = new StdioServerTransport(); await server.connect(transport); console.error("Ripgrep MCP Server running"); } main().catch((error) => { console.error("Fatal error:", error); process.exit(1); });