This is page 10 of 12. Use http://codebase.md/portel-dev/ncp?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .dockerignore ├── .dxtignore ├── .github │ ├── FEATURE_STORY_TEMPLATE.md │ ├── ISSUE_TEMPLATE │ │ ├── bug_report.yml │ │ ├── config.yml │ │ ├── feature_request.yml │ │ └── mcp_server_request.yml │ ├── pull_request_template.md │ └── workflows │ ├── ci.yml │ ├── publish-mcp-registry.yml │ └── release.yml ├── .gitignore ├── .mcpbignore ├── .npmignore ├── .release-it.json ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── COMPLETE-IMPLEMENTATION-SUMMARY.md ├── CONTRIBUTING.md ├── CRITICAL-ISSUES-FOUND.md ├── docs │ ├── clients │ │ ├── claude-desktop.md │ │ ├── cline.md │ │ ├── continue.md │ │ ├── cursor.md │ │ ├── perplexity.md │ │ └── README.md │ ├── download-stats.md │ ├── guides │ │ ├── clipboard-security-pattern.md │ │ ├── how-it-works.md │ │ ├── mcp-prompts-for-user-interaction.md │ │ ├── mcpb-installation.md │ │ ├── ncp-registry-command.md │ │ ├── pre-release-checklist.md │ │ ├── telemetry-design.md │ │ └── testing.md │ ├── images │ │ ├── ncp-add.png │ │ ├── ncp-find.png │ │ ├── ncp-help.png │ │ ├── ncp-import.png │ │ ├── ncp-list.png │ │ └── ncp-transformation-flow.png │ ├── mcp-registry-setup.md │ ├── pr-schema-additions.ts │ └── stories │ ├── 01-dream-and-discover.md │ ├── 02-secrets-in-plain-sight.md │ ├── 03-sync-and-forget.md │ ├── 04-double-click-install.md │ ├── 05-runtime-detective.md │ └── 06-official-registry.md ├── DYNAMIC-RUNTIME-SUMMARY.md ├── EXTENSION-CONFIG-DISCOVERY.md ├── INSTALL-EXTENSION.md ├── INTERNAL-MCP-ARCHITECTURE.md ├── jest.config.js ├── LICENSE ├── MANAGEMENT-TOOLS-COMPLETE.md ├── manifest.json ├── manifest.json.backup ├── MCP-CONFIG-SCHEMA-IMPLEMENTATION-EXAMPLE.ts ├── MCP-CONFIG-SCHEMA-SIMPLE-EXAMPLE.json ├── MCP-CONFIGURATION-SCHEMA-FORMAT.json ├── MCPB-ARCHITECTURE-DECISION.md ├── NCP-EXTENSION-COMPLETE.md ├── package-lock.json ├── package.json ├── parity-between-cli-and-mcp.txt ├── PROMPTS-IMPLEMENTATION.md ├── README-COMPARISON.md ├── README.md ├── README.new.md ├── REGISTRY-INTEGRATION-COMPLETE.md ├── RELEASE-PROCESS-IMPROVEMENTS.md ├── RELEASE-SUMMARY.md ├── RELEASE.md ├── RUNTIME-DETECTION-COMPLETE.md ├── scripts │ ├── cleanup │ │ └── scan-repository.js │ └── sync-server-version.cjs ├── SECURITY.md ├── server.json ├── src │ ├── analytics │ │ ├── analytics-formatter.ts │ │ ├── log-parser.ts │ │ └── visual-formatter.ts │ ├── auth │ │ ├── oauth-device-flow.ts │ │ └── token-store.ts │ ├── cache │ │ ├── cache-patcher.ts │ │ ├── csv-cache.ts │ │ └── schema-cache.ts │ ├── cli │ │ └── index.ts │ ├── discovery │ │ ├── engine.ts │ │ ├── mcp-domain-analyzer.ts │ │ ├── rag-engine.ts │ │ ├── search-enhancer.ts │ │ └── semantic-enhancement-engine.ts │ ├── extension │ │ └── extension-init.ts │ ├── index-mcp.ts │ ├── index.ts │ ├── internal-mcps │ │ ├── internal-mcp-manager.ts │ │ ├── ncp-management.ts │ │ └── types.ts │ ├── orchestrator │ │ └── ncp-orchestrator.ts │ ├── profiles │ │ └── profile-manager.ts │ ├── server │ │ ├── mcp-prompts.ts │ │ └── mcp-server.ts │ ├── services │ │ ├── config-prompter.ts │ │ ├── config-schema-reader.ts │ │ ├── error-handler.ts │ │ ├── output-formatter.ts │ │ ├── registry-client.ts │ │ ├── tool-context-resolver.ts │ │ ├── tool-finder.ts │ │ ├── tool-schema-parser.ts │ │ └── usage-tips-generator.ts │ ├── testing │ │ ├── create-real-mcp-definitions.ts │ │ ├── dummy-mcp-server.ts │ │ ├── mcp-definitions.json │ │ ├── real-mcp-analyzer.ts │ │ ├── real-mcp-definitions.json │ │ ├── real-mcps.csv │ │ ├── setup-dummy-mcps.ts │ │ ├── setup-tiered-profiles.ts │ │ ├── test-profile.json │ │ ├── test-semantic-enhancement.ts │ │ └── verify-profile-scaling.ts │ ├── transports │ │ └── filtered-stdio-transport.ts │ └── utils │ ├── claude-desktop-importer.ts │ ├── client-importer.ts │ ├── client-registry.ts │ ├── config-manager.ts │ ├── health-monitor.ts │ ├── highlighting.ts │ ├── logger.ts │ ├── markdown-renderer.ts │ ├── mcp-error-parser.ts │ ├── mcp-wrapper.ts │ ├── ncp-paths.ts │ ├── parameter-prompter.ts │ ├── paths.ts │ ├── progress-spinner.ts │ ├── response-formatter.ts │ ├── runtime-detector.ts │ ├── schema-examples.ts │ ├── security.ts │ ├── text-utils.ts │ ├── update-checker.ts │ ├── updater.ts │ └── version.ts ├── STORY-DRIVEN-DOCUMENTATION.md ├── STORY-FIRST-WORKFLOW.md ├── test │ ├── __mocks__ │ │ ├── chalk.js │ │ ├── transformers.js │ │ ├── updater.js │ │ └── version.ts │ ├── cache-loading-focused.test.ts │ ├── cache-optimization.test.ts │ ├── cli-help-validation.sh │ ├── coverage-boost.test.ts │ ├── curated-ecosystem-validation.test.ts │ ├── discovery-engine.test.ts │ ├── discovery-fallback-focused.test.ts │ ├── ecosystem-discovery-focused.test.ts │ ├── ecosystem-discovery-validation-simple.test.ts │ ├── final-80-percent-push.test.ts │ ├── final-coverage-push.test.ts │ ├── health-integration.test.ts │ ├── health-monitor.test.ts │ ├── helpers │ │ └── mock-server-manager.ts │ ├── integration │ │ └── mcp-client-simulation.test.cjs │ ├── logger.test.ts │ ├── mcp-ecosystem-discovery.test.ts │ ├── mcp-error-parser.test.ts │ ├── mcp-immediate-response-check.js │ ├── mcp-server-protocol.test.ts │ ├── mcp-timeout-scenarios.test.ts │ ├── mcp-wrapper.test.ts │ ├── mock-mcps │ │ ├── aws-server.js │ │ ├── base-mock-server.mjs │ │ ├── brave-search-server.js │ │ ├── docker-server.js │ │ ├── filesystem-server.js │ │ ├── git-server.mjs │ │ ├── github-server.js │ │ ├── neo4j-server.js │ │ ├── notion-server.js │ │ ├── playwright-server.js │ │ ├── postgres-server.js │ │ ├── shell-server.js │ │ ├── slack-server.js │ │ └── stripe-server.js │ ├── mock-smithery-mcp │ │ ├── index.js │ │ ├── package.json │ │ └── smithery.yaml │ ├── ncp-orchestrator.test.ts │ ├── orchestrator-health-integration.test.ts │ ├── orchestrator-simple-branches.test.ts │ ├── performance-benchmark.test.ts │ ├── quick-coverage.test.ts │ ├── rag-engine.test.ts │ ├── regression-snapshot.test.ts │ ├── search-enhancer.test.ts │ ├── session-id-passthrough.test.ts │ ├── setup.ts │ ├── tool-context-resolver.test.ts │ ├── tool-schema-parser.test.ts │ ├── user-story-discovery.test.ts │ └── version-util.test.ts └── tsconfig.json ``` # Files -------------------------------------------------------------------------------- /src/utils/config-manager.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { readFileSync, existsSync } from 'fs'; 2 | import { createInterface } from 'readline'; 3 | import chalk from 'chalk'; 4 | import clipboardy from 'clipboardy'; 5 | import { ProfileManager } from '../profiles/profile-manager.js'; 6 | import { OutputFormatter } from '../services/output-formatter.js'; 7 | import { ErrorHandler } from '../services/error-handler.js'; 8 | import { formatCommandDisplay } from '../utils/security.js'; 9 | import { TextUtils } from '../utils/text-utils.js'; 10 | import { logger } from '../utils/logger.js'; 11 | 12 | interface MCPConfig { 13 | command?: string; // Optional: for stdio transport 14 | args?: string[]; 15 | env?: Record<string, string>; 16 | url?: string; // Optional: for HTTP/SSE transport 17 | } 18 | 19 | interface MCPImportData { 20 | [mcpName: string]: MCPConfig; 21 | } 22 | 23 | export class ConfigManager { 24 | private profileManager: ProfileManager; 25 | 26 | constructor() { 27 | this.profileManager = new ProfileManager(); 28 | } 29 | 30 | /** 31 | * Show the location of NCP config files 32 | */ 33 | async showConfigLocations(): Promise<void> { 34 | await this.profileManager.initialize(); 35 | const configDir = this.profileManager.getConfigPath(); 36 | 37 | console.log(chalk.blue('📁 NCP Configuration:')); 38 | console.log(` Profiles Directory: ${configDir}`); 39 | 40 | if (existsSync(configDir)) { 41 | console.log(chalk.green(' ✓ Config directory exists')); 42 | 43 | // List existing profiles 44 | const profiles = this.profileManager.listProfiles(); 45 | if (profiles.length > 0) { 46 | console.log(` 📋 Found ${profiles.length} profiles:`); 47 | profiles.forEach(profile => { 48 | const profilePath = this.profileManager.getProfilePath(profile); 49 | console.log(` • ${profile}: ${profilePath}`); 50 | }); 51 | } else { 52 | console.log(chalk.yellow(' No profiles created yet')); 53 | } 54 | } else { 55 | console.log(chalk.yellow(' ⚠ Config directory will be created on first use')); 56 | } 57 | } 58 | 59 | /** 60 | * Open existing config directory in default editor/explorer 61 | */ 62 | async editConfig(): Promise<void> { 63 | await this.profileManager.initialize(); 64 | const configDir = this.profileManager.getConfigPath(); 65 | 66 | if (!existsSync(configDir)) { 67 | console.log(chalk.yellow('⚠ Config directory does not exist yet. Use "ncp config --import" to create it.')); 68 | return; 69 | } 70 | 71 | const profiles = this.profileManager.listProfiles(); 72 | if (profiles.length === 0) { 73 | console.log(chalk.yellow('⚠ No profile files exist yet. Use "ncp config --import" to create them.')); 74 | return; 75 | } 76 | 77 | // Just show the config location and files 78 | console.log(chalk.green('✓ Configuration location:')); 79 | console.log(OutputFormatter.info(`Config directory: ${configDir}`)); 80 | console.log(OutputFormatter.info(`Profile files:`)); 81 | profiles.forEach(profile => { 82 | console.log(OutputFormatter.bullet(`${profile}.json`)); 83 | }); 84 | console.log(''); 85 | console.log(chalk.dim('💡 You can edit these files directly with your preferred editor')) 86 | } 87 | 88 | /** 89 | * Import MCP configurations using interactive editor 90 | * 91 | * ⚠️ CRITICAL: Default profile MUST be 'all' - DO NOT CHANGE! 92 | * 93 | * The 'all' profile is the universal profile where MCPs are imported by default. 94 | * This matches the behavior of `ncp add` and auto-import functionality. 95 | * 96 | * Changing this to 'default' or any other name will break: 97 | * - User expectations (CLI help says "default: all") 98 | * - Consistency with `ncp add` command 99 | * - Auto-import from Claude Desktop 100 | * 101 | * If you change this, you WILL introduce bugs. Keep it as 'all'. 102 | */ 103 | async importConfig(filePath?: string, profileName: string = 'all', dryRun: boolean = false): Promise<void> { 104 | if (filePath) { 105 | // Import from file 106 | await this.importFromFile(filePath, profileName, dryRun); 107 | } else { 108 | // Interactive import with editor 109 | await this.importInteractive(profileName, dryRun); 110 | } 111 | } 112 | 113 | /** 114 | * Validate current configuration 115 | */ 116 | async validateConfig(): Promise<void> { 117 | await this.profileManager.initialize(); 118 | const configDir = this.profileManager.getConfigPath(); 119 | 120 | if (!existsSync(configDir)) { 121 | console.log(chalk.yellow('⚠ No config directory found. Nothing to validate.')); 122 | return; 123 | } 124 | 125 | const profiles = this.profileManager.listProfiles(); 126 | if (profiles.length === 0) { 127 | console.log(chalk.yellow('⚠ No profile files found. Nothing to validate.')); 128 | return; 129 | } 130 | 131 | let totalMCPs = 0; 132 | let issues: string[] = []; 133 | let validProfiles = 0; 134 | 135 | for (const profileName of profiles) { 136 | try { 137 | const profilePath = this.profileManager.getProfilePath(profileName); 138 | const profileContent = readFileSync(profilePath, 'utf-8'); 139 | const profile = JSON.parse(profileContent); 140 | 141 | // Validate profile structure 142 | if (!profile.name) { 143 | issues.push(`Profile "${profileName}" missing name field`); 144 | } 145 | 146 | if (!profile.mcpServers || typeof profile.mcpServers !== 'object') { 147 | issues.push(`Profile "${profileName}" missing or invalid mcpServers field`); 148 | continue; 149 | } 150 | 151 | // Validate each MCP in this profile 152 | for (const [mcpName, mcpConfig] of Object.entries(profile.mcpServers)) { 153 | totalMCPs++; 154 | const config = mcpConfig as MCPConfig; 155 | 156 | if (!config.command) { 157 | issues.push(`MCP "${mcpName}" in profile "${profileName}" missing command`); 158 | } 159 | 160 | if (config.args && !Array.isArray(config.args)) { 161 | issues.push(`MCP "${mcpName}" in profile "${profileName}" has invalid args (must be array)`); 162 | } 163 | 164 | if (config.env && typeof config.env !== 'object') { 165 | issues.push(`MCP "${mcpName}" in profile "${profileName}" has invalid env (must be object)`); 166 | } 167 | } 168 | 169 | validProfiles++; 170 | } catch (error: any) { 171 | issues.push(`Profile "${profileName}" has invalid JSON: ${error.message}`); 172 | } 173 | } 174 | 175 | if (issues.length === 0) { 176 | console.log(chalk.green(`✓ Configuration is valid`)); 177 | console.log(chalk.blue(` Found ${totalMCPs} MCP servers across ${validProfiles} profiles`)); 178 | } else { 179 | console.log(chalk.red(`✗ Configuration has ${issues.length} issues:`)); 180 | issues.forEach(issue => { 181 | console.log(chalk.red(` • ${issue}`)); 182 | }); 183 | } 184 | } 185 | 186 | /** 187 | * Import from a JSON file 188 | */ 189 | private async importFromFile(filePath: string, profileName: string, dryRun: boolean): Promise<void> { 190 | // Expand tilde to home directory 191 | const { homedir } = await import('os'); 192 | const expandedPath = filePath.startsWith('~') ? 193 | filePath.replace('~', homedir()) : 194 | filePath; 195 | 196 | if (!existsSync(expandedPath)) { 197 | throw new Error(`Configuration file not found at: ${filePath}\n\nPlease check that the file exists and the path is correct.`); 198 | } 199 | 200 | try { 201 | const content = readFileSync(expandedPath, 'utf-8'); 202 | const parsedData = JSON.parse(content); 203 | 204 | // Clean the data to handle Claude Desktop format and remove unwanted entries 205 | const mcpData = this.cleanImportData(parsedData); 206 | 207 | await this.processImportData(mcpData, profileName, dryRun); 208 | } catch (error: any) { 209 | const errorResult = ErrorHandler.handle(error, ErrorHandler.fileOperation('import', filePath)); 210 | console.log(ErrorHandler.formatForConsole(errorResult)); 211 | } 212 | } 213 | 214 | /** 215 | * Interactive import - clipboard-first approach 216 | */ 217 | private async importInteractive(profileName: string, dryRun: boolean): Promise<void> { 218 | console.log(chalk.blue('📋 NCP Config Import')); 219 | console.log(''); 220 | 221 | try { 222 | // Try to read from clipboard 223 | let clipboardContent = ''; 224 | try { 225 | clipboardContent = await clipboardy.read(); 226 | } catch (clipboardError) { 227 | console.log(chalk.red('❌ Could not access system clipboard')); 228 | console.log(chalk.yellow('💡 Copy your MCP configuration JSON first, then run this command again')); 229 | console.log(chalk.yellow('💡 Or use: ncp config import <file> to import from a file')); 230 | return; 231 | } 232 | 233 | // Check if clipboard has content 234 | if (!clipboardContent.trim()) { 235 | console.log(chalk.red('❌ Clipboard is empty')); 236 | console.log(chalk.yellow('💡 Copy your MCP configuration JSON first, then run this command again')); 237 | console.log(chalk.yellow('💡 Or use: ncp config import <file> to import from a file')); 238 | console.log(''); 239 | console.log(chalk.dim('Common config file locations:')); 240 | console.log(chalk.dim(' Claude Desktop (macOS): ~/Library/Application Support/Claude/claude_desktop_config.json')); 241 | console.log(chalk.dim(' Claude Desktop (Windows): %APPDATA%\\Claude\\claude_desktop_config.json')); 242 | return; 243 | } 244 | 245 | // Display clipboard content in a highlighted box 246 | console.log(chalk.blue('📋 Clipboard content detected:')); 247 | this.displayJsonInBox(clipboardContent); 248 | console.log(''); 249 | 250 | // Try to parse clipboard content as JSON 251 | let parsedData: any; 252 | try { 253 | parsedData = JSON.parse(clipboardContent); 254 | } catch (jsonError) { 255 | console.log(chalk.red('❌ Invalid JSON format in clipboard')); 256 | console.log(chalk.yellow('💡 Please ensure your clipboard contains valid JSON')); 257 | return; 258 | } 259 | 260 | // Check if it's a direct MCP config (has "command" property at root level) 261 | const isDirectConfig = parsedData.command && typeof parsedData === 'object' && !Array.isArray(parsedData); 262 | 263 | let mcpData: any; 264 | let mcpNames: string[]; 265 | 266 | if (isDirectConfig) { 267 | // Handle direct MCP configuration 268 | console.log(chalk.green('✅ Single MCP configuration detected')); 269 | 270 | // Prompt for name 271 | console.log(''); 272 | console.log(chalk.blue('❓ What should we name this MCP server?')); 273 | console.log(chalk.gray(' (e.g., \'filesystem\', \'web-search\', \'github\')')); 274 | 275 | const mcpName = await this.promptForMCPName(parsedData.command); 276 | 277 | mcpData = { [mcpName]: parsedData }; 278 | mcpNames = [mcpName]; 279 | } else { 280 | // Handle key-value format (multiple MCPs or client config) 281 | mcpData = this.cleanImportData(parsedData); 282 | mcpNames = Object.keys(mcpData).filter(key => { 283 | if (key.startsWith('//')) return false; 284 | const config = mcpData[key]; 285 | return config && typeof config === 'object' && config.command; 286 | }); 287 | 288 | if (mcpNames.length > 0) { 289 | console.log(chalk.green(`✅ ${mcpNames.length} MCP configuration(s) detected`)); 290 | } else { 291 | console.log(chalk.red('❌ No valid MCP configurations found')); 292 | console.log(chalk.yellow('💡 Expected JSON with MCP server configurations')); 293 | console.log(chalk.dim(' Example: {"server": {"command": "npx", "args": ["..."]}}')); 294 | return; 295 | } 296 | } 297 | 298 | console.log(''); 299 | await this.processImportData(mcpData, profileName, dryRun); 300 | 301 | } catch (error: any) { 302 | console.log(''); 303 | const errorResult = ErrorHandler.handle(error, ErrorHandler.createContext('config', 'import', undefined, ['Check the JSON format', 'Ensure the clipboard contains valid MCP configuration'])); 304 | console.log(ErrorHandler.formatForConsole(errorResult)); 305 | } 306 | } 307 | 308 | /** 309 | * Display JSON content in a highlighted box 310 | */ 311 | private displayJsonInBox(jsonContent: string): void { 312 | // Pretty format the JSON for display 313 | let formattedJson: string; 314 | try { 315 | const parsed = JSON.parse(jsonContent); 316 | formattedJson = JSON.stringify(parsed, null, 2); 317 | } catch { 318 | // If parsing fails, use original content 319 | formattedJson = jsonContent; 320 | } 321 | 322 | // Split into lines and add box borders 323 | const lines = formattedJson.split('\n'); 324 | const maxLength = Math.max(...lines.map(line => line.length), 20); 325 | const boxWidth = Math.min(maxLength + 4, 80); // Limit box width to 80 chars 326 | 327 | // Top border 328 | console.log(chalk.gray('┌' + '─'.repeat(boxWidth - 2) + '┐')); 329 | 330 | // Content lines (truncate if too long) 331 | lines.slice(0, 20).forEach(line => { // Limit to 20 lines 332 | let displayLine = line; 333 | if (line.length > boxWidth - 4) { 334 | displayLine = line.substring(0, boxWidth - 7) + '...'; 335 | } 336 | const padding = ' '.repeat(Math.max(0, boxWidth - displayLine.length - 4)); 337 | console.log(chalk.gray('│ ') + chalk.cyan(displayLine) + padding + chalk.gray(' │')); 338 | }); 339 | 340 | // Show truncation indicator if there are more lines 341 | if (lines.length > 20) { 342 | const truncatedMsg = `... (${lines.length - 20} more lines)`; 343 | const padding = ' '.repeat(Math.max(0, boxWidth - truncatedMsg.length - 4)); 344 | console.log(chalk.gray('│ ') + chalk.dim(truncatedMsg) + padding + chalk.gray(' │')); 345 | } 346 | 347 | // Bottom border 348 | console.log(chalk.gray('└' + '─'.repeat(boxWidth - 2) + '┘')); 349 | } 350 | 351 | /** 352 | * Process and import MCP data 353 | */ 354 | private async processImportData(mcpData: MCPImportData, profileName: string, dryRun: boolean): Promise<void> { 355 | await this.profileManager.initialize(); 356 | 357 | const mcpNames = Object.keys(mcpData).filter(key => !key.startsWith('//')); 358 | 359 | if (mcpNames.length === 0) { 360 | console.log(chalk.yellow('⚠ No MCP configurations found to import')); 361 | return; 362 | } 363 | 364 | if (dryRun) { 365 | console.log('\n' + chalk.blue(`📥 Would import ${mcpNames.length} MCP server(s):`)); 366 | console.log(''); 367 | 368 | mcpNames.forEach((name, index) => { 369 | const config = mcpData[name]; 370 | const isLast = index === mcpNames.length - 1; 371 | const connector = isLast ? '└──' : '├──'; 372 | const indent = isLast ? ' ' : '│ '; 373 | 374 | // MCP name (no indent - root level) 375 | console.log(chalk.gray(`${connector} `) + chalk.cyan(name)); 376 | 377 | // Command line or URL with reverse colors (like ncp list) 378 | const fullCommand = config.url 379 | ? `HTTP/SSE: ${config.url}` 380 | : formatCommandDisplay(config.command || '', config.args); 381 | const maxWidth = process.stdout.columns ? process.stdout.columns - 4 : 80; 382 | const wrappedLines = TextUtils.wrapTextWithBackground(fullCommand, maxWidth, chalk.gray(`${indent} `), (text: string) => chalk.bgGray.black(text)); 383 | console.log(wrappedLines); 384 | 385 | // Environment variables if present 386 | if (config.env && Object.keys(config.env).length > 0) { 387 | const envCount = Object.keys(config.env).length; 388 | console.log(chalk.gray(`${indent} `) + chalk.yellow(`${envCount} environment variable${envCount > 1 ? 's' : ''}`)); 389 | } 390 | 391 | if (!isLast) console.log(chalk.gray('│')); 392 | }); 393 | 394 | console.log(''); 395 | console.log(chalk.dim('💡 Run without --dry-run to perform the import')); 396 | return; 397 | } 398 | 399 | // Actually import the MCPs 400 | const successful: Array<{name: string, config: MCPConfig}> = []; 401 | const failed: Array<{name: string, error: string}> = []; 402 | 403 | for (const mcpName of mcpNames) { 404 | try { 405 | const config = mcpData[mcpName]; 406 | await this.profileManager.addMCPToProfile(profileName, mcpName, config); 407 | successful.push({ name: mcpName, config }); 408 | } catch (error: any) { 409 | failed.push({ name: mcpName, error: error.message }); 410 | } 411 | } 412 | 413 | // Import phase completed, now validate what actually works 414 | if (successful.length > 0) { 415 | console.log(''); // Add newline before spinner starts 416 | 417 | // Show loading animation during validation 418 | const spinner = this.createSpinner(`✅ Validating ${successful.length} imported MCP server(s)...`); 419 | spinner.start(); 420 | 421 | const discoveryResult = await this.discoverImportedMCPs(successful.map(s => s.name)); 422 | 423 | // Clear spinner and show final result 424 | spinner.stop(); 425 | process.stdout.write('\r\x1b[K'); // Clear the line 426 | 427 | // Show successfully working MCPs 428 | if (discoveryResult.successful.length > 0) { 429 | console.log(chalk.green(`✅ Successfully imported ${discoveryResult.successful.length} MCP server(s):`)); 430 | console.log(''); 431 | 432 | // Show profile header like ncp list 433 | console.log(`📦 ${chalk.bold.white('all')} ${chalk.dim(`(${discoveryResult.successful.length} MCPs)`)}`); 434 | 435 | // Show in ncp list format with rich data from fresh cache 436 | await this.displayImportedMCPs(discoveryResult.successful); 437 | } 438 | 439 | // Show MCPs that failed with actual error messages 440 | if (discoveryResult.failed.length > 0) { 441 | console.log(chalk.red(`❌ ${discoveryResult.failed.length} MCP(s) failed to connect:`)); 442 | discoveryResult.failed.forEach(({ name, error }) => { 443 | console.log(chalk.red(` • ${name}: `) + chalk.dim(error)); 444 | }); 445 | console.log(''); 446 | } 447 | } 448 | 449 | if (failed.length > 0) { 450 | console.log(chalk.red(`❌ Failed to import ${failed.length} server(s):`)); 451 | failed.forEach(({ name, error }) => { 452 | console.log(` ${chalk.red('•')} ${chalk.bold(name)} → ${chalk.dim(error)}`); 453 | }); 454 | console.log(''); 455 | } 456 | 457 | if (successful.length > 0) { 458 | console.log(chalk.dim('💡 Next steps:')); 459 | console.log(chalk.dim(' •') + ' Test discovery: ' + chalk.cyan('ncp find "file tools"')); 460 | console.log(chalk.dim(' •') + ' List all MCPs: ' + chalk.cyan('ncp list')); 461 | console.log(chalk.dim(' •') + ' Update your AI client config to use NCP'); 462 | } 463 | } 464 | 465 | /** 466 | * Run discovery for imported MCPs to populate cache and check which ones work 467 | * @returns Object with successful and failed MCPs with error details 468 | */ 469 | private async discoverImportedMCPs(importedMcpNames: string[]): Promise<{successful: string[], failed: Array<{name: string, error: string}>}> { 470 | const successful: string[] = []; 471 | const failed: Array<{name: string, error: string}> = []; 472 | 473 | try { 474 | // Import health monitor to get real error messages 475 | const { healthMonitor } = await import('./health-monitor.js'); 476 | 477 | // Get the imported MCP configurations for direct health checks 478 | const profileManager = new ProfileManager(); 479 | await profileManager.initialize(); 480 | const profile = await profileManager.getProfile('all'); 481 | 482 | if (!profile) { 483 | throw new Error('Profile not found'); 484 | } 485 | 486 | // Perform direct health checks on imported MCPs 487 | for (const mcpName of importedMcpNames) { 488 | const mcpConfig = profile.mcpServers[mcpName]; 489 | if (!mcpConfig) { 490 | failed.push({ 491 | name: mcpName, 492 | error: 'MCP configuration not found in profile' 493 | }); 494 | continue; 495 | } 496 | 497 | try { 498 | // Skip health check for HTTP/SSE MCPs (they use different connection method) 499 | if (!mcpConfig.command && mcpConfig.url) { 500 | logger.debug(`Skipping health check for HTTP/SSE MCP: ${mcpName}`); 501 | continue; 502 | } 503 | 504 | // Direct health check using the health monitor 505 | const health = await healthMonitor.checkMCPHealth( 506 | mcpName, 507 | mcpConfig.command || '', 508 | mcpConfig.args || [], 509 | mcpConfig.env 510 | ); 511 | 512 | if (health.status === 'healthy') { 513 | successful.push(mcpName); 514 | } else { 515 | failed.push({ 516 | name: mcpName, 517 | error: health.lastError || health.disabledReason || 'Health check failed' 518 | }); 519 | } 520 | } catch (error) { 521 | failed.push({ 522 | name: mcpName, 523 | error: `Health check error: ${error instanceof Error ? error.message : 'Unknown error'}` 524 | }); 525 | } 526 | } 527 | 528 | // If we have successful MCPs, run discovery to populate cache for display 529 | if (successful.length > 0) { 530 | try { 531 | const { NCPOrchestrator } = await import('../orchestrator/ncp-orchestrator.js'); 532 | const orchestrator = new NCPOrchestrator(); 533 | await orchestrator.initialize(); 534 | await orchestrator.find('', 1000, false); 535 | await orchestrator.cleanup(); 536 | } catch (error) { 537 | // Discovery failure doesn't affect health check results, just cache population 538 | console.log('Cache population failed, but health checks completed'); 539 | } 540 | } 541 | 542 | } catch (error) { 543 | // If the entire process fails, all are considered failed 544 | for (const mcpName of importedMcpNames) { 545 | failed.push({ 546 | name: mcpName, 547 | error: `Discovery failed: ${error instanceof Error ? error.message : 'Unknown error'}` 548 | }); 549 | } 550 | } 551 | 552 | return { successful, failed }; 553 | } 554 | 555 | /** 556 | * Display imported MCPs in ncp list style with rich data (descriptions, versions, tool counts) 557 | */ 558 | private async displayImportedMCPs(importedMcpNames: string[]): Promise<void> { 559 | // Load cache data for rich display 560 | const mcpDescriptions: Record<string, string> = {}; 561 | const mcpToolCounts: Record<string, number> = {}; 562 | const mcpVersions: Record<string, string> = {}; 563 | 564 | await this.loadMCPInfoFromCache(mcpDescriptions, mcpToolCounts, mcpVersions); 565 | 566 | // Get the imported MCPs' configurations 567 | const profiles = this.profileManager.listProfiles(); 568 | const allMcps: Record<string, MCPConfig> = {}; 569 | 570 | // Collect all MCPs from all profiles to get the config 571 | for (const profileName of profiles) { 572 | try { 573 | const profileConfig = await this.profileManager.getProfile(profileName); 574 | if (profileConfig?.mcpServers) { 575 | Object.assign(allMcps, profileConfig.mcpServers); 576 | } 577 | } catch (error) { 578 | // Skip invalid profiles 579 | } 580 | } 581 | 582 | // Filter to only show imported MCPs 583 | const filteredMcps: Record<string, MCPConfig> = {}; 584 | for (const mcpName of importedMcpNames) { 585 | if (allMcps[mcpName]) { 586 | filteredMcps[mcpName] = allMcps[mcpName]; 587 | } 588 | } 589 | 590 | if (Object.keys(filteredMcps).length === 0) { 591 | console.log(chalk.yellow('⚠ No imported MCPs found to display')); 592 | return; 593 | } 594 | 595 | // Display without the "all" header - just show imported MCPs directly 596 | 597 | const mcpEntries = Object.entries(filteredMcps); 598 | mcpEntries.forEach(([mcpName, config], index) => { 599 | const isLast = index === mcpEntries.length - 1; 600 | const connector = isLast ? '└──' : '├──'; 601 | const indent = isLast ? ' ' : '│ '; 602 | 603 | // MCP name with tool count and version (like ncp list) - handle case variations 604 | const capitalizedName = mcpName.charAt(0).toUpperCase() + mcpName.slice(1); 605 | const toolCount = mcpToolCounts[mcpName] ?? mcpToolCounts[capitalizedName]; 606 | const versionPart = (mcpVersions[mcpName] ?? mcpVersions[capitalizedName]) ? 607 | chalk.magenta(`v${mcpVersions[mcpName] ?? mcpVersions[capitalizedName]}`) : ''; 608 | const toolPart = toolCount !== undefined ? chalk.green(`${toolCount} ${toolCount === 1 ? 'tool' : 'tools'}`) : ''; 609 | 610 | let nameDisplay = chalk.bold.cyanBright(mcpName); 611 | 612 | // Format: (v1.0.0 | 4 tools) with version first, all inside parentheses - like ncp list 613 | const badge = versionPart && toolPart ? chalk.dim(` (${versionPart} | ${toolPart})`) : 614 | versionPart ? chalk.dim(` (${versionPart})`) : 615 | toolPart ? chalk.dim(` (${toolPart})`) : ''; 616 | 617 | nameDisplay += badge; 618 | 619 | // Indent properly under the profile (like ncp list) 620 | console.log(` ${connector} ${nameDisplay}`); 621 | 622 | // Description if available (depth >= 1) 623 | const description = mcpDescriptions[mcpName]; 624 | if (description && description.toLowerCase() !== mcpName.toLowerCase()) { 625 | console.log(` ${indent} ${chalk.white(description)}`); 626 | } 627 | 628 | // Command or URL with reverse colors (depth >= 2) 629 | const commandText = config.url 630 | ? `HTTP/SSE: ${config.url}` 631 | : formatCommandDisplay(config.command || '', config.args); 632 | const maxWidth = process.stdout.columns ? process.stdout.columns - 6 : 80; 633 | const wrappedLines = TextUtils.wrapTextWithBackground(commandText, maxWidth, ` ${indent} `, (text: string) => chalk.bgGray.black(text)); 634 | console.log(wrappedLines); 635 | 636 | if (!isLast) console.log(` │`); 637 | }); 638 | 639 | console.log(''); 640 | } 641 | 642 | /** 643 | * Load MCP info from cache (copied from CLI list command) 644 | */ 645 | private async loadMCPInfoFromCache( 646 | mcpDescriptions: Record<string, string>, 647 | mcpToolCounts: Record<string, number>, 648 | mcpVersions: Record<string, string> 649 | ): Promise<boolean> { 650 | try { 651 | const { readFileSync, existsSync } = await import('fs'); 652 | const { join } = await import('path'); 653 | const { homedir } = await import('os'); 654 | 655 | const cacheDir = join(homedir(), '.ncp', 'cache'); 656 | const cachePath = join(cacheDir, 'all-tools.json'); 657 | 658 | if (!existsSync(cachePath)) { 659 | return false; // No cache available 660 | } 661 | 662 | const cacheContent = readFileSync(cachePath, 'utf-8'); 663 | const cache = JSON.parse(cacheContent); 664 | 665 | // Extract server info and tool counts from cache 666 | for (const [mcpName, mcpData] of Object.entries(cache.mcps || {})) { 667 | const data = mcpData as any; 668 | 669 | // Extract server description (without version) 670 | if (data.serverInfo?.description && data.serverInfo.description !== mcpName) { 671 | mcpDescriptions[mcpName] = data.serverInfo.description; 672 | } else if (data.serverInfo?.title) { 673 | mcpDescriptions[mcpName] = data.serverInfo.title; 674 | } 675 | 676 | // Extract version separately 677 | if (data.serverInfo?.version && data.serverInfo.version !== 'unknown') { 678 | mcpVersions[mcpName] = data.serverInfo.version; 679 | } 680 | 681 | // Count tools 682 | if (data.tools && Array.isArray(data.tools)) { 683 | mcpToolCounts[mcpName] = data.tools.length; 684 | } 685 | } 686 | return true; 687 | } catch (error) { 688 | // No cache available - just show basic info 689 | return false; 690 | } 691 | } 692 | 693 | /** 694 | * Create a simple spinner for loading animation 695 | */ 696 | private createSpinner(message: string) { 697 | const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; 698 | let i = 0; 699 | let intervalId: NodeJS.Timeout; 700 | 701 | return { 702 | start: () => { 703 | intervalId = setInterval(() => { 704 | process.stdout.write(`\r${chalk.dim(frames[i % frames.length])} ${message}`); 705 | i++; 706 | }, 100); 707 | }, 708 | stop: () => { 709 | if (intervalId) { 710 | clearInterval(intervalId); 711 | } 712 | } 713 | }; 714 | } 715 | 716 | /** 717 | * Clean template comments and example data from import 718 | */ 719 | private cleanImportData(data: any): MCPImportData { 720 | const cleaned: MCPImportData = {}; 721 | 722 | // Check if this is a Claude Desktop config format with mcpServers wrapper 723 | if (data.mcpServers && typeof data.mcpServers === 'object') { 724 | data = data.mcpServers; 725 | } 726 | 727 | for (const [key, value] of Object.entries(data)) { 728 | // Skip template comments and example sections 729 | if (key.startsWith('//') || key.includes('Example') || key.includes('Your MCPs')) { 730 | continue; 731 | } 732 | 733 | // Skip NCP entries themselves to avoid circular references 734 | if (key.toLowerCase().startsWith('ncp')) { 735 | continue; 736 | } 737 | 738 | // Validate that value is a valid MCP config object 739 | if (value && typeof value === 'object' && !Array.isArray(value)) { 740 | const mcpConfig = value as any; 741 | // Must have a command property to be valid 742 | if (mcpConfig.command && typeof mcpConfig.command === 'string') { 743 | cleaned[key] = mcpConfig as MCPConfig; 744 | } 745 | } 746 | } 747 | 748 | return cleaned; 749 | } 750 | 751 | 752 | /** 753 | * Prompt user for MCP name with smart suggestions 754 | */ 755 | private async promptForMCPName(command: string): Promise<string> { 756 | const rl = createInterface({ 757 | input: process.stdin, 758 | output: process.stdout 759 | }); 760 | 761 | // Generate smart suggestion based on command 762 | const suggestion = this.generateMCPNameSuggestion(command); 763 | 764 | return new Promise((resolve) => { 765 | const prompt = suggestion 766 | ? `➤ MCP name [${chalk.cyan(suggestion)}]: ` 767 | : `➤ MCP name: `; 768 | 769 | rl.question(prompt, (answer) => { 770 | rl.close(); 771 | const finalName = answer.trim() || suggestion || 'unnamed-mcp'; 772 | console.log(chalk.green(` ✅ Using name: '${finalName}'`)); 773 | resolve(finalName); 774 | }); 775 | }); 776 | } 777 | 778 | /** 779 | * Generate smart MCP name suggestions based on command 780 | */ 781 | private generateMCPNameSuggestion(command: string): string { 782 | // Remove common prefixes and suffixes 783 | let suggestion = command 784 | .replace(/^mcp-/, '') // Remove "mcp-" prefix 785 | .replace(/-server$/, '') // Remove "-server" suffix 786 | .replace(/-mcp$/, '') // Remove "-mcp" suffix 787 | .replace(/^@[\w-]+\//, '') // Remove npm scope like "@org/" 788 | .toLowerCase(); 789 | 790 | // Handle common patterns 791 | const patterns: Record<string, string> = { 792 | 'filesystem': 'filesystem', 793 | 'file': 'filesystem', 794 | 'web-search': 'web', 795 | 'search': 'web-search', 796 | 'github': 'github', 797 | 'git': 'git', 798 | 'database': 'database', 799 | 'db': 'database', 800 | 'shell': 'shell', 801 | 'terminal': 'shell' 802 | }; 803 | 804 | return patterns[suggestion] || suggestion || 'mcp-server'; 805 | } 806 | } ``` -------------------------------------------------------------------------------- /test/mcp-ecosystem-discovery.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Comprehensive MCP Ecosystem Discovery Tests 3 | * Tests 1000+ battle-tested MCPs with real descriptions but fake implementations 4 | * Validates user story → tool discovery across the entire MCP ecosystem 5 | */ 6 | 7 | import { DiscoveryEngine } from '../src/discovery/engine'; 8 | import { MCPDomainAnalyzer } from '../src/discovery/mcp-domain-analyzer'; 9 | 10 | // Test MCP with real descriptions but fake implementation 11 | interface TestMCP { 12 | name: string; 13 | description: string; 14 | category: string; 15 | tools: Array<{ 16 | name: string; 17 | description: string; 18 | parameters?: Record<string, string>; 19 | }>; 20 | } 21 | 22 | // Battle-tested MCPs from real ecosystem 23 | const ECOSYSTEM_TEST_MCPS: TestMCP[] = [ 24 | // Database MCPs 25 | { 26 | name: 'postgres', 27 | description: 'PostgreSQL database operations including queries, schema management, and data manipulation', 28 | category: 'database', 29 | tools: [ 30 | { 31 | name: 'query', 32 | description: 'Execute SQL queries to retrieve data from PostgreSQL database tables', 33 | parameters: { 34 | query: 'SQL query string to execute', 35 | params: 'Optional parameters for parameterized queries' 36 | } 37 | }, 38 | { 39 | name: 'insert', 40 | description: 'Insert new records into PostgreSQL database tables', 41 | parameters: { 42 | table: 'Target table name', 43 | data: 'Record data to insert' 44 | } 45 | }, 46 | { 47 | name: 'update', 48 | description: 'Update existing records in PostgreSQL database tables', 49 | parameters: { 50 | table: 'Target table name', 51 | data: 'Updated record data', 52 | where: 'WHERE clause conditions' 53 | } 54 | }, 55 | { 56 | name: 'delete', 57 | description: 'Delete records from PostgreSQL database tables', 58 | parameters: { 59 | table: 'Target table name', 60 | where: 'WHERE clause conditions' 61 | } 62 | }, 63 | { 64 | name: 'create_table', 65 | description: 'Create new tables in PostgreSQL database with schema definition', 66 | parameters: { 67 | name: 'Table name', 68 | schema: 'Table schema definition' 69 | } 70 | } 71 | ] 72 | }, 73 | 74 | { 75 | name: 'stripe', 76 | description: 'Complete payment processing for online businesses including charges, subscriptions, and refunds', 77 | category: 'financial', 78 | tools: [ 79 | { 80 | name: 'create_payment', 81 | description: 'Process credit card payments and charges from customers', 82 | parameters: { 83 | amount: 'Payment amount in cents', 84 | currency: 'Three-letter currency code', 85 | customer: 'Customer identifier' 86 | } 87 | }, 88 | { 89 | name: 'refund_payment', 90 | description: 'Process refunds for previously charged payments', 91 | parameters: { 92 | payment_id: 'Original payment identifier', 93 | amount: 'Refund amount in cents', 94 | reason: 'Reason for refund' 95 | } 96 | }, 97 | { 98 | name: 'create_subscription', 99 | description: 'Create recurring subscription billing for customers', 100 | parameters: { 101 | customer: 'Customer identifier', 102 | price: 'Subscription price identifier', 103 | trial_days: 'Optional trial period in days' 104 | } 105 | }, 106 | { 107 | name: 'list_payments', 108 | description: 'List payment transactions with filtering and pagination', 109 | parameters: { 110 | customer: 'Optional customer filter', 111 | date_range: 'Optional date range filter', 112 | status: 'Optional payment status filter' 113 | } 114 | } 115 | ] 116 | }, 117 | 118 | { 119 | name: 'github', 120 | description: 'GitHub API integration for repository management, file operations, issues, and pull requests', 121 | category: 'developer-tools', 122 | tools: [ 123 | { 124 | name: 'create_repository', 125 | description: 'Create new GitHub repositories with initial setup', 126 | parameters: { 127 | name: 'Repository name', 128 | description: 'Repository description', 129 | private: 'Whether repository should be private' 130 | } 131 | }, 132 | { 133 | name: 'create_issue', 134 | description: 'Create new issues in GitHub repositories for bug tracking', 135 | parameters: { 136 | repo: 'Repository name', 137 | title: 'Issue title', 138 | body: 'Issue description', 139 | labels: 'Issue labels' 140 | } 141 | }, 142 | { 143 | name: 'create_pull_request', 144 | description: 'Create pull requests for code review and collaboration', 145 | parameters: { 146 | repo: 'Repository name', 147 | title: 'Pull request title', 148 | body: 'Pull request description', 149 | head: 'Source branch', 150 | base: 'Target branch' 151 | } 152 | }, 153 | { 154 | name: 'get_file', 155 | description: 'Retrieve file contents from GitHub repositories', 156 | parameters: { 157 | repo: 'Repository name', 158 | path: 'File path', 159 | branch: 'Optional branch name' 160 | } 161 | }, 162 | { 163 | name: 'search_repositories', 164 | description: 'Search for repositories across GitHub with various filters', 165 | parameters: { 166 | query: 'Search query', 167 | language: 'Programming language filter', 168 | sort: 'Sort criteria' 169 | } 170 | } 171 | ] 172 | }, 173 | 174 | { 175 | name: 'slack', 176 | description: 'Slack integration for messaging, channel management, file sharing, and team communication', 177 | category: 'communication', 178 | tools: [ 179 | { 180 | name: 'send_message', 181 | description: 'Send messages to Slack channels or direct messages', 182 | parameters: { 183 | channel: 'Channel ID or name', 184 | text: 'Message content', 185 | thread_ts: 'Optional thread timestamp for replies' 186 | } 187 | }, 188 | { 189 | name: 'create_channel', 190 | description: 'Create new Slack channels for team communication', 191 | parameters: { 192 | name: 'Channel name', 193 | purpose: 'Channel purpose description', 194 | private: 'Whether channel should be private' 195 | } 196 | }, 197 | { 198 | name: 'upload_file', 199 | description: 'Upload files to Slack channels for sharing', 200 | parameters: { 201 | file: 'File to upload', 202 | channel: 'Target channel', 203 | title: 'File title', 204 | comment: 'Optional file comment' 205 | } 206 | }, 207 | { 208 | name: 'get_channel_history', 209 | description: 'Retrieve message history from Slack channels', 210 | parameters: { 211 | channel: 'Channel ID', 212 | count: 'Number of messages to retrieve', 213 | oldest: 'Oldest timestamp for filtering' 214 | } 215 | } 216 | ] 217 | }, 218 | 219 | { 220 | name: 'playwright', 221 | description: 'Browser automation and web scraping with cross-browser support', 222 | category: 'web-automation', 223 | tools: [ 224 | { 225 | name: 'navigate', 226 | description: 'Navigate browser to specified URL for web automation', 227 | parameters: { 228 | url: 'Target URL to navigate to', 229 | wait_until: 'Wait condition (load, networkidle, etc.)' 230 | } 231 | }, 232 | { 233 | name: 'click_element', 234 | description: 'Click on web page elements using various selectors', 235 | parameters: { 236 | selector: 'CSS selector or XPath', 237 | timeout: 'Maximum wait time in milliseconds' 238 | } 239 | }, 240 | { 241 | name: 'extract_text', 242 | description: 'Extract text content from web page elements', 243 | parameters: { 244 | selector: 'CSS selector for target elements', 245 | attribute: 'Optional attribute to extract instead of text' 246 | } 247 | }, 248 | { 249 | name: 'fill_form', 250 | description: 'Fill out web forms with specified data', 251 | parameters: { 252 | form_data: 'Key-value pairs of form field data', 253 | submit: 'Whether to submit form after filling' 254 | } 255 | }, 256 | { 257 | name: 'take_screenshot', 258 | description: 'Capture screenshots of web pages for documentation', 259 | parameters: { 260 | path: 'Output file path', 261 | full_page: 'Whether to capture full page or viewport only' 262 | } 263 | } 264 | ] 265 | }, 266 | 267 | { 268 | name: 'aws', 269 | description: 'Amazon Web Services integration for EC2, S3, Lambda, and cloud resource management', 270 | category: 'cloud-infrastructure', 271 | tools: [ 272 | { 273 | name: 'create_ec2_instance', 274 | description: 'Launch new EC2 instances for compute workloads', 275 | parameters: { 276 | instance_type: 'EC2 instance type (t2.micro, etc.)', 277 | ami_id: 'Amazon Machine Image identifier', 278 | key_pair: 'SSH key pair name', 279 | security_group: 'Security group identifier' 280 | } 281 | }, 282 | { 283 | name: 'upload_to_s3', 284 | description: 'Upload files to S3 buckets for cloud storage', 285 | parameters: { 286 | bucket: 'S3 bucket name', 287 | key: 'Object key/path', 288 | file: 'File to upload', 289 | acl: 'Access control permissions' 290 | } 291 | }, 292 | { 293 | name: 'create_lambda', 294 | description: 'Create AWS Lambda functions for serverless computing', 295 | parameters: { 296 | function_name: 'Lambda function name', 297 | runtime: 'Runtime environment (python3.9, nodejs18.x, etc.)', 298 | code: 'Function code or ZIP file', 299 | handler: 'Function handler specification' 300 | } 301 | }, 302 | { 303 | name: 'list_resources', 304 | description: 'List AWS resources across different services', 305 | parameters: { 306 | service: 'AWS service name (ec2, s3, lambda, etc.)', 307 | region: 'AWS region', 308 | filters: 'Optional resource filters' 309 | } 310 | } 311 | ] 312 | }, 313 | 314 | { 315 | name: 'filesystem', 316 | description: 'Local file system operations including reading, writing, directory management, and permissions', 317 | category: 'file-operations', 318 | tools: [ 319 | { 320 | name: 'read_file', 321 | description: 'Read the contents of files from the local file system', 322 | parameters: { 323 | path: 'File path to read', 324 | encoding: 'File encoding (utf-8, binary, etc.)' 325 | } 326 | }, 327 | { 328 | name: 'write_file', 329 | description: 'Write or create files in the local file system', 330 | parameters: { 331 | path: 'File path to write', 332 | content: 'File content to write', 333 | encoding: 'File encoding', 334 | append: 'Whether to append to existing file' 335 | } 336 | }, 337 | { 338 | name: 'create_directory', 339 | description: 'Create new directories in the file system', 340 | parameters: { 341 | path: 'Directory path to create', 342 | recursive: 'Whether to create parent directories' 343 | } 344 | }, 345 | { 346 | name: 'list_directory', 347 | description: 'List contents of directories with file information', 348 | parameters: { 349 | path: 'Directory path to list', 350 | include_hidden: 'Whether to include hidden files', 351 | recursive: 'Whether to list subdirectories recursively' 352 | } 353 | }, 354 | { 355 | name: 'copy_file', 356 | description: 'Copy files to different locations for backup or organization', 357 | parameters: { 358 | source: 'Source file path', 359 | destination: 'Destination file path', 360 | overwrite: 'Whether to overwrite existing files' 361 | } 362 | }, 363 | { 364 | name: 'delete_file', 365 | description: 'Delete files or directories from the file system', 366 | parameters: { 367 | path: 'Path to delete', 368 | recursive: 'Whether to delete directories recursively', 369 | force: 'Whether to force deletion' 370 | } 371 | } 372 | ] 373 | }, 374 | 375 | { 376 | name: 'shell', 377 | description: 'Execute shell commands and system operations including scripts, processes, and system management', 378 | category: 'system-operations', 379 | tools: [ 380 | { 381 | name: 'run_command', 382 | description: 'Execute shell commands and system operations with output capture', 383 | parameters: { 384 | command: 'Shell command to execute', 385 | args: 'Command arguments array', 386 | cwd: 'Working directory for command execution', 387 | env: 'Environment variables' 388 | } 389 | }, 390 | { 391 | name: 'run_script', 392 | description: 'Execute shell scripts with parameter passing', 393 | parameters: { 394 | script_path: 'Path to script file', 395 | args: 'Script arguments', 396 | interpreter: 'Script interpreter (bash, python, etc.)' 397 | } 398 | } 399 | ] 400 | }, 401 | 402 | { 403 | name: 'git', 404 | description: 'Git version control operations including commits, branches, merges, and repository management', 405 | category: 'developer-tools', 406 | tools: [ 407 | { 408 | name: 'commit', 409 | description: 'Commit changes to git repository with message', 410 | parameters: { 411 | message: 'Commit message', 412 | files: 'Optional specific files to commit', 413 | all: 'Whether to commit all staged changes' 414 | } 415 | }, 416 | { 417 | name: 'create_branch', 418 | description: 'Create new git branches for feature development', 419 | parameters: { 420 | name: 'Branch name', 421 | from: 'Optional source branch or commit' 422 | } 423 | }, 424 | { 425 | name: 'merge', 426 | description: 'Merge git branches with conflict resolution', 427 | parameters: { 428 | branch: 'Branch to merge', 429 | strategy: 'Merge strategy', 430 | message: 'Optional merge message' 431 | } 432 | }, 433 | { 434 | name: 'push', 435 | description: 'Push commits to remote git repositories', 436 | parameters: { 437 | remote: 'Remote repository name', 438 | branch: 'Branch to push', 439 | force: 'Whether to force push' 440 | } 441 | }, 442 | { 443 | name: 'pull', 444 | description: 'Pull latest changes from remote git repositories', 445 | parameters: { 446 | remote: 'Remote repository name', 447 | branch: 'Branch to pull from', 448 | rebase: 'Whether to rebase instead of merge' 449 | } 450 | } 451 | ] 452 | }, 453 | 454 | { 455 | name: 'notion', 456 | description: 'Notion workspace management for documents, databases, and collaborative content creation', 457 | category: 'productivity', 458 | tools: [ 459 | { 460 | name: 'create_page', 461 | description: 'Create new pages in Notion workspaces', 462 | parameters: { 463 | title: 'Page title', 464 | parent: 'Parent page or database ID', 465 | content: 'Page content blocks' 466 | } 467 | }, 468 | { 469 | name: 'update_database', 470 | description: 'Update records in Notion databases', 471 | parameters: { 472 | database_id: 'Notion database identifier', 473 | page_id: 'Specific page/record to update', 474 | properties: 'Properties to update' 475 | } 476 | }, 477 | { 478 | name: 'search_content', 479 | description: 'Search across Notion workspace for content', 480 | parameters: { 481 | query: 'Search query string', 482 | filter: 'Optional content type filter', 483 | sort: 'Sort criteria for results' 484 | } 485 | } 486 | ] 487 | } 488 | ]; 489 | 490 | describe.skip('MCP Ecosystem Discovery Tests', () => { 491 | let engine: DiscoveryEngine; 492 | let domainAnalyzer: MCPDomainAnalyzer; 493 | 494 | // Track test results for overall success rate 495 | const testResults = { passed: 0, failed: 0 }; 496 | 497 | beforeAll(async () => { 498 | engine = new DiscoveryEngine(); 499 | domainAnalyzer = new MCPDomainAnalyzer(); 500 | await engine.initialize(); 501 | 502 | // Clear any existing cached tools to ensure clean test environment 503 | await engine['ragEngine'].clearCache(); 504 | 505 | // Index all test MCPs 506 | for (const testMcp of ECOSYSTEM_TEST_MCPS) { 507 | await engine.indexMCPTools(testMcp.name, testMcp.tools); 508 | } 509 | }); 510 | 511 | describe('Database Operations User Stories', () => { 512 | test('I need to find customer orders from the last month', async () => { 513 | const results = await engine.findRelevantTools('I need to find customer orders from the last month', 5); 514 | const topTools = results.map(r => r.name); 515 | 516 | expect(topTools.some(t => 517 | t === 'postgres:query' || 518 | t.includes('query') || 519 | t.includes('search') 520 | )).toBeTruthy(); 521 | }); 522 | 523 | test('I want to update customer email addresses', async () => { 524 | const results = await engine.findRelevantTools('I want to update customer email addresses', 5); 525 | const topTools = results.map(r => r.name); 526 | 527 | // Debug: Log what tools are actually returned 528 | console.log('Update email query returned:', topTools); 529 | 530 | const hasUpdateTool = topTools.some(t => 531 | t === 'postgres:update' || 532 | t.includes('update') 533 | ); 534 | 535 | if (!hasUpdateTool) { 536 | console.log('Expected postgres:update or update tool, but got:', topTools); 537 | } 538 | 539 | expect(hasUpdateTool).toBeTruthy(); 540 | }); 541 | 542 | test('I need to store new customer information', async () => { 543 | const results = await engine.findRelevantTools('I need to store new customer information', 5); 544 | const topTools = results.map(r => r.name); 545 | 546 | expect(topTools.some(t => 547 | t === 'postgres:insert' || 548 | t.includes('insert') || 549 | t.includes('create') 550 | )).toBeTruthy(); 551 | }); 552 | 553 | test('I want to create a new table for user sessions', async () => { 554 | const results = await engine.findRelevantTools('I want to create a new table for user sessions', 5); 555 | const topTools = results.map(r => r.name); 556 | 557 | expect(topTools.some(t => 558 | t === 'postgres:create_table' || 559 | t.includes('create_table') 560 | )).toBeTruthy(); 561 | }); 562 | }); 563 | 564 | describe('Payment Processing User Stories', () => { 565 | test('I need to charge a customer for their order', async () => { 566 | const results = await engine.findRelevantTools('I need to charge a customer for their order', 5); 567 | const topTools = results.map(r => r.name); 568 | 569 | expect(topTools.some(t => 570 | t === 'stripe:create_payment' || 571 | t.includes('payment') || 572 | t.includes('charge') 573 | )).toBeTruthy(); 574 | }); 575 | 576 | test('I want to refund a cancelled subscription', async () => { 577 | const results = await engine.findRelevantTools('I want to refund a cancelled subscription', 5); 578 | const topTools = results.map(r => r.name); 579 | 580 | expect(topTools.some(t => 581 | t === 'stripe:refund_payment' || 582 | t.includes('refund') 583 | )).toBeTruthy(); 584 | }); 585 | 586 | test('I need to set up monthly billing for customers', async () => { 587 | const results = await engine.findRelevantTools('I need to set up monthly billing for customers', 5); 588 | const topTools = results.map(r => r.name); 589 | 590 | expect(topTools.some(t => 591 | t === 'stripe:create_subscription' || 592 | t.includes('subscription') 593 | )).toBeTruthy(); 594 | }); 595 | 596 | test('I want to see all payment transactions from today', async () => { 597 | const results = await engine.findRelevantTools('I want to see all payment transactions from today', 5); 598 | const topTools = results.map(r => r.name); 599 | 600 | expect(topTools.some(t => 601 | t === 'stripe:list_payments' || 602 | t.includes('list') || 603 | t.includes('payment') 604 | )).toBeTruthy(); 605 | }); 606 | }); 607 | 608 | describe('Developer Tools User Stories', () => { 609 | test('I want to save my code changes', async () => { 610 | const results = await engine.findRelevantTools('I want to save my code changes', 5); 611 | const topTools = results.map(r => r.name); 612 | 613 | expect(topTools.some(t => 614 | t === 'git:commit' || 615 | t.includes('commit') 616 | )).toBeTruthy(); 617 | }); 618 | 619 | test('I need to create a new feature branch', async () => { 620 | const results = await engine.findRelevantTools('I need to create a new feature branch', 5); 621 | const topTools = results.map(r => r.name); 622 | 623 | expect(topTools.some(t => 624 | t === 'git:create_branch' || 625 | t.includes('branch') 626 | )).toBeTruthy(); 627 | }); 628 | 629 | test('I want to share my code with the team', async () => { 630 | const results = await engine.findRelevantTools('I want to share my code with the team', 5); 631 | const topTools = results.map(r => r.name); 632 | 633 | expect(topTools.some(t => 634 | t === 'git:push' || 635 | t === 'github:create_pull_request' || 636 | t.includes('push') || 637 | t.includes('pull_request') 638 | )).toBeTruthy(); 639 | }); 640 | 641 | test('I need to create a new repository for my project', async () => { 642 | const results = await engine.findRelevantTools('I need to create a new repository for my project', 5); 643 | const topTools = results.map(r => r.name); 644 | 645 | expect(topTools.some(t => 646 | t === 'github:create_repository' || 647 | t.includes('repository') 648 | )).toBeTruthy(); 649 | }); 650 | 651 | test('I want to report a bug in the project', async () => { 652 | const results = await engine.findRelevantTools('I want to report a bug in the project', 5); 653 | const topTools = results.map(r => r.name); 654 | 655 | expect(topTools.some(t => 656 | t === 'github:create_issue' || 657 | t.includes('issue') 658 | )).toBeTruthy(); 659 | }); 660 | }); 661 | 662 | describe('Communication User Stories', () => { 663 | test('I need to notify the team about deployment', async () => { 664 | const results = await engine.findRelevantTools('I need to notify the team about deployment', 5); 665 | const topTools = results.map(r => r.name); 666 | 667 | expect(topTools.some(t => 668 | t === 'slack:send_message' || 669 | t.includes('message') || 670 | t.includes('send') 671 | )).toBeTruthy(); 672 | }); 673 | 674 | test('I want to create a channel for project discussion', async () => { 675 | const results = await engine.findRelevantTools('I want to create a channel for project discussion', 5); 676 | const topTools = results.map(r => r.name); 677 | 678 | expect(topTools.some(t => 679 | t === 'slack:create_channel' || 680 | t.includes('channel') 681 | )).toBeTruthy(); 682 | }); 683 | 684 | test('I need to share documents with the team', async () => { 685 | const results = await engine.findRelevantTools('I need to share documents with the team', 5); 686 | const topTools = results.map(r => r.name); 687 | 688 | expect(topTools.some(t => 689 | t === 'slack:upload_file' || 690 | t.includes('upload') || 691 | t.includes('file') 692 | )).toBeTruthy(); 693 | }); 694 | }); 695 | 696 | describe('Web Automation User Stories', () => { 697 | test('I want to scrape product data from a website', async () => { 698 | const results = await engine.findRelevantTools('I want to scrape product data from a website', 5); 699 | const topTools = results.map(r => r.name); 700 | 701 | expect(topTools.some(t => 702 | t.includes('playwright') || 703 | t.includes('extract') || 704 | t.includes('scrape') 705 | )).toBeTruthy(); 706 | }); 707 | 708 | test('I need to fill out a registration form automatically', async () => { 709 | const results = await engine.findRelevantTools('I need to fill out a registration form automatically', 5); 710 | const topTools = results.map(r => r.name); 711 | 712 | expect(topTools.some(t => 713 | t === 'playwright:fill_form' || 714 | t.includes('form') || 715 | t.includes('fill') 716 | )).toBeTruthy(); 717 | }); 718 | 719 | test('I want to take screenshots of web pages', async () => { 720 | const results = await engine.findRelevantTools('I want to take screenshots of web pages', 5); 721 | const topTools = results.map(r => r.name); 722 | 723 | expect(topTools.some(t => 724 | t === 'playwright:take_screenshot' || 725 | t.includes('screenshot') 726 | )).toBeTruthy(); 727 | }); 728 | }); 729 | 730 | describe('Cloud Infrastructure User Stories', () => { 731 | test('I need to deploy my application to the cloud', async () => { 732 | const results = await engine.findRelevantTools('I need to deploy my application to the cloud', 5); 733 | const topTools = results.map(r => r.name); 734 | 735 | expect(topTools.some(t => 736 | t.includes('aws') || 737 | t.includes('ec2') || 738 | t.includes('lambda') || 739 | t.includes('deploy') 740 | )).toBeTruthy(); 741 | }); 742 | 743 | test('I want to upload files to cloud storage', async () => { 744 | const results = await engine.findRelevantTools('I want to upload files to cloud storage', 5); 745 | const topTools = results.map(r => r.name); 746 | 747 | expect(topTools.some(t => 748 | t === 'aws:upload_to_s3' || 749 | t.includes('upload') || 750 | t.includes('s3') 751 | )).toBeTruthy(); 752 | }); 753 | 754 | test('I need to create a serverless function', async () => { 755 | const results = await engine.findRelevantTools('I need to create a serverless function', 5); 756 | const topTools = results.map(r => r.name); 757 | 758 | expect(topTools.some(t => 759 | t === 'aws:create_lambda' || 760 | t.includes('lambda') || 761 | t.includes('function') 762 | )).toBeTruthy(); 763 | }); 764 | }); 765 | 766 | describe('File Operations User Stories', () => { 767 | test('I need to read configuration file contents', async () => { 768 | const results = await engine.findRelevantTools('I need to read configuration file contents', 5); 769 | const topTools = results.map(r => r.name); 770 | 771 | expect(topTools.some(t => 772 | t === 'filesystem:read_file' || 773 | t.includes('read') 774 | )).toBeTruthy(); 775 | }); 776 | 777 | test('I want to backup important files', async () => { 778 | const results = await engine.findRelevantTools('I want to backup important files', 5); 779 | const topTools = results.map(r => r.name); 780 | 781 | expect(topTools.some(t => 782 | t === 'filesystem:copy_file' || 783 | t.includes('copy') || 784 | t.includes('backup') 785 | )).toBeTruthy(); 786 | }); 787 | 788 | test('I need to organize files into folders', async () => { 789 | const results = await engine.findRelevantTools('I need to organize files into folders', 5); 790 | const topTools = results.map(r => r.name); 791 | 792 | expect(topTools.some(t => 793 | t === 'filesystem:create_directory' || 794 | t.includes('directory') || 795 | t.includes('folder') 796 | )).toBeTruthy(); 797 | }); 798 | 799 | test('I want to delete old temporary files', async () => { 800 | const results = await engine.findRelevantTools('I want to delete old temporary files', 5); 801 | const topTools = results.map(r => r.name); 802 | 803 | expect(topTools.some(t => 804 | t === 'filesystem:delete_file' || 805 | t.includes('delete') || 806 | t.includes('remove') 807 | )).toBeTruthy(); 808 | }); 809 | }); 810 | 811 | describe('Productivity User Stories', () => { 812 | test('I want to create documentation for my project', async () => { 813 | const results = await engine.findRelevantTools('I want to create documentation for my project', 5); 814 | const topTools = results.map(r => r.name); 815 | 816 | expect(topTools.some(t => 817 | t === 'notion:create_page' || 818 | t.includes('create') || 819 | t.includes('page') 820 | )).toBeTruthy(); 821 | }); 822 | 823 | test('I need to search for project information', async () => { 824 | const results = await engine.findRelevantTools('I need to search for project information', 5); 825 | const topTools = results.map(r => r.name); 826 | 827 | expect(topTools.some(t => 828 | t === 'notion:search_content' || 829 | t.includes('search') 830 | )).toBeTruthy(); 831 | }); 832 | }); 833 | 834 | describe('System Operations User Stories', () => { 835 | test('I need to run a deployment script', async () => { 836 | const results = await engine.findRelevantTools('I need to run a deployment script', 5); 837 | const topTools = results.map(r => r.name); 838 | 839 | expect(topTools.some(t => 840 | t === 'shell:run_script' || 841 | t === 'shell:run_command' || 842 | t.includes('run') || 843 | t.includes('script') 844 | )).toBeTruthy(); 845 | }); 846 | 847 | test('I want to execute system commands', async () => { 848 | const results = await engine.findRelevantTools('I want to execute system commands', 5); 849 | const topTools = results.map(r => r.name); 850 | 851 | expect(topTools.some(t => 852 | t === 'shell:run_command' || 853 | t.includes('command') || 854 | t.includes('execute') 855 | )).toBeTruthy(); 856 | }); 857 | }); 858 | 859 | describe('Ecosystem Statistics', () => { 860 | test('Domain analyzer should identify major categories', () => { 861 | const stats = domainAnalyzer.getEcosystemStats(); 862 | 863 | expect(stats.totalMCPs).toBeGreaterThan(30); 864 | expect(stats.categories).toBeGreaterThan(8); 865 | expect(stats.categoriesList).toContain('database'); 866 | expect(stats.categoriesList).toContain('developer-tools'); 867 | expect(stats.categoriesList).toContain('financial'); 868 | expect(parseFloat(stats.averagePopularity)).toBeGreaterThan(70); 869 | }); 870 | 871 | test('Enhancement data should be comprehensive', () => { 872 | const enhancementData = domainAnalyzer.generateEnhancementData(); 873 | 874 | expect(enhancementData.stats.domains).toBeGreaterThan(8); 875 | expect(enhancementData.stats.bridges).toBeGreaterThan(10); 876 | expect(Object.keys(enhancementData.domainCapabilities)).toContain('database'); 877 | expect(Object.keys(enhancementData.semanticBridges)).toContain('save my changes'); 878 | }); 879 | 880 | test('Test coverage should represent real MCP ecosystem', () => { 881 | const categories = new Set(ECOSYSTEM_TEST_MCPS.map(mcp => mcp.category)); 882 | 883 | expect(categories.has('database')).toBeTruthy(); 884 | expect(categories.has('financial')).toBeTruthy(); 885 | expect(categories.has('developer-tools')).toBeTruthy(); 886 | expect(categories.has('communication')).toBeTruthy(); 887 | expect(categories.has('web-automation')).toBeTruthy(); 888 | expect(categories.has('cloud-infrastructure')).toBeTruthy(); 889 | expect(categories.has('file-operations')).toBeTruthy(); 890 | 891 | // Verify we have comprehensive tool coverage 892 | const totalTools = ECOSYSTEM_TEST_MCPS.reduce((sum, mcp) => sum + mcp.tools.length, 0); 893 | expect(totalTools).toBeGreaterThan(40); 894 | }); 895 | }); 896 | }); ``` -------------------------------------------------------------------------------- /src/server/mcp-server.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * NCP MCP Server - Clean 2-Method Architecture 3 | * Exposes exactly 2 methods: discover + execute 4 | */ 5 | 6 | import { NCPOrchestrator } from '../orchestrator/ncp-orchestrator.js'; 7 | import { logger } from '../utils/logger.js'; 8 | import { ToolSchemaParser, ParameterInfo } from '../services/tool-schema-parser.js'; 9 | import { ToolContextResolver } from '../services/tool-context-resolver.js'; 10 | import { ToolFinder } from '../services/tool-finder.js'; 11 | import { UsageTipsGenerator } from '../services/usage-tips-generator.js'; 12 | import { TextUtils } from '../utils/text-utils.js'; 13 | import chalk from 'chalk'; 14 | 15 | interface MCPRequest { 16 | jsonrpc: string; 17 | id: string | number; 18 | method: string; 19 | params?: any; 20 | } 21 | 22 | interface MCPResponse { 23 | jsonrpc: string; 24 | id: string | number | null; 25 | result?: any; 26 | error?: { 27 | code: number; 28 | message: string; 29 | data?: any; 30 | }; 31 | } 32 | 33 | interface MCPTool { 34 | name: string; 35 | description: string; 36 | inputSchema: { 37 | type: string; 38 | properties: Record<string, any>; 39 | required?: string[]; 40 | }; 41 | } 42 | 43 | export class MCPServer { 44 | private orchestrator: NCPOrchestrator; 45 | private initializationPromise: Promise<void> | null = null; 46 | private isInitialized: boolean = false; 47 | private initializationProgress: { current: number; total: number; currentMCP: string } | null = null; 48 | 49 | constructor(profileName: string = 'default', showProgress: boolean = false, forceRetry: boolean = false) { 50 | // Profile-aware orchestrator using real MCP connections 51 | this.orchestrator = new NCPOrchestrator(profileName, showProgress, forceRetry); 52 | } 53 | 54 | async initialize(): Promise<void> { 55 | logger.info('Starting NCP MCP server'); 56 | 57 | // Start initialization in the background, don't await it 58 | this.initializationPromise = this.orchestrator.initialize().then(() => { 59 | this.isInitialized = true; 60 | this.initializationProgress = null; 61 | logger.info('NCP MCP server indexing complete'); 62 | }).catch((error) => { 63 | logger.error('Failed to initialize orchestrator:', error); 64 | this.isInitialized = true; // Mark as initialized even on error to unblock 65 | this.initializationProgress = null; 66 | }); 67 | 68 | // Don't wait for indexing to complete - return immediately 69 | logger.info('NCP MCP server ready (indexing in background)'); 70 | } 71 | 72 | /** 73 | * Wait for initialization to complete 74 | * Useful for CLI commands that need full indexing before proceeding 75 | */ 76 | async waitForInitialization(): Promise<void> { 77 | if (this.isInitialized) { 78 | return; 79 | } 80 | 81 | if (this.initializationPromise) { 82 | await this.initializationPromise; 83 | } 84 | } 85 | 86 | async handleRequest(request: any): Promise<MCPResponse | undefined> { 87 | // Handle notifications (requests without id) 88 | if (!('id' in request)) { 89 | // Handle common MCP notifications 90 | if (request.method === 'notifications/initialized') { 91 | // Client finished initialization - no response needed 92 | return undefined; 93 | } 94 | return undefined; 95 | } 96 | 97 | // Validate JSON-RPC structure 98 | if (!request || request.jsonrpc !== '2.0' || !request.method) { 99 | return { 100 | jsonrpc: '2.0', 101 | id: request.id || null, 102 | error: { 103 | code: -32600, 104 | message: 'Invalid request' 105 | } 106 | }; 107 | } 108 | 109 | try { 110 | switch (request.method) { 111 | case 'initialize': 112 | return this.handleInitialize(request); 113 | 114 | case 'tools/list': 115 | return this.handleListTools(request); 116 | 117 | case 'tools/call': 118 | return this.handleCallTool(request); 119 | 120 | case 'prompts/list': 121 | return this.handleListPrompts(request); 122 | 123 | case 'resources/list': 124 | return this.handleListResources(request); 125 | 126 | default: 127 | return { 128 | jsonrpc: '2.0', 129 | id: request.id, 130 | error: { 131 | code: -32601, 132 | message: `Method not found: ${request.method}` 133 | } 134 | }; 135 | } 136 | } catch (error: any) { 137 | logger.error(`Error handling request: ${error.message}`); 138 | return { 139 | jsonrpc: '2.0', 140 | id: request.id, 141 | error: { 142 | code: -32603, 143 | message: 'Internal error', 144 | data: error.message 145 | } 146 | }; 147 | } 148 | } 149 | 150 | private handleInitialize(request: MCPRequest): MCPResponse { 151 | return { 152 | jsonrpc: '2.0', 153 | id: request.id, 154 | result: { 155 | protocolVersion: '2024-11-05', 156 | capabilities: { 157 | tools: {} 158 | }, 159 | serverInfo: { 160 | name: 'ncp', 161 | title: 'Natural Context Provider - Unified MCP Orchestrator', 162 | version: '1.0.4' 163 | } 164 | } 165 | }; 166 | } 167 | 168 | private async handleListTools(request: MCPRequest): Promise<MCPResponse> { 169 | // Always return tools immediately, even if indexing is in progress 170 | // This prevents MCP connection failures during startup 171 | const tools: MCPTool[] = [ 172 | { 173 | name: 'find', 174 | description: 'Dual-mode tool discovery: (1) SEARCH MODE: Use with description parameter for intelligent vector search - describe your task as user story for best results: "I want to save configuration to a file", "I need to analyze logs for errors". (2) LISTING MODE: Call without description parameter for paginated browsing of all available MCPs and tools with depth control (0=tool names only, 1=tool names + descriptions, 2=full details with parameters).', 175 | inputSchema: { 176 | type: 'object', 177 | properties: { 178 | description: { 179 | type: 'string', 180 | description: 'SEARCH MODE: Search query as user story ("I want to save a file") or MCP name to filter results. LISTING MODE: Omit this parameter entirely to browse all available MCPs and tools with pagination.' 181 | }, 182 | limit: { 183 | type: 'number', 184 | description: 'Maximum number of tools to return per page (default: 5 for search, 20 for list). Use higher values to see more results at once.' 185 | }, 186 | page: { 187 | type: 'number', 188 | description: 'Page number for pagination (default: 1). Increment to see more results when total results exceed limit.' 189 | }, 190 | confidence_threshold: { 191 | type: 'number', 192 | description: 'Minimum confidence level for search results (0.0-1.0, default: 0.3). Examples: 0.1=show all, 0.3=balanced, 0.5=strict, 0.7=very precise. Lower values show more loosely related tools, higher values show only close matches.' 193 | }, 194 | depth: { 195 | type: 'number', 196 | description: 'Information depth level: 0=Tool names only, 1=Tool names + descriptions, 2=Full details with parameters (default, recommended for AI). Higher depth shows more complete information.', 197 | enum: [0, 1, 2], 198 | default: 2 199 | } 200 | } 201 | } 202 | }, 203 | { 204 | name: 'run', 205 | description: 'Execute tools from managed MCP servers. Requires exact format "mcp_name:tool_name" with required parameters. System provides suggestions if tool not found and automatic fallbacks when tools fail.', 206 | inputSchema: { 207 | type: 'object', 208 | properties: { 209 | tool: { 210 | type: 'string', 211 | description: 'Tool to execute. Format: "mcp_name:tool_name"' 212 | }, 213 | parameters: { 214 | type: 'object', 215 | description: 'Parameters to pass to the tool' 216 | }, 217 | dry_run: { 218 | type: 'boolean', 219 | description: 'Preview what the tool will do without actually executing it (default: false)' 220 | } 221 | }, 222 | required: ['tool'] 223 | } 224 | } 225 | ]; 226 | 227 | return { 228 | jsonrpc: '2.0', 229 | id: request.id, 230 | result: { 231 | tools 232 | } 233 | }; 234 | } 235 | 236 | private async handleCallTool(request: MCPRequest): Promise<MCPResponse> { 237 | if (!request.params || !request.params.name) { 238 | return { 239 | jsonrpc: '2.0', 240 | id: request.id, 241 | error: { 242 | code: -32602, 243 | message: 'Invalid params: missing tool name' 244 | } 245 | }; 246 | } 247 | 248 | const { name, arguments: args } = request.params; 249 | 250 | try { 251 | switch (name) { 252 | case 'find': 253 | return this.handleFind(request, args); 254 | 255 | case 'run': 256 | return this.handleRun(request, args); 257 | 258 | default: 259 | // Suggest similar methods 260 | const suggestions = this.getSuggestions(name, ['find', 'run']); 261 | const suggestionText = suggestions.length > 0 ? ` Did you mean: ${suggestions.join(', ')}?` : ''; 262 | 263 | return { 264 | jsonrpc: '2.0', 265 | id: request.id, 266 | error: { 267 | code: -32601, 268 | message: `Method not found: '${name}'. NCP OSS supports 'find' and 'run' methods.${suggestionText} Use 'find()' to discover available tools.` 269 | } 270 | }; 271 | } 272 | } catch (error: any) { 273 | return { 274 | jsonrpc: '2.0', 275 | id: request.id, 276 | error: { 277 | code: -32603, 278 | message: error.message || 'Internal error' 279 | } 280 | }; 281 | } 282 | } 283 | 284 | public async handleFind(request: MCPRequest, args: any): Promise<MCPResponse> { 285 | const isStillIndexing = !this.isInitialized && this.initializationPromise; 286 | 287 | const description = args?.description || ''; 288 | const page = Math.max(1, args?.page || 1); 289 | const limit = args?.limit || (description ? 5 : 20); 290 | const depth = args?.depth !== undefined ? Math.max(0, Math.min(2, args.depth)) : 2; 291 | 292 | // Use ToolFinder service for search logic - always run to get partial results 293 | const finder = new ToolFinder(this.orchestrator); 294 | const findResult = await finder.find({ 295 | query: description, 296 | page, 297 | limit, 298 | depth 299 | }); 300 | 301 | const { tools: results, groupedByMCP: mcpGroups, pagination, mcpFilter, isListing } = findResult; 302 | 303 | // Get indexing progress if still indexing 304 | const progress = isStillIndexing ? this.orchestrator.getIndexingProgress() : null; 305 | 306 | const filterText = mcpFilter ? ` (filtered to ${mcpFilter})` : ''; 307 | 308 | // Enhanced pagination display 309 | const paginationInfo = pagination.totalPages > 1 ? 310 | ` | Page ${pagination.page} of ${pagination.totalPages} (showing ${pagination.resultsInPage} of ${pagination.totalResults} results)` : 311 | ` (${pagination.totalResults} results)`; 312 | 313 | let output: string; 314 | if (description) { 315 | // Search mode - highlight the search query with reverse colors for emphasis 316 | const highlightedQuery = chalk.inverse(` ${description} `); 317 | output = `\n🔍 Found tools for ${highlightedQuery}${filterText}${paginationInfo}:\n\n`; 318 | } else { 319 | // Listing mode - show all available tools 320 | output = `\n🔍 Available tools${filterText}${paginationInfo}:\n\n`; 321 | } 322 | 323 | // Add MCP health status summary 324 | const healthStatus = this.orchestrator.getMCPHealthStatus(); 325 | if (healthStatus.total > 0) { 326 | const healthIcon = healthStatus.unhealthy > 0 ? '⚠️' : '✅'; 327 | output += `${healthIcon} **MCPs**: ${healthStatus.healthy}/${healthStatus.total} healthy`; 328 | 329 | if (healthStatus.unhealthy > 0) { 330 | const unhealthyNames = healthStatus.mcps 331 | .filter(mcp => !mcp.healthy) 332 | .map(mcp => mcp.name) 333 | .join(', '); 334 | output += ` (${unhealthyNames} unavailable)`; 335 | } 336 | output += '\n\n'; 337 | } 338 | 339 | // Add indexing progress if still indexing (parity with CLI) 340 | if (progress && progress.total > 0) { 341 | const percentComplete = Math.round((progress.current / progress.total) * 100); 342 | const remainingTime = progress.estimatedTimeRemaining ? 343 | ` (~${Math.ceil(progress.estimatedTimeRemaining / 1000)}s remaining)` : ''; 344 | 345 | output += `⏳ **Indexing in progress**: ${progress.current}/${progress.total} MCPs (${percentComplete}%)${remainingTime}\n`; 346 | output += ` Currently indexing: ${progress.currentMCP || 'initializing...'}\n\n`; 347 | 348 | if (results.length > 0) { 349 | output += `📋 **Showing partial results** - more tools will become available as indexing completes.\n\n`; 350 | } else { 351 | output += `📋 **No tools available yet** - please try again in a moment as indexing progresses.\n\n`; 352 | } 353 | } 354 | 355 | // Handle no results case (but only if not indexing - during indexing we already showed message above) 356 | if (results.length === 0 && !progress) { 357 | output += `❌ No tools found for "${description}"\n\n`; 358 | 359 | // Show sample of available MCPs 360 | const samples = await finder.getSampleTools(8); 361 | 362 | if (samples.length > 0) { 363 | output += `📝 Available MCPs to explore:\n`; 364 | samples.forEach(sample => { 365 | output += `📁 **${sample.mcpName}** - ${sample.description}\n`; 366 | }); 367 | output += `\n💡 *Try broader search terms or specify an MCP name in your query.*`; 368 | } 369 | 370 | return { 371 | jsonrpc: '2.0', 372 | id: request.id, 373 | result: { 374 | content: [{ type: 'text', text: output }] 375 | } 376 | }; 377 | } 378 | 379 | // If no results but still indexing, return progress message 380 | if (results.length === 0 && progress) { 381 | return { 382 | jsonrpc: '2.0', 383 | id: request.id, 384 | result: { 385 | content: [{ type: 'text', text: output }] 386 | } 387 | }; 388 | } 389 | 390 | // Format output based on depth and mode 391 | if (depth === 0) { 392 | // Depth 0: Tool names only (no parameters, no descriptions) 393 | // Use original results array to maintain confidence-based ordering 394 | results.forEach((tool) => { 395 | if (isListing) { 396 | output += `# **${tool.toolName}**\n`; 397 | } else { 398 | const confidence = Math.round(tool.confidence * 100); 399 | output += `# **${tool.toolName}** (${confidence}% match)\n`; 400 | } 401 | }); 402 | } else if (depth === 1) { 403 | // Depth 1: Tool name + description only (no parameters) 404 | // Use original results array to maintain confidence-based ordering 405 | results.forEach((tool, toolIndex) => { 406 | if (toolIndex > 0) output += '---\n'; 407 | 408 | // Tool name 409 | if (isListing) { 410 | output += `# **${tool.toolName}**\n`; 411 | } else { 412 | const confidence = Math.round(tool.confidence * 100); 413 | output += `# **${tool.toolName}** (${confidence}% match)\n`; 414 | } 415 | 416 | // Tool description 417 | if (tool.description) { 418 | const cleanDescription = tool.description 419 | .replace(/^[^:]+:\s*/, '') // Remove MCP prefix 420 | .replace(/\s+/g, ' ') // Normalize whitespace 421 | .trim(); 422 | output += `${cleanDescription}\n`; 423 | } 424 | 425 | // No parameters at depth 1 426 | }); 427 | } else { 428 | // Depth 2: Full details with parameter descriptions 429 | // Use original results array to maintain confidence-based ordering 430 | results.forEach((tool, toolIndex) => { 431 | if (toolIndex > 0) output += '---\n'; 432 | 433 | // Tool name 434 | if (isListing) { 435 | output += `# **${tool.toolName}**\n`; 436 | } else { 437 | const confidence = Math.round(tool.confidence * 100); 438 | output += `# **${tool.toolName}** (${confidence}% match)\n`; 439 | } 440 | 441 | // Tool description 442 | if (tool.description) { 443 | const cleanDescription = tool.description 444 | .replace(/^[^:]+:\s*/, '') // Remove MCP prefix 445 | .replace(/\s+/g, ' ') // Normalize whitespace 446 | .trim(); 447 | output += `${cleanDescription}\n`; 448 | } 449 | 450 | // Parameters with descriptions inline 451 | if (tool.schema) { 452 | const params = this.parseParameters(tool.schema); 453 | if (params.length > 0) { 454 | params.forEach(param => { 455 | const optionalText = param.required ? '' : ' *(optional)*'; 456 | const descText = param.description ? ` - ${param.description}` : ''; 457 | output += `### ${param.name}: ${param.type}${optionalText}${descText}\n`; 458 | }); 459 | } else { 460 | output += `*[no parameters]*\n`; 461 | } 462 | } else { 463 | output += `*[no parameters]*\n`; 464 | } 465 | }); 466 | } 467 | 468 | // Add comprehensive usage guidance 469 | output += await UsageTipsGenerator.generate({ 470 | depth, 471 | page: pagination.page, 472 | totalPages: pagination.totalPages, 473 | limit, 474 | totalResults: pagination.totalResults, 475 | description, 476 | mcpFilter, 477 | results 478 | }); 479 | 480 | return { 481 | jsonrpc: '2.0', 482 | id: request.id, 483 | result: { 484 | content: [{ 485 | type: 'text', 486 | text: output 487 | }] 488 | } 489 | }; 490 | } 491 | 492 | 493 | 494 | private getToolContext(toolName: string): string { 495 | return ToolContextResolver.getContext(toolName); 496 | } 497 | 498 | private parseParameters(schema: any): ParameterInfo[] { 499 | return ToolSchemaParser.parseParameters(schema); 500 | } 501 | 502 | private wrapText(text: string, maxWidth: number, indent: string): string { 503 | return TextUtils.wrapText(text, { 504 | maxWidth, 505 | indent, 506 | cleanupPrefixes: true 507 | }); 508 | } 509 | 510 | private getSuggestions(input: string, validOptions: string[]): string[] { 511 | const inputLower = input.toLowerCase(); 512 | return validOptions.filter(option => { 513 | const optionLower = option.toLowerCase(); 514 | // Simple fuzzy matching: check if input contains part of option or vice versa 515 | return optionLower.includes(inputLower) || inputLower.includes(optionLower) || 516 | this.levenshteinDistance(inputLower, optionLower) <= 2; 517 | }); 518 | } 519 | 520 | private levenshteinDistance(str1: string, str2: string): number { 521 | const matrix = Array(str2.length + 1).fill(null).map(() => Array(str1.length + 1).fill(null)); 522 | 523 | for (let i = 0; i <= str1.length; i += 1) { 524 | matrix[0][i] = i; 525 | } 526 | 527 | for (let j = 0; j <= str2.length; j += 1) { 528 | matrix[j][0] = j; 529 | } 530 | 531 | for (let j = 1; j <= str2.length; j += 1) { 532 | for (let i = 1; i <= str1.length; i += 1) { 533 | const indicator = str1[i - 1] === str2[j - 1] ? 0 : 1; 534 | matrix[j][i] = Math.min( 535 | matrix[j][i - 1] + 1, // deletion 536 | matrix[j - 1][i] + 1, // insertion 537 | matrix[j - 1][i - 1] + indicator, // substitution 538 | ); 539 | } 540 | } 541 | 542 | return matrix[str2.length][str1.length]; 543 | } 544 | 545 | private generateDryRunPreview(toolIdentifier: string, parameters: any): string { 546 | const parts = toolIdentifier.includes(':') ? toolIdentifier.split(':', 2) : ['unknown', toolIdentifier]; 547 | const mcpName = parts[0]; 548 | const toolName = parts[1]; 549 | 550 | let preview = `🛠️ Tool: ${toolName}\n📁 MCP: ${mcpName}\n📋 Parameters:\n`; 551 | 552 | if (Object.keys(parameters).length === 0) { 553 | preview += ' (none)\n'; 554 | } else { 555 | for (const [key, value] of Object.entries(parameters)) { 556 | preview += ` ${key}: ${JSON.stringify(value)}\n`; 557 | } 558 | } 559 | 560 | // Add operation-specific warnings and descriptions 561 | const warnings = this.getDryRunWarnings(toolName, parameters); 562 | if (warnings.length > 0) { 563 | preview += '\n⚠️ Warnings:\n'; 564 | warnings.forEach(warning => preview += ` • ${warning}\n`); 565 | } 566 | 567 | const description = this.getDryRunDescription(toolName, parameters); 568 | if (description) { 569 | preview += `\n📖 This operation will: ${description}`; 570 | } 571 | 572 | return preview; 573 | } 574 | 575 | private getDryRunWarnings(toolName: string, parameters: any): string[] { 576 | const warnings: string[] = []; 577 | 578 | if (toolName.includes('write') || toolName.includes('create')) { 579 | warnings.push('This operation will modify files/data'); 580 | } 581 | if (toolName.includes('delete') || toolName.includes('remove')) { 582 | warnings.push('This operation will permanently delete data'); 583 | } 584 | if (toolName.includes('move') || toolName.includes('rename')) { 585 | warnings.push('This operation will move/rename files'); 586 | } 587 | if (parameters.path && (parameters.path.includes('/') || parameters.path.includes('\\'))) { 588 | warnings.push('File system operation - check path permissions'); 589 | } 590 | 591 | return warnings; 592 | } 593 | 594 | private getDryRunDescription(toolName: string, parameters: any): string { 595 | if (toolName === 'write_file' && parameters.path) { 596 | return `Create or overwrite file at: ${parameters.path}`; 597 | } 598 | if (toolName === 'read_file' && parameters.path) { 599 | return `Read contents of file: ${parameters.path}`; 600 | } 601 | if (toolName === 'create_directory' && parameters.path) { 602 | return `Create directory at: ${parameters.path}`; 603 | } 604 | if (toolName === 'list_directory' && parameters.path) { 605 | return `List contents of directory: ${parameters.path}`; 606 | } 607 | 608 | return `Execute ${toolName} with provided parameters`; 609 | } 610 | 611 | private async handleRun(request: MCPRequest, args: any): Promise<MCPResponse> { 612 | // Check if indexing is still in progress 613 | if (!this.isInitialized && this.initializationPromise) { 614 | const progress = this.orchestrator.getIndexingProgress(); 615 | 616 | if (progress && progress.total > 0) { 617 | const percentComplete = Math.round((progress.current / progress.total) * 100); 618 | const remainingTime = progress.estimatedTimeRemaining ? 619 | ` (~${Math.ceil(progress.estimatedTimeRemaining / 1000)}s remaining)` : ''; 620 | 621 | const progressMessage = `⏳ **Indexing in progress**: ${progress.current}/${progress.total} MCPs (${percentComplete}%)${remainingTime}\n` + 622 | `Currently indexing: ${progress.currentMCP || 'initializing...'}\n\n` + 623 | `Tool execution will be available once indexing completes. Please try again in a moment.`; 624 | 625 | return { 626 | jsonrpc: '2.0', 627 | id: request.id, 628 | result: { 629 | content: [{ type: 'text', text: progressMessage }] 630 | } 631 | }; 632 | } 633 | 634 | // Wait briefly for initialization to complete (max 2 seconds) 635 | try { 636 | let timeoutId: NodeJS.Timeout; 637 | await Promise.race([ 638 | this.initializationPromise, 639 | new Promise((_, reject) => { 640 | timeoutId = setTimeout(() => reject(new Error('timeout')), 2000); 641 | }) 642 | ]).finally(() => { 643 | if (timeoutId) clearTimeout(timeoutId); 644 | }); 645 | } catch { 646 | // Continue even if timeout - try to execute with what's available 647 | } 648 | } 649 | 650 | if (!args?.tool) { 651 | return { 652 | jsonrpc: '2.0', 653 | id: request.id, 654 | error: { 655 | code: -32602, 656 | message: 'tool parameter is required' 657 | } 658 | }; 659 | } 660 | 661 | const toolIdentifier = args.tool; 662 | const parameters = args.parameters || {}; 663 | const dryRun = args.dry_run || false; 664 | 665 | // Extract _meta for transparent passthrough (session_id, etc.) 666 | const meta = request.params?._meta; 667 | 668 | if (dryRun) { 669 | // Dry run mode - show what would happen without executing 670 | const previewText = this.generateDryRunPreview(toolIdentifier, parameters); 671 | return { 672 | jsonrpc: '2.0', 673 | id: request.id, 674 | result: { 675 | content: [{ 676 | type: 'text', 677 | text: `🔍 DRY RUN PREVIEW:\n\n${previewText}\n\n⚠️ This was a preview only. Set dry_run: false to execute.` 678 | }] 679 | } 680 | }; 681 | } 682 | 683 | // Normal execution - pass _meta transparently 684 | const result = await this.orchestrator.run(toolIdentifier, parameters, meta); 685 | 686 | if (result.success) { 687 | return { 688 | jsonrpc: '2.0', 689 | id: request.id, 690 | result: { 691 | content: [{ 692 | type: 'text', 693 | text: typeof result.content === 'string' ? result.content : JSON.stringify(result.content, null, 2) 694 | }] 695 | } 696 | }; 697 | } else { 698 | return { 699 | jsonrpc: '2.0', 700 | id: request.id, 701 | error: { 702 | code: -32603, 703 | message: result.error || 'Tool execution failed' 704 | } 705 | }; 706 | } 707 | } 708 | 709 | private async handleListPrompts(request: MCPRequest): Promise<MCPResponse> { 710 | try { 711 | const prompts = await this.orchestrator.getAllPrompts(); 712 | return { 713 | jsonrpc: '2.0', 714 | id: request.id, 715 | result: { 716 | prompts: prompts || [] 717 | } 718 | }; 719 | } catch (error: any) { 720 | logger.error(`Error listing prompts: ${error.message}`); 721 | return { 722 | jsonrpc: '2.0', 723 | id: request.id, 724 | result: { 725 | prompts: [] 726 | } 727 | }; 728 | } 729 | } 730 | 731 | private async handleListResources(request: MCPRequest): Promise<MCPResponse> { 732 | try { 733 | const resources = await this.orchestrator.getAllResources(); 734 | return { 735 | jsonrpc: '2.0', 736 | id: request.id, 737 | result: { 738 | resources: resources || [] 739 | } 740 | }; 741 | } catch (error: any) { 742 | logger.error(`Error listing resources: ${error.message}`); 743 | return { 744 | jsonrpc: '2.0', 745 | id: request.id, 746 | result: { 747 | resources: [] 748 | } 749 | }; 750 | } 751 | } 752 | 753 | async cleanup(): Promise<void> { 754 | await this.shutdown(); 755 | } 756 | 757 | async shutdown(): Promise<void> { 758 | try { 759 | await this.orchestrator.cleanup(); 760 | logger.info('NCP MCP server shut down gracefully'); 761 | } catch (error: any) { 762 | logger.error(`Error during shutdown: ${error.message}`); 763 | } 764 | } 765 | 766 | /** 767 | * Set up stdio transport listener for MCP protocol messages. 768 | * Safe to call multiple times (idempotent). 769 | * 770 | * This should be called immediately when the process starts to ensure 771 | * the server is ready to receive protocol messages from any MCP client, 772 | * without requiring an explicit run() call. 773 | */ 774 | startStdioListener(): void { 775 | // Prevent duplicate listener setup 776 | if ((this as any)._stdioListenerActive) { 777 | return; 778 | } 779 | (this as any)._stdioListenerActive = true; 780 | 781 | // Simple STDIO server 782 | process.stdin.setEncoding('utf8'); 783 | let buffer = ''; 784 | 785 | process.stdin.on('data', async (chunk) => { 786 | buffer += chunk; 787 | const lines = buffer.split('\n'); 788 | buffer = lines.pop() || ''; 789 | 790 | for (const line of lines) { 791 | if (line.trim()) { 792 | try { 793 | const request = JSON.parse(line); 794 | const response = await this.handleRequest(request); 795 | if (response) { 796 | process.stdout.write(JSON.stringify(response) + '\n'); 797 | } 798 | } catch (error) { 799 | const errorResponse = { 800 | jsonrpc: '2.0', 801 | id: null, 802 | error: { 803 | code: -32700, 804 | message: 'Parse error' 805 | } 806 | }; 807 | process.stdout.write(JSON.stringify(errorResponse) + '\n'); 808 | } 809 | } 810 | } 811 | }); 812 | 813 | process.stdin.on('end', () => { 814 | this.shutdown(); 815 | }); 816 | } 817 | 818 | /** 819 | * Legacy run() method for backwards compatibility. 820 | * Used by command-line interface entry point. 821 | * 822 | * For MCP server usage, prefer calling startStdioListener() immediately 823 | * and initialize() separately to be protocol-compliant. 824 | */ 825 | async run(): Promise<void> { 826 | await this.initialize(); 827 | this.startStdioListener(); 828 | } 829 | } 830 | 831 | export class ParameterPredictor { 832 | predictValue(paramName: string, paramType: string, toolContext: string, description?: string, toolName?: string): any { 833 | const name = paramName.toLowerCase(); 834 | const desc = (description || '').toLowerCase(); 835 | const tool = (toolName || '').toLowerCase(); 836 | 837 | // String type predictions 838 | if (paramType === 'string') { 839 | return this.predictStringValue(name, desc, toolContext, tool); 840 | } 841 | 842 | // Number type predictions 843 | if (paramType === 'number' || paramType === 'integer') { 844 | return this.predictNumberValue(name, desc, toolContext); 845 | } 846 | 847 | // Boolean type predictions 848 | if (paramType === 'boolean') { 849 | return this.predictBooleanValue(name, desc); 850 | } 851 | 852 | // Array type predictions 853 | if (paramType === 'array') { 854 | return this.predictArrayValue(name, desc, toolContext); 855 | } 856 | 857 | // Object type predictions 858 | if (paramType === 'object') { 859 | return this.predictObjectValue(name, desc); 860 | } 861 | 862 | // Default fallback 863 | return this.getDefaultForType(paramType); 864 | } 865 | 866 | private predictStringValue(name: string, desc: string, context: string, tool?: string): string { 867 | // File and path patterns 868 | if (name.includes('path') || name.includes('file') || desc.includes('path') || desc.includes('file')) { 869 | // Check if tool name suggests directory operations 870 | const isDirectoryTool = tool && ( 871 | tool.includes('list_dir') || 872 | tool.includes('list_folder') || 873 | tool.includes('read_dir') || 874 | tool.includes('scan_dir') || 875 | tool.includes('get_dir') 876 | ); 877 | 878 | // Check if parameter or description suggests directory 879 | const isDirectoryParam = name.includes('dir') || 880 | name.includes('folder') || 881 | desc.includes('directory') || 882 | desc.includes('folder'); 883 | 884 | // Smart detection: if it's just "path" but tool is clearly for directories 885 | if (name === 'path' && isDirectoryTool) { 886 | return context === 'filesystem' ? '/home/user/documents' : './'; 887 | } 888 | 889 | if (context === 'filesystem') { 890 | if (isDirectoryParam || isDirectoryTool) { 891 | return '/home/user/documents'; 892 | } 893 | if (name.includes('config') || desc.includes('config')) { 894 | return '/etc/config.json'; 895 | } 896 | return '/home/user/document.txt'; 897 | } 898 | 899 | // Default based on whether it's likely a directory or file 900 | if (isDirectoryParam || isDirectoryTool) { 901 | return './'; 902 | } 903 | return './file.txt'; 904 | } 905 | 906 | // URL patterns 907 | if (name.includes('url') || name.includes('link') || desc.includes('url') || desc.includes('http')) { 908 | if (context === 'web') { 909 | return 'https://api.example.com/data'; 910 | } 911 | return 'https://example.com'; 912 | } 913 | 914 | // Email patterns 915 | if (name.includes('email') || name.includes('mail') || desc.includes('email')) { 916 | return '[email protected]'; 917 | } 918 | 919 | // Name patterns 920 | if (name.includes('name') || name === 'title' || name === 'label') { 921 | if (context === 'filesystem') { 922 | return 'my-file'; 923 | } 924 | return 'example-name'; 925 | } 926 | 927 | // Content/text patterns 928 | if (name.includes('content') || name.includes('text') || name.includes('message') || name.includes('body')) { 929 | return 'Hello, world!'; 930 | } 931 | 932 | // Query/search patterns 933 | if (name.includes('query') || name.includes('search') || name.includes('term')) { 934 | return 'search term'; 935 | } 936 | 937 | // Key/ID patterns 938 | if (name.includes('key') || name.includes('id') || name.includes('token')) { 939 | if (context === 'payment') { 940 | return 'sk_test_...'; 941 | } 942 | return 'abc123'; 943 | } 944 | 945 | // Command patterns 946 | if (name.includes('command') || name.includes('cmd')) { 947 | if (context === 'system') { 948 | return 'ls -la'; 949 | } 950 | return 'echo hello'; 951 | } 952 | 953 | // Default string 954 | return 'example'; 955 | } 956 | 957 | private predictNumberValue(name: string, desc: string, context: string): number { 958 | // Process ID patterns 959 | if (name.includes('pid') || desc.includes('process') || desc.includes('pid')) { 960 | return 1234; 961 | } 962 | 963 | // Port patterns 964 | if (name.includes('port') || desc.includes('port')) { 965 | return 8080; 966 | } 967 | 968 | // Size/length patterns 969 | if (name.includes('size') || name.includes('length') || name.includes('limit') || name.includes('count')) { 970 | return 10; 971 | } 972 | 973 | // Line number patterns 974 | if (name.includes('line') || name.includes('head') || name.includes('tail')) { 975 | return 5; 976 | } 977 | 978 | // Timeout patterns 979 | if (name.includes('timeout') || name.includes('delay') || desc.includes('timeout')) { 980 | return 5000; 981 | } 982 | 983 | // Default number 984 | return 1; 985 | } 986 | 987 | private predictBooleanValue(name: string, desc: string): boolean { 988 | // Negative patterns default to false 989 | if (name.includes('disable') || name.includes('skip') || name.includes('ignore')) { 990 | return false; 991 | } 992 | 993 | // Most booleans default to true for examples 994 | return true; 995 | } 996 | 997 | private predictArrayValue(name: string, desc: string, context: string): any[] { 998 | // File paths array 999 | if (name.includes('path') || name.includes('file') || desc.includes('path')) { 1000 | return ['/path/to/file1.txt', '/path/to/file2.txt']; 1001 | } 1002 | 1003 | // Arguments array 1004 | if (name.includes('arg') || name.includes('param') || desc.includes('argument')) { 1005 | return ['--verbose', '--output', 'result.txt']; 1006 | } 1007 | 1008 | // Tags/keywords 1009 | if (name.includes('tag') || name.includes('keyword') || name.includes('label')) { 1010 | return ['tag1', 'tag2']; 1011 | } 1012 | 1013 | // Default array 1014 | return ['item1', 'item2']; 1015 | } 1016 | 1017 | private predictObjectValue(name: string, desc: string): object { 1018 | // Options/config object 1019 | if (name.includes('option') || name.includes('config') || name.includes('setting')) { 1020 | return { enabled: true, timeout: 5000 }; 1021 | } 1022 | 1023 | // Default object 1024 | return { key: 'value' }; 1025 | } 1026 | 1027 | private getDefaultForType(type: string): any { 1028 | switch (type) { 1029 | case 'string': return 'value'; 1030 | case 'number': 1031 | case 'integer': return 0; 1032 | case 'boolean': return true; 1033 | case 'array': return []; 1034 | case 'object': return {}; 1035 | default: return null; 1036 | } 1037 | } 1038 | } 1039 | 1040 | export default MCPServer; ``` -------------------------------------------------------------------------------- /src/discovery/rag-engine.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Persistent RAG Engine for NCP 3 | * Uses transformer.js for embeddings with persistent caching 4 | */ 5 | 6 | import * as path from 'path'; 7 | import { getNcpBaseDirectory } from '../utils/ncp-paths.js'; 8 | import * as fs from 'fs/promises'; 9 | import * as crypto from 'crypto'; 10 | import { existsSync, mkdirSync, statSync } from 'fs'; 11 | import { logger } from '../utils/logger.js'; 12 | import { SemanticEnhancementEngine } from './semantic-enhancement-engine.js'; 13 | import { version } from '../utils/version.js'; 14 | 15 | // Import transformer.js (will be added to dependencies) 16 | declare const pipeline: any; 17 | 18 | export interface ToolEmbedding { 19 | embedding: Float32Array; 20 | hash: string; 21 | lastUpdated: string; 22 | toolName: string; 23 | description: string; 24 | enhancedDescription?: string; 25 | mcpName?: string; 26 | mcpDomain?: string; 27 | } 28 | 29 | export interface CacheMetadata { 30 | version: string; 31 | createdAt: string; 32 | lastValidated: string; 33 | configHash: string; 34 | mcpHashes: Record<string, string>; 35 | totalTools: number; 36 | } 37 | 38 | export interface DiscoveryResult { 39 | toolId: string; 40 | confidence: number; 41 | reason: string; 42 | similarity: number; 43 | originalSimilarity?: number; 44 | domain?: string; 45 | } 46 | 47 | export class PersistentRAGEngine { 48 | 49 | /** 50 | * Get domain classification for an MCP to improve cross-domain disambiguation 51 | */ 52 | private getMCPDomain(mcpName: string): string { 53 | const domainMappings: Record<string, string> = { 54 | // Web development and frontend 55 | 'context7-mcp': 'web development documentation', 56 | 'vscode-mcp': 'code editor', 57 | 58 | // Financial/payment services 59 | 'stripe': 'payment processing financial', 60 | 'paypal': 'payment processing financial', 61 | 62 | // File and system operations 63 | 'desktop-commander': 'file system operations', 64 | 'Shell': 'command line system', 65 | 'filesystem': 'file system operations', 66 | 67 | // Development tools 68 | 'portel': 'code analysis development', 69 | 'git': 'version control development', 70 | 'sequential-thinking': 'development workflow', 71 | 72 | // AI and search 73 | 'tavily': 'web search information', 74 | 'perplexity': 'web search information', 75 | 'anthropic': 'AI language model', 76 | 77 | // Database and data 78 | 'postgres': 'database operations', 79 | 'sqlite': 'database operations', 80 | 'mongodb': 'database operations', 81 | 82 | // Communication and social 83 | 'slack': 'team communication', 84 | 'email': 'email communication', 85 | 86 | // Cloud and infrastructure 87 | 'aws': 'cloud infrastructure', 88 | 'gcp': 'cloud infrastructure', 89 | 'docker': 'containerization infrastructure', 90 | }; 91 | 92 | return domainMappings[mcpName] || 'general utility'; 93 | } 94 | 95 | /** 96 | * Infer likely domains from query text to improve cross-domain disambiguation 97 | */ 98 | private inferQueryDomains(query: string): string[] { 99 | const domainKeywords: Record<string, string[]> = { 100 | 'web development': ['react', 'vue', 'angular', 'javascript', 'typescript', 'frontend', 'web', 'html', 'css', 'component', 'jsx', 'tsx'], 101 | 'payment processing': ['payment', 'stripe', 'paypal', 'billing', 'invoice', 'subscription', 'checkout', 'transaction'], 102 | 'file system': ['file', 'directory', 'folder', 'path', 'move', 'copy', 'delete', 'create', 'read', 'write'], 103 | 'command line': ['command', 'shell', 'bash', 'terminal', 'execute', 'run', 'script'], 104 | 'database': ['database', 'sql', 'query', 'table', 'record', 'postgres', 'mysql', 'mongodb'], 105 | 'cloud infrastructure': ['aws', 'gcp', 'azure', 'cloud', 'deploy', 'infrastructure', 'docker', 'kubernetes'], 106 | 'development': ['code', 'development', 'debug', 'build', 'compile', 'test', 'git', 'version', 'repository'], 107 | 'search': ['search', 'find', 'lookup', 'query', 'information', 'web search'], 108 | 'communication': ['email', 'slack', 'message', 'send', 'notification', 'team'] 109 | }; 110 | 111 | const inferredDomains: string[] = []; 112 | 113 | for (const [domain, keywords] of Object.entries(domainKeywords)) { 114 | const matchCount = keywords.filter(keyword => query.includes(keyword)).length; 115 | if (matchCount > 0) { 116 | inferredDomains.push(domain); 117 | } 118 | } 119 | 120 | return inferredDomains; 121 | } 122 | 123 | /** 124 | * Add capability enhancements for reverse domain mapping 125 | * Terminal/shell tools should advertise their git, build, and development capabilities 126 | */ 127 | private getCapabilityEnhancements(toolName: string, description: string): string { 128 | const enhancements: string[] = []; 129 | 130 | // Terminal/shell tools get comprehensive capability advertisements 131 | if (toolName.includes('start_process') || 132 | toolName.includes('run_command') || 133 | description.toLowerCase().includes('terminal') || 134 | description.toLowerCase().includes('shell') || 135 | description.toLowerCase().includes('command line') || 136 | description.toLowerCase().includes('execute')) { 137 | 138 | enhancements.push( 139 | // Git capabilities 140 | ' Can execute git commands: git commit, git push, git pull, git status, git add, git log, git diff, git branch, git checkout, git merge, git clone.', 141 | // Development tool capabilities 142 | ' Can run development tools: npm, yarn, bun, pip, cargo, make, build scripts.', 143 | // System command capabilities 144 | ' Can execute system commands: ls, cd, mkdir, rm, cp, mv, chmod, chown.', 145 | // Package manager capabilities 146 | ' Can run package managers: apt, brew, yum, pacman.', 147 | // Script execution capabilities 148 | ' Can execute scripts: bash scripts, python scripts, shell scripts.', 149 | // Build and deployment capabilities 150 | ' Can run build tools: webpack, vite, rollup, parcel, docker, kubernetes.' 151 | ); 152 | } 153 | 154 | // File management tools get development-related file capabilities 155 | if (toolName.includes('read_file') || 156 | toolName.includes('write_file') || 157 | toolName.includes('edit_file')) { 158 | 159 | enhancements.push( 160 | ' Can handle development files: package.json, tsconfig.json, .gitignore, README.md, configuration files.' 161 | ); 162 | } 163 | 164 | return enhancements.join(''); 165 | } 166 | 167 | private model: any; 168 | private vectorDB: Map<string, ToolEmbedding> = new Map(); 169 | private dbPath: string; 170 | private metadataPath: string; 171 | private cacheMetadata: CacheMetadata | null = null; 172 | private isInitialized = false; 173 | private indexingQueue: Array<{ mcpName: string; tools: any[] }> = []; 174 | private isIndexing = false; 175 | private semanticEnhancementEngine: SemanticEnhancementEngine; 176 | 177 | constructor() { 178 | const ncpDir = getNcpBaseDirectory(); 179 | this.dbPath = path.join(ncpDir, 'embeddings.json'); 180 | this.metadataPath = path.join(ncpDir, 'embeddings-metadata.json'); 181 | 182 | // Initialize semantic enhancement engine with industry-standard architecture 183 | this.semanticEnhancementEngine = new SemanticEnhancementEngine(); 184 | 185 | this.ensureDirectoryExists(ncpDir); 186 | 187 | logger.info('RAG Engine initialized with Semantic Enhancement Engine'); 188 | logger.debug(`Enhancement statistics: ${JSON.stringify(this.semanticEnhancementEngine.getEnhancementStatistics())}`); 189 | } 190 | 191 | /** 192 | * Validate cache against current configuration 193 | */ 194 | async validateCache(currentConfig?: any): Promise<boolean> { 195 | try { 196 | if (!existsSync(this.dbPath) || !existsSync(this.metadataPath)) { 197 | logger.debug('🔍 Cache files missing, needs rebuild'); 198 | return false; 199 | } 200 | 201 | // Load cache metadata 202 | const metadataContent = await fs.readFile(this.metadataPath, 'utf-8'); 203 | this.cacheMetadata = JSON.parse(metadataContent); 204 | 205 | if (!this.cacheMetadata) { 206 | logger.debug('🔍 Cache metadata invalid, needs rebuild'); 207 | return false; 208 | } 209 | 210 | // Check if cache is too old (older than 7 days) 211 | const cacheAge = Date.now() - new Date(this.cacheMetadata.createdAt).getTime(); 212 | const maxAge = 7 * 24 * 60 * 60 * 1000; // 7 days 213 | if (cacheAge > maxAge) { 214 | logger.info('🕐 Cache is older than 7 days, rebuilding for freshness'); 215 | return false; 216 | } 217 | 218 | // If current config provided, validate against it 219 | if (currentConfig) { 220 | const currentConfigHash = this.hashObject(currentConfig); 221 | if (this.cacheMetadata.configHash !== currentConfigHash) { 222 | logger.info('🔄 Configuration changed, invalidating cache'); 223 | return false; 224 | } 225 | } 226 | 227 | logger.debug('✅ Cache validation passed'); 228 | return true; 229 | 230 | } catch (error) { 231 | logger.warn(`⚠️ Cache validation failed: ${error}`); 232 | return false; 233 | } 234 | } 235 | 236 | /** 237 | * Generate hash of configuration for change detection 238 | */ 239 | private hashObject(obj: any): string { 240 | const str = JSON.stringify(obj, Object.keys(obj).sort()); 241 | return crypto.createHash('sha256').update(str).digest('hex'); 242 | } 243 | 244 | /** 245 | * Update cache metadata 246 | */ 247 | private async updateCacheMetadata(mcpHashes: Record<string, string>): Promise<void> { 248 | this.cacheMetadata = { 249 | version, 250 | createdAt: new Date().toISOString(), 251 | lastValidated: new Date().toISOString(), 252 | configHash: '', // Will be set when config is available 253 | mcpHashes, 254 | totalTools: this.vectorDB.size 255 | }; 256 | 257 | try { 258 | await fs.writeFile(this.metadataPath, JSON.stringify(this.cacheMetadata, null, 2)); 259 | logger.debug('💾 Cache metadata updated'); 260 | } catch (error) { 261 | logger.error(`❌ Failed to save cache metadata: ${error}`); 262 | } 263 | } 264 | 265 | /** 266 | * Initialize the RAG engine with embedding model 267 | * Falls back gracefully if transformer.js fails to load 268 | */ 269 | async initialize(currentConfig?: any): Promise<void> { 270 | if (this.isInitialized) return; 271 | 272 | logger.info('🧠 Initializing RAG engine...'); 273 | const startTime = Date.now(); 274 | 275 | // Validate cache before proceeding 276 | const cacheValid = await this.validateCache(currentConfig); 277 | 278 | if (!cacheValid) { 279 | logger.info('🔄 Cache invalid, clearing and will rebuild on demand'); 280 | await this.clearCache(); 281 | } 282 | 283 | // Store original console.warn before try block 284 | const originalConsoleWarn = console.warn; 285 | 286 | try { 287 | // Configure transformers environment to suppress content-length warnings 288 | process.env.TRANSFORMERS_VERBOSITY = 'error'; // Suppress info/warning logs 289 | 290 | // Temporarily suppress the specific content-length warning 291 | console.warn = (...args: any[]) => { 292 | const message = args.join(' '); 293 | if (message.includes('Unable to determine content-length') || 294 | message.includes('Will expand buffer when needed')) { 295 | return; // Suppress this specific warning 296 | } 297 | originalConsoleWarn.apply(console, args); 298 | }; 299 | 300 | // Dynamically import transformer.js 301 | const { pipeline, env } = await import('@xenova/transformers'); 302 | 303 | // Configure transformers to suppress download warnings 304 | env.allowLocalModels = false; 305 | env.allowRemoteModels = true; 306 | 307 | // Load sentence transformer model 308 | logger.info('📥 Loading embedding model (all-MiniLM-L6-v2)...'); 309 | this.model = await pipeline( 310 | 'feature-extraction', 311 | 'Xenova/all-MiniLM-L6-v2', 312 | { 313 | quantized: true, // Use quantized version for smaller size 314 | progress_callback: (progress: any) => { 315 | if (progress.status === 'downloading') { 316 | logger.info(`📥 Downloading model: ${Math.round(progress.progress)}%`); 317 | } 318 | } 319 | } 320 | ); 321 | 322 | // Restore original console.warn after model loading 323 | console.warn = originalConsoleWarn; 324 | 325 | // Load cached embeddings (if cache was valid) 326 | if (cacheValid) { 327 | await this.loadPersistedEmbeddings(); 328 | } 329 | 330 | const initTime = Date.now() - startTime; 331 | logger.info(`✅ RAG engine initialized in ${initTime}ms`); 332 | logger.info(`📊 Loaded ${this.vectorDB.size} cached embeddings`); 333 | 334 | this.isInitialized = true; 335 | 336 | // Process any queued indexing tasks 337 | this.processIndexingQueue(); 338 | 339 | } catch (error) { 340 | // Restore original console.warn in case of error 341 | console.warn = originalConsoleWarn; 342 | 343 | logger.warn(`⚠️ RAG engine failed to initialize: ${error}`); 344 | logger.info('🔄 Falling back to keyword-based discovery'); 345 | 346 | // Mark as initialized but without model (fallback mode) 347 | this.isInitialized = true; 348 | this.model = null; 349 | 350 | // Still load cached embeddings for basic functionality (if cache was valid) 351 | if (cacheValid) { 352 | try { 353 | await this.loadPersistedEmbeddings(); 354 | logger.info(`📊 Loaded ${this.vectorDB.size} cached embeddings (fallback mode)`); 355 | } catch { 356 | // Ignore cache loading errors in fallback mode 357 | } 358 | } 359 | 360 | // Process any queued indexing tasks (will use fallback) 361 | this.processIndexingQueue(); 362 | } 363 | } 364 | 365 | /** 366 | * Index tools from an MCP (progressive loading) 367 | */ 368 | async indexMCP(mcpName: string, tools: any[]): Promise<void> { 369 | if (!this.isInitialized) { 370 | // Queue for later processing 371 | this.indexingQueue.push({ mcpName, tools }); 372 | logger.info(`📋 Queued ${mcpName} for indexing (${tools.length} tools)`); 373 | return; 374 | } 375 | 376 | if (this.isIndexing) { 377 | // Add to queue if already indexing 378 | this.indexingQueue.push({ mcpName, tools }); 379 | return; 380 | } 381 | 382 | await this.performIndexing(mcpName, tools); 383 | } 384 | 385 | /** 386 | * Fast indexing for startup - loads from embeddings cache if available 387 | * This is called during optimized cache loading to avoid regenerating embeddings 388 | */ 389 | async indexMCPFromCache(mcpName: string, tools: any[]): Promise<void> { 390 | if (!this.isInitialized) { 391 | // Queue for later processing 392 | this.indexingQueue.push({ mcpName, tools }); 393 | return; 394 | } 395 | 396 | // Fast path: check if all tools are already in vectorDB 397 | let allCached = true; 398 | for (const tool of tools) { 399 | const toolId = tool.id || `${mcpName}:${tool.name}`; 400 | if (!this.vectorDB.has(toolId)) { 401 | allCached = false; 402 | break; 403 | } 404 | } 405 | 406 | if (allCached) { 407 | logger.debug(`⚡ All ${tools.length} tools for ${mcpName} already cached`); 408 | return; 409 | } 410 | 411 | // Fallback to normal indexing if not all cached 412 | await this.performIndexing(mcpName, tools); 413 | } 414 | 415 | /** 416 | * Perform actual indexing of tools 417 | */ 418 | private async performIndexing(mcpName: string, tools: any[]): Promise<void> { 419 | this.isIndexing = true; 420 | logger.info(`🔍 Indexing ${mcpName} (${tools.length} tools)...`); 421 | 422 | let newEmbeddings = 0; 423 | let cachedEmbeddings = 0; 424 | 425 | try { 426 | for (const tool of tools) { 427 | const toolId = tool.id || `${mcpName}:${tool.name}`; 428 | const description = tool.description || tool.name; 429 | const hash = this.hashDescription(description); 430 | 431 | const cached = this.vectorDB.get(toolId); 432 | 433 | // Skip if we already have this exact description 434 | if (cached && cached.hash === hash) { 435 | logger.debug(`💾 Using cached embedding for ${toolId}`); 436 | cachedEmbeddings++; 437 | continue; 438 | } 439 | 440 | // Generate new embedding (or skip in fallback mode) 441 | if (this.model) { 442 | logger.debug(`🧮 Computing embedding for ${toolId}...`); 443 | try { 444 | const mcpDomain = this.getMCPDomain(mcpName); 445 | const capabilityEnhancements = this.getCapabilityEnhancements(tool.name, description); 446 | // Include the tool identifier for exact searches: git:commit, filesystem:read_file, etc. 447 | const toolIdentifier = `${mcpName}:${tool.name}`; 448 | const enhancedDescription = `${toolIdentifier} ${mcpDomain} context: ${description}${capabilityEnhancements}`; 449 | 450 | const embedding = await this.model(enhancedDescription, { 451 | pooling: 'mean', 452 | normalize: true 453 | }); 454 | 455 | this.vectorDB.set(toolId, { 456 | embedding: new Float32Array(embedding.data), 457 | hash: hash, 458 | lastUpdated: new Date().toISOString(), 459 | toolName: tool.name, 460 | description: description, 461 | enhancedDescription: enhancedDescription, 462 | mcpName: mcpName, 463 | mcpDomain: mcpDomain 464 | }); 465 | 466 | newEmbeddings++; 467 | } catch (error) { 468 | logger.error(`❌ Failed to compute embedding for ${toolId}: ${error}`); 469 | } 470 | } else { 471 | // In fallback mode, just store tool metadata without embeddings 472 | const mcpDomain = this.getMCPDomain(mcpName); 473 | this.vectorDB.set(toolId, { 474 | embedding: new Float32Array([]), // Empty embedding 475 | hash: hash, 476 | lastUpdated: new Date().toISOString(), 477 | toolName: tool.name, 478 | description: description, 479 | enhancedDescription: `${mcpDomain} context: ${description}${this.getCapabilityEnhancements(tool.name, description)}`, 480 | mcpName: mcpName, 481 | mcpDomain: mcpDomain 482 | }); 483 | newEmbeddings++; 484 | } 485 | } 486 | 487 | // Update MCP hash for change detection 488 | const mcpHash = this.hashObject(tools); 489 | const mcpHashes = this.cacheMetadata?.mcpHashes || {}; 490 | mcpHashes[mcpName] = mcpHash; 491 | 492 | // Persist to disk after each MCP 493 | await this.persistEmbeddings(); 494 | await this.updateCacheMetadata(mcpHashes); 495 | 496 | logger.info(`✅ ${mcpName} indexed: ${newEmbeddings} new, ${cachedEmbeddings} cached`); 497 | 498 | } catch (error) { 499 | logger.error(`❌ Failed to index ${mcpName}: ${error}`); 500 | } finally { 501 | this.isIndexing = false; 502 | 503 | // Process next item in queue 504 | if (this.indexingQueue.length > 0) { 505 | const next = this.indexingQueue.shift()!; 506 | setImmediate(() => this.performIndexing(next.mcpName, next.tools)); 507 | } 508 | } 509 | } 510 | 511 | /** 512 | * Process queued indexing tasks 513 | */ 514 | private async processIndexingQueue(): Promise<void> { 515 | while (this.indexingQueue.length > 0) { 516 | const task = this.indexingQueue.shift()!; 517 | await this.performIndexing(task.mcpName, task.tools); 518 | } 519 | } 520 | 521 | /** 522 | * Discover tools using semantic similarity (or fallback to keyword matching) 523 | */ 524 | async discover(query: string, maxResults = 5): Promise<DiscoveryResult[]> { 525 | if (!this.isInitialized) { 526 | logger.warn('⚠️ RAG engine not initialized, falling back to keyword matching'); 527 | return this.fallbackKeywordSearch(query, maxResults); 528 | } 529 | 530 | if (this.vectorDB.size === 0) { 531 | logger.warn('⚠️ No embeddings available yet'); 532 | return []; 533 | } 534 | 535 | // If no model available (fallback mode), use keyword search 536 | if (!this.model) { 537 | logger.debug(`🔍 Keyword discovery (fallback mode): "${query}"`); 538 | return this.fallbackKeywordSearch(query, maxResults); 539 | } 540 | 541 | try { 542 | logger.debug(`🔍 RAG discovery: "${query}"`); 543 | 544 | // Check if any tools have actual embeddings 545 | let toolsWithEmbeddings = 0; 546 | for (const [toolId, toolData] of this.vectorDB) { 547 | if (toolData.embedding.length > 0) { 548 | toolsWithEmbeddings++; 549 | } 550 | } 551 | 552 | logger.debug(`Tools with embeddings: ${toolsWithEmbeddings}/${this.vectorDB.size}`); 553 | 554 | // If no tools have embeddings, fall back to keyword search 555 | if (toolsWithEmbeddings === 0) { 556 | logger.debug('No tools have embeddings, falling back to keyword search'); 557 | return this.fallbackKeywordSearch(query, maxResults); 558 | } 559 | 560 | // Generate query embedding 561 | const queryEmbedding = await this.model(query, { 562 | pooling: 'mean', 563 | normalize: true 564 | }); 565 | 566 | // Calculate similarities 567 | const similarities: Array<{ toolId: string; similarity: number }> = []; 568 | 569 | for (const [toolId, toolData] of this.vectorDB) { 570 | // Skip tools with empty embeddings (fallback mode entries) 571 | if (toolData.embedding.length === 0) { 572 | continue; 573 | } 574 | 575 | const similarity = this.cosineSimilarity( 576 | queryEmbedding.data, 577 | toolData.embedding 578 | ); 579 | 580 | similarities.push({ toolId, similarity }); 581 | } 582 | 583 | // Git-specific boosting: if query contains git terms, moderately boost Shell tools 584 | const queryLower = query.toLowerCase(); 585 | const gitTerms = ['git', 'commit', 'push', 'pull', 'checkout', 'branch', 'merge', 'clone', 'status', 'log', 'diff', 'add', 'remote', 'fetch', 'rebase', 'stash', 'tag']; 586 | const hasGitTerms = gitTerms.some(term => queryLower.includes(term)); 587 | 588 | if (hasGitTerms) { 589 | for (const result of similarities) { 590 | if (result.toolId.startsWith('Shell:')) { 591 | result.similarity = Math.min(0.85, result.similarity + 0.15); // Moderate boost for Shell tools only when git terms are explicit 592 | logger.debug(`🔧 Git query detected, boosting ${result.toolId} similarity to ${result.similarity}`); 593 | } 594 | } 595 | } 596 | 597 | // Enhanced filtering with domain awareness 598 | const inferredDomains = this.inferQueryDomains(queryLower); 599 | 600 | // Sort by similarity and apply enhancement system 601 | const results = similarities 602 | .sort((a, b) => b.similarity - a.similarity) 603 | .slice(0, maxResults * 2) // Get more candidates for domain filtering 604 | .filter(result => result.similarity > 0.25) // Lower initial threshold for domain filtering 605 | .map(result => { 606 | const toolData = this.vectorDB.get(result.toolId); 607 | let boostedSimilarity = result.similarity; 608 | let enhancementReasons: string[] = []; 609 | 610 | // Apply semantic enhancement engine (capability inference + intent resolution) 611 | if (toolData) { 612 | const semanticEnhancements = this.semanticEnhancementEngine.applySemanticalEnhancement( 613 | query, 614 | result.toolId, 615 | toolData.description 616 | ); 617 | 618 | for (const enhancement of semanticEnhancements) { 619 | boostedSimilarity += enhancement.relevanceBoost; 620 | enhancementReasons.push(`${enhancement.enhancementType}: ${enhancement.enhancementReason}`); 621 | 622 | logger.debug(`🚀 Semantic enhancement ${result.toolId}: +${enhancement.relevanceBoost.toFixed(3)} (${enhancement.enhancementType})`); 623 | } 624 | } 625 | 626 | // Legacy domain boosting (will be replaced by enhancement system over time) 627 | if (toolData?.mcpDomain && inferredDomains.length > 0) { 628 | const domainMatch = inferredDomains.some(domain => 629 | toolData.mcpDomain!.toLowerCase().includes(domain.toLowerCase()) || 630 | domain.toLowerCase().includes(toolData.mcpDomain!.toLowerCase()) 631 | ); 632 | if (domainMatch) { 633 | boostedSimilarity = Math.min(0.98, boostedSimilarity + 0.15); 634 | enhancementReasons.push(`legacy: domain match (${toolData.mcpDomain})`); 635 | } 636 | } 637 | 638 | const baseReason = toolData?.mcpDomain ? 639 | `${toolData.mcpDomain} tool (RAG)` : 640 | 'Semantic similarity (RAG)'; 641 | 642 | const enhancedReason = enhancementReasons.length > 0 ? 643 | `${baseReason} + ${enhancementReasons.join(', ')}` : 644 | baseReason; 645 | 646 | return { 647 | toolId: result.toolId, 648 | confidence: Math.min(0.95, boostedSimilarity), 649 | reason: enhancedReason, 650 | similarity: boostedSimilarity, 651 | originalSimilarity: result.similarity, 652 | domain: toolData?.mcpDomain || 'unknown' 653 | }; 654 | }) 655 | .sort((a, b) => b.similarity - a.similarity) // Re-sort after boosting 656 | .slice(0, maxResults) // Take final top results 657 | .filter(result => result.similarity > 0.3); // Final threshold 658 | 659 | logger.debug(`🎯 Found ${results.length} matches for "${query}"`); 660 | 661 | return results; 662 | 663 | } catch (error) { 664 | logger.error(`❌ RAG discovery failed: ${error}`); 665 | return this.fallbackKeywordSearch(query, maxResults); 666 | } 667 | } 668 | 669 | /** 670 | * Enhanced fallback keyword search when RAG fails 671 | */ 672 | private fallbackKeywordSearch(query: string, maxResults: number): DiscoveryResult[] { 673 | logger.debug('🔄 Using enhanced keyword search'); 674 | 675 | const queryWords = query.toLowerCase().split(/\s+/); 676 | const scores = new Map<string, { score: number; matches: string[] }>(); 677 | 678 | // Domain-specific patterns for better disambiguation 679 | const domainPatterns: Record<string, { tools: string[]; keywords: string[]; boost: number }> = { 680 | 'web_search': { 681 | tools: ['tavily:search', 'tavily:searchContext', 'tavily:searchQNA'], 682 | keywords: ['web', 'internet', 'google', 'online', 'website', 'url', 'tavily', 'search web', 'web search', 'search the web', 'google search', 'search online', 'online search', 'internet search', 'web information', 'search information', 'find online', 'look up online'], 683 | boost: 3.0 684 | }, 685 | 'code_search': { 686 | tools: ['desktop-commander:search_code'], 687 | keywords: ['code', 'text', 'pattern', 'grep', 'ripgrep', 'file content', 'search code', 'search text'], 688 | boost: 2.0 689 | }, 690 | 'file_search': { 691 | tools: ['desktop-commander:search_files'], 692 | keywords: ['file name', 'filename', 'find file', 'locate file', 'search files'], 693 | boost: 2.0 694 | }, 695 | 'create_file': { 696 | tools: ['desktop-commander:write_file'], 697 | keywords: ['create file', 'new file', 'make file', 'generate file'], 698 | boost: 3.0 699 | }, 700 | 'read_single_file': { 701 | tools: ['desktop-commander:read_file'], 702 | keywords: ['read file', 'get file', 'show file', 'view file', 'display file', 'file content', 'get content', 'show content', 'view file content', 'display file content', 'read single file', 'show single file'], 703 | boost: 5.0 704 | }, 705 | 'read_multiple_files': { 706 | tools: ['desktop-commander:read_multiple_files'], 707 | keywords: ['read multiple files', 'read many files', 'get multiple files', 'show multiple files', 'multiple file content'], 708 | boost: 3.0 709 | }, 710 | 'git_operations': { 711 | tools: ['Shell:run_command', 'desktop-commander:start_process'], 712 | keywords: [ 713 | // Basic git terms 714 | 'git', 'commit', 'push', 'pull', 'clone', 'branch', 'merge', 'repository', 715 | // Full git commands 716 | 'git commit', 'git push', 'git pull', 'git status', 'git add', 'git log', 'git diff', 'git branch', 'git checkout', 'git merge', 717 | // Hyphenated variants (common in user queries) 718 | 'git-commit', 'git-push', 'git-pull', 'git-status', 'git-add', 'git-log', 'git-diff', 'git-branch', 'git-checkout', 'git-merge', 719 | // Action-oriented phrases 720 | 'commit changes', 'push to git', 'pull from git', 'check git status', 'add files to git', 'create git branch', 721 | // Individual commands (for brevity) 722 | 'checkout', 'add', 'status', 'log', 'diff', 'remote', 'fetch', 'rebase', 'stash', 'tag' 723 | ], 724 | boost: 8.0 725 | }, 726 | 'script_execution': { 727 | tools: ['Shell:run_command'], 728 | keywords: ['python script', 'bash script', 'shell script', 'run python script', 'execute python script', 'run bash script', 'execute bash script', 'script execution', 'run a python script', 'run a bash script', 'execute a script'], 729 | boost: 2.0 // Reduced boost and more specific keywords 730 | }, 731 | 'shell_commands': { 732 | tools: ['Shell:run_command', 'desktop-commander:start_process'], 733 | keywords: ['npm install', 'yarn install', 'pip install', 'terminal command', 'shell command', 'command line interface'], 734 | boost: 1.5 // Much lower boost and more specific keywords 735 | }, 736 | 'ncp_meta_operations': { 737 | tools: [ 738 | 'ncp:list_available_tools', 739 | 'ncp:check_mcp_health', 740 | 'ncp:manage_ncp_profiles', 741 | 'ncp:show_token_savings', 742 | 'ncp:get_ncp_status' 743 | ], 744 | keywords: [ 745 | // NCP-specific terms (highest priority) 746 | 'ncp', 'mcp orchestrator', 'ncp orchestrator', 'connected mcps', 'ncp system', 747 | 748 | // Tool listing (specific to NCP context) 749 | 'what tools does ncp have', 'ncp available tools', 'mcp tools available', 750 | 'tools through ncp', 'ncp functionality', 'what can ncp do', 'available through ncp', 751 | 'list ncp tools', 'show ncp tools', 'ncp tool list', 752 | 753 | // Health checking (NCP-specific) 754 | 'mcp health', 'mcp server health', 'ncp health', 'mcp connection status', 755 | 'which mcps are working', 'mcp errors', 'server status ncp', 'ncp server status', 756 | 'check mcp health', 'mcp health check', 'health status ncp', 757 | 758 | // Profile management (NCP-specific) 759 | 'ncp profiles', 'ncp configuration', 'mcp profiles', 'which mcps to load', 760 | 'ncp setup', 'ncp server configuration', 'execution profiles', 'ncp profile management', 761 | 'manage ncp profiles', 'ncp profile config', 'profile settings ncp', 762 | 763 | // Token statistics (NCP-specific) 764 | 'ncp token savings', 'ncp efficiency', 'how much does ncp save', 765 | 'ncp performance', 'token usage ncp', 'ncp statistics', 'token savings ncp', 766 | 'ncp token stats', 'ncp savings report', 767 | 768 | // System status (NCP-specific) 769 | 'ncp status', 'ncp info', 'ncp system info', 'what is ncp running', 770 | 'ncp runtime', 'ncp configuration info', 'ncp system status' 771 | ], 772 | boost: 8.0 // Very high boost for NCP-specific context 773 | } 774 | }; 775 | 776 | // Check for domain-specific patterns first 777 | const queryLower = query.toLowerCase(); 778 | 779 | // Context detection for disambiguation 780 | const hasNcpContext = queryLower.includes('ncp') || 781 | queryLower.includes('mcp') || 782 | queryLower.includes('orchestrator') || 783 | queryLower.includes('connected'); 784 | 785 | // Boost script execution tools but don't force them (let RAG compete) 786 | const explicitScriptKeywords = ['python script', 'bash script', 'shell script', 'run python script', 'execute python script', 'run bash script', 'execute bash script']; 787 | const hasExplicitScript = explicitScriptKeywords.some(keyword => queryLower.includes(keyword)); 788 | 789 | // Only boost for very explicit script execution queries, not general "run" or "execute" 790 | 791 | for (const [domain, pattern] of Object.entries(domainPatterns)) { 792 | for (const keyword of pattern.keywords) { 793 | if (queryLower.includes(keyword)) { 794 | for (const toolId of pattern.tools) { 795 | if (this.vectorDB.has(toolId)) { 796 | const toolData = this.vectorDB.get(toolId)!; 797 | const existing = scores.get(toolId) || { score: 0, matches: [] }; 798 | existing.score += pattern.boost; 799 | existing.matches.push(`domain:${domain}:${keyword}`); 800 | scores.set(toolId, existing); 801 | } 802 | } 803 | } 804 | } 805 | } 806 | 807 | // Apply domain-aware penalties for incidental matches 808 | // Tools that mention git but can't actually execute git commands should be deprioritized 809 | const incidentalGitPatterns = ['git-style', 'git style', 'git format', 'git diff format']; 810 | const actualGitCapabilityTools = ['Shell:run_command', 'desktop-commander:start_process']; 811 | 812 | if (queryLower.includes('git')) { 813 | for (const [toolId, data] of scores) { 814 | const toolData = this.vectorDB.get(toolId); 815 | if (toolData) { 816 | const description = toolData.description.toLowerCase(); 817 | const hasIncidentalMention = incidentalGitPatterns.some(pattern => description.includes(pattern)); 818 | const hasActualCapability = actualGitCapabilityTools.includes(toolId) || 819 | toolData.enhancedDescription?.includes('Can execute git commands'); 820 | 821 | if (hasIncidentalMention && !hasActualCapability) { 822 | // Significantly reduce score for incidental mentions 823 | data.score *= 0.3; 824 | data.matches.push('penalty:incidental-git-mention'); 825 | } else if (hasActualCapability) { 826 | // Boost tools with actual git capabilities 827 | data.score *= 1.5; 828 | data.matches.push('boost:actual-git-capability'); 829 | } 830 | } 831 | } 832 | } 833 | 834 | // Semantic keyword mappings for general matching 835 | const synonyms: Record<string, string[]> = { 836 | 'create': ['make', 'add', 'new', 'generate', 'build'], // Removed 'write' to avoid confusion 837 | 'read': ['get', 'fetch', 'load', 'show', 'display', 'view'], 838 | 'update': ['edit', 'modify', 'change', 'set', 'alter'], 839 | 'delete': ['remove', 'kill', 'terminate', 'clear', 'destroy'], 840 | 'file': ['document', 'content', 'text', 'script', 'data'], 841 | 'list': ['display', 'enumerate'], // Removed 'show' and 'get' to avoid confusion with read operations 842 | 'search': ['find', 'look', 'query', 'seek'], 843 | 'run': ['execute', 'start', 'launch', 'invoke'], 844 | 'process': ['command', 'task', 'service', 'program', 'app'] 845 | }; 846 | 847 | // Expand query words with synonyms 848 | const expandedWords = [...queryWords]; 849 | for (const word of queryWords) { 850 | if (synonyms[word]) { 851 | expandedWords.push(...synonyms[word]); 852 | } 853 | } 854 | 855 | for (const [toolId, toolData] of this.vectorDB) { 856 | const toolName = toolData.toolName.toLowerCase(); 857 | const description = toolData.description.toLowerCase(); 858 | const allText = `${toolName} ${description}`; 859 | const textWords = allText.split(/\s+/); 860 | 861 | let score = 0; 862 | const matches: string[] = []; 863 | 864 | // Exact matches get highest score 865 | for (const queryWord of queryWords) { 866 | if (toolName.includes(queryWord)) { 867 | score += 10; 868 | matches.push(`name:${queryWord}`); 869 | } 870 | if (description.includes(queryWord)) { 871 | score += 5; 872 | matches.push(`desc:${queryWord}`); 873 | } 874 | } 875 | 876 | // Synonym matches get medium score 877 | for (const expandedWord of expandedWords) { 878 | if (expandedWord !== queryWords.find(w => w === expandedWord)) { // Only synonyms 879 | if (allText.includes(expandedWord)) { 880 | score += 3; 881 | matches.push(`syn:${expandedWord}`); 882 | } 883 | } 884 | } 885 | 886 | // Word containment gets lower score 887 | for (const queryWord of queryWords) { 888 | for (const textWord of textWords) { 889 | if (textWord.includes(queryWord) || queryWord.includes(textWord)) { 890 | if (textWord.length > 3 && queryWord.length > 3) { 891 | score += 1; 892 | matches.push(`partial:${textWord}`); 893 | } 894 | } 895 | } 896 | } 897 | 898 | if (score > 0) { 899 | const existing = scores.get(toolId) || { score: 0, matches: [] }; 900 | existing.score += score; // Add to domain pattern score 901 | existing.matches.push(...matches); 902 | scores.set(toolId, existing); 903 | } 904 | } 905 | 906 | // Apply context-aware scoring adjustments for disambiguation 907 | for (const [toolId, data] of scores) { 908 | // Reduce NCP tool scores if query lacks NCP/MCP context 909 | if (toolId.startsWith('ncp:') && !hasNcpContext) { 910 | data.score *= 0.3; // Significant penalty for NCP tools without NCP context 911 | } 912 | 913 | // Boost NCP tool scores if query has NCP/MCP context 914 | if (toolId.startsWith('ncp:') && hasNcpContext) { 915 | data.score *= 1.5; // Boost NCP tools when NCP context is present 916 | } 917 | } 918 | 919 | return Array.from(scores.entries()) 920 | .sort((a, b) => { 921 | // Prioritize domain pattern matches 922 | const aDomainMatches = a[1].matches.filter(m => m.startsWith('domain:')).length; 923 | const bDomainMatches = b[1].matches.filter(m => m.startsWith('domain:')).length; 924 | 925 | if (aDomainMatches !== bDomainMatches) { 926 | return bDomainMatches - aDomainMatches; // More domain matches first 927 | } 928 | 929 | // If domain matches are equal, sort by score 930 | return b[1].score - a[1].score; 931 | }) 932 | .slice(0, maxResults) 933 | .map(([toolId, data]) => { 934 | const maxScore = Math.max(...Array.from(scores.values()).map(v => v.score)); 935 | return { 936 | toolId, 937 | confidence: Math.min(0.75, data.score / maxScore), 938 | reason: `Enhanced keyword matching: ${data.matches.slice(0, 3).join(', ')}`, 939 | similarity: data.score / maxScore 940 | }; 941 | }); 942 | } 943 | 944 | /** 945 | * Calculate cosine similarity between two vectors 946 | */ 947 | private cosineSimilarity(a: ArrayLike<number>, b: ArrayLike<number>): number { 948 | let dotProduct = 0; 949 | let normA = 0; 950 | let normB = 0; 951 | 952 | for (let i = 0; i < a.length; i++) { 953 | dotProduct += a[i] * b[i]; 954 | normA += a[i] * a[i]; 955 | normB += b[i] * b[i]; 956 | } 957 | 958 | return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB)); 959 | } 960 | 961 | /** 962 | * Generate hash of tool description for change detection 963 | */ 964 | private hashDescription(description: string): string { 965 | return crypto.createHash('md5').update(description).digest('hex'); 966 | } 967 | 968 | /** 969 | * Load cached embeddings from disk 970 | */ 971 | private async loadPersistedEmbeddings(): Promise<void> { 972 | try { 973 | if (!existsSync(this.dbPath)) { 974 | logger.info('📄 No cached embeddings found, starting fresh'); 975 | return; 976 | } 977 | 978 | const data = await fs.readFile(this.dbPath, 'utf-8'); 979 | const cached = JSON.parse(data); 980 | 981 | for (const [toolId, embedding] of Object.entries(cached)) { 982 | const embeddingData = embedding as any; 983 | this.vectorDB.set(toolId, { 984 | embedding: new Float32Array(embeddingData.embedding), 985 | hash: embeddingData.hash, 986 | lastUpdated: embeddingData.lastUpdated, 987 | toolName: embeddingData.toolName, 988 | description: embeddingData.description, 989 | enhancedDescription: embeddingData.enhancedDescription, 990 | mcpName: embeddingData.mcpName, 991 | mcpDomain: embeddingData.mcpDomain 992 | }); 993 | } 994 | 995 | logger.info(`📥 Loaded ${this.vectorDB.size} cached embeddings`); 996 | } catch (error) { 997 | logger.warn(`⚠️ Failed to load cached embeddings: ${error}`); 998 | } 999 | } 1000 | 1001 | /** 1002 | * Persist embeddings to disk 1003 | */ 1004 | private async persistEmbeddings(): Promise<void> { 1005 | try { 1006 | const toSerialize: Record<string, any> = {}; 1007 | 1008 | for (const [toolId, embedding] of this.vectorDB) { 1009 | toSerialize[toolId] = { 1010 | embedding: Array.from(embedding.embedding), // Convert Float32Array to regular array 1011 | hash: embedding.hash, 1012 | lastUpdated: embedding.lastUpdated, 1013 | toolName: embedding.toolName, 1014 | description: embedding.description 1015 | }; 1016 | } 1017 | 1018 | await fs.writeFile(this.dbPath, JSON.stringify(toSerialize, null, 2)); 1019 | logger.debug(`💾 Persisted ${this.vectorDB.size} embeddings to cache`); 1020 | } catch (error) { 1021 | logger.error(`❌ Failed to persist embeddings: ${error}`); 1022 | } 1023 | } 1024 | 1025 | /** 1026 | * Ensure directory exists 1027 | */ 1028 | private ensureDirectoryExists(dirPath: string): void { 1029 | if (!existsSync(dirPath)) { 1030 | mkdirSync(dirPath, { recursive: true }); 1031 | } 1032 | } 1033 | 1034 | /** 1035 | * Get statistics about the RAG engine 1036 | */ 1037 | getStats(): { 1038 | isInitialized: boolean; 1039 | totalEmbeddings: number; 1040 | queuedTasks: number; 1041 | isIndexing: boolean; 1042 | cacheSize: string; 1043 | } { 1044 | const stats = { 1045 | isInitialized: this.isInitialized, 1046 | totalEmbeddings: this.vectorDB.size, 1047 | queuedTasks: this.indexingQueue.length, 1048 | isIndexing: this.isIndexing, 1049 | cacheSize: '0 KB' 1050 | }; 1051 | 1052 | // Calculate cache size 1053 | try { 1054 | if (existsSync(this.dbPath)) { 1055 | const size = statSync(this.dbPath).size; 1056 | stats.cacheSize = `${Math.round(size / 1024)} KB`; 1057 | } 1058 | } catch { 1059 | // Ignore errors 1060 | } 1061 | 1062 | return stats; 1063 | } 1064 | 1065 | /** 1066 | * Force cache refresh by clearing and rebuilding 1067 | */ 1068 | async refreshCache(): Promise<void> { 1069 | logger.info('🔄 Forcing cache refresh...'); 1070 | await this.clearCache(); 1071 | logger.info('💡 Cache cleared - embeddings will be rebuilt on next indexing'); 1072 | } 1073 | 1074 | /** 1075 | * Clear all cached embeddings and metadata 1076 | */ 1077 | async clearCache(): Promise<void> { 1078 | this.vectorDB.clear(); 1079 | this.cacheMetadata = null; 1080 | 1081 | try { 1082 | if (existsSync(this.dbPath)) { 1083 | await fs.unlink(this.dbPath); 1084 | } 1085 | if (existsSync(this.metadataPath)) { 1086 | await fs.unlink(this.metadataPath); 1087 | } 1088 | logger.info('🗑️ Cleared embedding cache and metadata'); 1089 | } catch (error) { 1090 | logger.error(`❌ Failed to clear cache: ${error}`); 1091 | } 1092 | } 1093 | } ```