Skip to main content
Glama
index.ts68.1 kB
#!/usr/bin/env node import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema } from "@modelcontextprotocol/sdk/types.js"; import * as dotenv from "dotenv"; import fs from 'fs'; import * as fsPromises from 'fs/promises'; import fetch from "node-fetch"; import * as os from 'os'; import path from 'path'; import { fileURLToPath } from 'url'; // Load environment variables dotenv.config(); // Get current file directory for ES modules const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // Debug: Log environment variables for MCP debugging console.error("[MCP DEBUG] Environment variables received:"); console.error("ANTHROPIC_API_KEY:", process.env.ANTHROPIC_API_KEY ? `${process.env.ANTHROPIC_API_KEY.substring(0, 10)}...` : "NOT SET"); console.error("OPENAI_API_KEY:", process.env.OPENAI_API_KEY ? `${process.env.OPENAI_API_KEY.substring(0, 10)}...` : "NOT SET"); console.error("GEMINI_API_KEY:", process.env.GEMINI_API_KEY ? `${process.env.GEMINI_API_KEY.substring(0, 10)}...` : "NOT SET"); console.error("OLLAMA_BASE_URL:", process.env.OLLAMA_BASE_URL || "NOT SET"); // WORKAROUND: VS Code MCP not passing environment variables properly // Fallback to .env file loading if environment variables are missing if (!process.env.ANTHROPIC_API_KEY && !process.env.OPENAI_API_KEY && !process.env.GEMINI_API_KEY) { console.error("[MCP WORKAROUND] Environment variables not set, attempting to load from .env file"); // Try loading from .env file in project directory const envPath = path.join(__dirname, '..', '.env'); if (fs.existsSync(envPath)) { console.error("[MCP WORKAROUND] Loading .env file from:", envPath); dotenv.config({ path: envPath }); // Debug: Confirm variables loaded after .env file console.error("[MCP DEBUG] After .env loading:"); console.error("ANTHROPIC_API_KEY:", process.env.ANTHROPIC_API_KEY ? `${process.env.ANTHROPIC_API_KEY.substring(0, 10)}...` : "STILL NOT SET"); console.error("OPENAI_API_KEY:", process.env.OPENAI_API_KEY ? `${process.env.OPENAI_API_KEY.substring(0, 10)}...` : "STILL NOT SET"); console.error("GEMINI_API_KEY:", process.env.GEMINI_API_KEY ? `${process.env.GEMINI_API_KEY.substring(0, 10)}...` : "STILL NOT SET"); } else { console.error("[MCP WORKAROUND] .env file not found at:", envPath); } } /** * AI Provider Configuration */ interface AIProvider { name: string; model: string; apiKey: string; baseUrl: string; specialty: string; } const AI_PROVIDERS: Record<string, AIProvider> = { claude: { name: "Claude", model: "claude-sonnet-4-20250514", apiKey: process.env.ANTHROPIC_API_KEY || "", baseUrl: "https://api.anthropic.com/v1/messages", specialty: "Analysis, reasoning, and comprehensive responses" }, gpt4: { name: "GPT-4 (OpenAI)", model: "gpt-4-turbo-preview", apiKey: process.env.OPENAI_API_KEY || "", baseUrl: "https://api.openai.com/v1/chat/completions", specialty: "General purpose, coding, and creative tasks" }, gemini: { name: "Gemini Pro (Google)", model: "gemini-1.5-pro", apiKey: process.env.GEMINI_API_KEY || "", baseUrl: "https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-pro:generateContent", specialty: "Multimodal understanding and research" }, ollama: { name: "Ollama (Local)", model: process.env.OLLAMA_MODEL || "llama3.2:latest", apiKey: "", // Ollama doesn't need an API key for local usage baseUrl: process.env.OLLAMA_BASE_URL || "http://localhost:11434", specialty: "Local AI, privacy-focused, and custom models" } }; /** * Enhanced Context Management System * Handles conversation history, project context, and automatic context injection */ // Workspace manager for handling dynamic workspace changes class WorkspaceManager { private static instance: WorkspaceManager; private currentWorkspace: string | null = null; static getInstance(): WorkspaceManager { if (!WorkspaceManager.instance) { WorkspaceManager.instance = new WorkspaceManager(); } return WorkspaceManager.instance; } setWorkspace(workspacePath: string): void { this.currentWorkspace = workspacePath; console.error(`[WORKSPACE] Set workspace to: ${workspacePath}`); } getCurrentWorkspace(): string | null { return this.currentWorkspace; } } interface ConversationEntry { timestamp: Date; tool: string; provider: string; query: string; response: string; contextFiles: string[]; tokenCount: number; } interface ProviderCallTracker { provider: string; calls: number; lastReset: Date; maxCalls: number; } interface ProjectContext { readme: string; packageJson: string; structure: string; lastUpdated: Date; } class ConversationHistoryManager { private history: ConversationEntry[] = []; private readonly HISTORY_FILE: string; private readonly MAX_HISTORY_ENTRIES = 20; private readonly MAX_AGE_HOURS = 24; // Only keep conversations from last 24 hours private readonly CONTEXT_FRESHNESS_HOURS = 6; // Consider context fresh for 6 hours constructor() { // SIMPLIFIED APPROACH: Use VS Code's current working directory // When VS Code opens a project, it should set CWD to that project const currentDir = process.cwd(); console.error(`[CONVERSATION] Current working directory: ${currentDir}`); // Check if workspace has been dynamically set const dynamicWorkspace = WorkspaceManager.getInstance().getCurrentWorkspace(); let workspaceDir: string; if (dynamicWorkspace) { console.error(`[CONVERSATION] Using dynamically set workspace: ${dynamicWorkspace}`); workspaceDir = dynamicWorkspace; } else if (currentDir !== os.homedir() && currentDir !== '/') { // If CWD is not home directory or root, use it as workspace console.error(`[CONVERSATION] Using current directory as workspace: ${currentDir}`); workspaceDir = currentDir; } else { // Fall back to detection logic only if CWD is home/root console.error(`[CONVERSATION] CWD is ${currentDir}, falling back to detection`); workspaceDir = this.findWorkspaceDirectory(); } this.HISTORY_FILE = path.join(workspaceDir, '.mcp-conversation-history.json'); console.error(`[CONVERSATION] Using history file: ${this.HISTORY_FILE}`); this.loadHistory(); } private findWorkspaceDirectory(): string { console.error(`[CONVERSATION DEBUG] Starting from CWD: ${process.cwd()}`); console.error(`[CONVERSATION DEBUG] Home dir: ${os.homedir()}`); console.error(`[CONVERSATION DEBUG] Script path: ${process.argv[1]}`); // Method 1: Check VS Code workspace environment variables const vscodeWorkspaceVars = [ 'MCP_WORKSPACE_PATH', // Custom variable we can set in mcp.json 'VSCODE_WORKSPACE_FOLDER', // VS Code sometimes sets this 'WORKSPACE_FOLDER', // Alternative VS Code variable 'VSCODE_CWD' // VS Code working directory (but can be wrong) ]; for (const envVar of vscodeWorkspaceVars) { const workspace = process.env[envVar]; console.error(`[CONVERSATION DEBUG] Checking ${envVar}: ${workspace || 'undefined'}`); // Skip VSCODE_CWD if it's root directory - it's clearly wrong if (workspace && workspace !== os.homedir() && workspace !== '/' && fs.existsSync(workspace)) { console.error(`[CONVERSATION] Found workspace via ${envVar}: ${workspace}`); return workspace; } } // CRITICAL: If CWD is home directory, VS Code didn't set workspace properly // This is the core issue - VS Code runs MCP server with CWD=home when switching projects if (process.cwd() === os.homedir()) { console.error(`[CONVERSATION ERROR] CWD is home directory - VS Code MCP integration issue!`); console.error(`[CONVERSATION ERROR] Cannot reliably detect workspace from home directory`); console.error(`[CONVERSATION ERROR] This will cause conversation history to save in wrong location`); // Try to get workspace from VS Code's recently opened workspaces const recentWorkspace = this.getRecentVSCodeWorkspace(); if (recentWorkspace) { console.error(`[CONVERSATION] Using recent VS Code workspace: ${recentWorkspace}`); return recentWorkspace; } } // Method 2: Analyze script path to infer project directory (MOST RELIABLE) const scriptPath = process.argv[1]; // The index.js file path console.error(`[CONVERSATION DEBUG] Script path: ${scriptPath}`); if (scriptPath) { // If script is in a project (e.g., /path/to/project/mcp-server/build/index.js) let projectDir = path.dirname(scriptPath); // /path/to/project/mcp-server/build // Go up directories looking for project indicators // Start by going up to the MCP server directory, then check its parent for (let i = 0; i < 4; i++) { // Check up to 4 levels up const parentDir = path.dirname(projectDir); console.error(`[CONVERSATION DEBUG] Checking parent dir: ${parentDir}`); // Prefer broader workspace over narrow MCP server directory // If current dir is valid but parent is also valid, choose parent if (this.isValidWorkspace(parentDir)) { // Check if parent seems like a broader workspace const currentDirName = path.basename(projectDir); const parentDirName = path.basename(parentDir); // If current directory looks like an MCP server directory, prefer parent if (currentDirName.includes('mcp') || currentDirName.includes('server')) { console.error(`[CONVERSATION] Detected workspace from script path (parent preferred): ${parentDir}`); return parentDir; } } if (this.isValidWorkspace(projectDir)) { console.error(`[CONVERSATION] Detected workspace from script path: ${projectDir}`); return projectDir; } projectDir = parentDir; } } // Method 3: Search upward from current directory for workspace indicators let currentDir = process.cwd(); const maxDepth = 10; // Prevent infinite loops let depth = 0; while (depth < maxDepth) { if (this.isValidWorkspace(currentDir)) { console.error(`[CONVERSATION] Found workspace at: ${currentDir}`); return currentDir; } // Move up one directory const parentDir = path.dirname(currentDir); if (parentDir === currentDir) { // Reached root directory break; } currentDir = parentDir; depth++; } // Method 4: If we're in home directory, try to find a reasonable default if (process.cwd() === os.homedir()) { // Look for common project directories in home const commonProjectDirs = [ path.join(os.homedir(), 'Projects'), path.join(os.homedir(), 'projects'), path.join(os.homedir(), 'workspace'), path.join(os.homedir(), 'Development'), path.join(os.homedir(), 'dev') ]; for (const projectsDir of commonProjectDirs) { if (fs.existsSync(projectsDir)) { console.error(`[CONVERSATION] Found projects directory: ${projectsDir}, but no specific project detected`); // Don't return this - it's too broad break; } } } // Fallback to current working directory console.error(`[CONVERSATION] No workspace found, using CWD: ${process.cwd()}`); const fallbackDir = process.cwd(); console.error(`[CONVERSATION DEBUG] Final workspace path will be: ${path.join(fallbackDir, '.mcp-conversation-history.json')}`); return fallbackDir; } private getRecentVSCodeWorkspace(): string | null { try { // Method 1: Try to read VS Code's current workspace from storage const vscodeDir = path.join(os.homedir(), 'Library/Application Support/Code/User'); const recentlyOpenedPath = path.join(vscodeDir, 'globalStorage/storage.json'); if (fs.existsSync(recentlyOpenedPath)) { const storage = JSON.parse(fs.readFileSync(recentlyOpenedPath, 'utf8')); const recentlyOpened = storage?.['history.recentlyOpenedPathsList']; if (recentlyOpened?.entries && Array.isArray(recentlyOpened.entries)) { // Get the most recently opened folder (not file) for (const entry of recentlyOpened.entries) { if (entry.folderUri && entry.folderUri.startsWith('file://')) { const workspacePath = entry.folderUri.replace('file://', ''); if (fs.existsSync(workspacePath) && this.isValidWorkspace(workspacePath)) { console.error(`[CONVERSATION] Found recent workspace: ${workspacePath}`); return workspacePath; } } } } } // Method 2: Try to read from VS Code's workspace state const workspaceStoragePath = path.join(vscodeDir, 'workspaceStorage'); if (fs.existsSync(workspaceStoragePath)) { const workspaceDirs = fs.readdirSync(workspaceStoragePath); // Find the most recently modified workspace directory let mostRecentWorkspace = null; let mostRecentTime = 0; for (const workspaceDir of workspaceDirs) { const workspacePath = path.join(workspaceStoragePath, workspaceDir); const workspaceJsonPath = path.join(workspacePath, 'workspace.json'); if (fs.existsSync(workspaceJsonPath)) { const stats = fs.statSync(workspaceJsonPath); if (stats.mtime.getTime() > mostRecentTime) { try { const workspaceConfig = JSON.parse(fs.readFileSync(workspaceJsonPath, 'utf8')); if (workspaceConfig.folder) { const folderPath = workspaceConfig.folder.replace('file://', ''); if (fs.existsSync(folderPath) && this.isValidWorkspace(folderPath)) { mostRecentWorkspace = folderPath; mostRecentTime = stats.mtime.getTime(); } } } catch (error) { // Skip invalid workspace files } } } } if (mostRecentWorkspace) { console.error(`[WORKSPACE] Found current workspace from storage: ${mostRecentWorkspace}`); return mostRecentWorkspace; } } } catch (error) { console.error(`[CONVERSATION] Error reading VS Code workspace: ${error}`); } return null; } private isValidWorkspace(dirPath: string): boolean { if (!fs.existsSync(dirPath)) return false; // Check for common workspace/project indicators const indicators = [ 'package.json', '.git', '.vscode', 'tsconfig.json', 'README.md', 'Cargo.toml', 'go.mod', 'requirements.txt', 'pom.xml', '.gitignore' ]; return indicators.some(indicator => fs.existsSync(path.join(dirPath, indicator)) ); } async addEntry(entry: Omit<ConversationEntry, 'timestamp'>): Promise<void> { const newEntry: ConversationEntry = { ...entry, timestamp: new Date() }; console.error(`[CONVERSATION DEBUG] Adding entry: ${entry.tool} - ${entry.query.substring(0, 50)}...`); this.history.unshift(newEntry); // Keep only recent entries to prevent memory bloat if (this.history.length > this.MAX_HISTORY_ENTRIES) { this.history = this.history.slice(0, this.MAX_HISTORY_ENTRIES); } console.error(`[CONVERSATION DEBUG] About to persist to: ${this.HISTORY_FILE}`); await this.persistHistory(); console.error(`[CONVERSATION DEBUG] Persist completed`); } getRelevantHistory(query: string, tool: string, maxEntries: number = 5): ConversationEntry[] { // First get fresh entries, then filter by relevance const freshEntries = this.getRecentHistory(10); // Get more fresh entries for filtering const keywords = this.extractKeywords(query); return freshEntries .filter(entry => { // Include same-tool conversations with higher priority const toolMatch = entry.tool === tool ? 2 : 1; // Check for keyword relevance const contentRelevance = this.calculateRelevance( entry.query + ' ' + entry.response, keywords ); return (toolMatch * contentRelevance) > 0.3; // Relevance threshold }) .slice(0, maxEntries); } private extractKeywords(text: string): string[] { return text.toLowerCase() .split(/\s+/) .filter(word => word.length > 3) .filter(word => !['that', 'this', 'with', 'from', 'they', 'were', 'been', 'have', 'will', 'would', 'could', 'should'].includes(word)); } private calculateRelevance(content: string, keywords: string[]): number { const contentLower = content.toLowerCase(); const matches = keywords.filter(keyword => contentLower.includes(keyword)); return matches.length / Math.max(keywords.length, 1); } private async loadHistory(): Promise<void> { try { if (fs.existsSync(this.HISTORY_FILE)) { const data = await fsPromises.readFile(this.HISTORY_FILE, 'utf8'); const allEntries = JSON.parse(data).map((entry: any) => ({ ...entry, timestamp: new Date(entry.timestamp) })); // Filter out old entries to prevent stale context const cutoffDate = new Date(); cutoffDate.setHours(cutoffDate.getHours() - this.MAX_AGE_HOURS); this.history = allEntries.filter((entry: ConversationEntry) => entry.timestamp > cutoffDate ); console.error(`[CONVERSATION] Loaded ${this.history.length} recent conversation entries (filtered from ${allEntries.length} total)`); // If we filtered out entries, persist the cleaned history if (allEntries.length !== this.history.length) { await this.persistHistory(); console.error(`[CONVERSATION] Cleaned up ${allEntries.length - this.history.length} old entries`); } } } catch (error) { console.error('[CONVERSATION] Failed to load conversation history:', error); this.history = []; } } private async persistHistory(): Promise<void> { try { console.error(`[CONVERSATION DEBUG] Persisting ${this.history.length} entries to: ${this.HISTORY_FILE}`); await fsPromises.writeFile( this.HISTORY_FILE, JSON.stringify(this.history, null, 2) ); console.error(`[CONVERSATION DEBUG] Successfully persisted conversation history`); } catch (error) { console.error('[CONVERSATION] Failed to persist conversation history:', error); } } getRecentHistory(limit: number = 10): ConversationEntry[] { // Only return entries from recent hours to ensure freshness const freshnessCutoff = new Date(); freshnessCutoff.setHours(freshnessCutoff.getHours() - this.CONTEXT_FRESHNESS_HOURS); const freshEntries = this.history.filter(entry => entry.timestamp > freshnessCutoff); return freshEntries.slice(0, limit); } getContextSummary(): string { const recentEntries = this.getRecentHistory(5); if (recentEntries.length === 0) { return "No recent conversation history in this workspace."; } const summary = recentEntries.map(entry => { const timeAgo = Math.round((Date.now() - entry.timestamp.getTime()) / (1000 * 60)); return `${timeAgo}m ago: ${entry.tool} (${entry.provider}) - "${entry.query.substring(0, 100)}..."`; }).join('\n'); return `Recent conversation context (last ${this.CONTEXT_FRESHNESS_HOURS}h):\n${summary}`; } } class ContextManager { private projectContext: ProjectContext | null = null; private readonly MAX_CONTEXT_TOKENS = 8000; // Conservative limit for most providers private readonly PROJECT_CONTEXT_TTL = 5 * 60 * 1000; // 5 minutes async getProjectContext(): Promise<ProjectContext> { if (this.projectContext && Date.now() - this.projectContext.lastUpdated.getTime() < this.PROJECT_CONTEXT_TTL) { return this.projectContext; } // Refresh project context try { // Get the current workspace directory const workspaceDir = this.getCurrentWorkspaceDir(); console.error(`[CONTEXT] Loading project context from: ${workspaceDir}`); const readme = await this.readFileIfExists(path.join(workspaceDir, 'README.md')); const packageJson = await this.readFileIfExists(path.join(workspaceDir, 'package.json')); const structure = await this.getProjectStructure(workspaceDir); this.projectContext = { readme: readme || 'No README.md found', packageJson: packageJson || 'No package.json found', structure: structure || 'Could not determine project structure', lastUpdated: new Date() }; console.error('[CONTEXT] Refreshed project context'); return this.projectContext; } catch (error) { console.error('[CONTEXT] Failed to load project context:', error); return { readme: 'Error loading README.md', packageJson: 'Error loading package.json', structure: 'Error determining project structure', lastUpdated: new Date() }; } } async discoverRelevantFiles(query: string): Promise<string[]> { const keywords = query.toLowerCase().split(/\s+/); const relevantFiles: string[] = []; try { // Look for specific file mentions in the query const fileExtensions = ['.ts', '.js', '.json', '.md', '.py', '.java', '.go', '.rs']; for (const ext of fileExtensions) { if (query.includes(ext)) { // Find files with this extension const files = await this.findFilesByExtension(ext); relevantFiles.push(...files.slice(0, 3)); // Limit to 3 files per extension } } // Look for keyword matches in common config files const configFiles = ['tsconfig.json', '.env.example', 'package-lock.json']; for (const file of configFiles) { if (keywords.some(keyword => file.toLowerCase().includes(keyword))) { if (await this.fileExists(file)) { relevantFiles.push(file); } } } } catch (error) { console.error('[CONTEXT] Error discovering relevant files:', error); } return [...new Set(relevantFiles)]; // Remove duplicates } async buildFullContext(query: string, tool: string, conversationHistory: ConversationHistoryManager): Promise<string> { const projectContext = await this.getProjectContext(); const relevantFiles = await this.discoverRelevantFiles(query); const fileContents = await this.readRelevantFileContents(relevantFiles); // Get only fresh conversation history to prevent stale context const freshHistory = conversationHistory.getRecentHistory(5); let context = `# PROJECT CONTEXT ## Project Overview ${this.summarizeProject(projectContext)} ## Relevant Files ${fileContents} ## Recent Conversation History ${this.formatConversationHistory(freshHistory)} ## Current Query Tool: ${tool} Query: ${query} --- `; // Check token limits and truncate if necessary if (this.estimateTokenCount(context) > this.MAX_CONTEXT_TOKENS) { context = this.truncateContext(context); } return context; } public clearProjectContext(): void { this.projectContext = null; console.error('[CONTEXT] Cleared cached project context'); } estimateTokenCount(text: string): number { // Rough estimate: ~4 characters per token return Math.ceil(text.length / 4); } private getCurrentWorkspaceDir(): string { // Get workspace from WorkspaceManager if set const dynamicWorkspace = WorkspaceManager.getInstance().getCurrentWorkspace(); if (dynamicWorkspace) { console.error(`[CONTEXT] Using workspace from WorkspaceManager: ${dynamicWorkspace}`); return dynamicWorkspace; } // Final fallback to current working directory console.error(`[CONTEXT] No workspace set, using CWD: ${process.cwd()}`); return process.cwd(); } private async readFileIfExists(filePath: string): Promise<string | null> { try { return await fsPromises.readFile(filePath, 'utf8'); } catch { return null; } } private async fileExists(filePath: string): Promise<boolean> { try { await fsPromises.access(filePath); return true; } catch { return false; } } private async getProjectStructure(workspaceDir: string = process.cwd()): Promise<string> { try { // Simple project structure - just list main directories and files console.error(`[CONTEXT] Reading project structure from: ${workspaceDir}`); const items = await fsPromises.readdir(workspaceDir, { withFileTypes: true }); const structure = items .filter(item => !item.name.startsWith('.') || ['.github', '.vscode'].includes(item.name)) .map(item => item.isDirectory() ? `${item.name}/` : item.name) .sort() .join('\n'); return structure; } catch (error) { console.error(`[CONTEXT] Error reading project structure from ${workspaceDir}:`, error); return 'Error reading project structure'; } } private async findFilesByExtension(extension: string): Promise<string[]> { try { const items = await fsPromises.readdir('.', { withFileTypes: true, recursive: true }); return items .filter(item => item.isFile() && item.name.endsWith(extension)) .map(item => item.name) .slice(0, 5); // Limit results } catch { return []; } } private async readRelevantFileContents(filePaths: string[]): Promise<string> { const contents: string[] = []; for (const filePath of filePaths.slice(0, 3)) { // Limit to 3 files try { const content = await fsPromises.readFile(filePath, 'utf8'); contents.push(`### ${filePath} \`\`\` ${content.slice(0, 1000)}${content.length > 1000 ? '\n... (truncated)' : ''} \`\`\``); } catch (error) { contents.push(`### ${filePath} Error reading file: ${error}`); } } return contents.length > 0 ? contents.join('\n\n') : 'No relevant files found'; } private summarizeProject(context: ProjectContext): string { return `**Package Info:** ${context.packageJson.slice(0, 500)}${context.packageJson.length > 500 ? '... (truncated)' : ''} **README:** ${context.readme.slice(0, 800)}${context.readme.length > 800 ? '... (truncated)' : ''} **Project Structure:** ${context.structure}`; } private formatConversationHistory(history: ConversationEntry[]): string { if (history.length === 0) return 'No previous conversation history'; return history.map(entry => `**${entry.tool}** (${entry.provider}) - ${entry.timestamp.toISOString()}: Q: ${entry.query.slice(0, 200)}${entry.query.length > 200 ? '...' : ''} A: ${entry.response.slice(0, 300)}${entry.response.length > 300 ? '...' : ''} ` ).join('\n'); } private truncateContext(context: string): string { const targetLength = this.MAX_CONTEXT_TOKENS * 4; // Convert back to characters if (context.length <= targetLength) return context; // Keep the project context and current query, truncate conversation history const sections = context.split('## Recent Conversation History'); if (sections.length === 2) { const beforeHistory = sections[0]; const afterHistory = sections[1].split('## Current Query'); if (afterHistory.length === 2) { const truncatedHistory = '## Recent Conversation History\n(Conversation history truncated due to length)\n\n## Current Query' + afterHistory[1]; return beforeHistory + truncatedHistory; } } // Fallback: simple truncation return context.slice(0, targetLength) + '\n... (context truncated due to length)'; } } class ProviderCallManager { private callTrackers: Map<string, ProviderCallTracker> = new Map(); private readonly MAX_CALLS_PER_PROVIDER = 6; private readonly RESET_INTERVAL = 60 * 60 * 1000; // 1 hour constructor() { // Initialize trackers for all providers Object.keys(AI_PROVIDERS).forEach(provider => { this.callTrackers.set(provider, { provider, calls: 0, lastReset: new Date(), maxCalls: this.MAX_CALLS_PER_PROVIDER }); }); } canMakeCall(provider: string): boolean { const tracker = this.callTrackers.get(provider); if (!tracker) return false; // Reset if enough time has passed if (Date.now() - tracker.lastReset.getTime() > this.RESET_INTERVAL) { tracker.calls = 0; tracker.lastReset = new Date(); } return tracker.calls < tracker.maxCalls; } recordCall(provider: string): void { const tracker = this.callTrackers.get(provider); if (tracker) { tracker.calls++; console.error(`[API LIMITS] ${provider}: ${tracker.calls}/${tracker.maxCalls} calls used`); } } getRemainingCalls(provider: string): number { const tracker = this.callTrackers.get(provider); if (!tracker) return 0; // Reset if enough time has passed if (Date.now() - tracker.lastReset.getTime() > this.RESET_INTERVAL) { tracker.calls = 0; tracker.lastReset = new Date(); } return Math.max(0, tracker.maxCalls - tracker.calls); } getCallStatus(): Record<string, { remaining: number; total: number }> { const status: Record<string, { remaining: number; total: number }> = {}; this.callTrackers.forEach((tracker, provider) => { status[provider] = { remaining: this.getRemainingCalls(provider), total: tracker.maxCalls }; }); return status; } } /** * Streamlined AI Collaboration MCP Server * * This server provides essential AI collaboration tools with enhanced context and conversation history. * Focus: consult_ai, multi_ai_research, and mandatory_execute for core functionality. */ class AICollaborationServer { private server: Server; private conversationHistory: ConversationHistoryManager; private contextManager: ContextManager; private providerCallManager: ProviderCallManager; constructor() { this.server = new Server( { name: "ai-collaboration-mcp", version: "1.0.0", }, { capabilities: { tools: {}, resources: {}, }, } ); // Initialize enhanced context management this.conversationHistory = new ConversationHistoryManager(); this.contextManager = new ContextManager(); this.providerCallManager = new ProviderCallManager(); this.setupToolHandlers(); this.setupResourceHandlers(); // Error handling this.server.onerror = (error) => { console.error("[MCP Error]", error); }; process.on("SIGINT", async () => { await this.server.close(); process.exit(0); }); } /** * Retry utility with exponential backoff for API calls */ private async retryWithBackoff<T>( operation: () => Promise<T>, maxRetries: number = 3, baseDelay: number = 200 ): Promise<T> { let lastError: Error = new Error("Unknown error"); for (let attempt = 0; attempt <= maxRetries; attempt++) { try { return await operation(); } catch (error) { lastError = error as Error; // Don't retry on authentication errors or client errors if (error instanceof Error) { const errorMessage = error.message.toLowerCase(); if (errorMessage.includes('401') || errorMessage.includes('403') || errorMessage.includes('invalid api key') || errorMessage.includes('unauthorized')) { throw error; } } if (attempt === maxRetries) { break; } // Exponential backoff: 200ms, 400ms, 800ms const delay = baseDelay * Math.pow(2, attempt); console.error(`[MCP RETRY] Attempt ${attempt + 1} failed, retrying in ${delay}ms...`); await new Promise(resolve => setTimeout(resolve, delay)); } } throw lastError; } /** * Command Parser for Mandatory Tool Execution */ private parseAndExecuteCommand(userMessage: string, toolName: string, args: any): boolean { // Define command patterns that trigger mandatory execution const mandatoryPatterns = [ /^use\s+(\w+)/i, // "use ai_supervisor" /^execute\s+(\w+)/i, // "execute ai_supervisor" /^run\s+(\w+)/i, // "run ai_supervisor" /^call\s+(\w+)/i, // "call ai_supervisor" /!(\w+)/, // "!ai_supervisor" (explicit trigger) ]; // Check if any mandatory pattern matches for (const pattern of mandatoryPatterns) { const match = userMessage.match(pattern); if (match && match[1] === toolName) { console.error(`[MANDATORY EXECUTION] Triggered for tool: ${toolName}`); return true; // Force execution } } return false; // Optional execution } private setupToolHandlers() { // List available tools - streamlined to just the essentials this.server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: "consult_ai", description: "Consult with a specific AI provider for expertise in their specialty area. Enhanced with automatic project context and conversation history.", inputSchema: { type: "object", properties: { provider: { type: "string", enum: Object.keys(AI_PROVIDERS), description: "Which AI provider to consult (claude, gpt4, gemini, ollama)" }, prompt: { type: "string", description: "The question or task to ask the AI provider" }, context: { type: "string", description: "Additional context for the consultation (optional - automatic context injection will also include project info)" } }, required: ["provider", "prompt"] } }, { name: "multi_ai_research", description: "Get comprehensive perspectives from ALL AI providers automatically (Claude, GPT-4, Gemini, Ollama). DO NOT specify providers parameter unless you want to limit to specific AIs only. Enhanced with full project context and conversation history.", inputSchema: { type: "object", properties: { research_question: { type: "string", description: "The research question to investigate across all AI providers" }, providers: { type: "array", items: { type: "string", enum: Object.keys(AI_PROVIDERS) }, description: "ONLY specify this if you want to limit research to specific providers. Leave blank for comprehensive multi-AI research across ALL providers." } }, required: ["research_question"] } }, { name: "mandatory_execute", description: "Enforces mandatory execution of tools when explicitly requested. Use syntax: !toolname or 'use toolname'", inputSchema: { type: "object", properties: { command: { type: "string", description: "The user's exact command that triggered mandatory execution" }, tool_name: { type: "string", description: "The name of the tool to execute mandatorily" }, tool_args: { type: "object", description: "Arguments to pass to the tool" } }, required: ["command", "tool_name"] } }, { name: "set_workspace", description: "Set the current workspace directory for conversation history. This should be called when VS Code switches to a different project.", inputSchema: { type: "object", properties: { workspace_path: { type: "string", description: "The absolute path to the current workspace/project directory" } }, required: ["workspace_path"] } } ] }; }); // Handle tool calls - only the essential tools this.server.setRequestHandler(CallToolRequestSchema, async (request) => { try { // CRITICAL: Extract workspace from VS Code context if available // When VS Code uses @workspace, it should provide context about the current workspace const workspaceFromContext = this.extractWorkspaceFromRequest(request); if (workspaceFromContext) { console.error(`[WORKSPACE] Auto-detected workspace from VS Code context: ${workspaceFromContext}`); WorkspaceManager.getInstance().setWorkspace(workspaceFromContext); // Reinitialize conversation history with correct workspace this.conversationHistory = new ConversationHistoryManager(); } switch (request.params.name) { case "consult_ai": return await this.consultAI(request.params.arguments); case "multi_ai_research": return await this.multiAIResearch(request.params.arguments); case "mandatory_execute": return await this.mandatoryExecute(request.params.arguments); case "set_workspace": return await this.setWorkspace(request.params.arguments); default: throw new Error(`Unknown tool: ${request.params.name}`); } } catch (error) { console.error("Tool execution error:", error); throw error; } }); } private setupResourceHandlers() { // List available resources this.server.setRequestHandler(ListResourcesRequestSchema, async () => { return { resources: [ { uri: "ai-providers://status", name: "AI Providers Status", description: "Status and configuration of all AI providers", mimeType: "text/plain" }, { uri: "ai-providers://capabilities", name: "AI Provider Capabilities", description: "Detailed capabilities and specialties of each AI provider", mimeType: "application/json" } ] }; }); // Handle resource requests this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => { const uri = request.params.uri; if (uri === "ai-providers://status") { const status = Object.entries(AI_PROVIDERS).map(([key, provider]) => { const callStatus = this.providerCallManager.getCallStatus()[key]; return `${provider.name}: ${provider.apiKey ? '✅ Configured' : '❌ Missing API Key'} (${callStatus.remaining}/${callStatus.total} calls remaining)`; }).join('\n'); return { contents: [ { uri, mimeType: "text/plain", text: status } ] }; } else if (uri === "ai-providers://capabilities") { return { contents: [ { uri, mimeType: "application/json", text: JSON.stringify(AI_PROVIDERS, null, 2) } ] }; } else { throw new Error(`Unknown resource: ${uri}`); } }); } private async consultAI(args: any): Promise<any> { const { provider, prompt, context } = args; const toolName = 'consult_ai'; if (!AI_PROVIDERS[provider]) { throw new Error(`Unknown AI provider: ${provider}`); } const aiProvider = AI_PROVIDERS[provider]; if (!aiProvider.apiKey) { throw new Error(`API key not configured for ${aiProvider.name}`); } // Check API call limits if (!this.providerCallManager.canMakeCall(provider)) { const remaining = this.providerCallManager.getRemainingCalls(provider); return { content: [ { type: "text", text: `## ❌ API Call Limit Reached for ${aiProvider.name}\n\n**Limit:** 6 calls per hour\n**Remaining:** ${remaining}\n\n**Suggestion:** Wait for limit reset or use a different provider.` } ] }; } try { // Build enhanced context with conversation history and project context const enhancedContext = await this.contextManager.buildFullContext(prompt, toolName, this.conversationHistory); // Combine user-provided context with enhanced context const fullPrompt = context ? `${enhancedContext}\n\n## Additional User Context\n${context}\n\n## Final Query\n${prompt}` : `${enhancedContext}\n\n## Final Query\n${prompt}`; console.error(`[CONSULT_AI] Calling ${aiProvider.name} with enhanced context (${this.contextManager.estimateTokenCount(fullPrompt)} estimated tokens)`); const response = await this.callAIProvider(aiProvider, fullPrompt); // Record the API call this.providerCallManager.recordCall(provider); // Store in conversation history await this.conversationHistory.addEntry({ tool: toolName, provider: provider, query: prompt, response: response, contextFiles: [], tokenCount: this.contextManager.estimateTokenCount(fullPrompt + response) }); const callStatus = this.providerCallManager.getCallStatus(); return { content: [ { type: "text", text: `## Consultation with ${aiProvider.name}\n\n**Specialty:** ${aiProvider.specialty}\n**Remaining API calls:** ${callStatus[provider].remaining}/${callStatus[provider].total}\n\n**Response:**\n\n${response}` } ] }; } catch (error) { console.error(`[consultAI] Error consulting ${aiProvider.name}:`, error); return { content: [ { type: "text", text: `## ❌ Error consulting ${aiProvider.name}\n\n**Error:** ${error}\n\n**Suggestion:** Check API key configuration and try again.` } ] }; } } private async multiAIResearch(args: any): Promise<any> { const { research_question, providers } = args; const toolName = 'multi_ai_research'; // ALWAYS query all providers by default - that's the point of multi-AI research! const providersToUse = providers && providers.length > 0 ? providers : Object.keys(AI_PROVIDERS); // Build enhanced context once for all providers const enhancedContext = await this.contextManager.buildFullContext(research_question, toolName, this.conversationHistory); const results: string[] = []; const responses: Array<{ provider: string; response: string }> = []; console.error(`[MULTI_AI_RESEARCH] Starting research with enhanced context (${this.contextManager.estimateTokenCount(enhancedContext)} estimated tokens)`); console.error(`[MULTI_AI_RESEARCH] Querying ${providersToUse.length} providers: ${providersToUse.join(', ')}`); for (const providerKey of providersToUse) { if (!AI_PROVIDERS[providerKey]) { continue; } const provider = AI_PROVIDERS[providerKey]; // Only check API key for cloud providers, not Ollama (local) if (providerKey !== 'ollama' && !provider.apiKey) { results.push(`**${provider.name}:** ❌ API key not configured`); continue; } // Check API call limits if (!this.providerCallManager.canMakeCall(providerKey)) { const remaining = this.providerCallManager.getRemainingCalls(providerKey); results.push(`**${provider.name}:** ❌ API call limit reached (${remaining} remaining)`); continue; } try { const fullPrompt = `${enhancedContext}\n\n## Final Research Question\n${research_question}`; console.error(`[MULTI_AI_RESEARCH] Querying ${provider.name}...`); const response = await this.callAIProvider(provider, fullPrompt); // Record the API call this.providerCallManager.recordCall(providerKey); responses.push({ provider: providerKey, response }); const remaining = this.providerCallManager.getRemainingCalls(providerKey); results.push(`**${provider.name}** (${provider.specialty}) - ${remaining}/3 calls remaining:\n\n${response}\n\n---\n`); } catch (error) { console.error(`[MULTI_AI_RESEARCH] Error with ${provider.name}:`, error); results.push(`**${provider.name}:** ❌ Error: ${error}\n\n---\n`); } } // Store conversation history for successful responses if (responses.length > 0) { const combinedResponse = responses.map(r => `${AI_PROVIDERS[r.provider].name}: ${r.response}`).join('\n\n'); await this.conversationHistory.addEntry({ tool: toolName, provider: 'multiple', query: research_question, response: combinedResponse, contextFiles: [], tokenCount: this.contextManager.estimateTokenCount(enhancedContext + combinedResponse) }); } // Store conversation history for successful responses const successfulResponses = responses.filter(r => !r.response.includes('❌')); if (successfulResponses.length > 0) { try { await this.conversationHistory.addEntry({ tool: toolName, provider: 'multiple', query: research_question, response: results.join('\n'), contextFiles: [], tokenCount: this.contextManager.estimateTokenCount(research_question + results.join('\n')) }); } catch (error) { console.error('[MULTI_AI_RESEARCH] Failed to save conversation history:', error); } } return { content: [ { type: "text", text: `# Multi-AI Research Results\n\n**Research Question:** ${research_question}\n\n${results.join('\n')}` } ] }; } private async mandatoryExecute(args: any): Promise<any> { const { command, tool_name, tool_args = {} } = args; console.error(`[MANDATORY EXECUTE] Command: "${command}" Tool: "${tool_name}"`); // Validate that this is indeed a mandatory execution request if (!this.parseAndExecuteCommand(command, tool_name, tool_args)) { return { content: [ { type: "text", text: `❌ **Mandatory Execution Failed**\n\nCommand "${command}" does not match mandatory execution patterns.\n\n**Valid patterns:**\n- \`use ${tool_name}\`\n- \`execute ${tool_name}\`\n- \`run ${tool_name}\`\n- \`!${tool_name}\`\n\nPlease use proper syntax for mandatory tool execution.` } ] }; } // Execute the requested tool directly try { // Use the same routing logic as the main tool handler switch (tool_name) { case "consult_ai": return await this.consultAI(tool_args); case "multi_ai_research": return await this.multiAIResearch(tool_args); default: throw new Error(`Mandatory execution not supported for tool: ${tool_name}`); } } catch (error) { return { content: [ { type: "text", text: `❌ **Mandatory Execution Error**\n\nTool: ${tool_name}\nCommand: ${command}\nError: ${error}\n\n**Suggestion:** Check tool arguments and try again.` } ] }; } } private extractWorkspaceFromRequest(request: any): string | null { try { // Check various places where VS Code might pass workspace information console.error(`[WORKSPACE DEBUG] Full request:`, JSON.stringify(request, null, 2)); // Method 1: Check request meta/context if (request.meta?.workspaceFolder) { console.error(`[WORKSPACE] Found in request.meta.workspaceFolder: ${request.meta.workspaceFolder}`); return request.meta.workspaceFolder; } // Method 2: Check request params context if (request.params?.context?.workspaceFolder) { console.error(`[WORKSPACE] Found in request.params.context.workspaceFolder: ${request.params.context.workspaceFolder}`); return request.params.context.workspaceFolder; } // Method 3: Check arguments for workspace hints if (request.params?.arguments?.context) { const contextStr = JSON.stringify(request.params.arguments.context); console.error(`[WORKSPACE DEBUG] Context string: ${contextStr}`); // Look for file paths that might indicate workspace const filePathMatch = contextStr.match(/file:\/\/([^"]+)/); if (filePathMatch) { const filePath = filePathMatch[1]; console.error(`[WORKSPACE DEBUG] Found file path: ${filePath}`); // Extract workspace root from file path let dir = path.dirname(filePath); while (dir && dir !== '/' && dir !== os.homedir()) { if (this.isValidWorkspaceDir(dir)) { console.error(`[WORKSPACE] Detected workspace from file path: ${dir}`); return dir; } dir = path.dirname(dir); } } // NEW: Try to infer workspace from context content patterns // Look for project-specific terms or paths that might hint at the workspace const contextLower = contextStr.toLowerCase(); // Check for common project patterns in context const projectPatterns = [ /\/([^\/]+)\/(src|lib|components|pages|app)/i, /project[:\s]+([^,\s]+)/i, /workspace[:\s]+([^,\s]+)/i, /working on[:\s]+([^,\s]+)/i ]; for (const pattern of projectPatterns) { const match = contextStr.match(pattern); if (match && match[1]) { const projectHint = match[1]; console.error(`[WORKSPACE DEBUG] Found project hint: ${projectHint}`); // Try to find this project in common locations const possiblePaths = [ path.join(os.homedir(), 'Projects', projectHint), path.join(os.homedir(), 'projects', projectHint), path.join(os.homedir(), 'Development', projectHint), path.join(os.homedir(), 'dev', projectHint), path.join(os.homedir(), projectHint) ]; for (const possiblePath of possiblePaths) { if (fs.existsSync(possiblePath) && this.isValidWorkspaceDir(possiblePath)) { console.error(`[WORKSPACE] Found workspace from context hint: ${possiblePath}`); return possiblePath; } } } } } // Method 4: Check for environment variables set by this request const envWorkspace = process.env.VSCODE_WORKSPACE_FOLDER || process.env.WORKSPACE_FOLDER; if (envWorkspace && envWorkspace !== '/' && fs.existsSync(envWorkspace)) { console.error(`[WORKSPACE] Found in environment: ${envWorkspace}`); return envWorkspace; } console.error(`[WORKSPACE DEBUG] No workspace found in request context`); return null; } catch (error) { console.error(`[WORKSPACE ERROR] Error extracting workspace: ${error}`); return null; } } private isValidWorkspaceDir(dirPath: string): boolean { if (!fs.existsSync(dirPath)) return false; // Check for common workspace/project indicators const indicators = [ 'package.json', '.git', 'pyproject.toml', 'requirements.txt', 'Cargo.toml', 'pom.xml', 'build.gradle', 'composer.json', 'go.mod', '.vscode', 'tsconfig.json' ]; return indicators.some(indicator => fs.existsSync(path.join(dirPath, indicator)) ); } private async setWorkspace(args: any): Promise<any> { const { workspace_path } = args; if (!workspace_path) { return { content: [ { type: "text", text: "❌ **Set Workspace Error**\n\nMissing required parameter: workspace_path" } ] }; } if (!fs.existsSync(workspace_path)) { return { content: [ { type: "text", text: `❌ **Set Workspace Error**\n\nWorkspace path does not exist: ${workspace_path}` } ] }; } // Set the workspace using the WorkspaceManager WorkspaceManager.getInstance().setWorkspace(workspace_path); // Reinitialize the conversation history manager with the new workspace this.conversationHistory = new ConversationHistoryManager(); // Force refresh the project context with the new workspace this.contextManager.clearProjectContext(); console.error(`[WORKSPACE] Successfully set workspace to: ${workspace_path}`); return { content: [ { type: "text", text: `✅ **Workspace Set Successfully**\n\nConversation history will now be saved to:\n\`${path.join(workspace_path, '.mcp-conversation-history.json')}\`\n\nAll future AI interactions will use this workspace-specific conversation history.` } ] }; } /** * Core AI Provider Communication */ private async callAIProvider(provider: AIProvider, prompt: string): Promise<string> { return await this.retryWithBackoff(async () => { if (provider.name.includes("Claude")) { return await this.callClaude(provider, prompt); } else if (provider.name.includes("GPT-4")) { return await this.callOpenAI(provider, prompt); } else if (provider.name.includes("Gemini")) { return await this.callGemini(provider, prompt); } else if (provider.name.includes("Ollama")) { return await this.callOllama(provider, prompt); } else { throw new Error(`Unsupported AI provider: ${provider.name}`); } }); } private async callClaude(provider: AIProvider, prompt: string): Promise<string> { const response = await fetch(provider.baseUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-key': provider.apiKey, 'anthropic-version': '2023-06-01' }, body: JSON.stringify({ model: provider.model, max_tokens: 4000, messages: [ { role: 'user', content: prompt } ] }) }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Claude API error (${response.status}): ${errorText}`); } const data = await response.json() as any; return data.content[0].text; } private async callOpenAI(provider: AIProvider, prompt: string): Promise<string> { const response = await fetch(provider.baseUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${provider.apiKey}` }, body: JSON.stringify({ model: provider.model, messages: [ { role: 'user', content: prompt } ], max_tokens: 4000, temperature: 0.7 }) }); if (!response.ok) { const errorText = await response.text(); throw new Error(`OpenAI API error (${response.status}): ${errorText}`); } const data = await response.json() as any; return data.choices[0].message.content; } private async callGemini(provider: AIProvider, prompt: string): Promise<string> { const url = `${provider.baseUrl}?key=${provider.apiKey}`; const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ contents: [ { parts: [ { text: prompt } ] } ], generationConfig: { maxOutputTokens: 4000, temperature: 0.7 } }) }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Gemini API error (${response.status}): ${errorText}`); } const data = await response.json() as any; if (data.candidates && data.candidates.length > 0) { return data.candidates[0].content.parts[0].text; } else { throw new Error('No response from Gemini API'); } } private async callOllama(provider: AIProvider, prompt: string): Promise<string> { const response = await fetch(`${provider.baseUrl}/api/generate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: provider.model, prompt: prompt, stream: false }) }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Ollama API error (${response.status}): ${errorText}`); } const data = await response.json() as any; return data.response; } async run(): Promise<void> { const transport = new StdioServerTransport(); await this.server.connect(transport); console.error("AI Collaboration MCP Server running on stdio (streamlined version)"); } } // Start the server const server = new AICollaborationServer(); server.run().catch(console.error);

Latest Blog Posts

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/hurryupmitch/ai-collaboration-mcp-server'

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