UNPKG

mongodb-memory-bank-mcp

Version:

FIXED: MongoDB Memory Bank MCP with bulletproof error handling, smart operations, and session state management. Eliminates [object Object] errors and user confusion.

697 lines (696 loc) 27.2 kB
/** * Project Name Normalization & Path-Based Detection Utility * * Provides consistent project name normalization and automatic project detection * from working directory paths to ensure 100% project isolation. * * Based on MongoDB best practices: lowercase, hyphen-separated * Inspired by alioshr/memory-bank-mcp path-based approach */ import path from 'path'; import fs from 'fs'; import { execSync } from 'child_process'; import os from 'os'; /** * Normalizes a project name to ensure consistency across different sources * Follows MongoDB best practices: lowercase, hyphen-separated * * @param name - The project name to normalize * @returns Normalized project name * * @example * normalizeProjectName("My Project Name") // "my-project-name" * normalizeProjectName("mongodb_memory_bank_mcp") // "mongodb-memory-bank-mcp" * normalizeProjectName("MongoDB-Memory-Bank-MCP") // "mongodb-memory-bank-mcp" */ export function normalizeProjectName(name) { if (!name || typeof name !== 'string') { throw new Error('Project name must be a non-empty string'); } return name .toLowerCase() .replace(/[^a-z0-9\-_]/g, '-') // Replace non-alphanumeric chars with hyphens .replace(/_/g, '-') // Convert underscores to hyphens for consistency .replace(/-+/g, '-') // Replace multiple hyphens with single hyphen .replace(/^-|-$/g, '') // Remove leading/trailing hyphens .trim(); } /** * Validates that a project name is properly normalized * * @param name - The project name to validate * @returns True if the name is already normalized */ export function isNormalizedProjectName(name) { try { return normalizeProjectName(name) === name; } catch { return false; } } /** * Normalizes project name with validation * Throws descriptive error if normalization fails * * @param name - The project name to normalize * @returns Normalized project name * @throws Error if name is invalid or normalization fails */ export function normalizeProjectNameSafe(name) { if (!name || typeof name !== 'string') { throw new Error('Invalid parameter: Project name must be a non-empty string'); } const normalized = normalizeProjectName(name); if (!normalized) { throw new Error(`Invalid parameter: ${name} - Project name cannot be normalized to a valid identifier`); } return normalized; } /** * Finds the Git repository root directory * @param startPath - Starting directory path * @returns Git root path or null if not found */ function findGitRoot(startPath) { let currentPath = path.resolve(startPath); while (currentPath !== path.dirname(currentPath)) { try { const gitPath = path.join(currentPath, '.git'); if (fs.existsSync(gitPath)) { return currentPath; } } catch { // Continue searching } currentPath = path.dirname(currentPath); } return null; } /** * Finds and reads package.json * @param startPath - Starting directory path * @returns Package name or null if not found */ function findPackageName(startPath) { let currentPath = path.resolve(startPath); while (currentPath !== path.dirname(currentPath)) { try { const packagePath = path.join(currentPath, 'package.json'); if (fs.existsSync(packagePath)) { const packageContent = fs.readFileSync(packagePath, 'utf8'); const packageJson = JSON.parse(packageContent); if (packageJson.name) { // Handle scoped packages like @org/package-name const cleanName = packageJson.name.split('/').pop() || packageJson.name; return cleanName; } } } catch { // Continue searching } currentPath = path.dirname(currentPath); } return null; } /** * Detects project name from working directory using multiple strategies * This is the core function that ensures 100% project isolation * * @param workingDirectory - Current working directory (defaults to process.cwd()) * @returns Project detection result with normalized name */ export function detectProjectFromPath(workingDirectory) { const cwd = workingDirectory || process.cwd(); // Strategy 1: Git repository root (highest confidence) const gitRoot = findGitRoot(cwd); if (gitRoot) { const gitProjectName = path.basename(gitRoot); return { projectName: normalizeProjectName(gitProjectName), detectionMethod: 'git-root', confidence: 95, workingDirectory: cwd, gitRoot, packageName: findPackageName(gitRoot) || undefined }; } // Strategy 2: Package.json name (high confidence) const packageName = findPackageName(cwd); if (packageName) { return { projectName: normalizeProjectName(packageName), detectionMethod: 'package-name', confidence: 90, workingDirectory: cwd, packageName }; } // Strategy 3: Directory name (fallback) const dirName = path.basename(cwd); return { projectName: normalizeProjectName(dirName), detectionMethod: 'directory-name', confidence: 80, workingDirectory: cwd }; } /** * Gets the current project name automatically from working directory * This is the main function used by MCP tools for automatic project detection * * @param workingDirectory - Optional working directory (defaults to process.cwd()) * @returns Normalized project name */ export function getCurrentProjectName(workingDirectory) { const detection = detectProjectFromPath(workingDirectory); return detection.projectName; } /** * Universal project detection that works in ANY scenario * Following MCP patterns and MongoDB best practices * * @param workingDirectory - Optional working directory * @returns Universal project detection result */ export async function detectProjectUniversally(workingDirectory) { const cwd = workingDirectory || process.env.MCP_WORKING_DIRECTORY || process.cwd(); const signals = []; try { // Signal 1: Git-based detection (highest confidence) const gitSignal = await detectFromGit(cwd); if (gitSignal) signals.push(gitSignal); // Signal 2: Package file detection (high confidence) const packageSignal = await detectFromPackageFiles(cwd); if (packageSignal) signals.push(packageSignal); // Signal 3: Recent file activity (medium confidence) const activitySignal = await detectFromFileActivity(cwd); if (activitySignal) signals.push(activitySignal); // Signal 4: Directory structure analysis (medium confidence) const structureSignal = await detectFromDirectoryStructure(cwd); if (structureSignal) signals.push(structureSignal); // Signal 5: Project markers (low-medium confidence) const markersSignal = await detectFromProjectMarkers(cwd); if (markersSignal) signals.push(markersSignal); // Select best signal based on confidence const bestSignal = signals.length > 0 ? signals.reduce((best, current) => current.confidence > best.confidence ? current : best) : null; // SMART FALLBACK: If no good signals, low confidence, or empty project name, check existing projects if (!bestSignal || bestSignal.confidence < 80 || !bestSignal.projectName || bestSignal.projectName.trim() === '') { const fallbackResult = await smartProjectFallback(cwd, signals); if (fallbackResult) { return fallbackResult; } // If fallback also fails, use smart default const defaultSignal = generateSmartDefault(cwd); signals.push(defaultSignal); return { projectName: defaultSignal.projectName, confidence: defaultSignal.confidence, detectionMethod: 'smart-default', workingDirectory: cwd, signals }; } return { projectName: bestSignal.projectName, confidence: bestSignal.confidence, detectionMethod: bestSignal.type === 'git' ? 'git-root' : bestSignal.type === 'package' ? 'package-name' : bestSignal.type === 'file-activity' ? 'recent-activity' : bestSignal.type === 'directory-structure' ? 'directory-name' : bestSignal.type === 'smart-default' ? 'smart-default' : 'file-markers', workingDirectory: cwd, signals }; } catch (error) { // Fallback to smart default on any error const defaultSignal = generateSmartDefault(cwd); return { projectName: defaultSignal.projectName, confidence: defaultSignal.confidence, detectionMethod: 'smart-default', workingDirectory: cwd, signals: [defaultSignal] }; } } /** * Git-based project detection * Following MongoDB MCP patterns for robust detection */ async function detectFromGit(workingDirectory) { try { // Try to find git root using git command const gitRoot = execSync('git rev-parse --show-toplevel', { cwd: workingDirectory, encoding: 'utf8', stdio: 'pipe' }).trim(); if (gitRoot && fs.existsSync(gitRoot)) { const projectName = normalizeProjectName(path.basename(gitRoot)); return { type: 'git', confidence: 95, projectName, evidence: [`Git repository root: ${gitRoot}`] }; } } catch (error) { // Git not available or not in a git repository } return null; } /** * Package file detection * Supports multiple package managers following MCP patterns */ async function detectFromPackageFiles(workingDirectory) { const packageMarkers = [ { file: 'package.json', confidence: 95, parser: parsePackageJson }, { file: 'pyproject.toml', confidence: 90, parser: parsePyprojectToml }, { file: 'Cargo.toml', confidence: 90, parser: parseCargoToml }, { file: 'go.mod', confidence: 90, parser: parseGoMod }, { file: 'composer.json', confidence: 85, parser: parseComposerJson }, { file: 'requirements.txt', confidence: 70, parser: null } ]; let currentPath = path.resolve(workingDirectory); while (currentPath !== path.dirname(currentPath)) { for (const marker of packageMarkers) { const filePath = path.join(currentPath, marker.file); if (fs.existsSync(filePath)) { let projectName = path.basename(currentPath); // Try to extract name from package file if (marker.parser) { try { const extractedName = marker.parser(filePath); if (extractedName) { projectName = extractedName; } } catch (error) { // Use directory name as fallback } } return { type: 'package', confidence: marker.confidence, projectName: normalizeProjectName(projectName), evidence: [`Package file: ${marker.file}`, `Path: ${filePath}`] }; } } currentPath = path.dirname(currentPath); } return null; } /** * Package file parsers * Following MongoDB MCP patterns for robust parsing */ function parsePackageJson(filePath) { try { const content = fs.readFileSync(filePath, 'utf8'); const packageJson = JSON.parse(content); if (packageJson.name) { // Handle scoped packages like @org/package-name return packageJson.name.split('/').pop() || packageJson.name; } } catch (error) { // Invalid JSON or file read error } return null; } function parsePyprojectToml(filePath) { try { const content = fs.readFileSync(filePath, 'utf8'); const nameMatch = content.match(/name\s*=\s*["']([^"']+)["']/); return nameMatch ? nameMatch[1] : null; } catch (error) { return null; } } function parseCargoToml(filePath) { try { const content = fs.readFileSync(filePath, 'utf8'); const nameMatch = content.match(/name\s*=\s*["']([^"']+)["']/); return nameMatch ? nameMatch[1] : null; } catch (error) { return null; } } function parseGoMod(filePath) { try { const content = fs.readFileSync(filePath, 'utf8'); const moduleMatch = content.match(/module\s+([^\s]+)/); if (moduleMatch) { // Extract last part of module path return moduleMatch[1].split('/').pop() || moduleMatch[1]; } } catch (error) { return null; } return null; } function parseComposerJson(filePath) { try { const content = fs.readFileSync(filePath, 'utf8'); const composerJson = JSON.parse(content); if (composerJson.name) { // Handle vendor/package format return composerJson.name.split('/').pop() || composerJson.name; } } catch (error) { return null; } return null; } /** * File activity detection * Detects projects based on recent file modifications */ async function detectFromFileActivity(workingDirectory) { try { const stats = fs.statSync(workingDirectory); const now = Date.now(); const dayAgo = now - (24 * 60 * 60 * 1000); // 24 hours ago // Check if directory has recent activity if (stats.mtime.getTime() > dayAgo) { const projectName = normalizeProjectName(path.basename(workingDirectory)); return { type: 'file-activity', confidence: 75, projectName, evidence: [`Recent activity in directory: ${workingDirectory}`, `Modified: ${stats.mtime.toISOString()}`] }; } } catch (error) { // Directory doesn't exist or permission error } return null; } /** * Directory structure analysis * Analyzes directory structure for project indicators */ async function detectFromDirectoryStructure(workingDirectory) { try { if (!fs.existsSync(workingDirectory)) { return null; } const files = fs.readdirSync(workingDirectory); const projectIndicators = [ 'src', 'lib', 'app', 'components', 'pages', 'routes', 'test', 'tests', '__tests__', 'spec', 'docs', 'documentation', 'config', 'configs', 'public', 'static', 'assets', 'build', 'dist', 'out', 'node_modules', 'vendor', 'target' ]; const foundIndicators = files.filter(file => projectIndicators.includes(file.toLowerCase())); if (foundIndicators.length >= 2) { const projectName = normalizeProjectName(path.basename(workingDirectory)); return { type: 'directory-structure', confidence: 70, projectName, evidence: [`Project structure indicators: ${foundIndicators.join(', ')}`] }; } } catch (error) { // Directory read error } return null; } /** * Project markers detection * Looks for common project files */ async function detectFromProjectMarkers(workingDirectory) { const markers = [ 'README.md', 'README.txt', 'README', 'LICENSE', 'LICENSE.txt', 'LICENSE.md', 'Dockerfile', 'docker-compose.yml', '.gitignore', '.gitattributes', 'Makefile', 'makefile', '.vscode', '.idea', 'tsconfig.json', 'jsconfig.json', '.eslintrc', '.prettierrc' ]; try { const files = fs.readdirSync(workingDirectory); const foundMarkers = markers.filter(marker => files.includes(marker)); if (foundMarkers.length >= 1) { const projectName = normalizeProjectName(path.basename(workingDirectory)); return { type: 'content-analysis', confidence: 65, projectName, evidence: [`Project markers found: ${foundMarkers.join(', ')}`] }; } } catch (error) { // Directory read error } return null; } /** * Smart default generator * Following MongoDB MCP patterns for robust fallbacks */ function generateSmartDefault(workingDirectory) { try { // Strategy 1: Use directory name if it looks like a project const dirName = path.basename(workingDirectory); if (dirName && dirName !== '/' && dirName !== '.' && dirName.length > 1) { return { type: 'smart-default', confidence: 60, projectName: normalizeProjectName(dirName), evidence: [`Directory name: ${dirName}`] }; } // Strategy 2: Generate name based on path only (NO DATE-BASED NAMING) const pathHash = workingDirectory.split('/').slice(-2).join('-'); const username = os.userInfo().username || 'user'; const fallbackName = `project-${pathHash}-${username}`; return { type: 'smart-default', confidence: 50, projectName: normalizeProjectName(fallbackName), evidence: [`Generated from path: ${workingDirectory}`, `User: ${username}`] }; } catch (error) { // Ultimate fallback const timestamp = Date.now().toString(36); return { type: 'smart-default', confidence: 40, projectName: `project-${timestamp}`, evidence: [`Emergency fallback: ${timestamp}`] }; } } /** * Smart fallback when normal project detection fails * Checks existing projects in MongoDB and provides intelligent selection */ async function smartProjectFallback(workingDirectory, existingSignals) { try { // Import here to avoid circular dependencies const { MongoDBProjectRepository } = await import('../../infra/mongodb/repositories/mongodb-project-repository.js'); const projectRepository = new MongoDBProjectRepository(); const existingProjects = await projectRepository.listProjects(); if (existingProjects.length === 0) { // No existing projects - let normal fallback handle this return null; } // REMOVED: All auto-selection logic - this breaks project isolation! // The system must ALWAYS respect the working directory, not auto-select existing projects // Return null to force directory-based detection return null; } catch (error) { console.error('[SMART-FALLBACK] Error accessing existing projects:', error); return null; } } // Session-based project cache for MCP environments // Since MCP servers don't have access to client working directory, // we cache the last detected project from explicit detect_project_context_secure calls let cachedProjectName = null; let cacheTimestamp = 0; const CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours (session-based persistence) /** * Sets the cached project name from explicit project detection * Called by detect_project_context_secure tool * Also persists to MongoDB for cross-session reliability */ export function setCachedProjectName(projectName) { cachedProjectName = projectName; cacheTimestamp = Date.now(); console.log(`[PROJECT-CACHE] Set cached project: "${projectName}" at ${new Date().toISOString()}`); console.log(`[PROJECT-CACHE] Cache state: name="${cachedProjectName}", timestamp=${cacheTimestamp}`); // Persist to MongoDB for cross-session reliability (async, non-blocking) persistProjectContext(projectName).catch(error => { console.warn(`[PROJECT-CACHE] Failed to persist project context: ${error.message}`); }); } /** * Gets the cached project name if still valid * Falls back to MongoDB-persisted context if memory cache is empty */ export function getCachedProjectName() { console.log(`[PROJECT-CACHE] Checking cache: name="${cachedProjectName}", timestamp=${cacheTimestamp}`); if (!cachedProjectName) { console.log(`[PROJECT-CACHE] No cached project name, checking MongoDB persistence`); // Try to restore from MongoDB (async, but we'll handle this in the MCP detection flow) return null; } const now = Date.now(); const age = now - cacheTimestamp; console.log(`[PROJECT-CACHE] Cache age: ${age}ms (max: ${CACHE_DURATION}ms)`); if (age > CACHE_DURATION) { console.log(`[PROJECT-CACHE] Cache expired for project: "${cachedProjectName}"`); cachedProjectName = null; // Try to restore from MongoDB persistence return null; } console.log(`[PROJECT-CACHE] Using cached project: "${cachedProjectName}"`); return cachedProjectName; } /** * Enhanced project detection for MCP environments * FIXED: Use cached project name from detect_project_context_secure first, then fallback */ export async function detectProjectForMCP(mcpContext) { try { // PRIORITY 1: Use cached project name if available (from detect_project_context_secure) const cachedProject = getCachedProjectName(); if (cachedProject) { console.log(`[MCP-PROJECT-DETECTION] Using cached project: "${cachedProject}"`); return cachedProject; } // PRIORITY 1.5: Try to restore from MongoDB persistence const restoredProject = await restoreProjectContext(); if (restoredProject) { console.log(`[MCP-PROJECT-DETECTION] Using restored project from MongoDB: "${restoredProject}"`); return restoredProject; } // PRIORITY 2: Use workingDirectory if provided const mcpWorkingDir = process.env.MCP_WORKING_DIRECTORY || mcpContext?.workingDirectory || process.cwd(); // Use the same logic as detectProjectFromPath (which works correctly) const detection = detectProjectFromPath(mcpWorkingDir); // Log detection for debugging console.log(`[MCP-PROJECT-DETECTION] No cached project, detecting from directory`); console.log(`[MCP-PROJECT-DETECTION] process.cwd(): ${process.cwd()}`); console.log(`[MCP-PROJECT-DETECTION] MCP_WORKING_DIRECTORY: ${process.env.MCP_WORKING_DIRECTORY || 'undefined'}`); console.log(`[MCP-PROJECT-DETECTION] mcpContext?.workingDirectory: ${mcpContext?.workingDirectory || 'undefined'}`); console.log(`[MCP-PROJECT-DETECTION] Final Working Dir: ${mcpWorkingDir}`); console.log(`[MCP-PROJECT-DETECTION] Method: ${detection.detectionMethod}, Confidence: ${detection.confidence}%, Project: "${detection.projectName}"`); return detection.projectName; } catch (error) { console.error('[MCP-PROJECT-DETECTION] Error in detectProjectForMCP:', error); // BETTER FALLBACK: Try to get project name from working directory path try { const mcpWorkingDir = process.env.MCP_WORKING_DIRECTORY || mcpContext?.workingDirectory || process.cwd(); // Extract project name from path as last resort const pathParts = mcpWorkingDir.split('/').filter(Boolean); const lastPart = pathParts[pathParts.length - 1]; if (lastPart && lastPart !== '.' && lastPart !== '..') { console.log(`[MCP-PROJECT-DETECTION] Using directory name as fallback: "${lastPart}"`); return lastPart; } } catch (fallbackError) { console.error('[MCP-PROJECT-DETECTION] Fallback also failed:', fallbackError); } // LAST RESORT: Use a consistent fallback instead of random const fallback = 'unknown-project'; console.log(`[MCP-PROJECT-DETECTION] Using last resort fallback: "${fallback}"`); return fallback; } } /** * Persists project context to MongoDB for cross-session reliability * Non-blocking operation that enhances cache persistence */ async function persistProjectContext(projectName) { try { // Import here to avoid circular dependencies const { MongoDBConnection } = await import('../../infra/mongodb/connection/mongodb-connection.js'); const { getCollectionNames } = await import('../../main/config/mongodb-config.js'); const db = await MongoDBConnection.getInstance().getDatabase(); const collection = db.collection('project_context'); await collection.replaceOne({ type: 'current_project' }, { type: 'current_project', projectName, timestamp: new Date(), cacheExpiry: new Date(Date.now() + CACHE_DURATION) }, { upsert: true }); console.log(`[PROJECT-CACHE] Persisted project context to MongoDB: "${projectName}"`); } catch (error) { // Non-blocking - just log the error console.warn(`[PROJECT-CACHE] Failed to persist to MongoDB: ${error instanceof Error ? error.message : error}`); } } /** * Restores project context from MongoDB persistence * Used when memory cache is empty or expired */ async function restoreProjectContext() { try { const { MongoDBConnection } = await import('../../infra/mongodb/connection/mongodb-connection.js'); const db = await MongoDBConnection.getInstance().getDatabase(); const collection = db.collection('project_context'); const doc = await collection.findOne({ type: 'current_project' }); if (!doc) { console.log(`[PROJECT-CACHE] No persisted project context found`); return null; } // Check if persisted context is still valid const now = new Date(); if (doc.cacheExpiry && now > doc.cacheExpiry) { console.log(`[PROJECT-CACHE] Persisted context expired for: "${doc.projectName}"`); // Clean up expired context await collection.deleteOne({ type: 'current_project' }); return null; } console.log(`[PROJECT-CACHE] Restored project context from MongoDB: "${doc.projectName}"`); // Update memory cache with restored context cachedProjectName = doc.projectName; cacheTimestamp = Date.now(); return doc.projectName; } catch (error) { console.warn(`[PROJECT-CACHE] Failed to restore from MongoDB: ${error instanceof Error ? error.message : error}`); return null; } }