UNPKG

@alvinveroy/codecompass

Version:

AI-powered MCP server for codebase navigation and LLM prompt optimization

1,047 lines (931 loc) 66.6 kB
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; // Added ResourceTemplate // Assuming these are correctly exported by the SDK, either from root or via defined subpaths. // If the SDK's "exports" map points these subpaths to .js files, add .js here. // If they are re-exported from the main SDK entry, use that. import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; // SessionManager import removed as it's not used or found at the specified path. // Session handling is managed by StreamableHTTPServerTransport options. import { randomUUID } from "crypto"; import express from 'express'; import http from 'http'; import axios from 'axios'; // Add this import import { ServerRequest, ServerNotification, isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; import { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; import { Variables } from "@modelcontextprotocol/sdk/shared/uriTemplate.js"; import fs from "fs/promises"; import path from "path"; import git from "isomorphic-git"; import { QdrantClient } from "@qdrant/js-client-rest"; import { configService, logger } from "./config-service"; import { DetailedQdrantSearchResult, FileChunkPayload, // New CommitInfoPayload, // New DiffChunkPayload // New } from "./types"; import { IndexingStatusReport } from './repository'; // Correct import for IndexingStatusReport import { z } from "zod"; import { checkOllama, checkOllamaModel } from "./ollama"; import { initializeQdrant } from "./qdrant"; import { searchWithRefinement } from "./query-refinement"; // Keep this import { validateGitRepository, indexRepository, getRepositoryDiff, getGlobalIndexingStatus } from "./repository"; import { getLLMProvider, switchSuggestionModel, LLMProvider } from "./llm-provider"; import { processAgentQuery } from './agent-service'; import { VERSION } from "./version"; import { getOrCreateSession, addQuery, addSuggestion, updateContext, getRecentQueries, getRelevantResults } from "./state"; // Define this interface at the top level of the file, e.g., after imports interface RequestBodyWithId { id?: unknown; // id can be string, number, or null [key: string]: unknown; // Allow other properties } interface PingResponseData { service?: string; status?: string; version?: string; } // Helper type for server startup errors class ServerStartupError extends Error { constructor(message: string, public exitCode = 1) { // Remove : number type annotation super(message); this.name = "ServerStartupError"; } } export function normalizeToolParams(params: unknown): Record<string, unknown> { if (typeof params === 'object' && params !== null) { // Ensure it's a standard object, not a null-prototype one. // Spread syntax creates a new object with a standard prototype. return { ...params } as Record<string, unknown>; } if (typeof params === 'string') { try { const parsed = JSON.parse(params) as unknown; if (typeof parsed === 'object' && parsed !== null) { return parsed as Record<string, unknown>; } return { query: params }; } catch { return { query: params }; } } if (params === null || params === undefined) { return { query: "" }; } // At this point, params can be boolean, number, bigint, or symbol. // For these types, String() or .toString() is the correct and safe way to convert. if (typeof params === 'number' || typeof params === 'boolean' || typeof params === 'bigint') { return { query: String(params) }; } if (typeof params === 'symbol') { return { query: params.toString() }; // Symbols require .toString() } // Fallback for any other unexpected type, though TS should prevent this with `unknown` logger.warn(`normalizeToolParams: Encountered unexpected param type at end of function: ${typeof params}. Defaulting query string.`); return { query: `[Unexpected type: ${typeof params}]` }; } // Add this function definition at the module level, before startServer // eslint-disable-next-line @typescript-eslint/require-await async function configureMcpServerInstance( mcpInstance: McpServer, qdrantClient: QdrantClient, repoPath: string, suggestionModelAvailable: boolean // Add other dependencies like VERSION if needed by resource/tool registration ) { // Register resources if (typeof mcpInstance.resource !== "function") { throw new Error("MCP server instance does not support 'resource' method"); } mcpInstance.resource("Server Health Status", "repo://health", async () => { const healthUri = "repo://health"; try { let ollamaStatus = "unhealthy"; try { await checkOllama(); ollamaStatus = "healthy"; } catch (err) { logger.warn(`Ollama health check failed during repo://health: ${err instanceof Error ? err.message : String(err)}`); } let qdrantStatus = "unhealthy"; try { await qdrantClient.getCollections(); qdrantStatus = "healthy"; } catch (err) { logger.warn(`Qdrant health check failed during repo://health: ${err instanceof Error ? err.message : String(err)}`); } const repositoryStatus = await validateGitRepository(repoPath) ? "healthy" : "unhealthy"; const status = { ollama: ollamaStatus, qdrant: qdrantStatus, repository: repositoryStatus, version: VERSION, // Ensure VERSION is imported and accessible timestamp: new Date().toISOString() }; return { contents: [{ uri: healthUri, text: JSON.stringify(status, null, 2) }] }; } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : String(error); logger.error(`Critical error in repo://health resource handler: ${errorMessage}`); const errorPayload = { error: "Failed to retrieve complete health status due to a critical error.", details: errorMessage, version: VERSION, // Ensure VERSION is imported and accessible timestamp: new Date().toISOString(), ollama: "unknown", qdrant: "unknown", repository: "unknown" }; return { contents: [{ uri: healthUri, text: JSON.stringify(errorPayload, null, 2) }] }; } }); mcpInstance.resource("Server Version", "repo://version", () => { return { contents: [{ uri: "repo://version", text: VERSION }] }; // Ensure VERSION is imported and accessible }); mcpInstance.resource("Repository File Structure", "repo://structure", async () => { const uriStr = "repo://structure"; const isGitRepo = await validateGitRepository(repoPath); if (!isGitRepo) { return { contents: [{ uri: uriStr, text: "" }] }; } try { const files = await git.listFiles({ fs, dir: repoPath, gitdir: path.join(repoPath, ".git"), ref: "HEAD" }); return { contents: [{ uri: uriStr, text: files.join("\n") }] }; } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : String(error); logger.error(`Error listing repository files for ${repoPath}: ${errorMessage}`); return { contents: [{ uri: uriStr, text: "", error: `Failed to list repository files: ${errorMessage}` }] }; } }); mcpInstance.resource( "Repository File Content", new ResourceTemplate("repo://files/{filepath}", { list: undefined }), {}, async (uri: URL, variables: Variables, _extra: RequestHandlerExtra<ServerRequest, ServerNotification>) => { const rawFilepathValue = variables.filepath; let relativeFilepath = ''; if (typeof rawFilepathValue === 'string') { relativeFilepath = rawFilepathValue.trim(); } else if (Array.isArray(rawFilepathValue) && rawFilepathValue.length > 0 && typeof rawFilepathValue[0] === 'string') { logger.warn(`Filepath parameter '${JSON.stringify(rawFilepathValue)}' resolved to an array. Using the first element: '${rawFilepathValue[0]}'`); relativeFilepath = rawFilepathValue[0].trim(); } else if (rawFilepathValue !== undefined) { logger.warn(`Filepath parameter '${Array.isArray(rawFilepathValue) ? JSON.stringify(rawFilepathValue) : rawFilepathValue}' resolved to an unexpected type: ${typeof rawFilepathValue}. Treating as empty.`); } if (!relativeFilepath) { const errMsg = "File path cannot be empty."; logger.error(`Error accessing resource for URI ${uri.toString()}: ${errMsg}`); return { contents: [{ uri: uri.toString(), text: "", error: errMsg }] }; } try { const resolvedRepoPath = path.resolve(repoPath); const requestedFullPath = path.resolve(repoPath, relativeFilepath); if (!requestedFullPath.startsWith(resolvedRepoPath + path.sep) && requestedFullPath !== resolvedRepoPath) { throw new Error(`Access denied: Path '${relativeFilepath}' attempts to traverse outside the repository directory.`); } let finalPathToRead = requestedFullPath; try { const stats = await fs.lstat(requestedFullPath); if (stats.isSymbolicLink()) { const symlinkTargetPath = await fs.realpath(requestedFullPath); if (!path.resolve(symlinkTargetPath).startsWith(resolvedRepoPath + path.sep) && path.resolve(symlinkTargetPath) !== resolvedRepoPath) { throw new Error(`Access denied: Symbolic link '${relativeFilepath}' points outside the repository directory.`); } finalPathToRead = symlinkTargetPath; } else if (!stats.isFile()) { throw new Error(`Access denied: Path '${relativeFilepath}' is not a file.`); } } catch (statError: unknown) { if ((statError as NodeJS.ErrnoException).code === 'ENOENT') { throw new Error(`File not found: ${relativeFilepath}`); } throw statError; } const content = await fs.readFile(finalPathToRead, "utf8"); return { contents: [{ uri: uri.toString(), text: content }] }; } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : String(error); logger.error(`Error accessing resource for URI ${uri.toString()} (relative path: ${relativeFilepath}): ${errorMessage}`); return { contents: [{ uri: uri.toString(), text: "", error: errorMessage }] }; } }); // Assuming registerTools and registerPrompts are defined elsewhere and correctly use mcpInstance registerTools(mcpInstance, qdrantClient, repoPath, suggestionModelAvailable); registerPrompts(mcpInstance); mcpInstance.tool( "bb7_get_indexing_status", "Retrieves the current status of repository indexing. Provides information on whether indexing is idle, in-progress, completed, or failed, along with progress percentage and any error messages.", {}, (_args: Record<string, never>, _extra: RequestHandlerExtra<ServerRequest, ServerNotification>) => { logger.info("Tool 'get_indexing_status' execution started."); const currentStatus = getGlobalIndexingStatus(); return { content: [{ type: "text", text: `# Indexing Status - Status: ${currentStatus.status} - Progress: ${currentStatus.overallProgress}% - Message: ${currentStatus.message} - Last Updated: ${currentStatus.lastUpdatedAt} ${currentStatus.currentFile ? `- Current File: ${currentStatus.currentFile}` : ''} ${currentStatus.currentCommit ? `- Current Commit: ${currentStatus.currentCommit}` : ''} ${currentStatus.totalFilesToIndex ? `- Total Files: ${currentStatus.totalFilesToIndex}` : ''} ${currentStatus.filesIndexed ? `- Files Indexed: ${currentStatus.filesIndexed}` : ''} ${currentStatus.totalCommitsToIndex ? `- Total Commits: ${currentStatus.totalCommitsToIndex}` : ''} ${currentStatus.commitsIndexed ? `- Commits Indexed: ${currentStatus.commitsIndexed}` : ''} ${currentStatus.errorDetails ? `- Error: ${currentStatus.errorDetails}` : ''} `, }], }; } ); mcpInstance.tool( "bb7_switch_suggestion_model", "Switches the primary model and provider used for generating suggestions. Embeddings continue to be handled by the configured Ollama embedding model. \nExample: To switch to 'deepseek-coder' (DeepSeek provider), use `{\"model\": \"deepseek-coder\", \"provider\": \"deepseek\"}`. To switch to 'llama3.1:8b' (Ollama provider), use `{\"model\": \"llama3.1:8b\", \"provider\": \"ollama\"}`. If provider is omitted, it may be inferred for known model patterns. For other providers like 'openai', 'gemini', 'claude', specify both model and provider: `{\"model\": \"gpt-4\", \"provider\": \"openai\"}`.", { model: z.string().describe("The suggestion model to switch to (e.g., 'llama3.1:8b', 'deepseek-coder', 'gpt-4')."), provider: z.string().optional().describe("The LLM provider for the model (e.g., 'ollama', 'deepseek', 'openai', 'gemini', 'claude'). If omitted, an attempt will be made to infer it.") }, async (args: { model: string; provider?: string }, _extra: RequestHandlerExtra<ServerRequest, ServerNotification>) => { logger.info("Received args for switch_suggestion_model", { args }); const modelToSwitchTo = args.model; const providerToSwitchTo = args.provider?.toLowerCase(); if (!modelToSwitchTo || typeof modelToSwitchTo !== 'string' || modelToSwitchTo.trim() === "") { const errorMsg = "Invalid or missing 'model' parameter. Please provide a non-empty model name string."; logger.error(errorMsg, { receivedModel: modelToSwitchTo }); return { content: [{ type: "text", text: `# Error Switching Suggestion Model\n\n${errorMsg}`, }], }; } if (args.provider !== undefined && (typeof args.provider !== 'string' || args.provider.trim() === "")) { const errorMsg = "Invalid 'provider' parameter. If provided, it must be a non-empty string."; logger.error(errorMsg, { receivedProvider: args.provider }); return { content: [{ type: "text", text: `# Error Switching Suggestion Model\n\n${errorMsg}`, }], }; } logger.info(`Requested model switch: Model='${modelToSwitchTo}', Provider='${providerToSwitchTo || "(infer)"}'`); try { const success = await switchSuggestionModel(modelToSwitchTo, providerToSwitchTo); if (!success) { return { content: [{ type: "text", text: `# Failed to Switch Suggestion Model\n\nUnable to switch to model '${modelToSwitchTo}'${providerToSwitchTo ? ` with provider '${providerToSwitchTo}'` : ''}. Please check your configuration and server logs for details. Ensure the provider is supported and any necessary API keys or host configurations are correctly set.`, }], }; } const actualModel = configService.SUGGESTION_MODEL; const actualProvider = configService.SUGGESTION_PROVIDER; const embeddingProvider = configService.EMBEDDING_PROVIDER; logger.info(`Successfully switched. ConfigService reports: Model='${actualModel}', Provider='${actualProvider}', Embedding Provider='${embeddingProvider}'`); let message = `# Suggestion Model Switched\n\nSuccessfully switched to model '${actualModel}' using provider '${actualProvider}' for suggestions.\nEmbeddings continue to use '${embeddingProvider}'.\n\n`; message += `To make this change permanent, update your environment variables (e.g., SUGGESTION_MODEL='${actualModel}', SUGGESTION_PROVIDER='${actualProvider}') or the relevant configuration files (e.g., ~/.codecompass/model-config.json).`; if (actualProvider === 'deepseek' && !configService.DEEPSEEK_API_KEY) { message += `\n\nWarning: DeepSeek provider is selected, but DEEPSEEK_API_KEY is not found in current configuration. Ensure it is set for DeepSeek to function.`; } else if (actualProvider === 'openai' && !configService.OPENAI_API_KEY) { message += `\n\nWarning: OpenAI provider is selected, but OPENAI_API_KEY is not found. Ensure it is set.`; } else if (actualProvider === 'gemini' && !configService.GEMINI_API_KEY) { message += `\n\nWarning: Gemini provider is selected, but GEMINI_API_KEY is not found. Ensure it is set.`; } else if (actualProvider === 'claude' && !configService.CLAUDE_API_KEY) { message += `\n\nWarning: Claude provider is selected, but CLAUDE_API_KEY is not found. Ensure it is set.`; } return { content: [{ type: "text", text: message, }], }; } catch (error: unknown) { logger.error("Error switching suggestion model", { message: error instanceof Error ? error.message : String(error) }); return { content: [{ type: "text", text: `# Error Switching Suggestion Model\n\n${error instanceof Error ? error.message : String(error)}`, }], }; } } ); } export async function startServer(repoPath: string): Promise<void> { process.on('uncaughtException', (error: Error) => { logger.error('UNCAUGHT EXCEPTION:', { message: error.message, stack: error.stack }); if (process.env.NODE_ENV !== 'test') { process.exit(1); } }); process.on('unhandledRejection', (reason: unknown, promise: Promise<unknown>) => { logger.error('UNHANDLED PROMISE REJECTION:', { reason, promise }); if (process.env.NODE_ENV !== 'test') { process.exit(1); } }); logger.info("Starting CodeCompass MCP server..."); // eslint-disable-next-line @typescript-eslint/no-empty-function -- Initializing with a no-op, will be reassigned. let httpServerSetupReject: (reason?: unknown) => void = () => {}; const httpServerSetupPromise = new Promise<void>((_resolve, reject) => { httpServerSetupReject = reject; }); try { configService.reloadConfigsFromFile(true); logger.info(`Initial suggestion model from config: ${configService.SUGGESTION_MODEL}`); if (!repoPath || repoPath === "${workspaceFolder}" || repoPath.trim() === "") { logger.warn("Invalid repository path provided, defaulting to current directory"); repoPath = process.cwd(); } const llmProvider = await getLLMProvider(); const isLlmAvailable = await llmProvider.checkConnection(); if (!isLlmAvailable) { logger.warn(`LLM provider (${configService.SUGGESTION_PROVIDER}) is not available. Some features may not work.`); } let suggestionModelAvailable = false; try { const currentSuggestionProvider = configService.SUGGESTION_PROVIDER.toLowerCase(); if (currentSuggestionProvider === 'ollama') { await checkOllama(); // Assumes checkOllama is imported await checkOllamaModel(configService.EMBEDDING_MODEL, true); // Assumes checkOllamaModel is imported await checkOllamaModel(configService.SUGGESTION_MODEL, false); suggestionModelAvailable = true; } else if (currentSuggestionProvider === 'deepseek') { suggestionModelAvailable = isLlmAvailable; } else { suggestionModelAvailable = isLlmAvailable; } } catch (error: unknown) { logger.warn(`Warning: Model not available. Suggestion tools may be limited: ${(error as Error).message}`); } const qdrantClient = await initializeQdrant(); logger.info(`Initial indexing process started for ${repoPath} in the background.`); indexRepository(qdrantClient, repoPath, llmProvider) .then(() => { logger.info(`Initial indexing process completed successfully for ${repoPath}.`); }) .catch((error: unknown) => { const errorMessage = error instanceof Error ? error.message : String(error); logger.error(`Initial indexing process failed for ${repoPath}: ${errorMessage}`); }); const serverCapabilities = { /* ... capabilities definition as in your file ... */ resources: { "repo://structure": { name: "Repository File Structure", description: "Lists all files in the current Git repository.", mimeType: "text/plain" }, "repo://files/{filepath}": { name: "Repository File Content", description: "Retrieves the content of a specific file...", mimeType: "text/plain", template: true, parameters: { filepath: { type: "string", description: "..." }}}, "repo://health": { name: "Server Health Status", description: "Provides the health status...", mimeType: "application/json" }, "repo://version": { name: "Server Version", description: "Provides the current version...", mimeType: "text/plain" } }, tools: { bb7_search_code: {}, bb7_get_repository_context: {}, ...(suggestionModelAvailable ? { bb7_generate_suggestion: {} } : {}), bb7_get_changelog: {}, bb7_agent_query: {}, bb7_switch_suggestion_model: {}, bb7_get_indexing_status: {}, }, prompts: { "bb7_repository-context": {}, "bb7_code-suggestion": {}, "bb7_code-analysis": {} }, }; // This McpServer instance is primarily for defining capabilities. // Per-session instances will be created for actual MCP communication. const _globalMcpServer = new McpServer({ name: "CodeCompass", version: VERSION, vendor: "CodeCompass", capabilities: serverCapabilities, }); // Resource/tool/prompt registration for the global server instance is not strictly necessary // if all MCP communication goes through per-session instances that are configured individually. // However, if any global handlers were intended, they would be registered on _globalMcpServer. // For now, configureMcpServerInstance will be called on per-session servers. const finalDeclaredTools = Object.keys(serverCapabilities.tools); logger.info(`Declared tools in capabilities: ${finalDeclaredTools.join(', ')}`); const finalDeclaredPrompts = Object.keys(serverCapabilities.prompts); logger.info(`Declared prompts in capabilities: ${finalDeclaredPrompts.join(', ')}`); const expressApp = express(); expressApp.use(express.json()); expressApp.get('/api/indexing-status', (_req: express.Request, res: express.Response): void => { res.json(getGlobalIndexingStatus()); }); expressApp.get('/api/ping', (_req: express.Request, res: express.Response): void => { res.json({ service: "CodeCompass", status: "ok", version: VERSION }); }); expressApp.post('/api/repository/notify-update', (_req: express.Request, res: express.Response): void => { logger.info('Received /api/repository/notify-update.'); const currentStatus = getGlobalIndexingStatus(); if (['initializing', 'validating_repo', 'listing_files', 'cleaning_stale_entries', 'indexing_file_content', 'indexing_commits_diffs'].includes(currentStatus.status)) { res.status(409).json({ message: 'Indexing already in progress.' }); return; } indexRepository(qdrantClient, repoPath, llmProvider).catch(err => logger.error("Re-indexing error:", err)); res.status(202).json({ message: 'Re-indexing initiated.' }); }); const activeSessionTransports: Map<string, StreamableHTTPServerTransport> = new Map(); expressApp.post('/mcp', async (req: express.Request, res: express.Response) => { const sessionId = req.headers['mcp-session-id'] as string | undefined; let transport: StreamableHTTPServerTransport | undefined = sessionId ? activeSessionTransports.get(sessionId) : undefined; if (transport) { logger.debug(`MCP POST: Reusing transport for session ${sessionId}`); } else if (isInitializeRequest(req.body)) { logger.info('MCP POST: Initialization request, creating new transport and server instance.'); const newTransport = new StreamableHTTPServerTransport({ sessionIdGenerator: randomUUID, onsessioninitialized: (newSessionId) => { activeSessionTransports.set(newSessionId, newTransport); logger.info(`MCP Session initialized: ${newSessionId}`); } }); newTransport.onclose = () => { if (newTransport.sessionId) { activeSessionTransports.delete(newTransport.sessionId); logger.info(`MCP Session closed and transport removed: ${newTransport.sessionId}`); } }; const sessionServer = new McpServer({ name: "CodeCompass", version: VERSION, vendor: "CodeCompass", capabilities: serverCapabilities, }); await configureMcpServerInstance(sessionServer, qdrantClient, repoPath, suggestionModelAvailable); await sessionServer.connect(newTransport); transport = newTransport; } else { logger.warn(`MCP POST: Bad Request. No valid session ID and not an init request.`); const bodyWithId = req.body as RequestBodyWithId; res.status(400).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Bad Request: No valid session ID or not an init request.' }, id: (typeof bodyWithId === 'object' && bodyWithId !== null && 'id' in bodyWithId) ? bodyWithId.id : null, }); return; } try { await transport.handleRequest(req, res, req.body); } catch (transportError) { logger.error("Error handling MCP POST request via transport:", transportError); if (!res.headersSent) { const bodyWithId = req.body as RequestBodyWithId; res.status(500).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Internal MCP transport error.' }, id: (typeof bodyWithId === 'object' && bodyWithId !== null && 'id' in bodyWithId) ? bodyWithId.id : null, }); } } }); const handleSessionRequest = async (req: express.Request, res: express.Response) => { const sessionId = req.headers['mcp-session-id'] as string | undefined; if (!sessionId || !activeSessionTransports.has(sessionId)) { logger.warn(`MCP ${req.method}: Invalid or missing session ID: ${sessionId}.`); res.status(400).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Invalid or missing session ID.' }, id: null }); return; } const transport = activeSessionTransports.get(sessionId)!; logger.debug(`MCP ${req.method}: Handling request for session ${sessionId}`); try { await transport.handleRequest(req, res); } catch (transportError) { logger.error(`Error handling MCP ${req.method} request for session ${sessionId}:`, transportError); if (!res.headersSent) { res.status(500).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Internal MCP transport error.' }, id: null }); } } }; expressApp.get('/mcp', handleSessionRequest); expressApp.delete('/mcp', handleSessionRequest); logger.info(`MCP communication will be available at the /mcp endpoint via POST, GET, DELETE.`); const httpPort = configService.HTTP_PORT; const httpServer = http.createServer(expressApp as (req: http.IncomingMessage, res: http.ServerResponse) => void); // eslint-disable-next-line @typescript-eslint/no-misused-promises httpServer.on('error', async (error: NodeJS.ErrnoException) => { if (error.code === 'EADDRINUSE') { logger.warn(`HTTP Port ${httpPort} is already in use. Attempting to ping...`); try { const pingResponse = await axios.get<PingResponseData>(`http://localhost:${httpPort}/api/ping`, { timeout: 500 }); if (pingResponse.status === 200 && pingResponse.data?.service === "CodeCompass") { logger.info(`Another CodeCompass instance (v${pingResponse.data.version || 'unknown'}) is running on port ${httpPort}.`); // ... (rest of EADDRINUSE handling logic as in your file) ... // Full EADDRINUSE logic from user's provided file: try { const statusResponse = await axios.get<IndexingStatusReport>(`http://localhost:${httpPort}/api/indexing-status`, { timeout: 1000 }); if (statusResponse.status === 200 && statusResponse.data) { const existingStatus = statusResponse.data; console.info(`\n--- Status of existing CodeCompass instance on port ${httpPort} ---`); console.info(`Version: ${pingResponse.data.version || 'unknown'}`); console.info(`Status: ${existingStatus.status}`); console.info(`Message: ${existingStatus.message}`); if (existingStatus.overallProgress !== undefined) { console.info(`Progress: ${existingStatus.overallProgress}%`); } if (existingStatus.currentFile) { console.info(`Current File: ${existingStatus.currentFile}`); } if (existingStatus.currentCommit) { console.info(`Current Commit: ${existingStatus.currentCommit}`); } console.info(`Last Updated: ${existingStatus.lastUpdatedAt}`); console.info(`-----------------------------------------------------------\n`); logger.info("Current instance will exit as another CodeCompass server is already running."); httpServerSetupReject(new ServerStartupError(`Port ${httpPort} in use by another CodeCompass instance.`, 0)); } else { logger.error(`Failed to retrieve status from existing CodeCompass server on port ${httpPort}. It responded to ping but status endpoint failed. Status: ${statusResponse.status}`); httpServerSetupReject(new ServerStartupError(`Port ${httpPort} in use, status fetch failed.`, 1)); } } catch (statusError: unknown) { if (axios.isAxiosError(statusError)) { if (statusError.response) { logger.error(`Error fetching status from existing CodeCompass server (port ${httpPort}): ${statusError.message}, Status: ${statusError.response.status}, Data: ${JSON.stringify(statusError.response.data)}`); } else if (statusError.request) { logger.error(`Error fetching status from existing CodeCompass server (port ${httpPort}): No response received. ${statusError.message}`); } else { logger.error(`Error fetching status from existing CodeCompass server (port ${httpPort}): ${statusError.message}`); } } else { logger.error(`Error fetching status from existing CodeCompass server (port ${httpPort}): ${String(statusError)}`); } httpServerSetupReject(new ServerStartupError(`Port ${httpPort} in use, status fetch error.`, 1)); } } else { logger.error(`Port ${httpPort} is in use by non-CodeCompass server. Response: ${JSON.stringify(pingResponse.data)}`); logger.error(`Please free the port or configure a different one (e.g., via HTTP_PORT environment variable or in ~/.codecompass/model-config.json).`); httpServerSetupReject(new ServerStartupError(`Port ${httpPort} in use by non-CodeCompass server.`, 1)); } } catch (pingError) { logger.error(`Port ${httpPort} is in use by an unknown service or the existing CodeCompass server is unresponsive to pings.`); if (axios.isAxiosError(pingError)) { if (pingError.code === 'ECONNREFUSED') { logger.error(`Connection refused on port ${httpPort}.`); } else if (pingError.code === 'ETIMEDOUT' || pingError.code === 'ECONNABORTED') { logger.error(`Ping attempt to port ${httpPort} timed out.`); } else { logger.error(`Ping error details: ${pingError.message}`); } } else { logger.error(`Ping error details: ${String(pingError)}`); } logger.error(`Please free the port or configure a different one (e.g., via HTTP_PORT environment variable or in ~/.codecompass/model-config.json).`); httpServerSetupReject(new ServerStartupError(`Port ${httpPort} in use or ping failed.`, 1)); } } else { logger.error(`Failed to start HTTP server on port ${httpPort}: ${error.message}`); httpServerSetupReject(new ServerStartupError(`HTTP server error: ${error.message}`, 1)); } }); const listenPromise = new Promise<void>((resolve) => { httpServer.listen(httpPort, () => { logger.info(`CodeCompass HTTP server listening on port ${httpPort} for status and notifications.`); resolve(); }); }); await Promise.race([listenPromise, httpServerSetupPromise]); logger.info(`CodeCompass MCP server v${VERSION} running for repository: ${repoPath}`); console.error(`CodeCompass v${VERSION} HTTP Server running on port ${httpPort}, with MCP at /mcp`); // Changed to console.error as per user's new code if (process.env.NODE_ENV === 'test') { logger.info("Test environment detected, server setup complete. Skipping SIGINT wait."); } else { await new Promise<void>((resolve) => { process.on('SIGINT', () => { logger.info("SIGINT received, shutting down server."); resolve(); }); }); } } catch (error: unknown) { const err = error instanceof ServerStartupError ? error : new Error(String(error)); // Ensure err is Error type logger.error("Failed to start CodeCompass", { message: err.message }); if (process.env.NODE_ENV === 'test') { throw err; } const exitCode = error instanceof ServerStartupError ? error.exitCode : 1; process.exit(exitCode); } } function registerPrompts(server: McpServer): void { if (typeof server.prompt !== "function") { logger.warn("MCP server instance does not support 'prompt' method. Prompts may not be available."); return; } server.prompt( "bb7_repository-context", "Get context about your repository", { query: z.string().describe("The specific topic or question for which context is needed.") }, ({ query }) => ({ messages: [{ role: "user", content: { type: "text", text: `Provide context about ${query} in this repository` } }] }) ); server.prompt( "bb7_code-suggestion", "Generate code suggestions", { query: z.string().describe("The specific topic or problem for which a code suggestion is needed.") }, ({ query }) => ({ messages: [{ role: "user", content: { type: "text", text: `Generate a code suggestion for: ${query}` } }] }) ); server.prompt( "bb7_code-analysis", "Analyze code problems", { query: z.string().describe("The code problem or snippet to be analyzed.") }, ({ query }) => ({ messages: [{ role: "user", content: { type: "text", text: `Analyze this code problem: ${query}` } }] }) ); } function registerTools( // Removed async server: McpServer, qdrantClient: QdrantClient, repoPath: string, suggestionModelAvailable: boolean ): void { if (typeof server.tool !== "function") { throw new Error("MCP server does not support 'tool' method"); } // Add the agent_query tool server.tool( "bb7_agent_query", "Provides a detailed plan and a comprehensive summary for addressing complex questions or tasks related to the codebase. This tool generates these insights in a single pass. \nExample: `{\"query\": \"How is user authentication handled in this project?\"}`.", { query: z.string().describe("The question or task for the agent to process"), sessionId: z.string().optional().describe("Optional session ID to maintain context between requests") // maxSteps removed }, async (args: { query: string; sessionId?: string }, _extra: RequestHandlerExtra<ServerRequest, ServerNotification>) => { logger.info(`Tool 'agent_query' execution started with args:`, args); const query = args.query; const sessionId = args.sessionId; if (!query || typeof query !== 'string' || query.trim() === "") { const errorMsg = "Invalid or missing 'query' parameter for agent_query. Please provide a non-empty query string."; logger.error(errorMsg, { receivedQuery: query }); return { content: [{ type: "text", text: `# Agent Query Error\n\n${errorMsg}`, }], }; } try { // Ensure config is fresh for this operation, especially if models/providers might have changed // configService.reloadConfigsFromFile(true); // processAgentQuery will use current configService state // processAgentQuery will internally get the LLMProvider and QdrantClient const agentResponseText = await processAgentQuery(query, sessionId); return { content: [{ type: "text", text: agentResponseText, }], }; } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : String(error); logger.error("Critical error in agent_query tool handler", { message: errorMessage, stack: error instanceof Error ? error.stack : undefined, }); return { content: [{ type: "text", text: `# Agent Query Failed\n\nAn unexpected error occurred: ${errorMessage}\nPlease check server logs for details.`, }], }; } } ); // Tool to execute the next step of an agent's plan - REMOVED // Search Code Tool with iterative refinement server.tool( "bb7_search_code", "Performs a semantic search for code snippets within the repository that are relevant to the given query. Results include file paths, code snippets, and relevance scores. \nExample: `{\"query\": \"function to handle user login\"}`. For a broader search: `{\"query\": \"database connection setup\"}`.", { query: z.string().describe("The search query to find relevant code in the repository"), sessionId: z.string().optional().describe("Optional session ID to maintain context between requests") }, async (args: { query: string; sessionId?: string }, _extra: RequestHandlerExtra<ServerRequest, ServerNotification>) => { logger.info(`Tool 'search_code' execution started.`); logger.info("Received args for search_code", { args }); const searchQuery = args.query || "code search"; // Default if query is empty string const searchSessionId = args.sessionId; if (args.query === undefined || args.query === null || args.query.trim() === "") { logger.warn("No query provided or query is empty for search_code, using default 'code search'"); } try { const session = getOrCreateSession(searchSessionId, repoPath); logger.info("Using query for search_code", { query: searchQuery, sessionId: session.id }); const isGitRepo = await validateGitRepository(repoPath); const files = isGitRepo ? await git.listFiles({ fs, dir: repoPath, gitdir: path.join(repoPath, ".git"), ref: "HEAD" }) : []; updateContext(session.id, repoPath, files); const { results, refinedQuery, relevanceScore } = await searchWithRefinement( qdrantClient, searchQuery, files ); addQuery(session.id, searchQuery, results, relevanceScore); const fileChunkResults = results.filter( (result): result is DetailedQdrantSearchResult & { payload: FileChunkPayload } => result.payload?.dataType === 'file_chunk' ); if (fileChunkResults.length === 0 && results.length > 0) { logger.info(`Search for "${searchQuery}" found ${results.length} results, but none were file_chunks. Matched data might be from commits or diffs.`); } else if (results.length === 0) { logger.info(`Search for "${searchQuery}" found no results.`); } const summaries = await Promise.all(fileChunkResults.map(async result => { // Now, result.payload is known to be FileChunkPayload const snippet = result.payload.file_content_chunk.slice(0, configService.MAX_SNIPPET_LENGTH); let summaryText = "Summary unavailable"; // Renamed from 'summary' to avoid conflict with outer scope if (suggestionModelAvailable) { try { const summarizePrompt = `Summarize this code snippet in 50 words or less:\n\n${snippet}`; // Ensure llmProvider is available in this scope. It's initialized in startServer. // If not directly available, it needs to be passed or retrieved via getLLMProvider(). // Assuming llmProvider is accessible here (e.g., passed to registerTools or retrieved). // For now, let's assume getLLMProvider() is the way if not passed down. const currentLlmProvider = await getLLMProvider(); // Get it if not passed down summaryText = await currentLlmProvider.generateText(summarizePrompt); } catch (error: unknown) { logger.warn(`Failed to generate summary for ${result.payload.filepath}: ${(error as Error).message}`); summaryText = "Summary generation failed"; } } return { filepath: result.payload.filepath, snippet, summary: summaryText, // Use the renamed variable last_modified: result.payload.last_modified, relevance: result.score, }; })); const formattedResponse = `# Search Results for: "${searchQuery}" ${refinedQuery !== searchQuery ? `\n> Query refined to: "${refinedQuery}"` : ''} ${summaries.length > 0 ? summaries.map(s => ` ## ${s.filepath} - Last Modified: ${s.last_modified} - Relevance: ${s.relevance.toFixed(2)} ### Code Snippet \`\`\` ${s.snippet} \`\`\` ### Summary ${s.summary} `).join('\n') : "\nNo relevant code snippets found in files for your query. The query might have matched commit messages or diffs, which are not detailed by this tool."} Session ID: ${session.id} (Use this ID in future requests to maintain context)`; return { content: [{ type: "text", text: formattedResponse, }], }; } catch (error: unknown) { logger.error("Error in search_code tool", { error: error instanceof Error ? error.message : String(error) }); return { content: [{ type: "text", text: `# Error in Search Code Tool\n\nThere was an unexpected error processing your query: ${error instanceof Error ? error.message : String(error)}\n\nPlease check the server logs for more details.`, }], }; } }); // Add get_changelog tool server.tool( "bb7_get_changelog", // name "Retrieves the content of the `CHANGELOG.md` file from the root of the repository. This provides a history of changes and versions for the project. \nExample: Call this tool without parameters: `{}`. Title: Get Changelog", // description (Title incorporated here) {}, // paramsSchema (as ZodRawShape for no parameters) async (_args: Record<string, never>, _extra: RequestHandlerExtra<ServerRequest, ServerNotification>) => { // handler try { const changelogPath = path.join(repoPath, 'CHANGELOG.md'); const changelog = await fs.readFile(changelogPath, 'utf8'); return { content: [{ type: "text" as const, text: `# CodeCompass Changelog (v${VERSION})\n\n${changelog}`, }], }; } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : String(error); logger.error("Failed to read changelog", { message: errorMessage }); return { content: [{ type: "text", text: `# Error Reading Changelog\n\nFailed to read the changelog file. Current version is ${VERSION}.`, }], }; } } // The 5th argument (annotations object) has been removed. ); // Add reset_metrics tool - REMOVED // Add check_provider tool - REMOVED // Add get_session_history tool server.tool( "bb7_get_session_history", "Retrieves the history of interactions (queries, suggestions, feedback) for a given session ID. This allows you to review past activities within a specific CodeCompass session. \nExample: `{\"sessionId\": \"your_session_id_here\"}`.", { sessionId: z.string().describe("The session ID to retrieve history for") }, // Ensure this handler is synchronous if no await is used. (args: { sessionId: string }, _extra: RequestHandlerExtra<ServerRequest, ServerNotification>) => { // Removed async logger.info("Received args for get_session_history", { args }); const sessionIdValue = args.sessionId; if (typeof sessionIdValue !== 'string' || !sessionIdValue) { const errorMsg = "Session ID is required and must be a non-empty string."; logger.error(errorMsg, { receivedSessionId: String(sessionIdValue) }); // Return an error structure consistent with other tools return { content: [{ type: "text", text: `# Error Getting Session History\n\n${errorMsg}`, }], }; } try { const session = getOrCreateSession(sessionIdValue); return { content: [{ type: "text", text: `# Session History (${session.id}) ## Session Info - Created: ${new Date(session.createdAt).toISOString()} - Last Updated: ${new Date(session.lastUpdated).toISOString()} - Repository: ${session.context.repoPath} ## Queries (${session.queries.length}) ${session.queries.map((q, i) => ` ### Query ${i+1}: "${q.query}" - Timestamp: ${new Date(q.timestamp).toISOString()} - Results: ${q.results.length} - Relevance Score: ${q.relevanceScore.toFixed(2)} `).join('')} ## Suggestions (${session.suggestions.length}) ${session.suggestions.map((s, i) => ` ### Suggestion ${i+1} - Timestamp: ${new Date(s.timestamp).toISOString()} - Prompt: "${s.prompt.substring(0, 100)}..." ${s.feedback ? `- Feedback Score: ${s.feedback.score}/10 - Feedback Comments: ${s.feedback.comments}` : '- No feedback provided'} `).join('')}`, }], }; } catch (error: unknown) { return { content: [{ type: "text", text: `# Error\n\n${error instanceof Error ? error.message : String(error)}`, }], }; } }); if (suggestionModelAvailable) { // Generate Suggestion Tool with multi-step reasoning server.tool( "bb7_generate_suggestion", "Generates code suggestions, implementation ideas, or examples based on a natural language query. It leverages repository context and relevant code snippets to provide targeted advice. \nExample: `{\"query\": \"Suggest an optimized way to fetch user data\"}`. For a specific task: `{\"query\": \"Write a Python function to parse a CSV file\"}`.", { query: z.string().describe("The query or prompt for generating code suggestions"), sessionId: z.string().optional().describe("Optional session ID to maintain context between requests") }, async (args: { query: string; sessionId?: string }, _extra: RequestHandlerExtra<ServerRequest, ServerNotification>) => { logger.info(`Tool 'generate_suggestion' execution started.`); logger.info("Received args for generate_suggestion", { args }); const queryStr = args.query || "code suggestion"; // Default if query is empty string const sessionIdFromParams = args.sessionId; if (args.query === undefined || args.query === null || args.query.trim() === "") { logger.warn("No query provided or query is empty for generate_suggestion, using default 'code suggestion'"); } try { const session = getOrCreateSession(sessionIdFromParams, repoPath); logger.info("Using query for generate_suggestion", { query: queryStr, sessionId: session.id }); const isGitRepo = await validateGitRepository(repoPath); const files = isGitRepo ? await git.listFiles({ fs, dir: repoPath, gitdir: path.join(repoPath, ".git"), ref: "HEAD" }) : []; const _diff = await getRepositoryDiff(repoPath); updateContext(session.id, repoPath, files, _diff); const recentQueries = getRecentQueries(session.id); const relevantResults = getRelevantResults(session.id); const { results, refinedQuery } = await searchWithRefinement( qdrantClient, queryStr, files ); // Map search results to context const context = results .map(r => { if (r.payload?.dataType === 'file_chunk') { const payload = r.payload; return { type: 'file_chunk', filepath: payload.filepath, snippet: payload.file_content_chunk.slice(0, configService.MAX_SNIPPET_LENGTH), last_modified: payload.last_modified, relevance: r.score, note: "" }; } else if (r.payload?.dataType === 'commit_info') { const payload = r.payload; return { type: 'commit_info', commit_oid: payload.commit_oid, message: payload.commit_message.slice(0, configService.MAX_SNIPPET_LENGTH), author: payload.commit_author_name, date: payload.commit_date, relevance: r.score, note: "Commit Information" }; } else if (r.payload?.dataType === 'diff_chunk') { const payload = r.payload; return {