This is page 12 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/orchestrator/ncp-orchestrator.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * NCP Orchestrator - Real MCP Connections 3 | * Based on commercial NCP implementation 4 | */ 5 | 6 | import { readFileSync, existsSync } from 'fs'; 7 | import { getCacheDirectory } from '../utils/ncp-paths.js'; 8 | import { join } from 'path'; 9 | import { createHash } from 'crypto'; 10 | import ProfileManager from '../profiles/profile-manager.js'; 11 | import { logger } from '../utils/logger.js'; 12 | import { Client } from '@modelcontextprotocol/sdk/client/index.js'; 13 | import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; 14 | import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; 15 | import { DiscoveryEngine } from '../discovery/engine.js'; 16 | import { MCPHealthMonitor } from '../utils/health-monitor.js'; 17 | import { SearchEnhancer } from '../discovery/search-enhancer.js'; 18 | import { mcpWrapper } from '../utils/mcp-wrapper.js'; 19 | import { withFilteredOutput } from '../transports/filtered-stdio-transport.js'; 20 | import { ToolSchemaParser, ParameterInfo } from '../services/tool-schema-parser.js'; 21 | import { InternalMCPManager } from '../internal-mcps/internal-mcp-manager.js'; 22 | import { ToolContextResolver } from '../services/tool-context-resolver.js'; 23 | import type { OAuthConfig } from '../auth/oauth-device-flow.js'; 24 | import { getRuntimeForExtension, logRuntimeInfo } from '../utils/runtime-detector.js'; 25 | // Simple string similarity for tool name matching 26 | function calculateSimilarity(str1: string, str2: string): number { 27 | const s1 = str1.toLowerCase(); 28 | const s2 = str2.toLowerCase(); 29 | 30 | // Exact match 31 | if (s1 === s2) return 1.0; 32 | 33 | // Check if one contains the other 34 | if (s1.includes(s2) || s2.includes(s1)) { 35 | return 0.8; 36 | } 37 | 38 | // Simple Levenshtein-based similarity 39 | const longer = s1.length > s2.length ? s1 : s2; 40 | const shorter = s1.length > s2.length ? s2 : s1; 41 | 42 | if (longer.length === 0) return 1.0; 43 | 44 | const editDistance = levenshteinDistance(s1, s2); 45 | return (longer.length - editDistance) / longer.length; 46 | } 47 | 48 | function levenshteinDistance(str1: string, str2: string): number { 49 | const matrix: number[][] = []; 50 | 51 | for (let i = 0; i <= str2.length; i++) { 52 | matrix[i] = [i]; 53 | } 54 | 55 | for (let j = 0; j <= str1.length; j++) { 56 | matrix[0][j] = j; 57 | } 58 | 59 | for (let i = 1; i <= str2.length; i++) { 60 | for (let j = 1; j <= str1.length; j++) { 61 | if (str2.charAt(i - 1) === str1.charAt(j - 1)) { 62 | matrix[i][j] = matrix[i - 1][j - 1]; 63 | } else { 64 | matrix[i][j] = Math.min( 65 | matrix[i - 1][j - 1] + 1, 66 | matrix[i][j - 1] + 1, 67 | matrix[i - 1][j] + 1 68 | ); 69 | } 70 | } 71 | } 72 | 73 | return matrix[str2.length][str1.length]; 74 | } 75 | import { CachePatcher } from '../cache/cache-patcher.js'; 76 | import { CSVCache, CachedTool } from '../cache/csv-cache.js'; 77 | import { spinner } from '../utils/progress-spinner.js'; 78 | 79 | interface DiscoveryResult { 80 | toolName: string; 81 | mcpName: string; 82 | confidence: number; 83 | description?: string; 84 | schema?: any; 85 | } 86 | 87 | interface ExecutionResult { 88 | success: boolean; 89 | content?: any; 90 | error?: string; 91 | } 92 | 93 | interface MCPConfig { 94 | name: string; 95 | command?: string; // Optional: for stdio transport 96 | args?: string[]; 97 | env?: Record<string, string>; 98 | url?: string; // Optional: for HTTP/SSE transport (Claude Desktop native) 99 | auth?: { 100 | type: 'oauth' | 'bearer' | 'apiKey' | 'basic'; 101 | oauth?: OAuthConfig; // OAuth 2.0 Device Flow configuration 102 | token?: string; // Bearer token or API key 103 | username?: string; // Basic auth username 104 | password?: string; // Basic auth password 105 | }; 106 | } 107 | 108 | interface Profile { 109 | name: string; 110 | description: string; 111 | mcpServers: Record<string, { 112 | command?: string; // Optional: for stdio transport 113 | args?: string[]; 114 | env?: Record<string, string>; 115 | url?: string; // Optional: for HTTP/SSE transport 116 | auth?: { 117 | type: 'oauth' | 'bearer' | 'apiKey' | 'basic'; 118 | oauth?: OAuthConfig; // OAuth 2.0 Device Flow configuration 119 | token?: string; // Bearer token or API key 120 | username?: string; // Basic auth username 121 | password?: string; // Basic auth password 122 | }; 123 | }>; 124 | metadata?: any; 125 | } 126 | 127 | interface MCPConnection { 128 | client: Client; 129 | transport: StdioClientTransport | SSEClientTransport; 130 | tools: Array<{name: string; description: string}>; 131 | serverInfo?: { 132 | name: string; 133 | title?: string; 134 | version: string; 135 | description?: string; 136 | websiteUrl?: string; 137 | }; 138 | lastUsed: number; 139 | connectTime: number; 140 | executionCount: number; 141 | } 142 | 143 | interface MCPDefinition { 144 | name: string; 145 | config: MCPConfig; 146 | tools: Array<{name: string; description: string}>; 147 | serverInfo?: { 148 | name: string; 149 | title?: string; 150 | version: string; 151 | description?: string; 152 | websiteUrl?: string; 153 | }; 154 | } 155 | 156 | export class NCPOrchestrator { 157 | private definitions: Map<string, MCPDefinition> = new Map(); 158 | private connections: Map<string, MCPConnection> = new Map(); 159 | private toolToMCP: Map<string, string> = new Map(); 160 | private allTools: Array<{ name: string; description: string; mcpName: string }> = []; 161 | private profileName: string; 162 | private readonly QUICK_PROBE_TIMEOUT = 8000; // 8 seconds - first attempt 163 | private readonly SLOW_PROBE_TIMEOUT = 30000; // 30 seconds - retry for slow MCPs 164 | private readonly CONNECTION_TIMEOUT = 10000; // 10 seconds 165 | private readonly IDLE_TIMEOUT = 5 * 60 * 1000; // 5 minutes 166 | private readonly CLEANUP_INTERVAL = 60 * 1000; // Check every minute 167 | private cleanupTimer?: NodeJS.Timeout; 168 | private discovery: DiscoveryEngine; 169 | private healthMonitor: MCPHealthMonitor; 170 | private cachePatcher: CachePatcher; 171 | private csvCache: CSVCache; 172 | private showProgress: boolean; 173 | private indexingProgress: { current: number; total: number; currentMCP: string; estimatedTimeRemaining?: number } | null = null; 174 | private indexingStartTime: number = 0; 175 | private profileManager: ProfileManager | null = null; 176 | private internalMCPManager: InternalMCPManager; 177 | 178 | private forceRetry: boolean = false; 179 | 180 | /** 181 | * ⚠️ CRITICAL: Default profile MUST be 'all' - DO NOT CHANGE! 182 | * 183 | * The 'all' profile is the universal profile that contains all MCPs. 184 | * This default is used by MCPServer and all CLI commands. 185 | * 186 | * DO NOT change this to 'default' or any other name - it will break everything. 187 | */ 188 | constructor(profileName: string = 'all', showProgress: boolean = false, forceRetry: boolean = false) { 189 | this.profileName = profileName; 190 | this.discovery = new DiscoveryEngine(); 191 | this.healthMonitor = new MCPHealthMonitor(); 192 | this.cachePatcher = new CachePatcher(); 193 | this.csvCache = new CSVCache(getCacheDirectory(), profileName); 194 | this.showProgress = showProgress; 195 | this.forceRetry = forceRetry; 196 | this.internalMCPManager = new InternalMCPManager(); 197 | } 198 | 199 | private async loadProfile(): Promise<Profile | null> { 200 | try { 201 | // Create and store ProfileManager instance (reused for auto-import) 202 | if (!this.profileManager) { 203 | this.profileManager = new ProfileManager(); 204 | await this.profileManager.initialize(); 205 | 206 | // Initialize internal MCPs with ProfileManager 207 | this.internalMCPManager.initialize(this.profileManager); 208 | } 209 | 210 | const profile = await this.profileManager.getProfile(this.profileName); 211 | 212 | if (!profile) { 213 | logger.error(`Profile not found: ${this.profileName}`); 214 | return null; 215 | } 216 | 217 | return profile; 218 | } catch (error: any) { 219 | logger.error(`Failed to load profile: ${error.message}`); 220 | return null; 221 | } 222 | } 223 | 224 | async initialize(): Promise<void> { 225 | const startTime = Date.now(); 226 | this.indexingStartTime = startTime; 227 | 228 | // Debug logging 229 | if (process.env.NCP_DEBUG === 'true') { 230 | console.error(`[DEBUG ORC] Initializing with profileName: ${this.profileName}`); 231 | console.error(`[DEBUG ORC] Cache will use: ${this.csvCache ? 'csvCache exists' : 'NO CACHE'}`); 232 | } 233 | 234 | logger.info(`Initializing NCP orchestrator with profile: ${this.profileName}`); 235 | 236 | // Log runtime detection info (how NCP is running) 237 | if (process.env.NCP_DEBUG === 'true') { 238 | logRuntimeInfo(); 239 | } 240 | 241 | // Initialize progress immediately to prevent race condition 242 | // Total will be updated once we know how many MCPs need indexing 243 | this.indexingProgress = { 244 | current: 0, 245 | total: 0, 246 | currentMCP: 'initializing...' 247 | }; 248 | 249 | const profile = await this.loadProfile(); 250 | 251 | if (process.env.NCP_DEBUG === 'true') { 252 | console.error(`[DEBUG ORC] Loaded profile: ${profile ? 'YES' : 'NO'}`); 253 | if (profile) { 254 | console.error(`[DEBUG ORC] Profile MCPs: ${Object.keys(profile.mcpServers || {}).join(', ')}`); 255 | } 256 | } 257 | 258 | if (!profile) { 259 | logger.error('Failed to load profile'); 260 | this.indexingProgress = null; 261 | return; 262 | } 263 | 264 | // Initialize discovery engine first 265 | await this.discovery.initialize(); 266 | 267 | // Initialize CSV cache 268 | await this.csvCache.initialize(); 269 | 270 | // Get profile hash for cache validation 271 | const profileHash = CSVCache.hashProfile(profile.mcpServers); 272 | 273 | // Check if cache is valid 274 | const cacheValid = this.csvCache.validateCache(profileHash); 275 | 276 | const mcpConfigs: MCPConfig[] = Object.entries(profile.mcpServers).map(([name, config]) => ({ 277 | name, 278 | command: config.command, 279 | args: config.args, 280 | env: config.env || {}, 281 | url: config.url // HTTP/SSE transport support 282 | })); 283 | 284 | if (cacheValid) { 285 | // Load from cache 286 | logger.info('Loading tools from CSV cache...'); 287 | const cachedMCPCount = await this.loadFromCSVCache(mcpConfigs); 288 | } else { 289 | // Cache invalid - clear it to force full re-indexing 290 | logger.info('Cache invalid, clearing for full re-index...'); 291 | await this.csvCache.clear(); 292 | await this.csvCache.initialize(); 293 | } 294 | 295 | // Get list of MCPs that need indexing 296 | const indexedMCPs = this.csvCache.getIndexedMCPs(); 297 | const mcpsToIndex = mcpConfigs.filter(config => { 298 | const tools = profile.mcpServers[config.name]; 299 | const currentHash = CSVCache.hashProfile(tools); 300 | 301 | // Check if already indexed 302 | if (this.csvCache.isMCPIndexed(config.name, currentHash)) { 303 | return false; 304 | } 305 | 306 | // Check if failed and should retry 307 | return this.csvCache.shouldRetryFailed(config.name, this.forceRetry); 308 | }); 309 | 310 | if (mcpsToIndex.length > 0) { 311 | // Update progress tracking with actual count 312 | if (this.indexingProgress) { 313 | this.indexingProgress.total = mcpsToIndex.length; 314 | } 315 | 316 | if (this.showProgress) { 317 | const action = 'Indexing'; 318 | const cachedCount = this.csvCache.getIndexedMCPs().size; 319 | 320 | // Count only failed MCPs that are NOT being retried in this run 321 | const allFailedCount = this.csvCache.getFailedMCPsCount(); 322 | const retryingNowCount = mcpsToIndex.filter(config => { 323 | // Check if this MCP is in the failed list (being retried) 324 | return this.csvCache.isMCPFailed(config.name); 325 | }).length; 326 | const failedNotRetryingCount = allFailedCount - retryingNowCount; 327 | 328 | const totalProcessed = cachedCount + failedNotRetryingCount; 329 | const statusMsg = `${action} MCPs: ${totalProcessed}/${mcpConfigs.length}`; 330 | spinner.start(statusMsg); 331 | spinner.updateSubMessage('Initializing discovery engine...'); 332 | } 333 | 334 | // Start incremental cache writing 335 | await this.csvCache.startIncrementalWrite(profileHash); 336 | 337 | // Index only the MCPs that need it 338 | await this.discoverMCPTools(mcpsToIndex, profile, true, mcpConfigs.length); 339 | 340 | // Finalize cache 341 | await this.csvCache.finalize(); 342 | 343 | if (this.showProgress) { 344 | const successfulMCPs = this.definitions.size; 345 | const failedMCPs = this.csvCache.getFailedMCPsCount(); 346 | const totalProcessed = successfulMCPs + failedMCPs; 347 | 348 | if (failedMCPs > 0) { 349 | spinner.success(`Indexed ${this.allTools.length} tools from ${successfulMCPs} MCPs | ${failedMCPs} failed (will retry later)`); 350 | } else { 351 | spinner.success(`Indexed ${this.allTools.length} tools from ${successfulMCPs} MCPs`); 352 | } 353 | } 354 | } 355 | 356 | // Clear progress tracking once complete 357 | this.indexingProgress = null; 358 | 359 | // Add internal MCPs to discovery 360 | this.addInternalMCPsToDiscovery(); 361 | 362 | // Start cleanup timer for idle connections 363 | this.cleanupTimer = setInterval( 364 | () => this.cleanupIdleConnections(), 365 | this.CLEANUP_INTERVAL 366 | ); 367 | 368 | const externalMCPs = this.definitions.size; 369 | const internalMCPs = this.internalMCPManager.getAllInternalMCPs().length; 370 | const loadTime = Date.now() - startTime; 371 | logger.info(`🚀 NCP-OSS initialized in ${loadTime}ms with ${this.allTools.length} tools from ${externalMCPs} external + ${internalMCPs} internal MCPs`); 372 | } 373 | 374 | /** 375 | * Load cached tools from CSV 376 | */ 377 | private async loadFromCSVCache(mcpConfigs: MCPConfig[]): Promise<number> { 378 | const cachedTools = this.csvCache.loadCachedTools(); 379 | 380 | // Group tools by MCP 381 | const toolsByMCP = new Map<string, CachedTool[]>(); 382 | for (const tool of cachedTools) { 383 | if (!toolsByMCP.has(tool.mcpName)) { 384 | toolsByMCP.set(tool.mcpName, []); 385 | } 386 | toolsByMCP.get(tool.mcpName)!.push(tool); 387 | } 388 | 389 | let loadedMCPCount = 0; 390 | 391 | // Rebuild definitions and tool mappings from cache 392 | for (const config of mcpConfigs) { 393 | const mcpTools = toolsByMCP.get(config.name) || []; 394 | if (mcpTools.length === 0) continue; 395 | 396 | loadedMCPCount++; 397 | 398 | // Create definition 399 | this.definitions.set(config.name, { 400 | name: config.name, 401 | config, 402 | tools: mcpTools.map(t => ({ 403 | name: t.toolName, 404 | description: t.description, 405 | inputSchema: {} 406 | })), 407 | serverInfo: undefined 408 | }); 409 | 410 | // Add to all tools and create mappings 411 | for (const cachedTool of mcpTools) { 412 | const tool = { 413 | name: cachedTool.toolName, 414 | description: cachedTool.description, 415 | mcpName: config.name 416 | }; 417 | this.allTools.push(tool); 418 | this.toolToMCP.set(cachedTool.toolId, config.name); 419 | } 420 | 421 | // Index tools in discovery engine 422 | const discoveryTools = mcpTools.map(t => ({ 423 | id: t.toolId, 424 | name: t.toolName, 425 | description: t.description 426 | })); 427 | 428 | // Use async indexing to avoid blocking 429 | this.discovery.indexMCPTools(config.name, discoveryTools); 430 | } 431 | 432 | logger.info(`Loaded ${this.allTools.length} tools from CSV cache`); 433 | return loadedMCPCount; 434 | } 435 | 436 | private async discoverMCPTools(mcpConfigs: MCPConfig[], profile?: Profile, incrementalMode: boolean = false, totalMCPCount?: number): Promise<void> { 437 | // Only clear allTools if not in incremental mode 438 | if (!incrementalMode) { 439 | this.allTools = []; 440 | } 441 | 442 | const displayTotal = totalMCPCount || mcpConfigs.length; 443 | 444 | for (let i = 0; i < mcpConfigs.length; i++) { 445 | const config = mcpConfigs[i]; 446 | try { 447 | logger.info(`Discovering tools from MCP: ${config.name}`); 448 | 449 | if (this.showProgress) { 450 | spinner.updateSubMessage(`Connecting to ${config.name}...`); 451 | } 452 | 453 | let result; 454 | try { 455 | // First attempt with quick timeout 456 | result = await this.probeMCPTools(config, this.QUICK_PROBE_TIMEOUT); 457 | } catch (firstError: any) { 458 | // If it timed out (not connection error), retry with longer timeout 459 | if (firstError.message.includes('Probe timeout') || firstError.message.includes('timeout')) { 460 | logger.debug(`${config.name} timed out on first attempt, retrying with longer timeout...`); 461 | if (this.showProgress) { 462 | spinner.updateSubMessage(`Retrying ${config.name} (heavy initialization)...`); 463 | } 464 | // Second attempt with slow timeout for heavy MCPs 465 | result = await this.probeMCPTools(config, this.SLOW_PROBE_TIMEOUT); 466 | } else { 467 | // Not a timeout - it's a real error (connection refused, etc), don't retry 468 | throw firstError; 469 | } 470 | } 471 | 472 | // Store definition with schema fallback applied 473 | this.definitions.set(config.name, { 474 | name: config.name, 475 | config, 476 | tools: result.tools.map(tool => ({ 477 | ...tool, 478 | inputSchema: tool.inputSchema || {} 479 | })), 480 | serverInfo: result.serverInfo 481 | }); 482 | 483 | // Add to all tools and create mappings 484 | const discoveryTools = []; 485 | for (const tool of result.tools) { 486 | // Store with prefixed name for consistency with commercial version 487 | const prefixedToolName = `${config.name}:${tool.name}`; 488 | const prefixedDescription = `${config.name}: ${tool.description || 'No description available'}`; 489 | 490 | this.allTools.push({ 491 | name: prefixedToolName, 492 | description: prefixedDescription, 493 | mcpName: config.name 494 | }); 495 | 496 | // Map both formats for backward compatibility 497 | this.toolToMCP.set(tool.name, config.name); 498 | this.toolToMCP.set(prefixedToolName, config.name); 499 | 500 | // Prepare for discovery engine indexing 501 | // Pass unprefixed name and description - RAG engine will add the prefix 502 | discoveryTools.push({ 503 | id: prefixedToolName, 504 | name: tool.name, // Use unprefixed name here 505 | description: tool.description || 'No description available', // Use unprefixed description 506 | mcpServer: config.name, 507 | inputSchema: tool.inputSchema || {} 508 | }); 509 | } 510 | 511 | if (this.showProgress) { 512 | // Add time estimate to indexing sub-message for parity 513 | let timeDisplay = ''; 514 | if (this.indexingProgress?.estimatedTimeRemaining) { 515 | const remainingSeconds = Math.ceil(this.indexingProgress.estimatedTimeRemaining / 1000); 516 | timeDisplay = ` (~${remainingSeconds}s remaining)`; 517 | } 518 | spinner.updateSubMessage(`Indexing ${result.tools.length} tools from ${config.name}...${timeDisplay}`); 519 | } 520 | 521 | // Index tools with discovery engine for vector search 522 | await this.discovery.indexMCPTools(config.name, discoveryTools); 523 | 524 | // Append to CSV cache incrementally (if in incremental mode) 525 | if (incrementalMode && profile) { 526 | const mcpHash = CSVCache.hashProfile(profile.mcpServers[config.name]); 527 | const cachedTools: CachedTool[] = result.tools.map(tool => ({ 528 | mcpName: config.name, 529 | toolId: `${config.name}:${tool.name}`, 530 | toolName: tool.name, 531 | description: tool.description || 'No description available', 532 | hash: this.hashString(tool.description || ''), 533 | timestamp: new Date().toISOString() 534 | })); 535 | 536 | await this.csvCache.appendMCP(config.name, cachedTools, mcpHash); 537 | } 538 | 539 | // Update indexing progress AFTER successfully appending to cache 540 | if (this.indexingProgress) { 541 | this.indexingProgress.current = i + 1; 542 | this.indexingProgress.currentMCP = config.name; 543 | 544 | // Estimate remaining time based on average time per MCP so far 545 | const elapsedTime = Date.now() - this.indexingStartTime; 546 | const averageTimePerMCP = elapsedTime / (i + 1); 547 | const remainingMCPs = mcpConfigs.length - (i + 1); 548 | this.indexingProgress.estimatedTimeRemaining = remainingMCPs * averageTimePerMCP; 549 | } 550 | 551 | if (this.showProgress) { 552 | // Calculate absolute position 553 | const cachedCount = displayTotal - mcpConfigs.length; 554 | const currentAbsolute = cachedCount + (i + 1); 555 | const percentage = Math.round((currentAbsolute / displayTotal) * 100); 556 | 557 | // Add time estimate 558 | let timeDisplay = ''; 559 | if (this.indexingProgress?.estimatedTimeRemaining) { 560 | const remainingSeconds = Math.ceil(this.indexingProgress.estimatedTimeRemaining / 1000); 561 | timeDisplay = ` ~${remainingSeconds}s remaining`; 562 | } 563 | 564 | spinner.updateMessage(`Indexing MCPs: ${currentAbsolute}/${displayTotal} (${percentage}%)${timeDisplay}`); 565 | } 566 | 567 | logger.info(`Discovered ${result.tools.length} tools from ${config.name}`); 568 | } catch (error: any) { 569 | // Probe failures are expected - don't alarm users with error messages 570 | logger.debug(`Failed to discover tools from ${config.name}: ${error.message}`); 571 | 572 | // Mark MCP as failed for scheduled retry (if in incremental mode) 573 | if (incrementalMode && profile) { 574 | this.csvCache.markFailed(config.name, error); 575 | } 576 | 577 | // Update indexing progress even for failed MCPs 578 | if (this.indexingProgress) { 579 | this.indexingProgress.current = i + 1; 580 | this.indexingProgress.currentMCP = config.name; 581 | 582 | // Estimate remaining time based on average time per MCP so far 583 | const elapsedTime = Date.now() - this.indexingStartTime; 584 | const averageTimePerMCP = elapsedTime / (i + 1); 585 | const remainingMCPs = mcpConfigs.length - (i + 1); 586 | this.indexingProgress.estimatedTimeRemaining = remainingMCPs * averageTimePerMCP; 587 | } 588 | 589 | if (this.showProgress) { 590 | // Calculate absolute position 591 | const cachedCount = displayTotal - mcpConfigs.length; 592 | const currentAbsolute = cachedCount + (i + 1); 593 | const percentage = Math.round((currentAbsolute / displayTotal) * 100); 594 | 595 | // Add time estimate 596 | let timeDisplay = ''; 597 | if (this.indexingProgress?.estimatedTimeRemaining) { 598 | const remainingSeconds = Math.ceil(this.indexingProgress.estimatedTimeRemaining / 1000); 599 | timeDisplay = ` ~${remainingSeconds}s remaining`; 600 | } 601 | 602 | spinner.updateMessage(`Indexing MCPs: ${currentAbsolute}/${displayTotal} (${percentage}%)${timeDisplay}`); 603 | spinner.updateSubMessage(`Skipped ${config.name} (connection failed)`); 604 | } 605 | 606 | // Update health monitor with the actual error for import feedback 607 | this.healthMonitor.markUnhealthy(config.name, error.message); 608 | } 609 | } 610 | } 611 | 612 | /** 613 | * Create appropriate transport based on config 614 | * Supports both stdio (command/args) and HTTP/SSE (url) transports 615 | * Handles OAuth authentication for HTTP/SSE connections 616 | */ 617 | private async createTransport(config: MCPConfig, env?: Record<string, string>): Promise<StdioClientTransport | SSEClientTransport> { 618 | if (config.url) { 619 | // HTTP/SSE transport (Claude Desktop native support) 620 | const url = new URL(config.url); 621 | const headers: Record<string, string> = {}; 622 | 623 | // Handle authentication 624 | if (config.auth) { 625 | const token = await this.getAuthToken(config); 626 | 627 | switch (config.auth.type) { 628 | case 'oauth': 629 | case 'bearer': 630 | headers['Authorization'] = `Bearer ${token}`; 631 | break; 632 | case 'apiKey': 633 | // API key can be in header or query param - assume header for now 634 | headers['X-API-Key'] = token; 635 | break; 636 | case 'basic': 637 | if (config.auth.username && config.auth.password) { 638 | const credentials = Buffer.from(`${config.auth.username}:${config.auth.password}`).toString('base64'); 639 | headers['Authorization'] = `Basic ${credentials}`; 640 | } 641 | break; 642 | } 643 | } 644 | 645 | // Use requestInit to add custom headers to POST requests 646 | // and eventSourceInit to add headers to the initial SSE connection 647 | const options = Object.keys(headers).length > 0 ? { 648 | requestInit: { headers }, 649 | eventSourceInit: { headers } as EventSourceInit 650 | } : undefined; 651 | 652 | return new SSEClientTransport(url, options); 653 | } 654 | 655 | if (config.command) { 656 | // stdio transport (local process) 657 | const resolvedCommand = getRuntimeForExtension(config.command); 658 | const wrappedCommand = mcpWrapper.createWrapper( 659 | config.name, 660 | resolvedCommand, 661 | config.args || [] 662 | ); 663 | 664 | return new StdioClientTransport({ 665 | command: wrappedCommand.command, 666 | args: wrappedCommand.args, 667 | env: env as Record<string, string> 668 | }); 669 | } 670 | 671 | throw new Error(`Invalid config for ${config.name}: must have either 'command' or 'url'`); 672 | } 673 | 674 | /** 675 | * Get authentication token for MCP 676 | * Handles OAuth Device Flow and token refresh 677 | */ 678 | private async getAuthToken(config: MCPConfig): Promise<string> { 679 | if (!config.auth) { 680 | throw new Error('No auth configuration provided'); 681 | } 682 | 683 | // For non-OAuth auth types, return the token directly 684 | if (config.auth.type !== 'oauth') { 685 | return config.auth.token || ''; 686 | } 687 | 688 | // OAuth flow 689 | if (!config.auth.oauth) { 690 | throw new Error('OAuth configuration missing'); 691 | } 692 | 693 | const { getTokenStore } = await import('../auth/token-store.js'); 694 | const tokenStore = getTokenStore(); 695 | 696 | // Check for existing valid token 697 | const existingToken = await tokenStore.getToken(config.name); 698 | if (existingToken) { 699 | return existingToken.access_token; 700 | } 701 | 702 | // No valid token - trigger OAuth Device Flow 703 | const { DeviceFlowAuthenticator } = await import('../auth/oauth-device-flow.js'); 704 | const authenticator = new DeviceFlowAuthenticator(config.auth.oauth); 705 | 706 | logger.info(`No valid token found for ${config.name}, starting OAuth Device Flow...`); 707 | const tokenResponse = await authenticator.authenticate(); 708 | 709 | // Store token for future use 710 | await tokenStore.storeToken(config.name, tokenResponse); 711 | 712 | return tokenResponse.access_token; 713 | } 714 | 715 | // Based on commercial NCP's probeMCPTools method 716 | private async probeMCPTools(config: MCPConfig, timeout: number = this.QUICK_PROBE_TIMEOUT): Promise<{ 717 | tools: Array<{name: string; description: string; inputSchema?: any}>; 718 | serverInfo?: { 719 | name: string; 720 | title?: string; 721 | version: string; 722 | description?: string; 723 | websiteUrl?: string; 724 | }; 725 | }> { 726 | if (!config.command && !config.url) { 727 | throw new Error(`Invalid config for ${config.name}: must have either 'command' or 'url'`); 728 | } 729 | 730 | let client: Client | null = null; 731 | let transport: StdioClientTransport | SSEClientTransport | null = null; 732 | 733 | try { 734 | // Create temporary connection for discovery 735 | const silentEnv = { 736 | ...process.env, 737 | ...(config.env || {}), 738 | MCP_SILENT: 'true', 739 | QUIET: 'true', 740 | NO_COLOR: 'true' 741 | }; 742 | 743 | transport = await this.createTransport(config, silentEnv); 744 | 745 | client = new Client( 746 | { name: 'ncp-oss', version: '1.0.0' }, 747 | { capabilities: {} } 748 | ); 749 | 750 | // Connect with timeout and filtered output 751 | await withFilteredOutput(async () => { 752 | await Promise.race([ 753 | client!.connect(transport!), 754 | new Promise((_, reject) => 755 | setTimeout(() => reject(new Error('Probe timeout')), timeout) 756 | ) 757 | ]); 758 | }); 759 | 760 | // Capture server info after connection 761 | const serverInfo = client!.getServerVersion(); 762 | 763 | // Get tool list with filtered output 764 | const response = await withFilteredOutput(async () => { 765 | return await client!.listTools(); 766 | }); 767 | const tools = response.tools.map(t => ({ 768 | name: t.name, 769 | description: t.description || '', 770 | inputSchema: t.inputSchema || {} 771 | })); 772 | 773 | // Disconnect immediately 774 | await client.close(); 775 | 776 | return { 777 | tools, 778 | serverInfo: serverInfo ? { 779 | name: serverInfo.name || config.name, 780 | title: serverInfo.title, 781 | version: serverInfo.version || 'unknown', 782 | description: serverInfo.title || serverInfo.name || undefined, 783 | websiteUrl: serverInfo.websiteUrl 784 | } : undefined 785 | }; 786 | 787 | } catch (error: any) { 788 | // Clean up on error 789 | if (client) { 790 | try { await client.close(); } catch {} 791 | } 792 | 793 | // Log full error details for debugging 794 | logger.debug(`Full error details for ${config.name}: ${JSON.stringify({ 795 | message: error.message, 796 | code: error.code, 797 | data: error.data, 798 | stack: error.stack?.split('\n')[0] 799 | })}`); 800 | 801 | throw error; 802 | } 803 | } 804 | 805 | async find(query: string, limit: number = 5, detailed: boolean = false): Promise<DiscoveryResult[]> { 806 | if (!query) { 807 | // No query = list all tools, filtered by health 808 | const healthyTools = this.allTools.filter(tool => this.healthMonitor.getHealthyMCPs([tool.mcpName]).length > 0); 809 | const results = healthyTools.slice(0, limit).map(tool => { 810 | // Extract actual tool name from prefixed format 811 | const actualToolName = tool.name.includes(':') ? tool.name.split(':', 2)[1] : tool.name; 812 | return { 813 | toolName: tool.name, // Return prefixed name 814 | mcpName: tool.mcpName, 815 | confidence: 1.0, 816 | description: detailed ? tool.description : undefined, 817 | schema: detailed ? this.getToolSchema(tool.mcpName, actualToolName) : undefined 818 | }; 819 | }); 820 | return results; 821 | } 822 | 823 | // Use battle-tested vector search from commercial NCP 824 | // DOUBLE SEARCH TECHNIQUE: Request 2x results to account for filtering disabled MCPs 825 | try { 826 | const doubleLimit = limit * 2; // Request double to account for filtered MCPs 827 | const vectorResults = await this.discovery.findRelevantTools(query, doubleLimit); 828 | 829 | // Apply universal term frequency scoring boost 830 | const adjustedResults = this.adjustScoresUniversally(query, vectorResults); 831 | 832 | // Parse and filter results 833 | const parsedResults = adjustedResults.map(result => { 834 | // Parse tool format: "mcp:tool" or just "tool" 835 | const parts = result.name.includes(':') ? result.name.split(':', 2) : [this.toolToMCP.get(result.name) || 'unknown', result.name]; 836 | const mcpName = parts[0]; 837 | const toolName = parts[1] || result.name; 838 | 839 | // Find the tool - it should be stored with prefixed name 840 | const prefixedToolName = `${mcpName}:${toolName}`; 841 | const fullTool = this.allTools.find(t => 842 | (t.name === prefixedToolName || t.name === toolName) && t.mcpName === mcpName 843 | ); 844 | return { 845 | toolName: fullTool?.name || prefixedToolName, // Return the stored (prefixed) name 846 | mcpName, 847 | confidence: result.confidence, 848 | description: detailed ? fullTool?.description : undefined, 849 | schema: detailed ? this.getToolSchema(mcpName, toolName) : undefined 850 | }; 851 | }); 852 | 853 | // HEALTH FILTERING: Remove tools from disabled MCPs 854 | const healthyResults = parsedResults.filter(result => { 855 | return this.healthMonitor.getHealthyMCPs([result.mcpName]).length > 0; 856 | }); 857 | 858 | // SORT by confidence (highest first) after our scoring adjustments 859 | const sortedResults = healthyResults.sort((a, b) => b.confidence - a.confidence); 860 | 861 | // Return up to the original limit after filtering and sorting 862 | const finalResults = sortedResults.slice(0, limit); 863 | 864 | if (healthyResults.length < parsedResults.length) { 865 | logger.debug(`Health filtering: ${parsedResults.length - healthyResults.length} tools filtered out from disabled MCPs`); 866 | } 867 | 868 | return finalResults; 869 | 870 | } catch (error: any) { 871 | logger.error(`Vector search failed: ${error.message}`); 872 | 873 | // Fallback to healthy tools only 874 | const healthyTools = this.allTools.filter(tool => this.healthMonitor.getHealthyMCPs([tool.mcpName]).length > 0); 875 | return healthyTools.slice(0, limit).map(tool => { 876 | // Extract actual tool name from prefixed format for schema lookup 877 | const actualToolName = tool.name.includes(':') ? tool.name.split(':', 2)[1] : tool.name; 878 | return { 879 | toolName: tool.name, // Return prefixed name 880 | mcpName: tool.mcpName, 881 | confidence: 0.5, 882 | description: detailed ? tool.description : undefined, 883 | schema: detailed ? this.getToolSchema(tool.mcpName, actualToolName) : undefined 884 | }; 885 | }); 886 | } 887 | } 888 | 889 | async run(toolName: string, parameters: any, meta?: Record<string, any>): Promise<ExecutionResult> { 890 | // Parse tool format: "mcp:tool" or just "tool" 891 | let mcpName: string; 892 | let actualToolName: string; 893 | 894 | if (toolName.includes(':')) { 895 | [mcpName, actualToolName] = toolName.split(':', 2); 896 | } else { 897 | actualToolName = toolName; 898 | mcpName = this.toolToMCP.get(toolName) || ''; 899 | } 900 | 901 | if (!mcpName) { 902 | const similarTools = this.findSimilarTools(toolName); 903 | let errorMessage = `Tool '${toolName}' not found.`; 904 | 905 | if (similarTools.length > 0) { 906 | errorMessage += ` Did you mean: ${similarTools.join(', ')}?`; 907 | } 908 | 909 | errorMessage += ` Use 'ncp find "${toolName}"' to search for similar tools or 'ncp find --depth 0' to list all available tools.`; 910 | 911 | return { 912 | success: false, 913 | error: errorMessage 914 | }; 915 | } 916 | 917 | // Check if this is an internal MCP 918 | if (this.internalMCPManager.isInternalMCP(mcpName)) { 919 | try { 920 | const result = await this.internalMCPManager.executeInternalTool(mcpName, actualToolName, parameters); 921 | return { 922 | success: result.success, 923 | content: result.content, 924 | error: result.error 925 | }; 926 | } catch (error: any) { 927 | logger.error(`Internal tool execution failed for ${toolName}:`, error); 928 | return { 929 | success: false, 930 | error: error.message || 'Internal tool execution failed' 931 | }; 932 | } 933 | } 934 | 935 | const definition = this.definitions.get(mcpName); 936 | if (!definition) { 937 | const availableMcps = Array.from(this.definitions.keys()).join(', '); 938 | return { 939 | success: false, 940 | error: `MCP '${mcpName}' not found. Available MCPs: ${availableMcps}. Use 'ncp find' to discover tools or check your profile configuration.` 941 | }; 942 | } 943 | 944 | try { 945 | // Get or create pooled connection 946 | const connection = await this.getOrCreateConnection(mcpName); 947 | 948 | // Validate parameters before execution 949 | const validationError = this.validateToolParameters(mcpName, actualToolName, parameters); 950 | if (validationError) { 951 | return { 952 | success: false, 953 | error: validationError 954 | }; 955 | } 956 | 957 | // Execute tool with filtered output to suppress MCP server console messages 958 | // Forward _meta transparently to support session_id and other protocol-level metadata 959 | const result = await withFilteredOutput(async () => { 960 | return await connection.client.callTool({ 961 | name: actualToolName, 962 | arguments: parameters, 963 | _meta: meta 964 | }); 965 | }); 966 | 967 | // Mark MCP as healthy on successful execution 968 | this.healthMonitor.markHealthy(mcpName); 969 | 970 | return { 971 | success: true, 972 | content: result.content 973 | }; 974 | 975 | } catch (error: any) { 976 | logger.error(`Tool execution failed for ${toolName}:`, error); 977 | 978 | // Mark MCP as unhealthy on execution failure 979 | this.healthMonitor.markUnhealthy(mcpName, error.message); 980 | 981 | return { 982 | success: false, 983 | error: this.enhanceErrorMessage(error, actualToolName, mcpName) 984 | }; 985 | } 986 | } 987 | 988 | private async getOrCreateConnection(mcpName: string): Promise<MCPConnection> { 989 | // Return existing connection if available 990 | const existing = this.connections.get(mcpName); 991 | if (existing) { 992 | existing.lastUsed = Date.now(); 993 | existing.executionCount++; 994 | return existing; 995 | } 996 | 997 | const definition = this.definitions.get(mcpName); 998 | if (!definition) { 999 | const availableMcps = Array.from(this.definitions.keys()).join(', '); 1000 | throw new Error(`MCP '${mcpName}' not found. Available MCPs: ${availableMcps}. Use 'ncp find' to discover tools or check your profile configuration.`); 1001 | } 1002 | 1003 | logger.info(`🔌 Connecting to ${mcpName} (for execution)...`); 1004 | const connectStart = Date.now(); 1005 | 1006 | try { 1007 | // Add environment variables 1008 | const silentEnv = { 1009 | ...process.env, 1010 | ...(definition.config.env || {}), 1011 | // These may still help some servers 1012 | MCP_SILENT: 'true', 1013 | QUIET: 'true', 1014 | NO_COLOR: 'true' 1015 | }; 1016 | 1017 | const transport = await this.createTransport(definition.config, silentEnv); 1018 | 1019 | const client = new Client( 1020 | { name: 'ncp-oss', version: '1.0.0' }, 1021 | { capabilities: {} } 1022 | ); 1023 | 1024 | // Connect with timeout and filtered output 1025 | await withFilteredOutput(async () => { 1026 | await Promise.race([ 1027 | client.connect(transport), 1028 | new Promise((_, reject) => 1029 | setTimeout(() => reject(new Error('Connection timeout')), this.CONNECTION_TIMEOUT) 1030 | ) 1031 | ]); 1032 | }); 1033 | 1034 | // Capture server info after successful connection 1035 | const serverInfo = client.getServerVersion(); 1036 | 1037 | const connection: MCPConnection = { 1038 | client, 1039 | transport, 1040 | tools: [], // Will be populated if needed 1041 | serverInfo: serverInfo ? { 1042 | name: serverInfo.name || mcpName, 1043 | title: serverInfo.title, 1044 | version: serverInfo.version || 'unknown', 1045 | description: serverInfo.title || serverInfo.name || undefined, 1046 | websiteUrl: serverInfo.websiteUrl 1047 | } : undefined, 1048 | lastUsed: Date.now(), 1049 | connectTime: Date.now() - connectStart, 1050 | executionCount: 1 1051 | }; 1052 | 1053 | // Store connection for reuse 1054 | this.connections.set(mcpName, connection); 1055 | logger.info(`✅ Connected to ${mcpName} in ${connection.connectTime}ms`); 1056 | 1057 | return connection; 1058 | } catch (error: any) { 1059 | logger.error(`❌ Failed to connect to ${mcpName}: ${error.message}`); 1060 | throw error; 1061 | } 1062 | } 1063 | 1064 | /** 1065 | * New optimized cache loading with profile hash validation 1066 | * This is the key optimization - skips re-indexing when profile hasn't changed 1067 | */ 1068 | private async loadFromOptimizedCache(profile: Profile): Promise<boolean> { 1069 | try { 1070 | // 1. Validate cache integrity first 1071 | const integrity = await this.cachePatcher.validateAndRepairCache(); 1072 | if (!integrity.valid) { 1073 | logger.warn('Cache integrity check failed - rebuilding required'); 1074 | return false; 1075 | } 1076 | 1077 | // 2. Check if cache is valid using profile hash validation 1078 | const currentProfileHash = this.cachePatcher.generateProfileHash(profile); 1079 | const cacheIsValid = await this.cachePatcher.validateCacheWithProfile(currentProfileHash); 1080 | 1081 | if (!cacheIsValid) { 1082 | logger.info('Cache invalid or missing - profile changed'); 1083 | return false; 1084 | } 1085 | 1086 | // 3. Load tool metadata cache directly 1087 | const toolMetadataCache = await this.cachePatcher.loadToolMetadataCache(); 1088 | 1089 | if (!toolMetadataCache.mcps || Object.keys(toolMetadataCache.mcps).length === 0) { 1090 | logger.info('Tool metadata cache empty'); 1091 | return false; 1092 | } 1093 | 1094 | logger.info(`✅ Using valid cache (${Object.keys(toolMetadataCache.mcps).length} MCPs, hash: ${currentProfileHash.substring(0, 8)}...)`); 1095 | 1096 | // 4. Load MCPs and tools from cache directly (no re-indexing) 1097 | this.allTools = []; 1098 | let loadedMCPCount = 0; 1099 | let loadedToolCount = 0; 1100 | 1101 | for (const [mcpName, mcpData] of Object.entries(toolMetadataCache.mcps)) { 1102 | try { 1103 | // Validate MCP data structure 1104 | if (!mcpData.tools || !Array.isArray(mcpData.tools)) { 1105 | logger.warn(`Skipping ${mcpName}: invalid tools data in cache`); 1106 | continue; 1107 | } 1108 | 1109 | // Check if MCP still exists in current profile 1110 | if (!profile.mcpServers[mcpName]) { 1111 | logger.debug(`Skipping ${mcpName}: removed from profile`); 1112 | continue; 1113 | } 1114 | 1115 | this.definitions.set(mcpName, { 1116 | name: mcpName, 1117 | config: { 1118 | name: mcpName, 1119 | ...profile.mcpServers[mcpName] 1120 | }, 1121 | tools: mcpData.tools.map(tool => ({ 1122 | ...tool, 1123 | inputSchema: tool.inputSchema || {} 1124 | })), 1125 | serverInfo: mcpData.serverInfo || { name: mcpName, version: '1.0.0' } 1126 | }); 1127 | 1128 | // Build allTools array and tool mappings 1129 | const discoveryTools = []; 1130 | for (const tool of mcpData.tools) { 1131 | try { 1132 | const prefixedToolName = `${mcpName}:${tool.name}`; 1133 | const prefixedDescription = tool.description.startsWith(`${mcpName}:`) 1134 | ? tool.description 1135 | : `${mcpName}: ${tool.description || 'No description available'}`; 1136 | 1137 | this.allTools.push({ 1138 | name: prefixedToolName, 1139 | description: prefixedDescription, 1140 | mcpName: mcpName 1141 | }); 1142 | 1143 | // Create tool mappings 1144 | this.toolToMCP.set(tool.name, mcpName); 1145 | this.toolToMCP.set(prefixedToolName, mcpName); 1146 | 1147 | discoveryTools.push({ 1148 | id: prefixedToolName, 1149 | name: tool.name, 1150 | description: prefixedDescription, 1151 | mcpServer: mcpName, 1152 | inputSchema: tool.inputSchema || {} 1153 | }); 1154 | 1155 | loadedToolCount++; 1156 | } catch (toolError: any) { 1157 | logger.warn(`Error loading tool ${tool.name} from ${mcpName}: ${toolError.message}`); 1158 | } 1159 | } 1160 | 1161 | // Use fast indexing (load from embeddings cache, don't regenerate) 1162 | if (discoveryTools.length > 0) { 1163 | // Ensure discovery engine is fully initialized before indexing 1164 | await this.discovery.initialize(); 1165 | await this.discovery.indexMCPToolsFromCache(mcpName, discoveryTools); 1166 | loadedMCPCount++; 1167 | } 1168 | 1169 | } catch (mcpError: any) { 1170 | logger.warn(`Error loading MCP ${mcpName} from cache: ${mcpError.message}`); 1171 | } 1172 | } 1173 | 1174 | if (loadedMCPCount === 0) { 1175 | logger.warn('No valid MCPs loaded from cache'); 1176 | return false; 1177 | } 1178 | 1179 | logger.info(`⚡ Loaded ${loadedToolCount} tools from ${loadedMCPCount} MCPs (optimized cache)`); 1180 | return true; 1181 | 1182 | } catch (error: any) { 1183 | logger.warn(`Optimized cache load failed: ${error.message}`); 1184 | return false; 1185 | } 1186 | } 1187 | 1188 | /** 1189 | * Legacy cache loading (kept for fallback) 1190 | */ 1191 | private async loadFromCache(profile: Profile): Promise<boolean> { 1192 | try { 1193 | const cacheDir = getCacheDirectory(); 1194 | const cachePath = join(cacheDir, `${this.profileName}-tools.json`); 1195 | 1196 | if (!existsSync(cachePath)) { 1197 | return false; 1198 | } 1199 | 1200 | const content = readFileSync(cachePath, 'utf-8'); 1201 | const cache = JSON.parse(content); 1202 | 1203 | // Use cache if less than 24 hours old 1204 | if (Date.now() - cache.timestamp > 24 * 60 * 60 * 1000) { 1205 | logger.info('Cache expired, will refresh tools'); 1206 | return false; 1207 | } 1208 | 1209 | logger.info(`Using cached tools (${Object.keys(cache.mcps).length} MCPs)`) 1210 | 1211 | // Load MCPs and tools from cache 1212 | for (const [mcpName, mcpData] of Object.entries(cache.mcps)) { 1213 | const data = mcpData as any; 1214 | 1215 | this.definitions.set(mcpName, { 1216 | name: mcpName, 1217 | config: { 1218 | name: mcpName, 1219 | ...profile.mcpServers[mcpName] 1220 | }, 1221 | tools: data.tools || [], 1222 | serverInfo: data.serverInfo 1223 | }); 1224 | 1225 | // Add tools to allTools and create mappings 1226 | const discoveryTools = []; 1227 | for (const tool of data.tools || []) { 1228 | // Handle both old (unprefixed) and new (prefixed) formats in cache 1229 | const isAlreadyPrefixed = tool.name.startsWith(`${mcpName}:`); 1230 | const prefixedToolName = isAlreadyPrefixed ? tool.name : `${mcpName}:${tool.name}`; 1231 | const actualToolName = isAlreadyPrefixed ? tool.name.substring(mcpName.length + 1) : tool.name; 1232 | 1233 | // Ensure description is prefixed 1234 | const hasPrefixedDesc = tool.description?.startsWith(`${mcpName}: `); 1235 | const prefixedDescription = hasPrefixedDesc ? tool.description : `${mcpName}: ${tool.description || 'No description available'}`; 1236 | 1237 | this.allTools.push({ 1238 | name: prefixedToolName, 1239 | description: prefixedDescription, 1240 | mcpName: mcpName 1241 | }); 1242 | 1243 | // Map both formats for backward compatibility 1244 | this.toolToMCP.set(actualToolName, mcpName); 1245 | this.toolToMCP.set(prefixedToolName, mcpName); 1246 | 1247 | // Prepare for discovery engine indexing 1248 | // Pass unprefixed name - RAG engine will add the prefix 1249 | discoveryTools.push({ 1250 | id: prefixedToolName, 1251 | name: actualToolName, // Use unprefixed name here 1252 | description: prefixedDescription, 1253 | mcpServer: mcpName, 1254 | inputSchema: {} 1255 | }); 1256 | } 1257 | 1258 | // Index tools with discovery engine 1259 | await this.discovery.indexMCPTools(mcpName, discoveryTools); 1260 | } 1261 | 1262 | logger.info(`✅ Loaded ${this.allTools.length} tools from cache`); 1263 | return true; 1264 | 1265 | } catch (error: any) { 1266 | logger.warn(`Cache load failed: ${error.message}`); 1267 | return false; 1268 | } 1269 | } 1270 | 1271 | private async saveToCache(profile: Profile): Promise<void> { 1272 | try { 1273 | // Use new optimized cache saving with profile hash 1274 | await this.saveToOptimizedCache(profile); 1275 | 1276 | } catch (error: any) { 1277 | logger.warn(`Cache save failed: ${error.message}`); 1278 | } 1279 | } 1280 | 1281 | /** 1282 | * New optimized cache saving with profile hash and structured format 1283 | */ 1284 | private async saveToOptimizedCache(profile: Profile): Promise<void> { 1285 | try { 1286 | logger.info('💾 Saving tools to optimized cache...'); 1287 | 1288 | // Save all MCP definitions to tool metadata cache 1289 | for (const [mcpName, definition] of this.definitions.entries()) { 1290 | const mcpConfig = profile.mcpServers[mcpName]; 1291 | if (mcpConfig) { 1292 | await this.cachePatcher.patchAddMCP( 1293 | mcpName, 1294 | mcpConfig, 1295 | definition.tools, 1296 | definition.serverInfo 1297 | ); 1298 | } 1299 | } 1300 | 1301 | // Update profile hash 1302 | const profileHash = this.cachePatcher.generateProfileHash(profile); 1303 | await this.cachePatcher.updateProfileHash(profileHash); 1304 | 1305 | logger.info(`💾 Saved ${this.allTools.length} tools to optimized cache with profile hash: ${profileHash.substring(0, 8)}...`); 1306 | 1307 | } catch (error: any) { 1308 | logger.error(`Optimized cache save failed: ${error.message}`); 1309 | throw error; 1310 | } 1311 | } 1312 | 1313 | private getToolSchema(mcpName: string, toolName: string): any { 1314 | const connection = this.connections.get(mcpName); 1315 | if (!connection) { 1316 | // No persistent connection, try to get schema from definitions 1317 | const definition = this.definitions.get(mcpName); 1318 | if (!definition) return undefined; 1319 | 1320 | const tool = definition.tools.find(t => t.name === toolName); 1321 | return tool ? (tool as any).inputSchema : undefined; 1322 | } 1323 | 1324 | const tool = connection.tools.find(t => t.name === toolName); 1325 | if (!tool) return undefined; 1326 | 1327 | return (tool as any).inputSchema; 1328 | } 1329 | 1330 | /** 1331 | * Check if a tool requires parameters 1332 | */ 1333 | toolRequiresParameters(toolIdentifier: string): boolean { 1334 | const [mcpName, toolName] = toolIdentifier.split(':'); 1335 | if (!mcpName || !toolName) return false; 1336 | 1337 | const schema = this.getToolSchema(mcpName, toolName); 1338 | return ToolSchemaParser.hasRequiredParameters(schema); 1339 | } 1340 | 1341 | /** 1342 | * Get tool parameters for interactive prompting 1343 | */ 1344 | getToolParameters(toolIdentifier: string): ParameterInfo[] { 1345 | const [mcpName, toolName] = toolIdentifier.split(':'); 1346 | if (!mcpName || !toolName) return []; 1347 | 1348 | const schema = this.getToolSchema(mcpName, toolName); 1349 | return ToolSchemaParser.parseParameters(schema); 1350 | } 1351 | 1352 | /** 1353 | * Validate tool parameters before execution 1354 | */ 1355 | private validateToolParameters(mcpName: string, toolName: string, parameters: any): string | null { 1356 | const schema = this.getToolSchema(mcpName, toolName); 1357 | if (!schema) { 1358 | // No schema available, allow execution (tool may not require validation) 1359 | return null; 1360 | } 1361 | 1362 | const requiredParams = ToolSchemaParser.getRequiredParameters(schema); 1363 | const missingParams: string[] = []; 1364 | 1365 | // Check for missing required parameters 1366 | for (const param of requiredParams) { 1367 | if (parameters === null || parameters === undefined || !(param.name in parameters) || parameters[param.name] === null || parameters[param.name] === undefined || parameters[param.name] === '') { 1368 | missingParams.push(param.name); 1369 | } 1370 | } 1371 | 1372 | if (missingParams.length > 0) { 1373 | return `Missing required parameters: ${missingParams.join(', ')}. Use 'ncp find "${mcpName}:${toolName}" --depth 2' to see parameter details.`; 1374 | } 1375 | 1376 | return null; // Validation passed 1377 | } 1378 | 1379 | /** 1380 | * Get tool context for parameter prediction 1381 | */ 1382 | getToolContext(toolIdentifier: string): string { 1383 | return ToolContextResolver.getContext(toolIdentifier); 1384 | } 1385 | 1386 | /** 1387 | * Find similar tool names using fuzzy matching 1388 | */ 1389 | private findSimilarTools(targetTool: string, maxSuggestions: number = 3): string[] { 1390 | const allTools = Array.from(this.toolToMCP.keys()); 1391 | const similarities = allTools.map(tool => ({ 1392 | tool, 1393 | similarity: calculateSimilarity(targetTool, tool) 1394 | })); 1395 | 1396 | return similarities 1397 | .filter(item => item.similarity > 0.4) // Only suggest if reasonably similar 1398 | .sort((a, b) => b.similarity - a.similarity) 1399 | .slice(0, maxSuggestions) 1400 | .map(item => item.tool); 1401 | } 1402 | 1403 | /** 1404 | * Generate hash for each MCP configuration 1405 | */ 1406 | private generateConfigHashes(profile: Profile): Record<string, string> { 1407 | const hashes: Record<string, string> = {}; 1408 | const crypto = require('crypto'); 1409 | 1410 | for (const [mcpName, config] of Object.entries(profile.mcpServers)) { 1411 | // Hash command + args + env + url for change detection 1412 | const configString = JSON.stringify({ 1413 | command: config.command, 1414 | args: config.args || [], 1415 | env: config.env || {}, 1416 | url: config.url // Include HTTP/SSE URL in hash 1417 | }); 1418 | 1419 | hashes[mcpName] = crypto.createHash('sha256').update(configString).digest('hex'); 1420 | } 1421 | 1422 | return hashes; 1423 | } 1424 | 1425 | /** 1426 | * Get current indexing progress 1427 | */ 1428 | getIndexingProgress(): { current: number; total: number; currentMCP: string; estimatedTimeRemaining?: number } | null { 1429 | return this.indexingProgress; 1430 | } 1431 | 1432 | /** 1433 | * Get MCP health status summary 1434 | */ 1435 | getMCPHealthStatus(): { total: number; healthy: number; unhealthy: number; mcps: Array<{name: string; healthy: boolean}> } { 1436 | const allMCPs = Array.from(this.definitions.keys()); 1437 | const healthyMCPs = this.healthMonitor.getHealthyMCPs(allMCPs); 1438 | 1439 | const mcpStatus = allMCPs.map(mcp => ({ 1440 | name: mcp, 1441 | healthy: healthyMCPs.includes(mcp) 1442 | })); 1443 | 1444 | return { 1445 | total: allMCPs.length, 1446 | healthy: healthyMCPs.length, 1447 | unhealthy: allMCPs.length - healthyMCPs.length, 1448 | mcps: mcpStatus 1449 | }; 1450 | } 1451 | 1452 | /** 1453 | * Enhance generic error messages with better context 1454 | */ 1455 | private enhanceErrorMessage(error: any, toolName: string, mcpName: string): string { 1456 | const errorMessage = error.message || error.toString() || 'Unknown error'; 1457 | 1458 | // Always provide context and actionable guidance, regardless of specific error patterns 1459 | let enhancedMessage = `Tool '${toolName}' failed in MCP '${mcpName}': ${errorMessage}`; 1460 | 1461 | // Add generic troubleshooting guidance 1462 | const troubleshootingTips = [ 1463 | `• Check MCP '${mcpName}' status and configuration`, 1464 | `• Use 'ncp find "${mcpName}:${toolName}" --depth 2' to verify tool parameters`, 1465 | `• Ensure MCP server is running and accessible` 1466 | ]; 1467 | 1468 | enhancedMessage += `\n\nTroubleshooting:\n${troubleshootingTips.join('\n')}`; 1469 | 1470 | return enhancedMessage; 1471 | } 1472 | 1473 | /** 1474 | * Get all resources from active MCPs 1475 | */ 1476 | async getAllResources(): Promise<Array<any>> { 1477 | const resources: Array<any> = []; 1478 | const allMCPs = Array.from(this.definitions.keys()); 1479 | const healthyMCPs = this.healthMonitor.getHealthyMCPs(allMCPs); 1480 | 1481 | for (const mcpName of healthyMCPs) { 1482 | try { 1483 | const mcpResources = await this.getResourcesFromMCP(mcpName); 1484 | if (mcpResources && Array.isArray(mcpResources)) { 1485 | // Add MCP source information to each resource with prefix 1486 | const enrichedResources = mcpResources.map(resource => ({ 1487 | ...resource, 1488 | name: `${mcpName}:${resource.name}`, // Add MCP prefix 1489 | _source: mcpName 1490 | })); 1491 | resources.push(...enrichedResources); 1492 | } 1493 | } catch (error) { 1494 | logger.warn(`Failed to get resources from ${mcpName}: ${error}`); 1495 | } 1496 | } 1497 | 1498 | return resources; 1499 | } 1500 | 1501 | /** 1502 | * Get all prompts from active MCPs 1503 | */ 1504 | async getAllPrompts(): Promise<Array<any>> { 1505 | const prompts: Array<any> = []; 1506 | const allMCPs = Array.from(this.definitions.keys()); 1507 | const healthyMCPs = this.healthMonitor.getHealthyMCPs(allMCPs); 1508 | 1509 | for (const mcpName of healthyMCPs) { 1510 | try { 1511 | const mcpPrompts = await this.getPromptsFromMCP(mcpName); 1512 | if (mcpPrompts && Array.isArray(mcpPrompts)) { 1513 | // Add MCP source information to each prompt with prefix 1514 | const enrichedPrompts = mcpPrompts.map(prompt => ({ 1515 | ...prompt, 1516 | name: `${mcpName}:${prompt.name}`, // Add MCP prefix 1517 | _source: mcpName 1518 | })); 1519 | prompts.push(...enrichedPrompts); 1520 | } 1521 | } catch (error) { 1522 | logger.warn(`Failed to get prompts from ${mcpName}: ${error}`); 1523 | } 1524 | } 1525 | 1526 | return prompts; 1527 | } 1528 | 1529 | /** 1530 | * Get resources from a specific MCP 1531 | */ 1532 | private async getResourcesFromMCP(mcpName: string): Promise<Array<any>> { 1533 | try { 1534 | const definition = this.definitions.get(mcpName); 1535 | if (!definition) { 1536 | return []; 1537 | } 1538 | 1539 | // Create temporary connection for resources request 1540 | const { Client } = await import('@modelcontextprotocol/sdk/client/index.js'); 1541 | 1542 | const silentEnv = { 1543 | ...process.env, 1544 | ...(definition.config.env || {}), 1545 | MCP_SILENT: 'true', 1546 | QUIET: 'true', 1547 | NO_COLOR: 'true' 1548 | }; 1549 | 1550 | const transport = await this.createTransport(definition.config, silentEnv); 1551 | 1552 | const client = new Client( 1553 | { name: 'ncp-oss-resources', version: '1.0.0' }, 1554 | { capabilities: {} } 1555 | ); 1556 | 1557 | // Connect with timeout and filtered output 1558 | await withFilteredOutput(async () => { 1559 | await Promise.race([ 1560 | client.connect(transport), 1561 | new Promise((_, reject) => 1562 | setTimeout(() => reject(new Error('Resources connection timeout')), this.QUICK_PROBE_TIMEOUT) 1563 | ) 1564 | ]); 1565 | }); 1566 | 1567 | // Get resources list with filtered output 1568 | const response = await withFilteredOutput(async () => { 1569 | return await client.listResources(); 1570 | }); 1571 | await client.close(); 1572 | 1573 | return response.resources || []; 1574 | 1575 | } catch (error) { 1576 | logger.debug(`Resources probe failed for ${mcpName}: ${error}`); 1577 | return []; 1578 | } 1579 | } 1580 | 1581 | /** 1582 | * Get prompts from a specific MCP 1583 | */ 1584 | private async getPromptsFromMCP(mcpName: string): Promise<Array<any>> { 1585 | try { 1586 | const definition = this.definitions.get(mcpName); 1587 | if (!definition) { 1588 | return []; 1589 | } 1590 | 1591 | // Create temporary connection for prompts request 1592 | const { Client } = await import('@modelcontextprotocol/sdk/client/index.js'); 1593 | 1594 | const silentEnv = { 1595 | ...process.env, 1596 | ...(definition.config.env || {}), 1597 | MCP_SILENT: 'true', 1598 | QUIET: 'true', 1599 | NO_COLOR: 'true' 1600 | }; 1601 | 1602 | const transport = await this.createTransport(definition.config, silentEnv); 1603 | 1604 | const client = new Client( 1605 | { name: 'ncp-oss-prompts', version: '1.0.0' }, 1606 | { capabilities: {} } 1607 | ); 1608 | 1609 | // Connect with timeout and filtered output 1610 | await withFilteredOutput(async () => { 1611 | await Promise.race([ 1612 | client.connect(transport), 1613 | new Promise((_, reject) => 1614 | setTimeout(() => reject(new Error('Prompts connection timeout')), this.QUICK_PROBE_TIMEOUT) 1615 | ) 1616 | ]); 1617 | }); 1618 | 1619 | // Get prompts list with filtered output 1620 | const response = await withFilteredOutput(async () => { 1621 | return await client.listPrompts(); 1622 | }); 1623 | await client.close(); 1624 | 1625 | return response.prompts || []; 1626 | 1627 | } catch (error) { 1628 | logger.debug(`Prompts probe failed for ${mcpName}: ${error}`); 1629 | return []; 1630 | } 1631 | } 1632 | 1633 | /** 1634 | * Clean up idle connections (like commercial version) 1635 | */ 1636 | private async cleanupIdleConnections(): Promise<void> { 1637 | const now = Date.now(); 1638 | const toDisconnect: string[] = []; 1639 | 1640 | for (const [name, connection] of this.connections) { 1641 | const idleTime = now - connection.lastUsed; 1642 | 1643 | if (idleTime > this.IDLE_TIMEOUT) { 1644 | logger.info(`🧹 Disconnecting idle MCP: ${name} (idle for ${Math.round(idleTime / 1000)}s)`); 1645 | toDisconnect.push(name); 1646 | } 1647 | } 1648 | 1649 | // Disconnect idle connections 1650 | for (const name of toDisconnect) { 1651 | await this.disconnectMCP(name); 1652 | } 1653 | } 1654 | 1655 | /** 1656 | * Disconnect a specific MCP 1657 | */ 1658 | private async disconnectMCP(mcpName: string): Promise<void> { 1659 | const connection = this.connections.get(mcpName); 1660 | if (!connection) return; 1661 | 1662 | try { 1663 | await connection.client.close(); 1664 | this.connections.delete(mcpName); 1665 | logger.debug(`Disconnected ${mcpName}`); 1666 | } catch (error) { 1667 | logger.error(`Error disconnecting ${mcpName}:`, error); 1668 | } 1669 | } 1670 | 1671 | async cleanup(): Promise<void> { 1672 | logger.info('Shutting down NCP Orchestrator...'); 1673 | 1674 | // Stop cleanup timer 1675 | if (this.cleanupTimer) { 1676 | clearInterval(this.cleanupTimer); 1677 | } 1678 | 1679 | // Finalize cache if it's being written 1680 | if (this.csvCache) { 1681 | try { 1682 | await this.csvCache.finalize(); 1683 | } catch (error) { 1684 | // Ignore finalize errors 1685 | } 1686 | } 1687 | 1688 | // Stop progress spinner if active 1689 | if (this.showProgress) { 1690 | const { spinner } = await import('../utils/progress-spinner.js'); 1691 | spinner.stop(); 1692 | } 1693 | 1694 | // Close any active connections 1695 | for (const connection of this.connections.values()) { 1696 | try { 1697 | await connection.client.close(); 1698 | } catch (error) { 1699 | // Ignore cleanup errors 1700 | } 1701 | } 1702 | 1703 | this.connections.clear(); 1704 | logger.info('NCP orchestrator cleanup completed'); 1705 | } 1706 | 1707 | /** 1708 | * Get server descriptions for all configured MCPs 1709 | */ 1710 | getServerDescriptions(): Record<string, string> { 1711 | const descriptions: Record<string, string> = {}; 1712 | 1713 | // From active connections 1714 | for (const [mcpName, connection] of this.connections) { 1715 | if (connection.serverInfo?.description) { 1716 | descriptions[mcpName] = connection.serverInfo.description; 1717 | } else if (connection.serverInfo?.title) { 1718 | descriptions[mcpName] = connection.serverInfo.title; 1719 | } 1720 | } 1721 | 1722 | // From cached definitions 1723 | for (const [mcpName, definition] of this.definitions) { 1724 | if (!descriptions[mcpName] && definition.serverInfo?.description) { 1725 | descriptions[mcpName] = definition.serverInfo.description; 1726 | } else if (!descriptions[mcpName] && definition.serverInfo?.title) { 1727 | descriptions[mcpName] = definition.serverInfo.title; 1728 | } 1729 | } 1730 | 1731 | return descriptions; 1732 | } 1733 | 1734 | /** 1735 | * Apply universal term frequency scoring boost with action word weighting 1736 | * Uses SearchEnhancer for clean, extensible term classification and semantic mapping 1737 | */ 1738 | private adjustScoresUniversally(query: string, results: any[]): any[] { 1739 | const queryTerms = query.toLowerCase().split(/\s+/).filter(term => term.length > 2); // Skip very short terms 1740 | 1741 | return results.map(result => { 1742 | const toolName = result.name.toLowerCase(); 1743 | const toolDescription = (result.description || '').toLowerCase(); 1744 | 1745 | let nameBoost = 0; 1746 | let descBoost = 0; 1747 | 1748 | // Process each query term with SearchEnhancer classification 1749 | for (const term of queryTerms) { 1750 | const termType = SearchEnhancer.classifyTerm(term); 1751 | const weight = SearchEnhancer.getTypeWeights(termType); 1752 | 1753 | // Apply scoring based on term type 1754 | if (toolName.includes(term)) { 1755 | nameBoost += weight.name; 1756 | } 1757 | if (toolDescription.includes(term)) { 1758 | descBoost += weight.desc; 1759 | } 1760 | 1761 | // Apply semantic action matching for ACTION terms 1762 | if (termType === 'ACTION') { 1763 | const semantics = SearchEnhancer.getActionSemantics(term); 1764 | for (const semanticMatch of semantics) { 1765 | if (toolName.includes(semanticMatch)) { 1766 | nameBoost += weight.name * 1.2; // 120% of full action weight for semantic matches (boosted) 1767 | } 1768 | if (toolDescription.includes(semanticMatch)) { 1769 | descBoost += weight.desc * 1.2; 1770 | } 1771 | } 1772 | 1773 | // Apply intent penalties for conflicting actions 1774 | const penalty = SearchEnhancer.getIntentPenalty(term, toolName); 1775 | nameBoost -= penalty; 1776 | } 1777 | } 1778 | 1779 | // Apply diminishing returns to prevent excessive stacking 1780 | const baseWeight = 0.15; // Base weight for diminishing returns calculation 1781 | const finalNameBoost = nameBoost > 0 ? nameBoost * Math.pow(0.8, Math.max(0, nameBoost / baseWeight - 1)) : 0; 1782 | const finalDescBoost = descBoost > 0 ? descBoost * Math.pow(0.8, Math.max(0, descBoost / (baseWeight / 2) - 1)) : 0; 1783 | 1784 | const totalBoost = 1 + finalNameBoost + finalDescBoost; 1785 | 1786 | return { 1787 | ...result, 1788 | confidence: result.confidence * totalBoost 1789 | }; 1790 | }); 1791 | } 1792 | 1793 | /** 1794 | * Trigger auto-import from MCP client 1795 | * Called by MCPServer after it receives clientInfo from initialize request 1796 | */ 1797 | async triggerAutoImport(clientName: string): Promise<void> { 1798 | if (!this.profileManager) { 1799 | // ProfileManager not initialized yet, skip auto-import 1800 | logger.warn('ProfileManager not initialized, skipping auto-import'); 1801 | return; 1802 | } 1803 | 1804 | try { 1805 | await this.profileManager.tryAutoImportFromClient(clientName); 1806 | } catch (error: any) { 1807 | logger.error(`Auto-import failed: ${error.message}`); 1808 | } 1809 | } 1810 | 1811 | /** 1812 | * Add internal MCPs to tool discovery 1813 | * Called after external MCPs are indexed 1814 | */ 1815 | private addInternalMCPsToDiscovery(): void { 1816 | const internalMCPs = this.internalMCPManager.getAllInternalMCPs(); 1817 | 1818 | for (const mcp of internalMCPs) { 1819 | // Add to definitions (for consistency with external MCPs) 1820 | this.definitions.set(mcp.name, { 1821 | name: mcp.name, 1822 | config: { 1823 | name: mcp.name, 1824 | command: 'internal', 1825 | args: [] 1826 | }, 1827 | tools: mcp.tools.map(t => ({ name: t.name, description: t.description })), 1828 | serverInfo: { 1829 | name: mcp.name, 1830 | version: '1.0.0', 1831 | description: mcp.description 1832 | } 1833 | }); 1834 | 1835 | // Add tools to allTools and discovery 1836 | for (const tool of mcp.tools) { 1837 | const toolId = `${mcp.name}:${tool.name}`; 1838 | 1839 | // Add to allTools 1840 | this.allTools.push({ 1841 | name: tool.name, 1842 | description: tool.description, 1843 | mcpName: mcp.name 1844 | }); 1845 | 1846 | // Add to toolToMCP mapping 1847 | this.toolToMCP.set(toolId, mcp.name); 1848 | } 1849 | 1850 | // Index in discovery engine 1851 | const discoveryTools = mcp.tools.map(t => ({ 1852 | id: `${mcp.name}:${t.name}`, 1853 | name: t.name, 1854 | description: t.description 1855 | })); 1856 | 1857 | this.discovery.indexMCPTools(mcp.name, discoveryTools); 1858 | 1859 | logger.info(`Added internal MCP "${mcp.name}" with ${mcp.tools.length} tools`); 1860 | } 1861 | } 1862 | 1863 | /** 1864 | * Get the ProfileManager instance 1865 | * Used by MCP server for management operations (add/remove MCPs) 1866 | */ 1867 | getProfileManager(): ProfileManager | null { 1868 | return this.profileManager; 1869 | } 1870 | 1871 | /** 1872 | * Hash a string for change detection 1873 | */ 1874 | private hashString(str: string): string { 1875 | return createHash('sha256').update(str).digest('hex'); 1876 | } 1877 | } 1878 | 1879 | export default NCPOrchestrator; ``` -------------------------------------------------------------------------------- /src/cli/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | #!/usr/bin/env node 2 | 3 | import { Command } from 'commander'; 4 | import chalk from 'chalk'; 5 | import { readFileSync } from 'fs'; 6 | import { fileURLToPath } from 'url'; 7 | import { dirname, join } from 'path'; 8 | import { ProfileManager } from '../profiles/profile-manager.js'; 9 | import { MCPServer } from '../server/mcp-server.js'; 10 | import { ConfigManager } from '../utils/config-manager.js'; 11 | import { formatCommandDisplay } from '../utils/security.js'; 12 | import { TextUtils } from '../utils/text-utils.js'; 13 | import { OutputFormatter } from '../services/output-formatter.js'; 14 | import { ErrorHandler } from '../services/error-handler.js'; 15 | import { CachePatcher } from '../cache/cache-patcher.js'; 16 | import { Client } from '@modelcontextprotocol/sdk/client/index.js'; 17 | import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; 18 | import { mcpWrapper } from '../utils/mcp-wrapper.js'; 19 | import { withFilteredOutput } from '../transports/filtered-stdio-transport.js'; 20 | import { UpdateChecker } from '../utils/update-checker.js'; 21 | import { setOverrideWorkingDirectory } from '../utils/ncp-paths.js'; 22 | import { ConfigSchemaReader } from '../services/config-schema-reader.js'; 23 | import { ConfigPrompter } from '../services/config-prompter.js'; 24 | import { SchemaCache } from '../cache/schema-cache.js'; 25 | import { getCacheDirectory } from '../utils/ncp-paths.js'; 26 | 27 | // Check for no-color flag early 28 | const noColor = process.argv.includes('--no-color') || process.env.NO_COLOR === 'true'; 29 | if (noColor) { 30 | chalk.level = 0; // Disable colors globally 31 | } else { 32 | // Ensure colors are enabled for TTY and when FORCE_COLOR is set 33 | if (process.env.FORCE_COLOR || process.stdout?.isTTY) { 34 | chalk.level = 3; // Full color support 35 | } 36 | } 37 | 38 | // Fuzzy matching helper for finding similar names 39 | function findSimilarNames(target: string, availableNames: string[], maxSuggestions = 3): string[] { 40 | const targetLower = target.toLowerCase(); 41 | 42 | // Score each name based on similarity 43 | const scored = availableNames.map(name => { 44 | const nameLower = name.toLowerCase(); 45 | let score = 0; 46 | 47 | // Exact match gets highest score 48 | if (nameLower === targetLower) score += 100; 49 | 50 | // Contains target or target contains name 51 | if (nameLower.includes(targetLower)) score += 50; 52 | if (targetLower.includes(nameLower)) score += 50; 53 | 54 | // First few characters match 55 | const minLen = Math.min(targetLower.length, nameLower.length); 56 | for (let i = 0; i < minLen && i < 3; i++) { 57 | if (targetLower[i] === nameLower[i]) score += 10; 58 | } 59 | 60 | // Similar length bonus 61 | const lengthDiff = Math.abs(targetLower.length - nameLower.length); 62 | if (lengthDiff <= 2) score += 5; 63 | 64 | return { name, score }; 65 | }); 66 | 67 | // Filter out low scores and sort by score 68 | return scored 69 | .filter(item => item.score > 0) 70 | .sort((a, b) => b.score - a.score) 71 | .slice(0, maxSuggestions) 72 | .map(item => item.name); 73 | } 74 | 75 | // Enhanced remove validation helper 76 | async function validateRemoveCommand(name: string, manager: ProfileManager, profiles: string[]): Promise<{ 77 | mcpExists: boolean; 78 | suggestions: string[]; 79 | allMCPs: string[]; 80 | }> { 81 | const allMCPs = new Set<string>(); 82 | 83 | // Collect all MCP names from specified profiles 84 | for (const profileName of profiles) { 85 | const profile = await manager.getProfile(profileName); 86 | if (profile?.mcpServers) { 87 | Object.keys(profile.mcpServers).forEach(mcpName => allMCPs.add(mcpName)); 88 | } 89 | } 90 | 91 | const mcpList = Array.from(allMCPs); 92 | const mcpExists = mcpList.includes(name); 93 | 94 | let suggestions: string[] = []; 95 | if (!mcpExists && mcpList.length > 0) { 96 | suggestions = findSimilarNames(name, mcpList); 97 | } 98 | 99 | return { 100 | mcpExists, 101 | suggestions, 102 | allMCPs: mcpList 103 | }; 104 | } 105 | 106 | // Simple validation helper for ADD command 107 | async function validateAddCommand(name: string, command: string, args: any[]): Promise<{ 108 | message: string; 109 | suggestions: Array<{ command: string; description: string }> 110 | }> { 111 | const suggestions: Array<{ command: string; description: string }> = []; 112 | 113 | const fullCommand = `${command} ${args.join(' ')}`.trim(); 114 | 115 | // Basic command format validation and helpful tips 116 | if (command === 'npx' && args.length > 0) { 117 | // Clean up the command format - avoid duplication 118 | const cleanedArgs = args.filter(arg => arg !== '-y' || args.indexOf(arg) === 0); 119 | suggestions.push({ 120 | command: fullCommand, 121 | description: 'NPM package execution - health monitor will validate if package exists and starts correctly' 122 | }); 123 | } else if (command.startsWith('/') || command.startsWith('./') || command.includes('\\')) { 124 | suggestions.push({ 125 | command: fullCommand, 126 | description: 'Local executable - health monitor will validate if command works' 127 | }); 128 | } else if (command.includes('@') && !command.startsWith('npx')) { 129 | suggestions.push({ 130 | command: `npx -y ${fullCommand}`, 131 | description: 'Consider using npx for npm packages' 132 | }); 133 | } else { 134 | // Show the command as provided 135 | suggestions.push({ 136 | command: fullCommand, 137 | description: 'Custom command - health monitor will validate functionality' 138 | }); 139 | } 140 | 141 | return { 142 | message: chalk.dim('💡 MCP will be validated by health monitor after adding'), 143 | suggestions 144 | }; 145 | } 146 | 147 | // Simple emoji support detection for cross-platform compatibility 148 | const supportsEmoji = () => { 149 | // Windows Command Prompt and PowerShell often don't support emojis well 150 | if (process.platform === 'win32') { 151 | // Check if it's Windows Terminal (supports emojis) vs cmd/powershell 152 | return process.env.WT_SESSION || process.env.TERM_PROGRAM === 'vscode'; 153 | } 154 | // macOS and Linux terminals generally support emojis 155 | return true; 156 | }; 157 | 158 | const getIcon = (emoji: string, fallback: string) => 159 | supportsEmoji() ? emoji : fallback; 160 | 161 | // Configure OutputFormatter 162 | OutputFormatter.configure({ noColor: !!noColor, emoji: !!supportsEmoji() }); 163 | 164 | // Use centralized version utility 165 | import { version } from '../utils/version.js'; 166 | 167 | // Discovery function for single MCP - extracted from NCPOrchestrator.probeMCPTools 168 | async function discoverSingleMCP(name: string, command: string, args: string[] = [], env: Record<string, string> = {}): Promise<{ 169 | tools: Array<{name: string; description: string; inputSchema?: any}>; 170 | serverInfo?: { 171 | name: string; 172 | title?: string; 173 | version: string; 174 | description?: string; 175 | websiteUrl?: string; 176 | }; 177 | configurationSchema?: any; 178 | }> { 179 | const config = { name, command, args, env }; 180 | 181 | if (!config.command) { 182 | throw new Error(`Invalid config for ${config.name}`); 183 | } 184 | 185 | let client: Client | null = null; 186 | let transport: StdioClientTransport | null = null; 187 | const DISCOVERY_TIMEOUT = 8000; // 8 seconds 188 | 189 | try { 190 | // Create wrapper command for discovery phase 191 | const wrappedCommand = mcpWrapper.createWrapper( 192 | config.name, 193 | config.command, 194 | config.args || [] 195 | ); 196 | 197 | // Create temporary connection for discovery 198 | const silentEnv = { 199 | ...process.env, 200 | ...(config.env || {}), 201 | MCP_SILENT: 'true', 202 | QUIET: 'true', 203 | NO_COLOR: 'true' 204 | }; 205 | 206 | transport = new StdioClientTransport({ 207 | command: wrappedCommand.command, 208 | args: wrappedCommand.args, 209 | env: silentEnv as Record<string, string> 210 | }); 211 | 212 | client = new Client( 213 | { name: 'ncp-oss', version: '1.0.0' }, 214 | { capabilities: {} } 215 | ); 216 | 217 | // Connect with timeout and filtered output 218 | await withFilteredOutput(async () => { 219 | await Promise.race([ 220 | client!.connect(transport!), 221 | new Promise((_, reject) => 222 | setTimeout(() => reject(new Error('Discovery timeout')), DISCOVERY_TIMEOUT) 223 | ) 224 | ]); 225 | }); 226 | 227 | // Capture server info after connection 228 | const serverInfo = client!.getServerVersion(); 229 | 230 | // Capture configuration schema if available 231 | // TODO: Once MCP SDK is updated to support top-level configurationSchema, 232 | // also check for it directly. For now, check experimental capabilities. 233 | const serverCapabilities = client!.getServerCapabilities(); 234 | const configurationSchema = (serverCapabilities as any)?.experimental?.configurationSchema; 235 | 236 | // Get tool list with filtered output 237 | const response = await withFilteredOutput(async () => { 238 | return await client!.listTools(); 239 | }); 240 | 241 | const tools = response.tools.map(t => ({ 242 | name: t.name, 243 | description: t.description || '', 244 | inputSchema: t.inputSchema || {} 245 | })); 246 | 247 | // Disconnect immediately 248 | await client.close(); 249 | 250 | return { 251 | tools, 252 | serverInfo: serverInfo ? { 253 | name: serverInfo.name || config.name, 254 | title: serverInfo.title, 255 | version: serverInfo.version || 'unknown', 256 | description: serverInfo.title || serverInfo.name || undefined, 257 | websiteUrl: serverInfo.websiteUrl 258 | } : undefined, 259 | configurationSchema 260 | }; 261 | 262 | } catch (error: any) { 263 | // Clean up connections 264 | try { 265 | if (client) { 266 | await client.close(); 267 | } 268 | } catch (closeError) { 269 | // Ignore close errors 270 | } 271 | 272 | throw new Error(`Failed to discover tools from ${config.name}: ${error.message}`); 273 | } 274 | } 275 | 276 | const program = new Command(); 277 | 278 | 279 | // Set version 280 | program.version(version, '-v, --version', 'output the current version'); 281 | 282 | 283 | // Custom help configuration with colors and enhanced content 284 | program 285 | .name('ncp') 286 | .description(` 287 | ${chalk.bold.white('Natural Context Provider')} ${chalk.dim('v' + version)} - ${chalk.cyan('1 MCP to rule them all')} 288 | ${chalk.dim('Orchestrates multiple MCP servers through a unified interface for AI assistants.')} 289 | ${chalk.dim('Reduces cognitive load and clutter, saving tokens and speeding up AI interactions.')} 290 | ${chalk.dim('Enables smart tool discovery across all configured servers with vector similarity search.')}`) 291 | .option('--profile <name>', 'Profile to use (default: all)') 292 | .option('--working-dir <path>', 'Working directory for profile resolution (overrides current directory)') 293 | .option('--force-retry', 'Force retry all failed MCPs immediately (ignores scheduled retry times)') 294 | .option('--no-color', 'Disable colored output'); 295 | 296 | 297 | // Configure help with enhanced formatting, Quick Start, and examples 298 | program.configureHelp({ 299 | sortSubcommands: true, 300 | formatHelp: (cmd, helper) => { 301 | // Calculate proper padding based on actual command names and options separately 302 | const allCommands = cmd.commands.filter((cmd: any) => !cmd.hidden); 303 | const maxCmdLength = allCommands.length > 0 ? Math.max(...allCommands.map(cmd => cmd.name().length)) : 0; 304 | const maxOptionLength = cmd.options.length > 0 ? Math.max(...cmd.options.map(option => option.flags.length)) : 0; 305 | 306 | const cmdPad = maxCmdLength + 4; // Add extra space for command alignment 307 | const optionPad = maxOptionLength + 4; // Add extra space for option alignment 308 | const helpWidth = helper.helpWidth || 80; 309 | 310 | function formatItem(term: string, description?: string, padding?: number): string { 311 | if (description) { 312 | const pad = padding || cmdPad; 313 | return term.padEnd(pad) + description; 314 | } 315 | return term; 316 | } 317 | 318 | // Add description first 319 | let output = cmd.description() + '\n\n'; 320 | 321 | // Then usage and config info 322 | output += `${chalk.bold.white('Usage:')} ${cmd.name()} [options] [command]\n`; 323 | output += `${chalk.yellow('NCP config files:')} ~/.ncp/profiles/\n\n`; 324 | 325 | // Options 326 | if (cmd.options.length) { 327 | output += chalk.bold.white('Options:') + '\n'; 328 | cmd.options.forEach(option => { 329 | // Calculate padding based on raw flags, not styled version 330 | const rawPadding = ' ' + option.flags; 331 | const paddedRaw = rawPadding.padEnd(optionPad + 2); 332 | const styledFlags = chalk.cyan(option.flags); 333 | const description = chalk.white(option.description); 334 | 335 | output += ' ' + styledFlags + ' '.repeat(paddedRaw.length - rawPadding.length) + description + '\n'; 336 | }); 337 | output += '\n'; 338 | } 339 | 340 | // Commands 341 | const commands = cmd.commands.filter((cmd: any) => !cmd.hidden); 342 | if (commands.length) { 343 | output += chalk.bold.white('Commands:') + '\n'; 344 | commands.sort((a, b) => a.name().localeCompare(b.name())); 345 | 346 | commands.forEach(cmd => { 347 | // Group commands by category with enhanced styling 348 | const managementCommands = ['add', 'remove', 'import', 'list', 'config']; 349 | const discoveryCommands = ['find']; 350 | const executionCommands = ['run']; 351 | 352 | let cmdName = cmd.name(); 353 | let styledCmdName = cmdName; 354 | if (managementCommands.includes(cmd.name())) { 355 | styledCmdName = chalk.cyan(cmd.name()); 356 | } else if (discoveryCommands.includes(cmd.name())) { 357 | styledCmdName = chalk.green.bold(cmd.name()); 358 | } else if (executionCommands.includes(cmd.name())) { 359 | styledCmdName = chalk.yellow.bold(cmd.name()); 360 | } 361 | 362 | // Calculate padding based on raw command name, not styled version 363 | const rawPadding = ' ' + cmdName; 364 | const paddedRaw = rawPadding.padEnd(cmdPad + 2); // Use cmdPad + 2 for consistency 365 | const description = chalk.white(cmd.description()); 366 | 367 | output += ' ' + styledCmdName + ' '.repeat(paddedRaw.length - rawPadding.length) + description + '\n'; 368 | }); 369 | } 370 | 371 | return output; 372 | } 373 | }); 374 | 375 | 376 | // Add help command 377 | program 378 | .command('help [command]') 379 | .description('Show help for NCP or a specific command') 380 | .action((command) => { 381 | if (command) { 382 | const cmd = program.commands.find(cmd => cmd.name() === command); 383 | if (cmd) { 384 | cmd.help(); 385 | } else { 386 | console.log(`Unknown command: ${command}`); 387 | program.help(); 388 | } 389 | } else { 390 | program.help(); 391 | } 392 | }); 393 | 394 | // Add Quick Start and Examples after all commands are defined 395 | program.addHelpText('after', ` 396 | ${chalk.bold.white('Quick Start:')} 397 | ${chalk.cyan('1a')} Import existing MCPs: ${chalk.green('ncp config import')} ${chalk.dim('(copy JSON first)')} 398 | ${chalk.cyan('1b')} Or add manually: ${chalk.green('ncp add <name> <command>')} 399 | ${chalk.cyan('2')} Configure NCP in AI client settings 400 | 401 | ${chalk.bold.white('Examples:')} 402 | $ ${chalk.yellow('ncp config import config.json')} ${chalk.dim(' # Import from file')} 403 | $ ${chalk.yellow('ncp add filesystem npx @modelcontextprotocol/server-filesystem /tmp')} 404 | $ ${chalk.yellow('ncp find "file operations"')} 405 | $ ${chalk.yellow('ncp run filesystem:read_file --params \'{"path": "/tmp/example.txt"}\'')} 406 | $ ${chalk.yellow('ncp list --depth 1')}`); 407 | 408 | // Check if we should run as MCP server 409 | // MCP server mode: default when no CLI commands are provided, or when --profile is specified 410 | const profileIndex = process.argv.indexOf('--profile'); 411 | const hasCommands = process.argv.includes('find') || 412 | process.argv.includes('add') || 413 | process.argv.includes('list') || 414 | process.argv.includes('remove') || 415 | process.argv.includes('run') || 416 | process.argv.includes('config') || 417 | process.argv.includes('help') || 418 | process.argv.includes('--help') || 419 | process.argv.includes('-h') || 420 | process.argv.includes('--version') || 421 | process.argv.includes('-v') || 422 | process.argv.includes('import') || 423 | process.argv.includes('analytics') || 424 | process.argv.includes('visual') || 425 | process.argv.includes('update') || 426 | process.argv.includes('repair'); 427 | 428 | // Default to MCP server mode when no CLI commands are provided 429 | // This ensures compatibility with Claude Desktop and other MCP clients that expect server mode by default 430 | const shouldRunAsServer = !hasCommands; 431 | 432 | if (shouldRunAsServer) { 433 | // Handle --working-dir parameter for MCP server mode 434 | const workingDirIndex = process.argv.indexOf('--working-dir'); 435 | if (workingDirIndex !== -1 && workingDirIndex + 1 < process.argv.length) { 436 | const workingDirValue = process.argv[workingDirIndex + 1]; 437 | setOverrideWorkingDirectory(workingDirValue); 438 | } 439 | 440 | // Running as MCP server: ncp (defaults to 'all' profile) or ncp --profile <name> 441 | // ⚠️ CRITICAL: Default MUST be 'all' - DO NOT CHANGE to 'default' or anything else! 442 | const profileName = profileIndex !== -1 ? (process.argv[profileIndex + 1] || 'all') : 'all'; 443 | 444 | // Debug logging for integration tests 445 | if (process.env.NCP_DEBUG === 'true') { 446 | console.error(`[DEBUG] profileIndex: ${profileIndex}`); 447 | console.error(`[DEBUG] process.argv: ${process.argv.join(' ')}`); 448 | console.error(`[DEBUG] Selected profile: ${profileName}`); 449 | } 450 | 451 | const server = new MCPServer(profileName); 452 | server.run().catch(console.error); 453 | } else { 454 | // Handle --working-dir parameter for CLI mode 455 | const workingDirIndex = process.argv.indexOf('--working-dir'); 456 | if (workingDirIndex !== -1 && workingDirIndex + 1 < process.argv.length) { 457 | const workingDirValue = process.argv[workingDirIndex + 1]; 458 | setOverrideWorkingDirectory(workingDirValue); 459 | } 460 | 461 | // Running as CLI tool 462 | 463 | // Add MCP command 464 | program 465 | .command('add <name> <command> [args...]') 466 | .description('Add an MCP server to a profile') 467 | .option('--profile <names...>', 'Profile(s) to add to (can specify multiple, default: all)') 468 | .option('--env <vars...>', 'Environment variables (KEY=value)') 469 | .action(async (name, command, args, options) => { 470 | console.log(`\n${chalk.blue(`📦 Adding MCP server: ${chalk.bold(name)}`)}`); 471 | 472 | const manager = new ProfileManager(); 473 | await manager.initialize(); 474 | 475 | // Show helpful guidance without hard validation 476 | const guidance = await validateAddCommand(name, command, args); 477 | console.log(guidance.message); 478 | if (guidance.suggestions.length > 0) { 479 | console.log('\n📋 Command validation:'); 480 | guidance.suggestions.forEach((suggestion, index) => { 481 | if (index === 0) { 482 | // Main command 483 | console.log(` ${chalk.cyan(suggestion.command)}`); 484 | console.log(` ${chalk.dim(suggestion.description)}`); 485 | } else { 486 | // Alternative suggestions 487 | console.log(chalk.dim(`\n💡 Alternative: ${suggestion.command}`)); 488 | console.log(chalk.dim(` ${suggestion.description}`)); 489 | } 490 | }); 491 | console.log(''); 492 | } 493 | 494 | // Parse environment variables 495 | const env: Record<string, string> = {}; 496 | if (options.env) { 497 | console.log(chalk.dim('🔧 Processing environment variables...')); 498 | for (const envVar of options.env) { 499 | const [key, value] = envVar.split('='); 500 | if (key && value) { 501 | env[key] = value; 502 | console.log(chalk.dim(` ${key}=${formatCommandDisplay(value)}`)); 503 | } else { 504 | console.log(chalk.yellow(`⚠️ Invalid environment variable format: ${envVar}`)); 505 | } 506 | } 507 | } 508 | 509 | const config = { 510 | command, 511 | args: args || [], 512 | ...(Object.keys(env).length > 0 && { env }) 513 | }; 514 | 515 | // Show what will be added 516 | // Determine which profiles to add to 517 | // ⚠️ CRITICAL: Default MUST be ['all'] - DO NOT CHANGE! 518 | const profiles = options.profile || ['all']; 519 | 520 | console.log('\n📋 Profile configuration:'); 521 | console.log(` ${chalk.cyan('Target profiles:')} ${profiles.join(', ')}`); 522 | if (Object.keys(env).length > 0) { 523 | console.log(` ${chalk.cyan('Environment variables:')} ${Object.keys(env).length} configured`); 524 | Object.entries(env).forEach(([key, value]) => { 525 | console.log(chalk.dim(` ${key}=${formatCommandDisplay(value)}`)); 526 | }); 527 | } 528 | 529 | console.log(''); // spacing 530 | 531 | // Initialize schema services 532 | const schemaReader = new ConfigSchemaReader(); 533 | const configPrompter = new ConfigPrompter(); 534 | const schemaCache = new SchemaCache(getCacheDirectory()); 535 | 536 | // Try to discover and detect configuration requirements BEFORE adding to profile 537 | console.log(chalk.dim('🔍 Discovering tools and configuration requirements...')); 538 | const discoveryStart = Date.now(); 539 | 540 | let discoveryResult: Awaited<ReturnType<typeof discoverSingleMCP>> | null = null; 541 | let finalConfig = { ...config }; 542 | let detectedSchema: any = null; 543 | 544 | try { 545 | discoveryResult = await discoverSingleMCP(name, command, args, env); 546 | const discoveryTime = Date.now() - discoveryStart; 547 | 548 | console.log(`${chalk.green('✅')} Found ${discoveryResult.tools.length} tools in ${discoveryTime}ms`); 549 | 550 | // Two-tier configuration detection strategy: 551 | // Tier 1: MCP Protocol configurationSchema (from server capabilities) 552 | // Tier 2: Error parsing (fallback - happens on failure below) 553 | 554 | // Tier 1: Check for MCP protocol schema 555 | if (discoveryResult.configurationSchema) { 556 | detectedSchema = schemaReader.readSchema({ 557 | protocolVersion: '1.0', 558 | capabilities: {}, 559 | serverInfo: { name, version: '1.0' }, 560 | configurationSchema: discoveryResult.configurationSchema 561 | }); 562 | if (detectedSchema) { 563 | console.log(chalk.dim(' Configuration schema detected (MCP protocol)')); 564 | } 565 | } 566 | 567 | // Apply detected schema if we have one with required config 568 | if (detectedSchema && schemaReader.hasRequiredConfig(detectedSchema)) { 569 | console.log(chalk.cyan('\n📋 Configuration required')); 570 | 571 | // Prompt for configuration 572 | const promptedConfig = await configPrompter.promptForConfig(detectedSchema, name); 573 | 574 | // Merge prompted config with existing config 575 | finalConfig = { 576 | command: config.command, 577 | args: [...(config.args || []), ...(promptedConfig.arguments || [])], 578 | env: { ...(config.env || {}), ...(promptedConfig.environmentVariables || {}) } 579 | }; 580 | 581 | // Display summary 582 | configPrompter.displaySummary(promptedConfig, name); 583 | 584 | // Cache schema for future use 585 | schemaCache.save(name, detectedSchema); 586 | console.log(chalk.dim('✓ Configuration schema cached')); 587 | } 588 | } catch (discoveryError: any) { 589 | console.log(`${chalk.yellow('⚠️')} Discovery failed: ${discoveryError.message}`); 590 | console.log(chalk.dim(' Proceeding with manual configuration...')); 591 | // Tier 3: Error parsing would happen here in future enhancement 592 | // Continue with manual config - error will be saved to profile 593 | } 594 | 595 | // Initialize cache patcher 596 | const cachePatcher = new CachePatcher(); 597 | 598 | for (const profileName of profiles) { 599 | try { 600 | // 1. Update profile with final configuration 601 | await manager.addMCPToProfile(profileName, name, finalConfig); 602 | console.log(`\n${OutputFormatter.success(`Added ${name} to profile: ${profileName}`)}`); 603 | 604 | // 2. Update cache if we have discovery results 605 | if (discoveryResult) { 606 | if (discoveryResult.tools.length > 0) { 607 | console.log(chalk.dim(' Tools discovered:')); 608 | // Show first few tools 609 | const toolsToShow = discoveryResult.tools.slice(0, 3); 610 | toolsToShow.forEach(tool => { 611 | const shortDesc = tool.description?.length > 50 612 | ? tool.description.substring(0, 50) + '...' 613 | : tool.description; 614 | console.log(chalk.dim(` • ${tool.name}: ${shortDesc}`)); 615 | }); 616 | if (discoveryResult.tools.length > 3) { 617 | console.log(chalk.dim(` • ... and ${discoveryResult.tools.length - 3} more`)); 618 | } 619 | } 620 | 621 | // 3. Patch tool metadata cache with final config 622 | await cachePatcher.patchAddMCP(name, finalConfig, discoveryResult.tools, discoveryResult.serverInfo); 623 | 624 | // 4. Update profile hash 625 | const profile = await manager.getProfile(profileName); 626 | const profileHash = cachePatcher.generateProfileHash(profile); 627 | await cachePatcher.updateProfileHash(profileHash); 628 | 629 | console.log(`${chalk.green('✅')} Cache updated for ${name}`); 630 | } else { 631 | console.log(chalk.dim(' Profile updated, but cache not built. Run "ncp find <query>" to build cache later.')); 632 | } 633 | 634 | } catch (error: any) { 635 | const errorResult = ErrorHandler.handle(error, ErrorHandler.createContext('profile', 'add', `${name} to ${profileName}`)); 636 | console.log('\n' + ErrorHandler.formatForConsole(errorResult)); 637 | } 638 | } 639 | 640 | console.log(chalk.dim('\n💡 Next steps:')); 641 | console.log(chalk.dim(' •') + ' View profiles: ' + chalk.cyan('ncp list')); 642 | console.log(chalk.dim(' •') + ' Test discovery: ' + chalk.cyan('ncp find <query>')); 643 | }); 644 | 645 | 646 | // Lightweight function to read MCP info from cache without full orchestrator initialization 647 | async function loadMCPInfoFromCache(mcpDescriptions: Record<string, string>, mcpToolCounts: Record<string, number>, mcpVersions: Record<string, string>): Promise<boolean> { 648 | const { readFileSync, existsSync } = await import('fs'); 649 | const { getCacheDirectory } = await import('../utils/ncp-paths.js'); 650 | const { join } = await import('path'); 651 | 652 | const cacheDir = getCacheDirectory(); 653 | const cachePath = join(cacheDir, 'all-tools.json'); 654 | 655 | if (!existsSync(cachePath)) { 656 | return false; // No cache available 657 | } 658 | 659 | try { 660 | const cacheContent = readFileSync(cachePath, 'utf-8'); 661 | const cache = JSON.parse(cacheContent); 662 | 663 | // Extract server info and tool counts from cache 664 | for (const [mcpName, mcpData] of Object.entries(cache.mcps || {})) { 665 | const data = mcpData as any; 666 | 667 | // Extract server description (without version) 668 | if (data.serverInfo?.description && data.serverInfo.description !== mcpName) { 669 | mcpDescriptions[mcpName] = data.serverInfo.description; 670 | } else if (data.serverInfo?.title) { 671 | mcpDescriptions[mcpName] = data.serverInfo.title; 672 | } 673 | 674 | // Extract version separately 675 | if (data.serverInfo?.version && data.serverInfo.version !== 'unknown') { 676 | mcpVersions[mcpName] = data.serverInfo.version; 677 | } 678 | 679 | // Count tools 680 | if (data.tools && Array.isArray(data.tools)) { 681 | mcpToolCounts[mcpName] = data.tools.length; 682 | } 683 | } 684 | return true; // Cache was successfully loaded 685 | } catch (error) { 686 | // Ignore cache reading errors - will just show without descriptions 687 | return false; 688 | } 689 | } 690 | 691 | 692 | // List command 693 | program 694 | .command('list [filter]') 695 | .description('List all profiles and their MCPs with intelligent filtering') 696 | .option('--limit <number>', 'Maximum number of items to show (default: 20)') 697 | .option('--page <number>', 'Page number for pagination (default: 1)') 698 | .option('--depth <number>', 'Display depth: 0=profiles only, 1=profiles+MCPs+description, 2=profiles+MCPs+description+tools (default: 2)') 699 | .option('--search <query>', 'Search in MCP names and descriptions') 700 | .option('--profile <name>', 'Show only specific profile') 701 | .option('--sort <field>', 'Sort by: name, tools, profiles (default: name)', 'name') 702 | .option('--non-empty', 'Show only profiles with configured MCPs') 703 | .action(async (filter, options) => { 704 | const limit = parseInt(options.limit || '20'); 705 | const page = parseInt(options.page || '1'); 706 | const depth = parseInt(options.depth || '2'); 707 | 708 | const manager = new ProfileManager(); 709 | await manager.initialize(); 710 | 711 | let profiles = manager.listProfiles(); 712 | 713 | if (profiles.length === 0) { 714 | console.log(chalk.yellow('📋 No profiles configured')); 715 | console.log(chalk.dim('💡 Use: ncp add <name> <command> to add an MCP server')); 716 | return; 717 | } 718 | 719 | // Apply profile filtering first 720 | if (options.profile) { 721 | const targetProfile = options.profile.toLowerCase(); 722 | profiles = profiles.filter(p => p.toLowerCase() === targetProfile); 723 | 724 | if (profiles.length === 0) { 725 | console.log(chalk.yellow(`⚠️ Profile "${options.profile}" not found`)); 726 | 727 | // Suggest similar profiles 728 | const allProfiles = manager.listProfiles(); 729 | const suggestions = findSimilarNames(options.profile, allProfiles); 730 | if (suggestions.length > 0) { 731 | console.log(chalk.yellow('\n💡 Did you mean:')); 732 | suggestions.forEach((suggestion, index) => { 733 | console.log(` ${index + 1}. ${chalk.cyan(suggestion)}`); 734 | }); 735 | } else { 736 | console.log(chalk.yellow('\n📋 Available profiles:')); 737 | allProfiles.forEach((profile, index) => { 738 | console.log(` ${index + 1}. ${chalk.cyan(profile)}`); 739 | }); 740 | } 741 | return; 742 | } 743 | } 744 | 745 | // Initialize orchestrator to get MCP descriptions and tool counts if needed 746 | let orchestrator; 747 | let mcpDescriptions: Record<string, string> = {}; 748 | let mcpToolCounts: Record<string, number> = {}; 749 | let mcpVersions: Record<string, string> = {}; 750 | 751 | if (depth >= 1) { 752 | try { 753 | // Lightweight cache reading - no orchestrator initialization needed 754 | const cacheLoaded = await loadMCPInfoFromCache(mcpDescriptions, mcpToolCounts, mcpVersions); 755 | 756 | if (!cacheLoaded) { 757 | // Show helpful message about building cache 758 | console.log(chalk.dim('💡 No MCP cache found. Use `ncp find <query>` to discover tools and build cache.')); 759 | } 760 | } catch (error) { 761 | // If cache reading fails, continue without descriptions 762 | console.log(chalk.dim('Note: Could not load MCP descriptions and tool counts')); 763 | } 764 | } 765 | 766 | // Collect and filter data first 767 | const profileData: Array<{ 768 | name: string; 769 | mcps: Record<string, any>; 770 | filteredMcps: Record<string, any>; 771 | originalCount: number; 772 | filteredCount: number; 773 | }> = []; 774 | 775 | for (const profileName of profiles) { 776 | const mcps = await manager.getProfileMCPs(profileName) || {}; 777 | let filteredMcps = mcps; 778 | 779 | // Apply MCP filtering 780 | if (filter || options.search) { 781 | const query = filter || options.search; 782 | const queryLower = query.toLowerCase(); 783 | 784 | filteredMcps = Object.fromEntries( 785 | Object.entries(mcps).filter(([mcpName, config]) => { 786 | const description = mcpDescriptions[mcpName] || mcpName; 787 | return ( 788 | mcpName.toLowerCase().includes(queryLower) || 789 | description.toLowerCase().includes(queryLower) 790 | ); 791 | }) 792 | ); 793 | } 794 | 795 | // Apply non-empty filter 796 | if (options.nonEmpty && Object.keys(filteredMcps).length === 0) { 797 | continue; // Skip empty profiles when --non-empty is used 798 | } 799 | 800 | profileData.push({ 801 | name: profileName, 802 | mcps, 803 | filteredMcps, 804 | originalCount: Object.keys(mcps).length, 805 | filteredCount: Object.keys(filteredMcps).length 806 | }); 807 | } 808 | 809 | // Check if filtering returned no results 810 | if (profileData.length === 0) { 811 | const queryInfo = filter || options.search; 812 | console.log(chalk.yellow(`⚠️ No MCPs found${queryInfo ? ` matching "${queryInfo}"` : ''}`)); 813 | 814 | // Suggest available MCPs if search was used 815 | if (queryInfo) { 816 | const allMcps = new Set<string>(); 817 | for (const profile of manager.listProfiles()) { 818 | const mcps = await manager.getProfileMCPs(profile); 819 | if (mcps) { 820 | Object.keys(mcps).forEach(mcp => allMcps.add(mcp)); 821 | } 822 | } 823 | 824 | if (allMcps.size > 0) { 825 | const suggestions = findSimilarNames(queryInfo, Array.from(allMcps)); 826 | if (suggestions.length > 0) { 827 | console.log(chalk.yellow('\n💡 Did you mean:')); 828 | suggestions.forEach((suggestion, index) => { 829 | console.log(` ${index + 1}. ${chalk.cyan(suggestion)}`); 830 | }); 831 | } else { 832 | console.log(chalk.yellow('\n📋 Available MCPs:')); 833 | Array.from(allMcps).slice(0, 10).forEach((mcp, index) => { 834 | console.log(` ${index + 1}. ${chalk.cyan(mcp)}`); 835 | }); 836 | } 837 | } 838 | } 839 | return; 840 | } 841 | 842 | // Sort profiles if requested 843 | if (options.sort !== 'name') { 844 | profileData.sort((a, b) => { 845 | switch (options.sort) { 846 | case 'tools': 847 | return b.filteredCount - a.filteredCount; 848 | case 'profiles': 849 | return a.name.localeCompare(b.name); 850 | default: 851 | return a.name.localeCompare(b.name); 852 | } 853 | }); 854 | } 855 | 856 | // Display results 857 | console.log(''); 858 | console.log(chalk.bold.white('Profiles ▶ MCPs')); 859 | if (filter || options.search) { 860 | console.log(chalk.dim(`🔍 Filtered by: "${filter || options.search}"`)); 861 | } 862 | console.log(''); 863 | 864 | let totalMCPs = 0; 865 | 866 | for (const data of profileData) { 867 | const { name: profileName, filteredMcps, filteredCount } = data; 868 | totalMCPs += filteredCount; 869 | 870 | // Profile header with count 871 | const countBadge = filteredCount > 0 ? chalk.green(`${filteredCount} MCPs`) : chalk.dim('empty'); 872 | console.log(`📦 ${chalk.bold.white(profileName)}`, chalk.dim(`(${countBadge})`)); 873 | 874 | // Depth 0: profiles only - skip MCP details 875 | if (depth === 0) { 876 | // Already showing profile, nothing more needed 877 | } else if (filteredMcps && Object.keys(filteredMcps).length > 0) { 878 | const mcpEntries = Object.entries(filteredMcps); 879 | mcpEntries.forEach(([mcpName, config], index) => { 880 | const isLast = index === mcpEntries.length - 1; 881 | const connector = isLast ? '└──' : '├──'; 882 | const indent = isLast ? ' ' : '│ '; 883 | 884 | // MCP name with tool count (following profile count style) 885 | const toolCount = mcpToolCounts[mcpName]; 886 | const versionPart = mcpVersions[mcpName] ? chalk.magenta(`v${mcpVersions[mcpName]}`) : ''; 887 | 888 | // If not in cache, it means MCP hasn't connected successfully 889 | const toolPart = toolCount !== undefined ? chalk.green(`${toolCount} tools`) : chalk.gray('not available'); 890 | 891 | const badge = versionPart && toolPart ? chalk.dim(` (${versionPart} | ${toolPart})`) : 892 | versionPart ? chalk.dim(` (${versionPart})`) : 893 | toolPart ? chalk.dim(` (${toolPart})`) : ''; 894 | console.log(` ${connector} ${chalk.bold.cyanBright(mcpName)}${badge}`); 895 | 896 | // Depth 1+: Show description if available and meaningful 897 | if (depth >= 1 && mcpDescriptions[mcpName]) { 898 | const description = mcpDescriptions[mcpName]; 899 | // Skip descriptions that just repeat the MCP name (no value added) 900 | if (description.toLowerCase() !== mcpName.toLowerCase()) { 901 | console.log(` ${indent} ${chalk.white(description)}`); 902 | } 903 | } 904 | 905 | // Depth 2: Show command with reverse colors and text wrapping 906 | if (depth >= 2) { 907 | const commandText = formatCommandDisplay(config.command, config.args); 908 | const maxWidth = process.stdout.columns ? process.stdout.columns - 6 : 80; // Leave space for indentation 909 | const wrappedLines = TextUtils.wrapTextWithBackground(commandText, maxWidth, ` ${indent} `, (text: string) => chalk.bgGray.black(text)); 910 | console.log(wrappedLines); 911 | } 912 | }); 913 | } else if (depth > 0) { 914 | console.log(chalk.dim(' └── (empty)')); 915 | } 916 | console.log(''); 917 | } 918 | 919 | // No cleanup needed for lightweight approach 920 | 921 | }); 922 | 923 | 924 | // Helper function to format find command output with consistent color scheme 925 | function formatFindOutput(text: string): string { 926 | return text 927 | // Tool names in headers: # **toolname** -> bold light blue 928 | .replace(/^# \*\*([^*]+)\*\*/gm, (match, toolName) => chalk.bold.cyanBright(toolName)) 929 | // Parameters: ### param: type (optional) - description 930 | .replace(/^### ([^:]+): (.+)$/gm, (match, param, rest) => { 931 | // Handle: type (optional) - description 932 | const optionalDescMatch = rest.match(/^(.+?)\s+\*\(optional\)\*\s*-\s*(.+)$/); 933 | if (optionalDescMatch) { 934 | return `${chalk.yellow(param)}: ${chalk.cyan(optionalDescMatch[1])} ${chalk.dim('(optional)')} - ${chalk.white(optionalDescMatch[2])}`; 935 | } 936 | 937 | // Handle: type - description 938 | const descMatch = rest.match(/^(.+?)\s*-\s*(.+)$/); 939 | if (descMatch) { 940 | return `${chalk.yellow(param)}: ${chalk.cyan(descMatch[1])} - ${chalk.white(descMatch[2])}`; 941 | } 942 | 943 | // Handle: type (optional) 944 | const optionalMatch = rest.match(/^(.+)\s+\*\(optional\)\*$/); 945 | if (optionalMatch) { 946 | return `${chalk.yellow(param)}: ${chalk.cyan(optionalMatch[1])} ${chalk.dim('(optional)')}`; 947 | } 948 | 949 | // Handle: type only 950 | return `${chalk.yellow(param)}: ${chalk.cyan(rest)}`; 951 | }) 952 | // Parameter descriptions: #### description -> dim 953 | .replace(/^#### (.+)$/gm, (match, desc) => chalk.dim(desc)) 954 | // Separators: --- -> dim 955 | .replace(/^---$/gm, chalk.dim('---')) 956 | // Bold text in general: **text** -> bold for tool names in lists 957 | .replace(/\*\*([^*]+)\*\*/g, (match, text) => { 958 | // Check if it's a tool name (contains colon) 959 | if (text.includes(':')) { 960 | return chalk.bold.cyanBright(text); 961 | } else { 962 | // MCP name or other bold text 963 | return chalk.bold(text); 964 | } 965 | }) 966 | // [no parameters] -> dim 967 | .replace(/\*\[no parameters\]\*/g, chalk.dim('[no parameters]')) 968 | // Italic text: *text* -> dim for tips 969 | .replace(/\*([^*\[]+)\*/g, (match, text) => chalk.dim(text)) 970 | // Confidence percentages: (XX% match) -> green percentage 971 | .replace(/\((\d+)% match\)/g, (match, percentage) => chalk.dim(`(${chalk.green(percentage + '%')} match)`)) 972 | // Header search results - make query bold white 973 | .replace(/Found tools for "([^"]+)"/g, (match, query) => `Found tools for ${chalk.bold.white(`"${query}"`)}`) 974 | // No results message 975 | .replace(/❌ No tools found for "([^"]+)"/g, (match, query) => `❌ No tools found for ${chalk.bold.white(`"${query}"`)}`) 976 | // Usage tips 977 | .replace(/^💡 (.+)$/gm, (match, tip) => `💡 ${chalk.white(tip)}`); 978 | } 979 | 980 | 981 | 982 | // Remove command 983 | program 984 | .command('remove <name>') 985 | .description('Remove an MCP server from profiles') 986 | .option('--profile <names...>', 'Profile(s) to remove from (can specify multiple, default: all)') 987 | .action(async (name, options) => { 988 | console.log(chalk.blue(`🗑️ Removing MCP server: ${chalk.bold(name)}`)); 989 | 990 | const manager = new ProfileManager(); 991 | await manager.initialize(); 992 | 993 | // ⚠️ CRITICAL: Default MUST be ['all'] - DO NOT CHANGE! 994 | const profiles = options.profile || ['all']; 995 | 996 | // Validate if MCP exists and get suggestions 997 | const validation = await validateRemoveCommand(name, manager, profiles); 998 | 999 | if (!validation.mcpExists) { 1000 | console.log(chalk.yellow(`⚠️ MCP "${name}" not found in specified profiles`)); 1001 | 1002 | if (validation.suggestions.length > 0) { 1003 | console.log(chalk.yellow('\n💡 Did you mean:')); 1004 | validation.suggestions.forEach((suggestion, index) => { 1005 | console.log(` ${index + 1}. ${chalk.cyan(suggestion)}`); 1006 | }); 1007 | console.log(chalk.yellow('\n💡 Use the exact name from the list above')); 1008 | } else if (validation.allMCPs.length > 0) { 1009 | console.log(chalk.yellow('\n📋 Available MCPs in these profiles:')); 1010 | validation.allMCPs.forEach((mcp, index) => { 1011 | console.log(` ${index + 1}. ${chalk.cyan(mcp)}`); 1012 | }); 1013 | } else { 1014 | console.log(chalk.dim('\n📋 No MCPs found in specified profiles')); 1015 | console.log(chalk.dim('💡 Use \'ncp list\' to see all configured MCPs')); 1016 | } 1017 | 1018 | console.log(chalk.yellow('\n⚠️ No changes made')); 1019 | return; 1020 | } 1021 | 1022 | // MCP exists, proceed with removal 1023 | console.log(chalk.green('✅ MCP found, proceeding with removal...\n')); 1024 | 1025 | // Initialize cache patcher 1026 | const cachePatcher = new CachePatcher(); 1027 | 1028 | for (const profileName of profiles) { 1029 | try { 1030 | // 1. Remove from profile 1031 | await manager.removeMCPFromProfile(profileName, name); 1032 | console.log(OutputFormatter.success(`Removed ${name} from profile: ${profileName}`)); 1033 | 1034 | // 2. Clean up caches 1035 | console.log(chalk.dim('🔧 Cleaning up caches...')); 1036 | 1037 | try { 1038 | // Remove from tool metadata cache 1039 | await cachePatcher.patchRemoveMCP(name); 1040 | 1041 | // Remove from embeddings cache 1042 | await cachePatcher.patchRemoveEmbeddings(name); 1043 | 1044 | // Update profile hash 1045 | const profile = await manager.getProfile(profileName); 1046 | if (profile) { 1047 | const profileHash = cachePatcher.generateProfileHash(profile); 1048 | await cachePatcher.updateProfileHash(profileHash); 1049 | } 1050 | 1051 | console.log(`${chalk.green('✅')} Cache cleaned for ${name}`); 1052 | 1053 | } catch (cacheError: any) { 1054 | console.log(`${chalk.yellow('⚠️')} Could not clean cache: ${cacheError.message}`); 1055 | console.log(chalk.dim(' Profile updated successfully. Cache will rebuild on next discovery.')); 1056 | } 1057 | 1058 | } catch (error: any) { 1059 | const errorResult = ErrorHandler.handle(error, ErrorHandler.createContext('profile', 'remove', `${name} from ${profileName}`)); 1060 | console.log('\n' + ErrorHandler.formatForConsole(errorResult)); 1061 | } 1062 | } 1063 | }); 1064 | 1065 | // Config command group 1066 | const configCmd = program 1067 | .command('config') 1068 | .description('Manage NCP configuration (import, validate, edit)'); 1069 | 1070 | configCmd 1071 | .command('import [file]') 1072 | .description('Import MCP configurations from file or clipboard') 1073 | .option('--profile <name>', 'Target profile (default: all)') 1074 | .option('--dry-run', 'Show what would be imported without actually importing') 1075 | .action(async (file, options) => { 1076 | try { 1077 | const manager = new ConfigManager(); 1078 | await manager.importConfig(file, options.profile, options.dryRun); 1079 | } catch (error: any) { 1080 | const errorResult = ErrorHandler.handle(error, ErrorHandler.createContext('config', 'import', file || 'clipboard')); 1081 | console.log('\n' + ErrorHandler.formatForConsole(errorResult)); 1082 | process.exit(1); 1083 | } 1084 | }); 1085 | 1086 | configCmd 1087 | .command('edit') 1088 | .description('Open config directory in default editor') 1089 | .action(async () => { 1090 | const manager = new ConfigManager(); 1091 | await manager.editConfig(); 1092 | }); 1093 | 1094 | configCmd 1095 | .command('validate') 1096 | .description('Validate current configuration') 1097 | .action(async () => { 1098 | const manager = new ConfigManager(); 1099 | await manager.validateConfig(); 1100 | }); 1101 | 1102 | configCmd 1103 | .command('location') 1104 | .description('Show configuration file locations') 1105 | .action(async () => { 1106 | const manager = new ConfigManager(); 1107 | await manager.showConfigLocations(); 1108 | }); 1109 | 1110 | 1111 | // Repair command - fix failed MCPs interactively 1112 | program 1113 | .command('repair') 1114 | .description('Interactively configure failed MCPs') 1115 | .option('--profile <name>', 'Profile to repair (default: all)') 1116 | .action(async (options) => { 1117 | try { 1118 | // ⚠️ CRITICAL: Default MUST be 'all' - DO NOT CHANGE! 1119 | const profileName = options.profile || program.getOptionValue('profile') || 'all'; 1120 | 1121 | console.log(chalk.bold('\n🔧 MCP Repair Tool\n')); 1122 | 1123 | // Load failed MCPs from both sources 1124 | const { getCacheDirectory } = await import('../utils/ncp-paths.js'); 1125 | const { CSVCache } = await import('../cache/csv-cache.js'); 1126 | const { MCPErrorParser } = await import('../utils/mcp-error-parser.js'); 1127 | const { ProfileManager } = await import('../profiles/profile-manager.js'); 1128 | const { MCPWrapper } = await import('../utils/mcp-wrapper.js'); 1129 | const { healthMonitor } = await import('../utils/health-monitor.js'); 1130 | const { readFileSync, existsSync } = await import('fs'); 1131 | const { Client } = await import('@modelcontextprotocol/sdk/client/index.js'); 1132 | const { StdioClientTransport } = await import('@modelcontextprotocol/sdk/client/stdio.js'); 1133 | 1134 | const cache = new CSVCache(getCacheDirectory(), profileName); 1135 | await cache.initialize(); 1136 | 1137 | // Load profile first to know which MCPs to check 1138 | const profileManager = new ProfileManager(); 1139 | await profileManager.initialize(); 1140 | const profile = await profileManager.getProfile(profileName); 1141 | 1142 | if (!profile) { 1143 | console.log(chalk.red(`❌ Profile '${profileName}' not found`)); 1144 | return; 1145 | } 1146 | 1147 | // Merge failed MCPs from both sources 1148 | const failedMCPs = new Map<string, { 1149 | errorMessage: string; 1150 | attemptCount: number; 1151 | source: 'cache' | 'health' | 'both'; 1152 | lastAttempt: string; 1153 | }>(); 1154 | 1155 | // Add from CSV cache 1156 | const cacheMetadata = (cache as any).metadata; 1157 | if (cacheMetadata?.failedMCPs) { 1158 | for (const [mcpName, failedInfo] of cacheMetadata.failedMCPs) { 1159 | failedMCPs.set(mcpName, { 1160 | errorMessage: failedInfo.errorMessage, 1161 | attemptCount: failedInfo.attemptCount, 1162 | source: 'cache', 1163 | lastAttempt: failedInfo.lastAttempt 1164 | }); 1165 | } 1166 | } 1167 | 1168 | // Add from health monitor (unhealthy or disabled) 1169 | const healthReport = healthMonitor.generateHealthReport(); 1170 | for (const health of healthReport.details) { 1171 | if (health.status === 'unhealthy' || health.status === 'disabled') { 1172 | // Only include if it's in the current profile 1173 | if (profile.mcpServers[health.name]) { 1174 | const existing = failedMCPs.get(health.name); 1175 | if (existing) { 1176 | // Already in cache, mark as both 1177 | existing.source = 'both'; 1178 | } else { 1179 | // Only in health monitor 1180 | failedMCPs.set(health.name, { 1181 | errorMessage: health.lastError || 'Unknown error', 1182 | attemptCount: health.errorCount, 1183 | source: 'health', 1184 | lastAttempt: health.lastCheck 1185 | }); 1186 | } 1187 | } 1188 | } 1189 | } 1190 | 1191 | if (failedMCPs.size === 0) { 1192 | console.log(chalk.green('✅ No failed MCPs! Everything is working.')); 1193 | return; 1194 | } 1195 | 1196 | console.log(chalk.yellow(`Found ${failedMCPs.size} failed MCPs\n`)); 1197 | console.log(chalk.dim('This tool will help you configure them interactively.\n')); 1198 | 1199 | const errorParser = new MCPErrorParser(); 1200 | const mcpWrapper = new MCPWrapper(); 1201 | const prompts = (await import('prompts')).default; 1202 | 1203 | let fixedCount = 0; 1204 | let skippedCount = 0; 1205 | let stillFailingCount = 0; 1206 | 1207 | // Iterate through failed MCPs 1208 | for (const [mcpName, failedInfo] of failedMCPs) { 1209 | console.log(chalk.cyan(`\n📦 ${mcpName}`)); 1210 | console.log(chalk.dim(` Last error: ${failedInfo.errorMessage}`)); 1211 | console.log(chalk.dim(` Failed ${failedInfo.attemptCount} time(s)`)); 1212 | 1213 | // Show source of failure detection 1214 | const sourceLabel = failedInfo.source === 'both' 1215 | ? 'indexing & runtime' 1216 | : failedInfo.source === 'cache' 1217 | ? 'indexing' 1218 | : 'runtime'; 1219 | console.log(chalk.dim(` Detected during: ${sourceLabel}`)); 1220 | 1221 | // Ask if user wants to fix this MCP 1222 | const { shouldFix } = await prompts({ 1223 | type: 'confirm', 1224 | name: 'shouldFix', 1225 | message: `Try to fix ${mcpName}?`, 1226 | initial: true 1227 | }); 1228 | 1229 | if (!shouldFix) { 1230 | skippedCount++; 1231 | continue; 1232 | } 1233 | 1234 | // Get current MCP config first (needed for all tiers) 1235 | const currentConfig = profile.mcpServers[mcpName]; 1236 | if (!currentConfig) { 1237 | console.log(chalk.red(` ❌ MCP not found in profile`)); 1238 | skippedCount++; 1239 | continue; 1240 | } 1241 | 1242 | // Skip HTTP/SSE MCPs (they don't have command/args to repair) 1243 | if (currentConfig.url && !currentConfig.command) { 1244 | console.log(chalk.yellow(` ⚠️ Skipping HTTP/SSE MCP (remote connector)`)); 1245 | skippedCount++; 1246 | continue; 1247 | } 1248 | 1249 | // Two-tier configuration detection (same as ncp add): 1250 | // Tier 1: Cached schema (from previous successful add or MCP protocol) 1251 | // Tier 2: Error parsing (fallback) 1252 | 1253 | let detectedSchema: any = null; 1254 | 1255 | // Check for cached schema 1256 | const schemaCache = new SchemaCache(getCacheDirectory()); 1257 | detectedSchema = schemaCache.get(mcpName); 1258 | 1259 | if (detectedSchema) { 1260 | console.log(chalk.dim(` ✓ Using cached configuration schema`)); 1261 | } 1262 | 1263 | // If we have a schema, use schema-based prompting 1264 | if (detectedSchema) { 1265 | const schemaReader = new ConfigSchemaReader(); 1266 | const configPrompter = new ConfigPrompter(); 1267 | 1268 | if (schemaReader.hasRequiredConfig(detectedSchema)) { 1269 | console.log(chalk.cyan(`\n 📋 Configuration required`)); 1270 | 1271 | const promptedConfig = await configPrompter.promptForConfig(detectedSchema, mcpName); 1272 | 1273 | // Update config with prompted values 1274 | const updatedConfig = { 1275 | command: currentConfig.command, 1276 | args: [...(currentConfig.args || []), ...(promptedConfig.arguments || [])], 1277 | env: { ...(currentConfig.env || {}), ...(promptedConfig.environmentVariables || {}) } 1278 | }; 1279 | 1280 | // Save updated config 1281 | await profileManager.addMCPToProfile(profileName, mcpName, updatedConfig); 1282 | 1283 | console.log(chalk.green(`\n ✅ Configuration updated for ${mcpName}`)); 1284 | fixedCount++; 1285 | continue; 1286 | } 1287 | } 1288 | 1289 | // Tier 3: Fallback to error parsing if no schema available 1290 | const logPath = mcpWrapper.getLogFile(mcpName); 1291 | let stderr = ''; 1292 | 1293 | if (existsSync(logPath)) { 1294 | const logContent = readFileSync(logPath, 'utf-8'); 1295 | // Extract stderr lines 1296 | const stderrLines = logContent.split('\n').filter(line => line.includes('[STDERR]')); 1297 | stderr = stderrLines.map(line => line.replace(/\[STDERR\]\s*/, '')).join('\n'); 1298 | } else { 1299 | stderr = failedInfo.errorMessage; 1300 | } 1301 | 1302 | // Parse errors to detect configuration needs 1303 | const configNeeds = errorParser.parseError(mcpName, stderr, 1); 1304 | 1305 | if (configNeeds.length === 0) { 1306 | console.log(chalk.yellow(` ⚠️ Could not detect specific configuration needs`)); 1307 | console.log(chalk.dim(` Check logs manually: ${logPath}`)); 1308 | skippedCount++; 1309 | continue; 1310 | } 1311 | 1312 | // Check if it's a missing package 1313 | const packageMissing = configNeeds.find(n => n.type === 'package_missing'); 1314 | if (packageMissing) { 1315 | console.log(chalk.red(` ❌ Package not found on npm - cannot fix`)); 1316 | console.log(chalk.dim(` ${packageMissing.extractedFrom}`)); 1317 | skippedCount++; 1318 | continue; 1319 | } 1320 | 1321 | console.log(chalk.yellow(`\n Found ${configNeeds.length} configuration need(s):`)); 1322 | for (const need of configNeeds) { 1323 | console.log(chalk.dim(` • ${need.description}`)); 1324 | } 1325 | 1326 | // Collect new configuration from user 1327 | const newEnv = { ...(currentConfig.env || {}) }; 1328 | const newArgs = [...(currentConfig.args || [])]; 1329 | 1330 | for (const need of configNeeds) { 1331 | if (need.type === 'api_key' || need.type === 'env_var') { 1332 | const { value } = await prompts({ 1333 | type: need.sensitive ? 'password' : 'text', 1334 | name: 'value', 1335 | message: need.prompt, 1336 | validate: (val: string) => val.length > 0 ? true : 'Value required' 1337 | }); 1338 | 1339 | if (!value) { 1340 | console.log(chalk.yellow(` Skipped ${mcpName}`)); 1341 | skippedCount++; 1342 | continue; 1343 | } 1344 | 1345 | newEnv[need.variable] = value; 1346 | } else if (need.type === 'command_arg') { 1347 | const { value } = await prompts({ 1348 | type: 'text', 1349 | name: 'value', 1350 | message: need.prompt, 1351 | validate: (val: string) => val.length > 0 ? true : 'Value required' 1352 | }); 1353 | 1354 | if (!value) { 1355 | console.log(chalk.yellow(` Skipped ${mcpName}`)); 1356 | skippedCount++; 1357 | continue; 1358 | } 1359 | 1360 | newArgs.push(value); 1361 | } 1362 | } 1363 | 1364 | // Test MCP with new configuration 1365 | console.log(chalk.dim(`\n Testing ${mcpName} with new configuration...`)); 1366 | 1367 | const testConfig = { 1368 | name: mcpName, 1369 | command: currentConfig.command, 1370 | args: newArgs, 1371 | env: newEnv 1372 | }; 1373 | 1374 | try { 1375 | // Create wrapper command 1376 | const wrappedCommand = mcpWrapper.createWrapper( 1377 | testConfig.name, 1378 | testConfig.command || '', // Should never be undefined after HTTP/SSE check 1379 | testConfig.args || [] 1380 | ); 1381 | 1382 | // Test connection with 30 second timeout 1383 | const transport = new StdioClientTransport({ 1384 | command: wrappedCommand.command, 1385 | args: wrappedCommand.args, 1386 | env: { 1387 | ...process.env, 1388 | ...(testConfig.env || {}), 1389 | MCP_SILENT: 'true', 1390 | QUIET: 'true' 1391 | } 1392 | }); 1393 | 1394 | const client = new Client({ 1395 | name: 'ncp-repair-test', 1396 | version: '1.0.0' 1397 | }, { 1398 | capabilities: {} 1399 | }); 1400 | 1401 | await client.connect(transport); 1402 | 1403 | // Try to list tools 1404 | const result = await Promise.race([ 1405 | client.listTools(), 1406 | new Promise<never>((_, reject) => 1407 | setTimeout(() => reject(new Error('Test timeout')), 30000) 1408 | ) 1409 | ]); 1410 | 1411 | await client.close(); 1412 | 1413 | console.log(chalk.green(` ✅ Success! Found ${result.tools.length} tools`)); 1414 | 1415 | // Update profile with new configuration 1416 | profile.mcpServers[mcpName] = { 1417 | command: testConfig.command, 1418 | args: newArgs, 1419 | env: newEnv 1420 | }; 1421 | profile.metadata.modified = new Date().toISOString(); 1422 | 1423 | await profileManager.saveProfile(profile); 1424 | 1425 | // Remove from both failure tracking systems 1426 | if (cacheMetadata?.failedMCPs) { 1427 | cacheMetadata.failedMCPs.delete(mcpName); 1428 | (cache as any).saveMetadata(); 1429 | } 1430 | 1431 | // Clear from health monitor and mark as healthy 1432 | await healthMonitor.enableMCP(mcpName); 1433 | healthMonitor.markHealthy(mcpName); 1434 | await healthMonitor.saveHealth(); 1435 | 1436 | fixedCount++; 1437 | } catch (error: any) { 1438 | console.log(chalk.red(` ❌ Still failing: ${error.message}`)); 1439 | stillFailingCount++; 1440 | } 1441 | } 1442 | 1443 | // Final report 1444 | console.log(chalk.bold('\n📊 Repair Summary\n')); 1445 | console.log(chalk.green(`✅ Fixed: ${fixedCount}`)); 1446 | console.log(chalk.yellow(`⏭️ Skipped: ${skippedCount}`)); 1447 | console.log(chalk.red(`❌ Still failing: ${stillFailingCount}`)); 1448 | 1449 | if (fixedCount > 0) { 1450 | console.log(chalk.dim('\n💡 Run "ncp find --force-retry" to re-index fixed MCPs')); 1451 | } 1452 | } catch (error: any) { 1453 | console.error(chalk.red('\n❌ Repair command failed:'), error.message); 1454 | console.error(chalk.dim(error.stack)); 1455 | process.exit(1); 1456 | } 1457 | }); 1458 | 1459 | // Find command (CLI-optimized version for fast discovery) 1460 | program 1461 | .command('find [query]') 1462 | .description('Find tools matching a query or list all tools') 1463 | .option('--limit <number>', 'Maximum number of results (default: 5)') 1464 | .option('--page <number>', 'Page number (default: 1)') 1465 | .option('--depth <number>', 'Display depth: 0=overview, 1=tools, 2=details (default: 2)') 1466 | .option('--confidence_threshold <number>', 'Minimum confidence level (0.0-1.0, default: 0.3). Examples: 0.1=show all, 0.5=strict, 0.7=very precise') 1467 | .action(async (query, options) => { 1468 | // Add newline after command before any output 1469 | console.log(); 1470 | 1471 | // ⚠️ CRITICAL: Default MUST be 'all' - DO NOT CHANGE! 1472 | const profileName = program.getOptionValue('profile') || 'all'; 1473 | const forceRetry = program.getOptionValue('forceRetry') || false; 1474 | 1475 | // Use MCPServer for rich formatted output 1476 | const { MCPServer } = await import('../server/mcp-server.js'); 1477 | const server = new MCPServer(profileName, true, forceRetry); // Enable progress + force retry flag 1478 | 1479 | // Setup graceful shutdown on Ctrl+C 1480 | const gracefulShutdown = async () => { 1481 | process.stdout.write('\n\n💾 Saving progress...'); 1482 | try { 1483 | await server.cleanup(); 1484 | process.stdout.write('\r\u001B[K✅ Progress saved\n'); 1485 | } catch (error) { 1486 | process.stdout.write('\r\u001B[K❌ Error saving progress\n'); 1487 | } 1488 | process.exit(0); 1489 | }; 1490 | 1491 | process.on('SIGINT', gracefulShutdown); 1492 | process.on('SIGTERM', gracefulShutdown); 1493 | 1494 | await server.initialize(); 1495 | 1496 | // For CLI usage, wait for indexing to complete before searching 1497 | await server.waitForInitialization(); 1498 | 1499 | const limit = parseInt(options.limit || '5'); 1500 | const page = parseInt(options.page || '1'); 1501 | const depth = parseInt(options.depth || '2'); 1502 | const confidence_threshold = options.confidence_threshold ? parseFloat(options.confidence_threshold) : undefined; 1503 | 1504 | const result = await server.handleFind( 1505 | { jsonrpc: '2.0', id: 'cli', method: 'tools/call' }, 1506 | { description: query || '', limit, page, depth, confidence_threshold } 1507 | ); 1508 | 1509 | const formattedOutput = formatFindOutput(result.result.content[0].text); 1510 | console.log(formattedOutput); 1511 | await server.cleanup(); 1512 | }); 1513 | 1514 | // Analytics command group 1515 | const analyticsCmd = program 1516 | .command('analytics') 1517 | .description('View NCP usage analytics and performance metrics'); 1518 | 1519 | analyticsCmd 1520 | .command('dashboard') 1521 | .description('Show comprehensive analytics dashboard') 1522 | .option('--period <days>', 'Show data for last N days (e.g., --period 7)') 1523 | .option('--from <date>', 'Start date (YYYY-MM-DD format)') 1524 | .option('--to <date>', 'End date (YYYY-MM-DD format)') 1525 | .option('--today', 'Show only today\'s data') 1526 | .option('--visual', 'Enhanced visual dashboard with charts and graphs') 1527 | .action(async (options) => { 1528 | const { NCPLogParser } = await import('../analytics/log-parser.js'); 1529 | 1530 | console.log(chalk.dim('📊 Analyzing NCP usage data...')); 1531 | 1532 | const parser = new NCPLogParser(); 1533 | 1534 | // Parse time range options 1535 | const parseOptions: any = {}; 1536 | 1537 | if (options.today) { 1538 | parseOptions.today = true; 1539 | } else if (options.period) { 1540 | parseOptions.period = parseInt(options.period); 1541 | } else if (options.from || options.to) { 1542 | if (options.from) parseOptions.from = new Date(options.from); 1543 | if (options.to) parseOptions.to = new Date(options.to); 1544 | } 1545 | 1546 | const report = await parser.parseAllLogs(parseOptions); 1547 | 1548 | if (report.totalSessions === 0) { 1549 | console.log(chalk.yellow('📊 No analytics data available for the specified time range')); 1550 | console.log(chalk.dim('💡 Try a different time range or check if MCPs have been used through NCP')); 1551 | return; 1552 | } 1553 | 1554 | if (options.visual) { 1555 | const { VisualAnalyticsFormatter } = await import('../analytics/visual-formatter.js'); 1556 | const dashboard = await VisualAnalyticsFormatter.formatVisualDashboard(report); 1557 | console.log(dashboard); 1558 | } else { 1559 | const { AnalyticsFormatter } = await import('../analytics/analytics-formatter.js'); 1560 | const dashboard = AnalyticsFormatter.formatDashboard(report); 1561 | console.log(dashboard); 1562 | } 1563 | }); 1564 | 1565 | analyticsCmd 1566 | .command('performance') 1567 | .description('Show performance-focused analytics') 1568 | .option('--period <days>', 'Show data for last N days (e.g., --period 7)') 1569 | .option('--from <date>', 'Start date (YYYY-MM-DD format)') 1570 | .option('--to <date>', 'End date (YYYY-MM-DD format)') 1571 | .option('--today', 'Show only today\'s data') 1572 | .option('--visual', 'Enhanced visual performance report with gauges and charts') 1573 | .action(async (options) => { 1574 | const { NCPLogParser } = await import('../analytics/log-parser.js'); 1575 | 1576 | console.log(chalk.dim('⚡ Analyzing performance metrics...')); 1577 | 1578 | const parser = new NCPLogParser(); 1579 | 1580 | // Parse time range options 1581 | const parseOptions: any = {}; 1582 | if (options.today) { 1583 | parseOptions.today = true; 1584 | } else if (options.period) { 1585 | parseOptions.period = parseInt(options.period); 1586 | } else if (options.from || options.to) { 1587 | if (options.from) parseOptions.from = new Date(options.from); 1588 | if (options.to) parseOptions.to = new Date(options.to); 1589 | } 1590 | 1591 | const report = await parser.parseAllLogs(parseOptions); 1592 | 1593 | if (report.totalSessions === 0) { 1594 | console.log(chalk.yellow('📊 No performance data available for the specified time range')); 1595 | return; 1596 | } 1597 | 1598 | if (options.visual) { 1599 | const { VisualAnalyticsFormatter } = await import('../analytics/visual-formatter.js'); 1600 | const performance = await VisualAnalyticsFormatter.formatVisualPerformance(report); 1601 | console.log(performance); 1602 | } else { 1603 | const { AnalyticsFormatter } = await import('../analytics/analytics-formatter.js'); 1604 | const performance = AnalyticsFormatter.formatPerformanceReport(report); 1605 | console.log(performance); 1606 | } 1607 | }); 1608 | 1609 | analyticsCmd 1610 | .command('visual') 1611 | .description('Show enhanced visual analytics with charts and graphs') 1612 | .option('--period <days>', 'Show data for last N days (e.g., --period 7)') 1613 | .option('--from <date>', 'Start date (YYYY-MM-DD format)') 1614 | .option('--to <date>', 'End date (YYYY-MM-DD format)') 1615 | .option('--today', 'Show only today\'s data') 1616 | .action(async (options) => { 1617 | const { NCPLogParser } = await import('../analytics/log-parser.js'); 1618 | const { VisualAnalyticsFormatter } = await import('../analytics/visual-formatter.js'); 1619 | 1620 | console.log(chalk.dim('🎨 Generating visual analytics...')); 1621 | 1622 | const parser = new NCPLogParser(); 1623 | 1624 | // Parse time range options 1625 | const parseOptions: any = {}; 1626 | if (options.today) { 1627 | parseOptions.today = true; 1628 | } else if (options.period) { 1629 | parseOptions.period = parseInt(options.period); 1630 | } else if (options.from || options.to) { 1631 | if (options.from) parseOptions.from = new Date(options.from); 1632 | if (options.to) parseOptions.to = new Date(options.to); 1633 | } 1634 | 1635 | const report = await parser.parseAllLogs(parseOptions); 1636 | 1637 | if (report.totalSessions === 0) { 1638 | console.log(chalk.yellow('📊 No analytics data available for the specified time range')); 1639 | console.log(chalk.dim('💡 Try a different time range or check if MCPs have been used through NCP')); 1640 | return; 1641 | } 1642 | 1643 | const dashboard = await VisualAnalyticsFormatter.formatVisualDashboard(report); 1644 | console.log(dashboard); 1645 | }); 1646 | 1647 | analyticsCmd 1648 | .command('export') 1649 | .description('Export analytics data to CSV') 1650 | .option('--output <file>', 'Output file (default: ncp-analytics.csv)') 1651 | .option('--period <days>', 'Export data for last N days (e.g., --period 7)') 1652 | .option('--from <date>', 'Start date (YYYY-MM-DD format)') 1653 | .option('--to <date>', 'End date (YYYY-MM-DD format)') 1654 | .option('--today', 'Export only today\'s data') 1655 | .action(async (options) => { 1656 | const { NCPLogParser } = await import('../analytics/log-parser.js'); 1657 | const { AnalyticsFormatter } = await import('../analytics/analytics-formatter.js'); 1658 | const { writeFileSync } = await import('fs'); 1659 | 1660 | console.log(chalk.dim('📊 Generating analytics export...')); 1661 | 1662 | const parser = new NCPLogParser(); 1663 | 1664 | // Parse time range options 1665 | const parseOptions: any = {}; 1666 | if (options.today) { 1667 | parseOptions.today = true; 1668 | } else if (options.period) { 1669 | parseOptions.period = parseInt(options.period); 1670 | } else if (options.from || options.to) { 1671 | if (options.from) parseOptions.from = new Date(options.from); 1672 | if (options.to) parseOptions.to = new Date(options.to); 1673 | } 1674 | 1675 | const report = await parser.parseAllLogs(parseOptions); 1676 | 1677 | if (report.totalSessions === 0) { 1678 | console.log(chalk.yellow('📊 No data to export for the specified time range')); 1679 | return; 1680 | } 1681 | 1682 | const csv = AnalyticsFormatter.formatCSV(report); 1683 | const filename = options.output || 'ncp-analytics.csv'; 1684 | 1685 | writeFileSync(filename, csv, 'utf-8'); 1686 | console.log(chalk.green(`✅ Analytics exported to ${filename}`)); 1687 | console.log(chalk.dim(`📊 Exported ${report.totalSessions} sessions across ${report.uniqueMCPs} MCPs`)); 1688 | }); 1689 | 1690 | // Run command (existing functionality) 1691 | program 1692 | .command('run <tool>') 1693 | 1694 | .description('Run a specific tool') 1695 | .option('--params <json>', 'Tool parameters as JSON string (optional - will prompt interactively if not provided)') 1696 | .option('--no-prompt', 'Skip interactive prompting for missing parameters') 1697 | .option('--output-format <format>', 'Output format: auto (smart rendering), json (raw JSON)', 'auto') 1698 | .option('-y, --yes', 'Automatically answer yes to prompts (e.g., open media files)') 1699 | .configureHelp({ 1700 | formatHelp: (cmd, helper) => { 1701 | const indent = ' '; 1702 | let output = '\n'; 1703 | 1704 | // Header first - context before syntax 1705 | output += chalk.bold.white('NCP Run Command') + ' - ' + chalk.cyan('Direct MCP Tool Execution') + '\n\n'; 1706 | output += chalk.dim('Execute MCP tools with intelligent parameter prompting and rich media support.') + '\n'; 1707 | output += chalk.dim('Automatically handles parameter collection, validation, and response formatting.') + '\n\n'; 1708 | 1709 | // Then usage 1710 | output += chalk.bold.white('Usage:') + ' ' + helper.commandUsage(cmd) + '\n\n'; 1711 | 1712 | const visibleOptions = helper.visibleOptions(cmd); 1713 | if (visibleOptions.length) { 1714 | output += chalk.bold.white('Options:') + '\n'; 1715 | visibleOptions.forEach(option => { 1716 | const flags = option.flags; 1717 | const description = helper.optionDescription(option); 1718 | const paddingNeeded = Math.max(0, 42 - flags.length); 1719 | const padding = ' '.repeat(paddingNeeded); 1720 | output += indent + chalk.cyan(flags) + padding + ' ' + chalk.white(description) + '\n'; 1721 | }); 1722 | output += '\n'; 1723 | } 1724 | 1725 | // Examples section 1726 | output += chalk.bold.white('Examples:') + '\n'; 1727 | output += chalk.dim(' Basic execution:') + '\n'; 1728 | output += indent + chalk.yellow('ncp run memory:create_entities') + chalk.gray(' # Interactive parameter prompting') + '\n'; 1729 | output += indent + chalk.yellow('ncp run memory:create_entities --params \'{"entities":["item1"]}\'') + '\n\n'; 1730 | 1731 | output += chalk.dim(' Output control:') + '\n'; 1732 | output += indent + chalk.yellow('ncp run tool --output-format json') + chalk.gray(' # Raw JSON output') + '\n'; 1733 | output += indent + chalk.yellow('ncp run tool -y') + chalk.gray(' # Auto-open media files') + '\n\n'; 1734 | 1735 | output += chalk.dim(' Non-interactive:') + '\n'; 1736 | output += indent + chalk.yellow('ncp run tool --no-prompt --params \'{}\'') + chalk.gray(' # Scripting/automation') + '\n\n'; 1737 | 1738 | // Media support note 1739 | output += chalk.bold.white('Media Support:') + '\n'; 1740 | output += chalk.dim(' • Images and audio are displayed with metadata') + '\n'; 1741 | output += chalk.dim(' • Use') + chalk.cyan(' -y ') + chalk.dim('to auto-open media in default applications') + '\n'; 1742 | output += chalk.dim(' • Without') + chalk.cyan(' -y') + chalk.dim(', prompts before opening media files') + '\n\n'; 1743 | 1744 | return output; 1745 | } 1746 | }) 1747 | .action(async (tool, options) => { 1748 | // ⚠️ CRITICAL: Default MUST be 'all' - DO NOT CHANGE! 1749 | const profileName = program.getOptionValue('profile') || 'all'; 1750 | 1751 | const { NCPOrchestrator } = await import('../orchestrator/ncp-orchestrator.js'); 1752 | const orchestrator = new NCPOrchestrator(profileName, false); // Silent indexing for run command 1753 | 1754 | await orchestrator.initialize(); 1755 | 1756 | // If tool doesn't contain a colon, try to find matching tools first 1757 | if (!tool.includes(':')) { 1758 | console.log(chalk.dim(`🔍 Searching for tools matching "${tool}"...`)); 1759 | 1760 | try { 1761 | const matchingTools = await orchestrator.find(tool, 5, false); 1762 | 1763 | if (matchingTools.length === 0) { 1764 | console.log('\n' + OutputFormatter.error(`No tools found matching "${tool}"`)); 1765 | console.log(chalk.yellow('💡 Try \'ncp find\' to explore all available tools')); 1766 | await orchestrator.cleanup(); 1767 | return; 1768 | } 1769 | 1770 | if (matchingTools.length === 1) { 1771 | // Only one match, use it automatically 1772 | const matchedTool = matchingTools[0]; 1773 | tool = matchedTool.toolName; 1774 | console.log(chalk.green(`✅ Found exact match: ${tool}`)); 1775 | } else { 1776 | // Multiple matches, show them and ask user to be more specific 1777 | console.log(chalk.yellow(`Found ${matchingTools.length} matching tools:`)); 1778 | matchingTools.forEach((match, index) => { 1779 | const confidence = Math.round(match.confidence * 100); 1780 | console.log(` ${index + 1}. ${chalk.cyan(match.toolName)} (${confidence}% match)`); 1781 | if (match.description) { 1782 | console.log(` ${chalk.dim(match.description)}`); 1783 | } 1784 | }); 1785 | console.log(chalk.yellow('\n💡 Please specify the exact tool name from the list above')); 1786 | console.log(chalk.yellow(`💡 Example: ncp run ${matchingTools[0].toolName}`)); 1787 | await orchestrator.cleanup(); 1788 | return; 1789 | } 1790 | } catch (error: any) { 1791 | console.log('\n' + OutputFormatter.error(`Error searching for tools: ${error.message}`)); 1792 | await orchestrator.cleanup(); 1793 | return; 1794 | } 1795 | } 1796 | 1797 | // Check if parameters are provided 1798 | let parameters = {}; 1799 | if (options.params) { 1800 | parameters = JSON.parse(options.params); 1801 | } else { 1802 | // Get tool schema and parameters 1803 | const toolParams = orchestrator.getToolParameters(tool); 1804 | 1805 | if (toolParams && toolParams.length > 0) { 1806 | const requiredParams = toolParams.filter(p => p.required); 1807 | 1808 | if (requiredParams.length > 0 && options.prompt !== false) { 1809 | // Interactive prompting for parameters (default behavior) 1810 | const { ParameterPrompter } = await import('../utils/parameter-prompter.js'); 1811 | const { ParameterPredictor } = await import('../server/mcp-server.js'); 1812 | 1813 | const prompter = new ParameterPrompter(); 1814 | const predictor = new ParameterPredictor(); 1815 | const toolContext = orchestrator.getToolContext(tool); 1816 | 1817 | try { 1818 | parameters = await prompter.promptForParameters(tool, toolParams, predictor, toolContext); 1819 | prompter.close(); 1820 | } catch (error) { 1821 | prompter.close(); 1822 | console.log('\n' + OutputFormatter.error('Error during parameter input')); 1823 | await orchestrator.cleanup(); 1824 | return; 1825 | } 1826 | } else if (requiredParams.length > 0 && options.prompt === false) { 1827 | console.log('\n' + OutputFormatter.error('This tool requires parameters')); 1828 | console.log(chalk.yellow(`💡 Use: ncp run ${tool} --params '{"param": "value"}'`)); 1829 | console.log(chalk.yellow(`💡 Or use: ncp find "${tool}" --depth 2 to see required parameters`)); 1830 | console.log(chalk.yellow(`💡 Or remove --no-prompt to use interactive prompting`)); 1831 | await orchestrator.cleanup(); 1832 | return; 1833 | } 1834 | } 1835 | } 1836 | 1837 | console.log(OutputFormatter.running(tool) + '\n'); 1838 | 1839 | const result = await orchestrator.run(tool, parameters); 1840 | 1841 | if (result.success) { 1842 | // Check if the content indicates an actual error despite "success" status 1843 | const contentStr = JSON.stringify(result.content); 1844 | const isActualError = contentStr.includes('"type":"text"') && 1845 | (contentStr.includes('Error:') || contentStr.includes('not found') || contentStr.includes('Unknown tool')); 1846 | 1847 | if (isActualError) { 1848 | const errorText = result.content?.[0]?.text || 'Unknown error occurred'; 1849 | let suggestions: string[] = []; 1850 | 1851 | if (errorText.includes('not configured') || errorText.includes('Unknown tool')) { 1852 | // Extract the query from the tool name for vector search 1853 | const [mcpName, toolName] = tool.split(':'); 1854 | 1855 | // Try multiple search strategies to find the best matches 1856 | let similarTools: any[] = []; 1857 | 1858 | try { 1859 | // Strategy 1: Search with both MCP context and tool name for better domain matching 1860 | if (toolName && mcpName) { 1861 | const contextualQuery = `${mcpName} ${toolName}`; 1862 | similarTools = await orchestrator.find(contextualQuery, 3, false); 1863 | } 1864 | 1865 | // Strategy 2: If no results, try just the tool name 1866 | if (similarTools.length === 0 && toolName) { 1867 | similarTools = await orchestrator.find(toolName, 3, false); 1868 | } 1869 | 1870 | // Strategy 3: If still no results, try just the MCP name (domain search) 1871 | if (similarTools.length === 0) { 1872 | similarTools = await orchestrator.find(mcpName, 3, false); 1873 | } 1874 | 1875 | if (similarTools.length > 0) { 1876 | suggestions.push('💡 Did you mean:'); 1877 | similarTools.forEach(similar => { 1878 | const confidence = Math.round(similar.confidence * 100); 1879 | suggestions.push(` • ${similar.toolName} (${confidence}% match)`); 1880 | }); 1881 | } 1882 | } catch (error: any) { 1883 | // Fallback to basic suggestions if vector search fails 1884 | suggestions = ['Try \'ncp find <keyword>\' to discover similar tools']; 1885 | } 1886 | } 1887 | 1888 | const context = ErrorHandler.createContext('mcp', 'run', tool, suggestions); 1889 | const errorResult = ErrorHandler.handle(errorText, context); 1890 | console.log('\n' + ErrorHandler.formatForConsole(errorResult)); 1891 | } else { 1892 | console.log(OutputFormatter.success('Tool execution completed')); 1893 | 1894 | // Respect user's output format choice 1895 | if (options.outputFormat === 'json') { 1896 | // Raw JSON output - test different formatters to pick the best 1897 | const { formatJson } = await import('../utils/highlighting.js'); 1898 | console.log(formatJson(result.content, 'cli-highlight')); // Let's test this one 1899 | } else { 1900 | // Smart response formatting (default) 1901 | const { ResponseFormatter } = await import('../utils/response-formatter.js'); 1902 | 1903 | // Check if this is text content that should be formatted naturally 1904 | const isTextResponse = Array.isArray(result.content) && 1905 | result.content.every((item: any) => item?.type === 'text'); 1906 | 1907 | if (isTextResponse || (result.content?.[0]?.type === 'text' && result.content.length === 1)) { 1908 | // Format as natural text with proper newlines 1909 | console.log(ResponseFormatter.format(result.content, true, options.yes)); 1910 | } else if (ResponseFormatter.isPureData(result.content)) { 1911 | // Pure data - use JSON formatting 1912 | const { formatJson } = await import('../utils/highlighting.js'); 1913 | console.log(formatJson(result.content, 'cli-highlight')); 1914 | } else { 1915 | // Mixed content or unknown - use smart formatter 1916 | console.log(ResponseFormatter.format(result.content, true, options.yes)); 1917 | } 1918 | } 1919 | } 1920 | } else { 1921 | // Check if this is a tool not found error and provide "did you mean" suggestions 1922 | const errorMessage = result.error || 'Unknown error occurred'; 1923 | let suggestions: string[] = []; 1924 | 1925 | if (errorMessage.includes('not configured') || errorMessage.includes('Unknown tool')) { 1926 | // Extract the query from the tool name for vector search 1927 | const [mcpName, toolName] = tool.split(':'); 1928 | 1929 | // Try multiple search strategies to find the best matches 1930 | let similarTools: any[] = []; 1931 | 1932 | try { 1933 | // Strategy 1: Search with both MCP context and tool name for better domain matching 1934 | if (toolName && mcpName) { 1935 | const contextualQuery = `${mcpName} ${toolName}`; 1936 | similarTools = await orchestrator.find(contextualQuery, 3, false); 1937 | } 1938 | 1939 | // Strategy 2: If no results, try just the tool name 1940 | if (similarTools.length === 0 && toolName) { 1941 | similarTools = await orchestrator.find(toolName, 3, false); 1942 | } 1943 | 1944 | // Strategy 3: If still no results, try just the MCP name (domain search) 1945 | if (similarTools.length === 0) { 1946 | similarTools = await orchestrator.find(mcpName, 3, false); 1947 | } 1948 | if (similarTools.length > 0) { 1949 | suggestions.push('💡 Did you mean:'); 1950 | similarTools.forEach(similar => { 1951 | const confidence = Math.round(similar.confidence * 100); 1952 | suggestions.push(` • ${similar.toolName} (${confidence}% match)`); 1953 | }); 1954 | } 1955 | } catch (error: any) { 1956 | // Fallback to basic suggestions if vector search fails 1957 | suggestions = ['Try \'ncp find <keyword>\' to discover similar tools']; 1958 | } 1959 | } 1960 | 1961 | const context = ErrorHandler.createContext('mcp', 'run', tool, suggestions); 1962 | const errorResult = ErrorHandler.handle(errorMessage, context); 1963 | console.log('\n' + ErrorHandler.formatForConsole(errorResult)); 1964 | } 1965 | 1966 | await orchestrator.cleanup(); 1967 | }); 1968 | 1969 | // Auth command 1970 | program 1971 | .command('auth <mcp>') 1972 | .description('Authenticate an MCP server using OAuth Device Flow') 1973 | .option('--profile <name>', 'Profile to use (default: all)') 1974 | .configureHelp({ 1975 | formatHelp: () => { 1976 | let output = '\n'; 1977 | output += chalk.bold.white('NCP Auth Command') + ' - ' + chalk.cyan('OAuth Authentication') + '\n\n'; 1978 | output += chalk.dim('Authenticate an MCP server that requires OAuth 2.0 Device Flow authentication.') + '\n'; 1979 | output += chalk.dim('Tokens are securely stored and automatically refreshed.') + '\n\n'; 1980 | output += chalk.bold.white('Usage:') + '\n'; 1981 | output += ' ' + chalk.yellow('ncp auth <mcp>') + ' # Authenticate an MCP server\n'; 1982 | output += ' ' + chalk.yellow('ncp auth <mcp> --profile <name>') + ' # Authenticate for specific profile\n\n'; 1983 | output += chalk.bold.white('Examples:') + '\n'; 1984 | output += ' ' + chalk.gray('$ ') + chalk.yellow('ncp auth github') + '\n'; 1985 | output += ' ' + chalk.gray('$ ') + chalk.yellow('ncp auth my-api --profile production') + '\n\n'; 1986 | return output; 1987 | } 1988 | }) 1989 | .action(async (mcpName, options) => { 1990 | try { 1991 | const profileName = options.profile || 'all'; 1992 | 1993 | // Load profile 1994 | const manager = new ProfileManager(); 1995 | await manager.initialize(); 1996 | 1997 | const profile = await manager.getProfile(profileName); 1998 | if (!profile) { 1999 | console.error(chalk.red(`❌ Profile '${profileName}' not found`)); 2000 | process.exit(1); 2001 | } 2002 | 2003 | // Check if MCP exists in profile 2004 | const mcpConfig = profile.mcpServers[mcpName]; 2005 | if (!mcpConfig) { 2006 | console.error(chalk.red(`❌ MCP '${mcpName}' not found in profile '${profileName}'`)); 2007 | 2008 | // Suggest similar MCPs 2009 | const availableMCPs = Object.keys(profile.mcpServers); 2010 | if (availableMCPs.length > 0) { 2011 | const suggestions = findSimilarNames(mcpName, availableMCPs); 2012 | if (suggestions.length > 0) { 2013 | console.log(chalk.yellow('\n💡 Did you mean:')); 2014 | suggestions.forEach((suggestion, index) => { 2015 | console.log(` ${index + 1}. ${chalk.cyan(suggestion)}`); 2016 | }); 2017 | } 2018 | } 2019 | process.exit(1); 2020 | } 2021 | 2022 | // Check if MCP has OAuth configuration 2023 | if (!mcpConfig.auth || mcpConfig.auth.type !== 'oauth' || !mcpConfig.auth.oauth) { 2024 | console.error(chalk.red(`❌ MCP '${mcpName}' does not have OAuth configuration`)); 2025 | console.log(chalk.yellow('\n💡 To add OAuth configuration, edit your profile configuration file:')); 2026 | console.log(chalk.dim(' Add "auth": { "type": "oauth", "oauth": { ... } } to the MCP configuration')); 2027 | process.exit(1); 2028 | } 2029 | 2030 | // Perform OAuth Device Flow 2031 | const { DeviceFlowAuthenticator } = await import('../auth/oauth-device-flow.js'); 2032 | const { getTokenStore } = await import('../auth/token-store.js'); 2033 | 2034 | const authenticator = new DeviceFlowAuthenticator(mcpConfig.auth.oauth); 2035 | const tokenStore = getTokenStore(); 2036 | 2037 | console.log(chalk.blue(`🔐 Starting OAuth Device Flow for '${mcpName}'...\n`)); 2038 | 2039 | try { 2040 | const tokenResponse = await authenticator.authenticate(); 2041 | 2042 | // Store the token 2043 | await tokenStore.storeToken(mcpName, tokenResponse); 2044 | 2045 | console.log(chalk.green(`✅ Successfully authenticated '${mcpName}'!`)); 2046 | console.log(chalk.dim(` Token expires: ${new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString()}`)); 2047 | console.log(chalk.dim(` Token stored securely in: ~/.ncp/tokens/${mcpName}.token`)); 2048 | } catch (error: any) { 2049 | console.error(chalk.red(`❌ Authentication failed: ${error.message}`)); 2050 | process.exit(1); 2051 | } 2052 | } catch (error: any) { 2053 | console.error(chalk.red(`❌ Error: ${error.message}`)); 2054 | process.exit(1); 2055 | } 2056 | }); 2057 | 2058 | // Update command 2059 | program 2060 | .command('update') 2061 | .description('Update NCP to the latest version') 2062 | .option('--check', 'Check for updates without installing') 2063 | .configureHelp({ 2064 | formatHelp: () => { 2065 | let output = '\n'; 2066 | output += chalk.bold.white('NCP Update Command') + ' - ' + chalk.cyan('Version Management') + '\n\n'; 2067 | output += chalk.dim('Keep NCP up to date with the latest features and bug fixes.') + '\n\n'; 2068 | output += chalk.bold.white('Usage:') + '\n'; 2069 | output += ' ' + chalk.yellow('ncp update') + ' # Update to latest version\n'; 2070 | output += ' ' + chalk.yellow('ncp update --check') + ' # Check for updates without installing\n\n'; 2071 | output += chalk.bold.white('Examples:') + '\n'; 2072 | output += ' ' + chalk.gray('$ ') + chalk.yellow('ncp update --check') + '\n'; 2073 | output += ' ' + chalk.gray('$ ') + chalk.yellow('ncp update') + '\n\n'; 2074 | return output; 2075 | } 2076 | }) 2077 | .action(async (options) => { 2078 | try { 2079 | const updateChecker = new UpdateChecker(); 2080 | 2081 | if (options.check) { 2082 | // Check for updates only 2083 | console.log(chalk.blue('🔍 Checking for updates...')); 2084 | const result = await updateChecker.checkForUpdates(true); 2085 | 2086 | if (result.hasUpdate) { 2087 | console.log(chalk.yellow('📦 Update Available!')); 2088 | console.log(chalk.dim(` Current: ${result.currentVersion}`)); 2089 | console.log(chalk.green(` Latest: ${result.latestVersion}`)); 2090 | console.log(); 2091 | console.log(chalk.cyan(' Run: ncp update')); 2092 | } else { 2093 | console.log(chalk.green('✅ You are using the latest version!')); 2094 | console.log(chalk.dim(` Version: ${result.currentVersion}`)); 2095 | } 2096 | } else { 2097 | // Perform update 2098 | const result = await updateChecker.checkForUpdates(true); 2099 | 2100 | if (result.hasUpdate) { 2101 | console.log(chalk.yellow('📦 Update Available!')); 2102 | console.log(chalk.dim(` Current: ${result.currentVersion}`)); 2103 | console.log(chalk.green(` Latest: ${result.latestVersion}`)); 2104 | console.log(); 2105 | 2106 | const success = await updateChecker.performUpdate(); 2107 | if (!success) { 2108 | process.exit(1); 2109 | } 2110 | } else { 2111 | console.log(chalk.green('✅ You are already using the latest version!')); 2112 | console.log(chalk.dim(` Version: ${result.currentVersion}`)); 2113 | } 2114 | } 2115 | } catch (error) { 2116 | console.error(chalk.red('❌ Failed to check for updates:'), error); 2117 | process.exit(1); 2118 | } 2119 | }); 2120 | 2121 | // Check for updates on CLI startup (non-intrusive) 2122 | // Temporarily disabled - causing hangs in some environments 2123 | // TODO: Re-enable with proper timeout handling 2124 | // (async () => { 2125 | // try { 2126 | // const updateChecker = new UpdateChecker(); 2127 | // await updateChecker.showUpdateNotification(); 2128 | // } catch { 2129 | // // Silently fail - don't interrupt normal CLI usage 2130 | // } 2131 | // })(); 2132 | 2133 | program.parse(); 2134 | } ```