@buger/probe-mcp
Version:
MCP server for probe CLI
407 lines (406 loc) • 19.9 kB
JavaScript
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 the probe package with type declarations
// @ts-ignore - Ignore missing type declarations for @buger/probe
import { search, query, extract } from '@buger/probe';
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.log(`Found package.json at: ${packageJsonPath}`);
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
if (packageJson.version) {
packageVersion = packageJson.version;
console.log(`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 @buger/probe-mcp --json');
const npmList = JSON.parse(result.stdout);
if (npmList.dependencies && npmList.dependencies['@buger/probe-mcp']) {
packageVersion = npmList.dependencies['@buger/probe-mcp'].version;
console.log(`Using version from npm list: ${packageVersion}`);
}
}
catch (error) {
console.error('Error getting version from npm:', error);
}
}
// Get the path to the bin directory
const binDir = path.resolve(__dirname, '..', 'bin');
console.log(`Bin directory: ${binDir}`);
class ProbeServer {
constructor() {
this.server = new Server({
name: '@buger/probe-mcp',
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);
});
}
setupToolHandlers() {
// Use the tool descriptions defined at the top of the file
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'search_code',
description: "Search code in the repository using ElasticSearch. Use this tool first for any code-related questions.",
inputSchema: {
type: 'object',
properties: {
path: {
type: 'string',
description: 'Absolute path to the directory to search in (e.g., "/Users/username/projects/myproject").',
},
query: {
type: 'string',
description: 'Elastic search query. Supports logical operators (AND, OR, NOT), and grouping with parentheses. Examples: "config", "(term1 OR term2) AND term3". Use quotes for exact matches, like function or type names.',
},
filesOnly: {
type: 'boolean',
description: 'Skip AST parsing and just output unique files',
},
ignore: {
type: 'array',
items: { type: 'string' },
description: 'Custom patterns to ignore (in addition to .gitignore and common patterns)'
},
excludeFilenames: {
type: 'boolean',
description: 'Exclude filenames from being used for matching'
},
allowTests: {
type: 'boolean',
description: 'Allow test files and test code blocks in results (disabled by default)'
},
session: {
type: 'string',
description: 'Session identifier for caching. Set to "new" if unknown, or want to reset cache. Re-use session ID returned from previous searches',
default: "new",
}
},
required: ['query']
},
},
{
name: 'query_code',
description: "Search code using ast-grep structural pattern matching. Use this tool to find specific code structures like functions, classes, or methods.",
inputSchema: {
type: 'object',
properties: {
path: {
type: 'string',
description: 'Absolute path to the directory to search in (e.g., "/Users/username/projects/myproject").',
},
pattern: {
type: 'string',
description: 'The ast-grep pattern to search for. Examples: "fn $NAME($$$PARAMS) $$$BODY" for Rust functions, "def $NAME($$$PARAMS): $$$BODY" for Python functions.',
},
language: {
type: 'string',
description: 'The programming language to search in. If not specified, the tool will try to infer the language from file extensions. Supported languages: rust, javascript, typescript, python, go, c, cpp, java, ruby, php, swift, csharp.',
},
ignore: {
type: 'array',
items: { type: 'string' },
description: 'Custom patterns to ignore (in addition to common patterns)',
},
maxResults: {
type: 'number',
description: 'Maximum number of results to return'
},
format: {
type: 'string',
enum: ['markdown', 'plain', 'json', 'color'],
description: 'Output format for the query results'
}
},
required: ['pattern']
},
},
{
name: 'extract_code',
description: "Extract code blocks from files based on line number, or symbol name. Fetch full file when line number is not provided.",
inputSchema: {
type: 'object',
properties: {
path: {
type: 'string',
description: 'Absolute path to the directory to search in (e.g., "/Users/username/projects/myproject").',
},
files: {
type: 'array',
items: { type: 'string' },
description: 'Files and lines or sybmbols to extract from: /path/to/file.rs:10, /path/to/file.rs#func_name Path should be absolute.',
},
allowTests: {
type: 'boolean',
description: 'Allow test files and test code blocks in results (disabled by default)',
},
contextLines: {
type: 'number',
description: 'Number of context lines to include before and after the extracted block when AST parsing fails to find a suitable node',
default: 0
},
format: {
type: 'string',
enum: ['markdown', 'plain', 'json'],
description: 'Output format for the extracted code',
default: 'markdown'
},
},
required: ['path', 'files'],
},
},
],
}));
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name !== 'search_code' && request.params.name !== 'query_code' && request.params.name !== 'extract_code' &&
request.params.name !== 'probe' && request.params.name !== 'query' && request.params.name !== 'extract') {
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}`);
}
try {
let result;
// 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;
// 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 === 'query_code' || request.params.name === 'query') {
const args = request.params.arguments;
result = await this.executeCodeQuery(args);
}
else { // extract_code or extract
const args = request.params.arguments;
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,
};
}
});
}
async executeCodeSearch(args) {
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");
}
// Log the arguments we received for debugging
console.error(`Received search arguments: path=${args.path}, query=${JSON.stringify(args.query)}`);
// Create a clean options object with only the essential properties first
const options = {
path: args.path.trim(), // Ensure path is trimmed
query: args.query
};
// Add optional parameters only if they exist
if (args.filesOnly !== undefined)
options.filesOnly = args.filesOnly;
if (args.ignore !== undefined)
options.ignore = args.ignore;
if (args.excludeFilenames !== undefined)
options.excludeFilenames = args.excludeFilenames;
if (args.maxResults !== undefined)
options.maxResults = args.maxResults;
if (args.maxTokens !== undefined)
options.maxTokens = args.maxTokens;
if (args.allowTests !== undefined)
options.allowTests = args.allowTests;
if (args.session !== undefined && args.session.trim() !== '') {
options.session = args.session;
}
else {
options.session = "new";
}
console.error("Executing search with options:", JSON.stringify(options, null, 2));
// Double-check that path is still in the options object
if (!options.path) {
console.error("Path is missing from options object after construction");
throw new Error("Path is missing from options object");
}
try {
// Call search with the options object
const result = await search(options);
return result;
}
catch (searchError) {
console.error("Search function error:", searchError);
throw new Error(`Search function error: ${searchError.message || String(searchError)}`);
}
}
catch (error) {
console.error('Error executing code search:', error);
throw new McpError('MethodNotFound', `Error executing code search: ${error.message || String(error)}`);
}
}
async executeCodeQuery(args) {
try {
// Validate required parameters
if (!args.path) {
throw new Error("Path is required");
}
if (!args.pattern) {
throw new Error("Pattern is required");
}
// Create a single options object with both pattern and path
const options = {
path: args.path,
pattern: args.pattern,
language: args.language,
ignore: args.ignore,
allowTests: args.allowTests,
maxResults: args.maxResults,
format: args.format
};
console.log("Executing query with options:", JSON.stringify({
path: options.path,
pattern: options.pattern
}));
const result = await query(options);
return result;
}
catch (error) {
console.error('Error executing code query:', error);
throw new McpError('MethodNotFound', `Error executing code query: ${error.message || String(error)}`);
}
}
async executeCodeExtract(args) {
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");
}
// Create a single options object with files and other parameters
const options = {
files: args.files,
path: args.path,
allowTests: args.allowTests,
contextLines: args.contextLines,
format: args.format
};
// 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) {
console.error(`Error extracting:`, error);
return `Error extracting: ${error.message || String(error)}`;
}
}
catch (error) {
console.error('Error executing code extract:', error);
throw new McpError('MethodNotFound', `Error executing code extract: ${error.message || String(error)}`);
}
}
async run() {
// The @buger/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();
server.run().catch(console.error);