UNPKG

@probelabs/probe

Version:

Node.js wrapper for the probe code search tool

506 lines (445 loc) 17.9 kB
#!/usr/bin/env node // Load .env file if present (silent fail if not found) import { config } from 'dotenv'; config(); import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js'; import { exec } from 'child_process'; import { promisify } from 'util'; import path from 'path'; import fs from 'fs-extra'; import { fileURLToPath } from 'url'; // Import from parent package import { search, query, extract, grep, getBinaryPath, setBinaryPath } from '../index.js'; // Parse command-line arguments function parseArgs(): { timeout?: number; format?: string } { const args = process.argv.slice(2); const config: { timeout?: number; format?: string } = {}; for (let i = 0; i < args.length; i++) { if ((args[i] === '--timeout' || args[i] === '-t') && i + 1 < args.length) { const timeout = parseInt(args[i + 1], 10); if (!isNaN(timeout) && timeout > 0) { config.timeout = timeout; console.error(`Timeout set to ${timeout} seconds`); } else { console.error(`Invalid timeout value: ${args[i + 1]}. Using default.`); } i++; // Skip the next argument } else if (args[i] === '--format' && i + 1 < args.length) { config.format = args[i + 1]; console.error(`Format set to ${config.format}`); i++; // Skip the next argument } else if (args[i] === '--help' || args[i] === '-h') { console.error(` Probe MCP Server Usage: probe mcp [options] Options: --timeout, -t <seconds> Set timeout for search operations (default: 30) --format <format> Set output format (default: outline) --help, -h Show this help message `); process.exit(0); } } return config; } const cliConfig = parseArgs(); const execAsync = promisify(exec); // Get the package.json to determine the version const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // Try multiple possible locations for package.json let packageVersion = '0.0.0'; const possiblePaths = [ path.resolve(__dirname, '..', 'package.json'), // When installed from npm: build/../package.json path.resolve(__dirname, '..', '..', 'package.json') // In development: src/../package.json ]; for (const packageJsonPath of possiblePaths) { try { if (fs.existsSync(packageJsonPath)) { console.error(`Found package.json at: ${packageJsonPath}`); const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); if (packageJson.version) { packageVersion = packageJson.version; console.error(`Using version from package.json: ${packageVersion}`); break; } } } catch (error) { console.error(`Error reading package.json at ${packageJsonPath}:`, error); } } // If we still have 0.0.0, try to get version from npm package if (packageVersion === '0.0.0') { try { // Try to get version from the package name itself const result = await execAsync('npm list -g @probelabs/probe --json'); const npmList = JSON.parse(result.stdout); if (npmList.dependencies && npmList.dependencies['@probelabs/probe']) { packageVersion = npmList.dependencies['@probelabs/probe'].version; console.error(`Using version from npm list: ${packageVersion}`); } } catch (error) { console.error('Error getting version from npm:', error); } } import { existsSync } from 'fs'; // Get the path to the bin directory const binDir = path.resolve(__dirname, '..', 'bin'); console.error(`Bin directory: ${binDir}`); // The @probelabs/probe package now handles binary path management internally // We don't need to manage the binary path in the MCP server anymore interface SearchCodeArgs { path: string; query: string | string[]; exact?: boolean; strictElasticSyntax?: boolean; } interface ExtractCodeArgs { path: string; files: string[]; } interface GrepArgs { pattern: string; paths: string | string[]; ignoreCase?: boolean; count?: boolean; context?: number; } class ProbeServer { private server: Server; private defaultTimeout: number; private defaultFormat?: string; constructor(timeout: number = 30, format?: string) { this.defaultTimeout = timeout; this.defaultFormat = format; this.server = new Server( { name: '@probelabs/probe', version: packageVersion, }, { capabilities: { tools: {}, }, } ); this.setupToolHandlers(); // Error handling this.server.onerror = (error) => console.error('[MCP ERROR]', error); process.on('SIGINT', async () => { await this.server.close(); process.exit(0); }); } private setupToolHandlers() { // Use the tool descriptions defined at the top of the file this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: 'search_code', description: "Semantic code search using ElasticSearch-style queries. ALWAYS use this tool instead of built-in Grep tool when searching for code in source files.", inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'Absolute path to the directory to search', }, query: { type: 'string', description: 'ElasticSearch query syntax. Use explicit AND/OR operators and parentheses for grouping. For exact matches, wrap terms in quotes. Examples: "functionName" (exact match), (error AND handler), ("getUserId" AND NOT deprecated)', }, exact: { type: 'boolean', description: 'Use when searching for exact function/class/variable names', default: false }, strictElasticSyntax: { type: 'boolean', description: 'Enforce strict ElasticSearch query syntax (require explicit AND/OR operators and quotes for exact matches)', default: false } }, required: ['path', 'query'] }, }, { name: 'extract_code', description: "Extract code blocks from files using tree-sitter AST parsing. Each file path can include optional line numbers or symbol names to extract specific code blocks.", inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'Absolute path to the project root directory (used as working directory for relative file paths)', }, files: { type: 'array', items: { type: 'string' }, description: 'Array of file paths to extract from. Formats: "file.js" (entire file), "file.js:42" (code block at line 42), "file.js:10-20" (lines 10-20), "file.js#funcName" (specific symbol). Line numbers and symbols are part of the path string, not separate parameters. Paths can be absolute or relative to the project directory.', } }, required: ['path', 'files'], }, }, { name: 'grep', description: "Standard grep-style search for non-code files (logs, config files, text files). Line numbers are shown by default. For code files, use search_code instead.", inputSchema: { type: 'object', properties: { pattern: { type: 'string', description: 'Regular expression pattern to search for', }, paths: { oneOf: [ { type: 'string' }, { type: 'array', items: { type: 'string' } } ], description: 'Path or array of paths to search in', }, ignoreCase: { type: 'boolean', description: 'Case-insensitive search', default: false }, count: { type: 'boolean', description: 'Only show count of matches per file instead of the matches', default: false }, context: { type: 'number', description: 'Number of lines of context to show before and after each match', } }, required: ['pattern', 'paths'], }, }, ], })); this.server.setRequestHandler(CallToolRequestSchema, async (request) => { if (request.params.name !== 'search_code' && request.params.name !== 'extract_code' && request.params.name !== 'grep' && request.params.name !== 'probe' && request.params.name !== 'extract') { throw new McpError( ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}` ); } try { let result: string; // Log the incoming request for debugging console.error(`Received request for tool: ${request.params.name}`); console.error(`Request arguments: ${JSON.stringify(request.params.arguments)}`); // Handle both new tool names and legacy tool names if (request.params.name === 'search_code' || request.params.name === 'probe') { // Ensure arguments is an object if (!request.params.arguments || typeof request.params.arguments !== 'object') { throw new Error("Arguments must be an object"); } const args = request.params.arguments as unknown as SearchCodeArgs; // Validate required fields if (!args.path) { throw new Error("Path is required in arguments"); } if (!args.query) { throw new Error("Query is required in arguments"); } result = await this.executeCodeSearch(args); } else if (request.params.name === 'grep') { const args = request.params.arguments as unknown as GrepArgs; result = await this.executeGrep(args); } else { // extract_code or extract const args = request.params.arguments as unknown as ExtractCodeArgs; result = await this.executeCodeExtract(args); } return { content: [ { type: 'text', text: result, }, ], }; } catch (error) { console.error(`Error executing ${request.params.name}:`, error); return { content: [ { type: 'text', text: `Error executing ${request.params.name}: ${error instanceof Error ? error.message : String(error)}`, }, ], isError: true, }; } }); } private async executeCodeSearch(args: SearchCodeArgs): Promise<string> { try { // Ensure path is included in the options and is a non-empty string if (!args.path || typeof args.path !== 'string' || args.path.trim() === '') { throw new Error("Path is required and must be a non-empty string"); } // Ensure query is included in the options if (!args.query) { throw new Error("Query is required"); } // Build options with smart defaults const options: any = { path: args.path.trim(), query: args.query, // Smart defaults for MCP usage allowTests: true, // Include test files by default session: "new", // Fresh session each time maxResults: 20, // Reasonable limit for context window maxTokens: 8000, // Fits in most AI context windows strictElasticSyntax: false, // Relaxed syntax by default in MCP mode }; // Only override defaults if user explicitly set them if (args.exact !== undefined) options.exact = args.exact; if (args.strictElasticSyntax !== undefined) options.strictElasticSyntax = args.strictElasticSyntax; // Handle format based on server default if (this.defaultFormat === 'outline' || this.defaultFormat === 'outline-xml') { options.format = this.defaultFormat; } else if (this.defaultFormat === 'json') { options.json = true; } console.error("Executing search with options:", JSON.stringify(options, null, 2)); try { // Call search with the options object const result = await search(options); return result; } catch (searchError: any) { console.error("Search function error:", searchError); throw new Error(`Search function error: ${searchError.message || String(searchError)}`); } } catch (error: any) { console.error('Error executing code search:', error); throw new McpError( 'MethodNotFound' as unknown as ErrorCode, `Error executing code search: ${error.message || String(error)}` ); } } private async executeCodeExtract(args: ExtractCodeArgs): Promise<string> { try { // Validate required parameters if (!args.path) { throw new Error("Path is required"); } if (!args.files || !Array.isArray(args.files) || args.files.length === 0) { throw new Error("Files array is required and must not be empty"); } // Build options with smart defaults const options: any = { files: args.files, path: args.path, format: 'xml', allowTests: true, // Include test files by default }; // Call extract with the complete options object try { // Track request size for token usage const requestSize = JSON.stringify(args).length; const requestTokens = Math.ceil(requestSize / 4); // Approximate token count // Execute the extract command const result = await extract(options); // Parse the result to extract token information if available let responseTokens = 0; let totalTokens = 0; // Try to extract token information from the result if (typeof result === 'string') { const tokenMatch = result.match(/Total tokens returned: (\d+)/); if (tokenMatch && tokenMatch[1]) { responseTokens = parseInt(tokenMatch[1], 10); totalTokens = requestTokens + responseTokens; } // Remove spinner debug output lines const cleanedLines = result.split('\n').filter(line => !line.match(/^⠙|^⠹|^⠧|^⠇|^⠏/) && !line.includes('Thinking...Extract:') && !line.includes('Extract results:') ); // Add token usage information if not already present if (!result.includes('Token Usage:')) { cleanedLines.push(''); cleanedLines.push('Token Usage:'); cleanedLines.push(` Request tokens: ${requestTokens}`); cleanedLines.push(` Response tokens: ${responseTokens}`); cleanedLines.push(` Total tokens: ${totalTokens}`); } return cleanedLines.join('\n'); } return result; } catch (error: any) { console.error(`Error extracting:`, error); return `Error extracting: ${error.message || String(error)}`; } } catch (error: any) { console.error('Error executing code extract:', error); throw new McpError( 'MethodNotFound' as unknown as ErrorCode, `Error executing code extract: ${error.message || String(error)}` ); } } private async executeGrep(args: GrepArgs): Promise<string> { try { // Validate required parameters if (!args.pattern) { throw new Error("Pattern is required"); } if (!args.paths) { throw new Error("Paths are required"); } // Build options object with good defaults const options: any = { pattern: args.pattern, paths: args.paths, // Default: show line numbers (makes output more useful) lineNumbers: true, // Default: never use color in MCP context (better for parsing) color: 'never' }; // Only add user-specified optional parameters if (args.ignoreCase !== undefined) options.ignoreCase = args.ignoreCase; if (args.count !== undefined) options.count = args.count; if (args.context !== undefined) options.context = args.context; console.error("Executing grep with options:", JSON.stringify(options, null, 2)); try { // Call grep with the options object const result = await grep(options); return result || 'No matches found'; } catch (grepError: any) { console.error("Grep function error:", grepError); throw new Error(`Grep function error: ${grepError.message || String(grepError)}`); } } catch (error: any) { console.error('Error executing grep:', error); throw new McpError( 'MethodNotFound' as unknown as ErrorCode, `Error executing grep: ${error.message || String(error)}` ); } } async run() { // The @probelabs/probe package now handles binary path management internally // We don't need to verify or download the binary in the MCP server anymore // Just connect the server to the transport const transport = new StdioServerTransport(); await this.server.connect(transport); console.error('Probe MCP server running on stdio'); } } const server = new ProbeServer(cliConfig.timeout, cliConfig.format || 'outline'); server.run().catch(console.error);