This is page 7 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 -------------------------------------------------------------------------------- /INTERNAL-MCP-ARCHITECTURE.md: -------------------------------------------------------------------------------- ```markdown 1 | # Internal MCP Architecture - Complete! 🎉 2 | 3 | ## ✅ **What Was Implemented** 4 | 5 | We've successfully implemented an **internal MCP architecture** where NCP exposes management tools as if they were regular MCPs, but they're handled internally without external processes. 6 | 7 | --- 8 | 9 | ## 🏗️ **Architecture Overview** 10 | 11 | ### **Before: Direct Exposure** ❌ 12 | ``` 13 | NCP MCP Server 14 | ├── find (top-level) 15 | ├── run (top-level) 16 | ├── add_mcp (top-level) ← Exposed directly! 17 | └── remove_mcp (top-level) ← Exposed directly! 18 | ``` 19 | 20 | ### **After: Internal MCP Pattern** ✅ 21 | ``` 22 | NCP MCP Server 23 | ├── find (top-level) ← Search tools in configured MCPs 24 | └── run (top-level) ← Execute ANY tool (external or internal) 25 | 26 | Internal MCPs (discovered via find, executed via run): 27 | └── ncp (internal MCP) 28 | ├── add ← ncp:add 29 | ├── remove ← ncp:remove 30 | ├── list ← ncp:list 31 | ├── import ← ncp:import (clipboard/file/discovery) 32 | └── export ← ncp:export (clipboard/file) 33 | ``` 34 | 35 | --- 36 | 37 | ## 🔑 **Key Concepts** 38 | 39 | ### **1. The "Inception" Pattern** 40 | 41 | | Tool | Purpose | Analogy | 42 | |------|---------|---------| 43 | | **`find`** (top-level) | Find tools in **configured** MCPs | "What can I do with what I have?" | 44 | | **`ncp:import`** (internal) | Find **new MCPs** from registry | "What else can I add?" (inception!) | 45 | 46 | ### **2. Internal vs External MCPs** 47 | 48 | | Aspect | External MCPs | Internal MCPs | 49 | |--------|---------------|---------------| 50 | | **Process** | Separate process (node, python, etc.) | No process (handled internally) | 51 | | **Discovery** | Same (appears in `find` results) | Same (appears in `find` results) | 52 | | **Execution** | Via MCP protocol (stdio transport) | Direct method call | 53 | | **Configuration** | Needs command, args, env | Hardcoded in NCP | 54 | | **Examples** | github, filesystem, brave-search | ncp (management tools) | 55 | 56 | --- 57 | 58 | ## 📁 **Files Created** 59 | 60 | ### **1. Internal MCP Types** (`src/internal-mcps/types.ts`) 61 | ```typescript 62 | export interface InternalTool { 63 | name: string; 64 | description: string; 65 | inputSchema: { /* JSON Schema */ }; 66 | } 67 | 68 | export interface InternalMCP { 69 | name: string; 70 | description: string; 71 | tools: InternalTool[]; 72 | executeTool(toolName: string, parameters: any): Promise<InternalToolResult>; 73 | } 74 | ``` 75 | 76 | ### **2. NCP Management MCP** (`src/internal-mcps/ncp-management.ts`) 77 | 78 | Implements all management tools: 79 | 80 | ```typescript 81 | export class NCPManagementMCP implements InternalMCP { 82 | name = 'ncp'; 83 | description = 'NCP configuration management tools'; 84 | 85 | tools = [ 86 | { 87 | name: 'add', 88 | description: 'Add single MCP (with clipboard security)', 89 | inputSchema: { mcp_name, command, args?, profile? } 90 | }, 91 | { 92 | name: 'remove', 93 | description: 'Remove MCP', 94 | inputSchema: { mcp_name, profile? } 95 | }, 96 | { 97 | name: 'list', 98 | description: 'List configured MCPs', 99 | inputSchema: { profile? } 100 | }, 101 | { 102 | name: 'import', 103 | description: 'Bulk import MCPs', 104 | inputSchema: { 105 | from: 'clipboard' | 'file' | 'discovery', 106 | source?: string // file path or search query 107 | } 108 | }, 109 | { 110 | name: 'export', 111 | description: 'Export configuration', 112 | inputSchema: { 113 | to: 'clipboard' | 'file', 114 | destination?: string, // file path 115 | profile?: string 116 | } 117 | } 118 | ]; 119 | } 120 | ``` 121 | 122 | ### **3. Internal MCP Manager** (`src/internal-mcps/internal-mcp-manager.ts`) 123 | 124 | Manages all internal MCPs: 125 | 126 | ```typescript 127 | export class InternalMCPManager { 128 | private internalMCPs: Map<string, InternalMCP> = new Map(); 129 | 130 | constructor() { 131 | // Register internal MCPs 132 | this.registerInternalMCP(new NCPManagementMCP()); 133 | } 134 | 135 | initialize(profileManager: ProfileManager): void { 136 | // Initialize each internal MCP with ProfileManager 137 | } 138 | 139 | async executeInternalTool(mcpName: string, toolName: string, params: any) { 140 | // Route to appropriate internal MCP 141 | } 142 | 143 | isInternalMCP(mcpName: string): boolean { 144 | // Check if MCP is internal 145 | } 146 | } 147 | ``` 148 | 149 | --- 150 | 151 | ## 🔄 **Integration with Orchestrator** 152 | 153 | ### **Changes to `NCPOrchestrator`** 154 | 155 | **1. Added InternalMCPManager:** 156 | ```typescript 157 | private internalMCPManager: InternalMCPManager; 158 | 159 | constructor() { 160 | // ... 161 | this.internalMCPManager = new InternalMCPManager(); 162 | } 163 | ``` 164 | 165 | **2. Initialize internal MCPs after ProfileManager:** 166 | ```typescript 167 | private async loadProfile() { 168 | if (!this.profileManager) { 169 | this.profileManager = new ProfileManager(); 170 | await this.profileManager.initialize(); 171 | 172 | // Initialize internal MCPs with ProfileManager 173 | this.internalMCPManager.initialize(this.profileManager); 174 | } 175 | } 176 | ``` 177 | 178 | **3. Add internal MCPs to tool discovery:** 179 | ```typescript 180 | async initialize() { 181 | // ... index external MCPs ... 182 | 183 | // Add internal MCPs to discovery 184 | this.addInternalMCPsToDiscovery(); 185 | } 186 | 187 | private addInternalMCPsToDiscovery() { 188 | const internalMCPs = this.internalMCPManager.getAllInternalMCPs(); 189 | 190 | for (const mcp of internalMCPs) { 191 | // Add to definitions 192 | this.definitions.set(mcp.name, { /* ... */ }); 193 | 194 | // Add tools to allTools 195 | for (const tool of mcp.tools) { 196 | this.allTools.push({ name: tool.name, description: tool.description, mcpName: mcp.name }); 197 | this.toolToMCP.set(`${mcp.name}:${tool.name}`, mcp.name); 198 | } 199 | 200 | // Index in discovery engine 201 | this.discovery.indexMCPTools(mcp.name, discoveryTools); 202 | } 203 | } 204 | ``` 205 | 206 | **4. Route internal tool execution:** 207 | ```typescript 208 | async run(toolName: string, parameters: any) { 209 | // Parse tool name 210 | const [mcpName, actualToolName] = toolName.split(':'); 211 | 212 | // Check if internal MCP 213 | if (this.internalMCPManager.isInternalMCP(mcpName)) { 214 | return await this.internalMCPManager.executeInternalTool( 215 | mcpName, 216 | actualToolName, 217 | parameters 218 | ); 219 | } 220 | 221 | // Otherwise, execute as external MCP 222 | // ... 223 | } 224 | ``` 225 | 226 | --- 227 | 228 | ## 🎯 **Tool Definitions** 229 | 230 | ### **`ncp:add`** 231 | ```typescript 232 | { 233 | from: 'clipboard' | 'file' | 'discovery', 234 | source?: string 235 | } 236 | 237 | // Examples: 238 | ncp:add { mcp_name: "github", command: "npx", args: ["-y", "@modelcontextprotocol/server-github"] } 239 | // User can copy {"env":{"GITHUB_TOKEN":"secret"}} to clipboard before approving 240 | ``` 241 | 242 | ### **`ncp:remove`** 243 | ```typescript 244 | { 245 | mcp_name: string, 246 | profile?: string 247 | } 248 | 249 | // Example: 250 | ncp:remove { mcp_name: "github" } 251 | ``` 252 | 253 | ### **`ncp:list`** 254 | ```typescript 255 | { 256 | profile?: string 257 | } 258 | 259 | // Example: 260 | ncp:list { } // Lists all MCPs in 'all' profile 261 | ``` 262 | 263 | ### **`ncp:import`** (Unified bulk import) 264 | ```typescript 265 | { 266 | from: 'clipboard' | 'file' | 'discovery', 267 | source?: string 268 | } 269 | 270 | // Mode 1: From clipboard 271 | ncp:import { } // Reads JSON from clipboard 272 | 273 | // Mode 2: From file 274 | ncp:import { from: "file", source: "~/configs/my-mcps.json" } 275 | 276 | // Mode 3: From discovery (registry) 277 | ncp:import { from: "discovery", source: "github automation" } 278 | // Shows numbered list → User selects → Prompts for each → Imports all 279 | ``` 280 | 281 | ### **`ncp:export`** 282 | ```typescript 283 | { 284 | to: 'clipboard' | 'file', 285 | destination?: string, 286 | profile?: string 287 | } 288 | 289 | // Example 1: To clipboard 290 | ncp:export { } // Exports to clipboard 291 | 292 | // Example 2: To file 293 | ncp:export { to: "file", destination: "~/backups/ncp-config.json" } 294 | ``` 295 | 296 | --- 297 | 298 | ## 🚀 **User Experience** 299 | 300 | ### **Scenario: Add GitHub MCP** 301 | 302 | **User:** "Add GitHub MCP" 303 | 304 | **AI workflow:** 305 | 1. Calls `prompts/get confirm_add_mcp` → Shows dialog 306 | 2. User copies `{"env":{"GITHUB_TOKEN":"ghp_..."}}` → Clicks YES 307 | 3. AI calls `run` with `ncp:add` → Tool executes internally 308 | 4. Returns success (secrets never seen by AI!) 309 | 310 | ### **Scenario: Bulk Import from Clipboard** 311 | 312 | **User:** "Import MCPs from my clipboard" 313 | 314 | **AI workflow:** 315 | 1. User copies JSON config to clipboard: 316 | ```json 317 | { 318 | "mcpServers": { 319 | "github": { "command": "npx", "args": [...] }, 320 | "filesystem": { "command": "npx", "args": [...] } 321 | } 322 | } 323 | ``` 324 | 2. AI calls `run` with `ncp:import { }` 325 | 3. NCP reads clipboard → Imports all MCPs 326 | 4. Returns: "✅ Imported 2 MCPs from clipboard" 327 | 328 | ### **Scenario: Discovery Mode** (Future) 329 | 330 | **User:** "Find MCPs for GitHub automation" 331 | 332 | **AI workflow:** 333 | 1. Calls `run` with `ncp:import { from: "discovery", source: "github automation" }` 334 | 2. NCP queries registry → Returns numbered list: 335 | ``` 336 | 1. github - Official GitHub MCP 337 | 2. github-actions - Trigger workflows 338 | 3. octokit - Full GitHub API 339 | ``` 340 | 3. AI shows list to user → User responds "1,3" 341 | 4. For each selected: 342 | - Show `confirm_add_mcp` prompt 343 | - User copies secrets if needed → Clicks YES 344 | - Add MCP with clipboard config 345 | 5. Returns: "✅ Imported 2 MCPs" 346 | 347 | --- 348 | 349 | ## 🔒 **Security Benefits** 350 | 351 | ### **Clipboard Security Pattern** (From Phase 1) 352 | - ✅ User explicitly instructed to copy before clicking YES 353 | - ✅ Secrets read server-side (never exposed to AI) 354 | - ✅ Audit trail shows approval, not secrets 355 | - ✅ Informed consent (not sneaky background reading) 356 | 357 | ### **Internal MCP Architecture** (Phase 2) 358 | - ✅ Management tools discoverable like any MCP 359 | - ✅ No direct exposure in top-level tools 360 | - ✅ Consistent interface (find → run) 361 | - ✅ Can be extended with more internal MCPs 362 | 363 | --- 364 | 365 | ## 📊 **Before vs After** 366 | 367 | ### **Before: Direct Exposure** 368 | ``` 369 | tools/list → 4 tools 370 | - find 371 | - run 372 | - add_mcp ← Direct exposure! 373 | - remove_mcp ← Direct exposure! 374 | ``` 375 | 376 | ### **After: Internal MCP Pattern** 377 | ``` 378 | tools/list → 2 tools 379 | - find 380 | - run 381 | 382 | find results → Includes internal MCPs 383 | - ncp:add 384 | - ncp:remove 385 | - ncp:list 386 | - ncp:import 387 | - ncp:export 388 | 389 | run → Routes internal MCPs to InternalMCPManager 390 | ``` 391 | 392 | --- 393 | 394 | ## 🎯 **Benefits** 395 | 396 | 1. **Clean Separation** - Top-level tools remain minimal (find, run) 397 | 2. **Consistency** - Internal MCPs work exactly like external MCPs 398 | 3. **Discoverability** - Users find management tools via `find` 399 | 4. **Extensibility** - Easy to add more internal MCPs 400 | 5. **Security** - Clipboard pattern integrated into management tools 401 | 6. **No Process Overhead** - Internal MCPs execute instantly (no stdio transport) 402 | 403 | --- 404 | 405 | ## 🧪 **Testing** 406 | 407 | ### **Test 1: Discover Internal MCPs** 408 | ```bash 409 | echo '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"find","arguments":{"description":"ncp"}}}' | npx ncp 410 | ``` 411 | 412 | **Expected:** Returns `ncp:add`, `ncp:remove`, `ncp:list`, `ncp:import`, `ncp:export` 413 | 414 | ### **Test 2: List Configured MCPs** 415 | ```bash 416 | echo '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"run","arguments":{"tool":"ncp:list"}}}' | npx ncp 417 | ``` 418 | 419 | **Expected:** Returns list of configured MCPs 420 | 421 | ### **Test 3: Add MCP** 422 | ```bash 423 | # First show prompt 424 | echo '{"jsonrpc":"2.0","id":3,"method":"prompts/get","params":{"name":"confirm_add_mcp","arguments":{"mcp_name":"test","command":"echo","args":["hello"]}}}' | npx ncp 425 | 426 | # Then call add tool 427 | echo '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"run","arguments":{"tool":"ncp:add","parameters":{"mcp_name":"test","command":"echo","args":["hello"]}}}}' | npx ncp 428 | ``` 429 | 430 | **Expected:** MCP added to profile 431 | 432 | --- 433 | 434 | ## 🚀 **Next Steps** (Future Phases) 435 | 436 | ### **Phase 3: Registry Integration** (Pending) 437 | - Implement `ncp:import` discovery mode 438 | - Query MCP registry API 439 | - Show numbered/checkbox list 440 | - Batch prompt + import workflow 441 | 442 | ### **Phase 4: Advanced Features** 443 | - `ncp:update` - Update MCP configuration 444 | - `ncp:enable` / `ncp:disable` - Toggle MCPs without removing 445 | - `ncp:validate` - Test MCP before adding 446 | - `ncp:clone` - Duplicate MCP with different config 447 | 448 | --- 449 | 450 | ## 📝 **Key Implementation Details** 451 | 452 | ### **Tool ID Format** 453 | ```typescript 454 | // External MCPs: "mcpName:toolName" 455 | "github:create_issue" 456 | "filesystem:read_file" 457 | 458 | // Internal MCPs: "mcpName:toolName" 459 | "ncp:add" 460 | "ncp:import" 461 | ``` 462 | 463 | ### **Tool Routing Logic** 464 | ```typescript 465 | if (toolIdentifier.includes(':')) { 466 | const [mcpName, toolName] = toolIdentifier.split(':'); 467 | 468 | if (internalMCPManager.isInternalMCP(mcpName)) { 469 | // Route to internal MCP 470 | return internalMCPManager.executeInternalTool(mcpName, toolName, params); 471 | } else { 472 | // Route to external MCP via MCP protocol 473 | return await connection.client.callTool({ name: toolName, arguments: params }); 474 | } 475 | } 476 | ``` 477 | 478 | --- 479 | 480 | ## ✅ **Implementation Complete!** 481 | 482 | We've successfully created an elegant internal MCP architecture that: 483 | - ✅ Keeps top-level tools minimal (find, run only) 484 | - ✅ Exposes management tools as an internal MCP (`ncp`) 485 | - ✅ Maintains clipboard security pattern 486 | - ✅ Provides clean parameter design (`from/to` + `source/destination`) 487 | - ✅ Integrates seamlessly with tool discovery 488 | - ✅ Routes execution correctly (internal vs external) 489 | 490 | **The foundation is solid. Ready for registry integration (Phase 3)!** 🎉 491 | ``` -------------------------------------------------------------------------------- /docs/stories/05-runtime-detective.md: -------------------------------------------------------------------------------- ```markdown 1 | # 🕵️ Story 5: Runtime Detective 2 | 3 | *How NCP automatically uses the right Node.js - even when you toggle Claude Desktop settings* 4 | 5 | **Reading time:** 2 minutes 6 | 7 | --- 8 | 9 | ## 😵 The Pain 10 | 11 | You installed NCP as a .mcpb extension in Claude Desktop. It works perfectly! Then... 12 | 13 | **Scenario 1: The Mystery Crash** 14 | 15 | ``` 16 | [You toggle "Use Built-in Node.js for MCP" setting in Claude Desktop] 17 | [Restart Claude Desktop] 18 | [NCP starts loading your MCPs...] 19 | [Filesystem MCP: ❌ FAILED] 20 | [GitHub MCP: ❌ FAILED] 21 | [Database MCP: ❌ FAILED] 22 | 23 | You: "What broke?! It was working 5 minutes ago!" 24 | ``` 25 | 26 | **The Problem:** Your .mcpb extensions were using Claude Desktop's bundled Node.js (v20). You toggled the setting. Now they're trying to use your system Node.js (v18). Some Node.js 20 features don't exist in v18. Everything breaks. 27 | 28 | **Scenario 2: The Path Confusion** 29 | 30 | ``` 31 | [NCP installed globally via npm] 32 | [Uses system Node.js /usr/local/bin/node] 33 | [.mcpb extensions installed in Claude Desktop] 34 | [Expect Claude's bundled Node.js] 35 | 36 | NCP spawns extension: 37 | command: "node /path/to/extension/index.js" 38 | 39 | Which node??? 40 | - System node (/usr/local/bin/node)? Wrong version! 41 | - Claude's bundled node? Don't know the path! 42 | - Extension breaks silently 43 | ``` 44 | 45 | **The Root Problem:** Node.js runtime is a **moving target**: 46 | 47 | - Claude Desktop ships its own Node.js (predictable version) 48 | - Your system has different Node.js (unpredictable version) 49 | - Users toggle settings (changes which runtime to use) 50 | - Extensions need to match the runtime NCP is using 51 | - **Getting it wrong = everything breaks** 52 | 53 | --- 54 | 55 | ## 🕵️ The Journey 56 | 57 | NCP acts as a **runtime detective** - it figures out which runtime it's using, then ensures all MCPs use the same one. 58 | 59 | ### **How Detection Works:** 60 | 61 | **On Every Startup** (not just once): 62 | 63 | ```typescript 64 | // Step 1: Check how NCP itself was launched 65 | const myPath = process.execPath; 66 | // Example: /Applications/Claude.app/.../node 67 | 68 | // Step 2: Is this Claude Desktop's bundled runtime? 69 | if (myPath.includes('/Claude.app/') || 70 | myPath.includes('/Claude/resources/')) { 71 | // Yes! I'm running via Claude's bundled Node.js 72 | runtime = 'bundled'; 73 | nodePath = '/Applications/Claude.app/.../node'; 74 | pythonPath = '/Applications/Claude.app/.../python3'; 75 | } else { 76 | // No! I'm running via system runtime 77 | runtime = 'system'; 78 | nodePath = 'node'; // Use system node 79 | pythonPath = 'python3'; // Use system python 80 | } 81 | 82 | // Step 3: Log what we detected (for debugging) 83 | console.log(`Runtime detected: ${runtime}`); 84 | console.log(`Node path: ${nodePath}`); 85 | ``` 86 | 87 | **Why Every Startup?** Because the runtime can change! 88 | 89 | - User toggles "Use Built-in Node.js" → Runtime changes 90 | - User switches between .mcpb and npm install → Runtime changes 91 | - User updates Claude Desktop → Bundled runtime path changes 92 | 93 | **Static detection (at install time) would break. Dynamic detection (at runtime) adapts.** 94 | 95 | ### **How MCP Spawning Works:** 96 | 97 | When NCP needs to start an MCP: 98 | 99 | ```typescript 100 | // MCP config from manifest.json 101 | const mcpConfig = { 102 | command: "node", // Generic command 103 | args: ["${__dirname}/dist/index.js"] 104 | }; 105 | 106 | // Runtime detector translates to actual runtime 107 | const actualCommand = getRuntimeForCommand(mcpConfig.command); 108 | // If detected bundled: "/Applications/Claude.app/.../node" 109 | // If detected system: "node" 110 | 111 | // Spawn MCP with correct runtime 112 | spawn(actualCommand, mcpConfig.args); 113 | ``` 114 | 115 | **Result:** MCPs always use the same runtime NCP is using. No mismatches. No breaks. 116 | 117 | --- 118 | 119 | ## ✨ The Magic 120 | 121 | What you get with dynamic runtime detection: 122 | 123 | ### **🎯 Just Works** 124 | - Install NCP any way (npm, .mcpb, manual) 125 | - NCP detects runtime automatically 126 | - MCPs use correct runtime automatically 127 | - Zero configuration required 128 | 129 | ### **🔄 Adapts to Settings** 130 | - Toggle "Use Built-in Node.js" → NCP adapts on next startup 131 | - Switch between Claude Desktop and system → NCP adapts 132 | - Update Claude Desktop → NCP finds new runtime path 133 | 134 | ### **🐛 No Version Mismatches** 135 | - NCP running via Node 20 → MCPs use Node 20 136 | - NCP running via Node 18 → MCPs use Node 18 137 | - **Always matched.** No subtle version bugs. 138 | 139 | ### **🔍 Debuggable** 140 | - NCP logs detected runtime on startup 141 | - Shows Node path, Python path 142 | - Easy to verify correct runtime selected 143 | 144 | ### **⚡ Works Across Platforms** 145 | - macOS: Detects `/Applications/Claude.app/...` 146 | - Windows: Detects `C:\...\Claude\resources\...` 147 | - Linux: Detects `/opt/Claude/resources/...` 148 | 149 | --- 150 | 151 | ## 🔍 How It Works (The Technical Story) 152 | 153 | ### **Runtime Detection Algorithm:** 154 | 155 | ```typescript 156 | // src/utils/runtime-detector.ts 157 | 158 | export function detectRuntime(): RuntimeInfo { 159 | const currentNodePath = process.execPath; 160 | 161 | // Check if we're running via Claude Desktop's bundled Node 162 | const claudeBundledNode = getBundledRuntimePath('claude-desktop', 'node'); 163 | // Returns: "/Applications/Claude.app/.../node" (platform-specific) 164 | 165 | // If our execPath matches the bundled Node path → bundled runtime 166 | if (currentNodePath === claudeBundledNode) { 167 | return { 168 | type: 'bundled', 169 | nodePath: claudeBundledNode, 170 | pythonPath: getBundledRuntimePath('claude-desktop', 'python') 171 | }; 172 | } 173 | 174 | // Check if execPath is inside Claude.app → probably bundled 175 | const isInsideClaudeApp = currentNodePath.includes('/Claude.app/') || 176 | currentNodePath.includes('\\Claude\\'); 177 | 178 | if (isInsideClaudeApp && existsSync(claudeBundledNode)) { 179 | return { 180 | type: 'bundled', 181 | nodePath: claudeBundledNode, 182 | pythonPath: getBundledRuntimePath('claude-desktop', 'python') 183 | }; 184 | } 185 | 186 | // Otherwise → system runtime 187 | return { 188 | type: 'system', 189 | nodePath: 'node', // Use system node 190 | pythonPath: 'python3' // Use system python 191 | }; 192 | } 193 | ``` 194 | 195 | ### **Command Translation:** 196 | 197 | ```typescript 198 | // src/utils/runtime-detector.ts 199 | 200 | export function getRuntimeForExtension(command: string): string { 201 | const runtime = detectRuntime(); 202 | 203 | // If command is 'node' → translate to actual runtime 204 | if (command === 'node' || command.endsWith('/node')) { 205 | return runtime.nodePath; 206 | } 207 | 208 | // If command is 'python3' → translate to actual runtime 209 | if (command === 'python3' || command === 'python') { 210 | return runtime.pythonPath || command; 211 | } 212 | 213 | // Other commands → return as-is 214 | return command; 215 | } 216 | ``` 217 | 218 | ### **Client Registry (Platform-Specific Paths):** 219 | 220 | ```typescript 221 | // src/utils/client-registry.ts 222 | 223 | export const CLIENT_REGISTRY = { 224 | 'claude-desktop': { 225 | bundledRuntimes: { 226 | node: { 227 | darwin: '/Applications/Claude.app/.../node', 228 | win32: '%LOCALAPPDATA%/Programs/Claude/.../node.exe', 229 | linux: '/opt/Claude/resources/.../node' 230 | }, 231 | python: { 232 | darwin: '/Applications/Claude.app/.../python3', 233 | win32: '%LOCALAPPDATA%/Programs/Claude/.../python.exe', 234 | linux: '/opt/Claude/resources/.../python3' 235 | } 236 | } 237 | } 238 | }; 239 | ``` 240 | 241 | **NCP knows where Claude Desktop hides its runtimes on every platform!** 242 | 243 | --- 244 | 245 | ## 🎨 The Analogy That Makes It Click 246 | 247 | **Static Runtime (Wrong Approach) = Directions Written on Paper** 🗺️ 248 | 249 | ``` 250 | "Go to 123 Main Street" 251 | [Next week: Store moves to 456 Oak Avenue] 252 | [Your paper still says 123 Main Street] 253 | [You arrive at wrong location] 254 | [Confused why nothing works] 255 | ``` 256 | 257 | **Dynamic Runtime (NCP Approach) = GPS Navigation** 📍 258 | 259 | ``` 260 | "Navigate to Store" 261 | [GPS finds current location of store] 262 | [Store moves? GPS updates automatically] 263 | [You always arrive at correct location] 264 | [Never confused, always works] 265 | ``` 266 | 267 | **NCP doesn't remember where runtime was. It detects where runtime IS.** 268 | 269 | --- 270 | 271 | ## 🧪 See It Yourself 272 | 273 | Try this experiment: 274 | 275 | ### **Test 1: Detect Current Runtime** 276 | 277 | ```bash 278 | # Install NCP and check logs 279 | ncp list 280 | 281 | # Look for startup logs: 282 | [Runtime Detection] 283 | Type: bundled 284 | Node: /Applications/Claude.app/.../node 285 | Python: /Applications/Claude.app/.../python3 286 | Process execPath: /Applications/Claude.app/.../node 287 | ``` 288 | 289 | ### **Test 2: Toggle Setting and See Adaptation** 290 | 291 | ```bash 292 | # Before toggle 293 | [Claude Desktop: "Use Built-in Node.js for MCP" = ON] 294 | [Restart Claude Desktop] 295 | [Check logs: Type: bundled] 296 | 297 | # Toggle setting 298 | [Claude Desktop: "Use Built-in Node.js for MCP" = OFF] 299 | [Restart Claude Desktop] 300 | [Check logs: Type: system] 301 | 302 | # NCP adapted automatically! 303 | ``` 304 | 305 | ### **Test 3: Install via npm and Compare** 306 | 307 | ```bash 308 | # Install NCP globally 309 | npm install -g @portel/ncp 310 | 311 | # Run and check detection 312 | ncp list 313 | 314 | # Look for startup logs: 315 | [Runtime Detection] 316 | Type: system 317 | Node: node 318 | Python: python3 319 | Process execPath: /usr/local/bin/node 320 | 321 | # Different runtime detected! But MCPs will still use system runtime consistently. 322 | ``` 323 | 324 | --- 325 | 326 | ## 🚀 Why This Changes Everything 327 | 328 | ### **Before Runtime Detection (Chaos):** 329 | 330 | ``` 331 | User installs .mcpb extension 332 | → Works with bundled Node.js 333 | 334 | User toggles "Use Built-in Node.js" setting 335 | → MCPs try to use system Node.js 336 | → Version mismatch 337 | → Cryptic errors 338 | → User spends 2 hours debugging 339 | 340 | User gives up, uninstalls 341 | ``` 342 | 343 | ### **After Runtime Detection (Harmony):** 344 | 345 | ``` 346 | User installs .mcpb extension 347 | → Works with bundled Node.js 348 | 349 | User toggles "Use Built-in Node.js" setting 350 | → NCP detects change on next startup 351 | → MCPs automatically switch to system Node.js 352 | → Everything still works 353 | 354 | User: "That was easy! It just works." 355 | ``` 356 | 357 | **The difference:** **Adaptability.** 358 | 359 | --- 360 | 361 | ## 🎯 Why Dynamic (Not Static)? 362 | 363 | **Question:** Why detect runtime on every startup? Why not cache the result? 364 | 365 | **Answer:** Because the runtime isn't stable! 366 | 367 | **Things that change runtime:** 368 | 369 | 1. **User toggles settings** (most common) 370 | 2. **User updates Claude Desktop** (bundled runtime path changes) 371 | 3. **User updates system Node.js** (system runtime version changes) 372 | 4. **User switches installation method** (.mcpb → npm or vice versa) 373 | 5. **CI/CD environment** (different runtime per environment) 374 | 375 | **Static detection** = Breaks when any of these change (frequent!) 376 | 377 | **Dynamic detection** = Adapts automatically (resilient!) 378 | 379 | **Cost:** ~5ms on startup to detect runtime. 380 | 381 | **Benefit:** Never breaks due to runtime changes. 382 | 383 | **Obvious trade-off.** 384 | 385 | --- 386 | 387 | ## 🔒 Edge Cases Handled 388 | 389 | ### **Edge Case 1: Claude Desktop Not Installed** 390 | 391 | ```typescript 392 | // getBundledRuntimePath returns null if Claude Desktop not found 393 | if (!claudeBundledNode) { 394 | // Fall back to system runtime 395 | return { type: 'system', nodePath: 'node', pythonPath: 'python3' }; 396 | } 397 | ``` 398 | 399 | ### **Edge Case 2: Bundled Runtime Missing** 400 | 401 | ```typescript 402 | // Check if bundled runtime actually exists 403 | if (claudeBundledNode && existsSync(claudeBundledNode)) { 404 | // Use it 405 | } else { 406 | // Fall back to system 407 | } 408 | ``` 409 | 410 | ### **Edge Case 3: Running in Test Environment** 411 | 412 | ```typescript 413 | // In tests, use system runtime (for predictability) 414 | if (process.env.NODE_ENV === 'test') { 415 | return { type: 'system', nodePath: 'node', pythonPath: 'python3' }; 416 | } 417 | ``` 418 | 419 | ### **Edge Case 4: Symlinked Global Install** 420 | 421 | ```typescript 422 | // process.execPath follows symlinks 423 | // /usr/local/bin/ncp (symlink) → /usr/lib/node_modules/ncp/... (real) 424 | const realPath = realpathSync(process.execPath); 425 | // Use real path for detection 426 | ``` 427 | 428 | **NCP handles all the weird scenarios. You don't have to think about it.** 429 | 430 | --- 431 | 432 | ## 📚 Deep Dive 433 | 434 | Want the full technical implementation? 435 | 436 | - **Runtime Detector:** [src/utils/runtime-detector.ts] 437 | - **Client Registry:** [src/utils/client-registry.ts] 438 | - **Command Translation:** [Runtime detection summary] 439 | - **Platform Support:** [docs/technical/platform-detection.md] 440 | 441 | --- 442 | 443 | ## 🔗 Next Story 444 | 445 | **[Story 6: Official Registry →](06-official-registry.md)** 446 | 447 | *How AI discovers 2,200+ MCPs without you lifting a finger* 448 | 449 | --- 450 | 451 | ## 💬 Questions? 452 | 453 | **Q: What if I want to force a specific runtime?** 454 | 455 | A: Set environment variable: `NCP_FORCE_RUNTIME=/path/to/node`. NCP will respect it. (Advanced users only!) 456 | 457 | **Q: Can I see which runtime was detected?** 458 | 459 | A: Yes! Check NCP startup logs or run `ncp --debug`. Shows detected runtime type and paths. 460 | 461 | **Q: What if Claude Desktop's bundled runtime is broken?** 462 | 463 | A: NCP will detect it's not working (spawn fails) and log error. You can manually configure system runtime as fallback. 464 | 465 | **Q: Does runtime detection work for Python MCPs?** 466 | 467 | A: Yes! NCP detects both Node.js and Python bundled runtimes. Same logic applies. 468 | 469 | **Q: What about other runtimes (Go, Rust, etc.)?** 470 | 471 | A: MCPs in compiled languages (Go, Rust) don't need runtime detection. They're self-contained binaries. NCP just runs them as-is. 472 | 473 | --- 474 | 475 | **[← Previous Story](04-double-click-install.md)** | **[Back to Story Index](../README.md#the-six-stories)** | **[Next Story →](06-official-registry.md)** 476 | ``` -------------------------------------------------------------------------------- /test/integration/mcp-client-simulation.test.cjs: -------------------------------------------------------------------------------- ``` 1 | #!/usr/bin/env node 2 | /** 3 | * Integration Test: MCP Client Simulation 4 | * 5 | * Simulates real AI client behavior (Claude Desktop, Perplexity) to catch bugs 6 | * that unit tests miss. This should be run before EVERY release. 7 | * 8 | * Tests: 9 | * 1. Server responds to initialize immediately 10 | * 2. tools/list returns tools < 100ms even during indexing 11 | * 3. find returns partial results during indexing (not empty) 12 | * 4. Cache profileHash persists across restarts 13 | * 5. Second startup uses cache (no re-indexing) 14 | */ 15 | 16 | const { spawn } = require('child_process'); 17 | const fs = require('fs'); 18 | const path = require('path'); 19 | const os = require('os'); 20 | 21 | // Test configuration 22 | const NCP_DIR = path.join(os.homedir(), '.ncp'); 23 | const PROFILES_DIR = path.join(NCP_DIR, 'profiles'); 24 | const CACHE_DIR = path.join(NCP_DIR, 'cache'); 25 | const TEST_PROFILE = 'integration-test'; 26 | const TIMEOUT_MS = 10000; 27 | 28 | // Ensure test profile exists 29 | function setupTestProfile() { 30 | // Create .ncp directory structure 31 | if (!fs.existsSync(PROFILES_DIR)) { 32 | fs.mkdirSync(PROFILES_DIR, { recursive: true }); 33 | } 34 | if (!fs.existsSync(CACHE_DIR)) { 35 | fs.mkdirSync(CACHE_DIR, { recursive: true }); 36 | } 37 | 38 | // Create minimal test profile with filesystem MCP 39 | const profilePath = path.join(PROFILES_DIR, `${TEST_PROFILE}.json`); 40 | const testProfile = { 41 | mcpServers: { 42 | filesystem: { 43 | command: 'npx', 44 | args: ['@modelcontextprotocol/server-filesystem'] 45 | } 46 | } 47 | }; 48 | 49 | fs.writeFileSync(profilePath, JSON.stringify(testProfile, null, 2)); 50 | logInfo(`Created test profile at ${profilePath}`); 51 | } 52 | 53 | // ANSI colors for output 54 | const colors = { 55 | green: '\x1b[32m', 56 | red: '\x1b[31m', 57 | yellow: '\x1b[33m', 58 | blue: '\x1b[34m', 59 | reset: '\x1b[0m' 60 | }; 61 | 62 | function log(emoji, message, color = 'reset') { 63 | console.log(`${emoji} ${colors[color]}${message}${colors.reset}`); 64 | } 65 | 66 | function logError(message) { 67 | log('❌', `FAIL: ${message}`, 'red'); 68 | } 69 | 70 | function logSuccess(message) { 71 | log('✓', message, 'green'); 72 | } 73 | 74 | function logInfo(message) { 75 | log('ℹ️', message, 'blue'); 76 | } 77 | 78 | class MCPClientSimulator { 79 | constructor() { 80 | this.ncp = null; 81 | this.responses = []; 82 | this.responseBuffer = ''; 83 | this.requestId = 0; 84 | } 85 | 86 | start() { 87 | return new Promise((resolve, reject) => { 88 | logInfo('Starting NCP MCP server...'); 89 | 90 | this.ncp = spawn('node', ['dist/index.js', '--profile', TEST_PROFILE], { 91 | stdio: ['pipe', 'pipe', 'pipe'], 92 | env: { 93 | ...process.env, 94 | NCP_MODE: 'mcp', 95 | NO_COLOR: 'true', // Disable colors in output 96 | NCP_DEBUG: 'true' // Enable debug logging 97 | } 98 | }); 99 | 100 | this.ncp.stdout.on('data', (data) => { 101 | this.responseBuffer += data.toString(); 102 | const lines = this.responseBuffer.split('\n'); 103 | 104 | lines.slice(0, -1).forEach(line => { 105 | if (line.trim()) { 106 | try { 107 | const response = JSON.parse(line); 108 | this.responses.push(response); 109 | } catch (e) { 110 | // Ignore non-JSON lines (logs, etc.) 111 | } 112 | } 113 | }); 114 | 115 | this.responseBuffer = lines[lines.length - 1]; 116 | }); 117 | 118 | this.ncp.stderr.on('data', (data) => { 119 | // Collect stderr for debugging 120 | const msg = data.toString(); 121 | if (msg.includes('[DEBUG]')) { 122 | console.log(msg.trim()); 123 | } 124 | }); 125 | 126 | this.ncp.on('error', reject); 127 | 128 | // Give it a moment to start 129 | setTimeout(resolve, 100); 130 | }); 131 | } 132 | 133 | sendRequest(method, params = {}) { 134 | this.requestId++; 135 | const request = { 136 | jsonrpc: '2.0', 137 | id: this.requestId, 138 | method, 139 | params 140 | }; 141 | 142 | this.ncp.stdin.write(JSON.stringify(request) + '\n'); 143 | return this.requestId; 144 | } 145 | 146 | waitForResponse(id, timeoutMs = 5000) { 147 | return new Promise((resolve, reject) => { 148 | const startTime = Date.now(); 149 | 150 | const checkResponse = () => { 151 | const response = this.responses.find(r => r.id === id); 152 | if (response) { 153 | resolve(response); 154 | return; 155 | } 156 | 157 | if (Date.now() - startTime > timeoutMs) { 158 | reject(new Error(`Timeout waiting for response to request ${id}`)); 159 | return; 160 | } 161 | 162 | setTimeout(checkResponse, 10); 163 | }; 164 | 165 | checkResponse(); 166 | }); 167 | } 168 | 169 | async stop() { 170 | if (this.ncp) { 171 | this.ncp.kill(); 172 | await new Promise(resolve => setTimeout(resolve, 100)); 173 | } 174 | } 175 | } 176 | 177 | async function test1_Initialize() { 178 | logInfo('Test 1: Initialize request responds immediately'); 179 | 180 | const client = new MCPClientSimulator(); 181 | await client.start(); 182 | 183 | const startTime = Date.now(); 184 | const id = client.sendRequest('initialize', { 185 | protocolVersion: '2024-11-05', 186 | capabilities: {}, 187 | clientInfo: { name: 'test-client', version: '1.0.0' } 188 | }); 189 | 190 | const response = await client.waitForResponse(id); 191 | const duration = Date.now() - startTime; 192 | 193 | await client.stop(); 194 | 195 | if (response.error) { 196 | logError(`Initialize failed: ${response.error.message}`); 197 | return false; 198 | } 199 | 200 | if (duration > 1000) { 201 | logError(`Initialize took ${duration}ms (should be < 1000ms)`); 202 | return false; 203 | } 204 | 205 | if (!response.result?.protocolVersion) { 206 | logError('Initialize response missing protocolVersion'); 207 | return false; 208 | } 209 | 210 | logSuccess(`Initialize responded in ${duration}ms`); 211 | return true; 212 | } 213 | 214 | async function test2_ToolsListDuringIndexing() { 215 | logInfo('Test 2: tools/list responds < 100ms even during indexing'); 216 | 217 | const client = new MCPClientSimulator(); 218 | await client.start(); 219 | 220 | // Call tools/list immediately (during indexing) 221 | const startTime = Date.now(); 222 | const id = client.sendRequest('tools/list'); 223 | 224 | const response = await client.waitForResponse(id); 225 | const duration = Date.now() - startTime; 226 | 227 | await client.stop(); 228 | 229 | if (response.error) { 230 | logError(`tools/list failed: ${response.error.message}`); 231 | return false; 232 | } 233 | 234 | if (duration > 100) { 235 | logError(`tools/list took ${duration}ms (should be < 100ms)`); 236 | return false; 237 | } 238 | 239 | if (!response.result?.tools || response.result.tools.length === 0) { 240 | logError('tools/list returned no tools'); 241 | return false; 242 | } 243 | 244 | const toolNames = response.result.tools.map(t => t.name); 245 | if (!toolNames.includes('find') || !toolNames.includes('run')) { 246 | logError(`tools/list missing required tools. Got: ${toolNames.join(', ')}`); 247 | return false; 248 | } 249 | 250 | logSuccess(`tools/list responded in ${duration}ms with ${response.result.tools.length} tools`); 251 | return true; 252 | } 253 | 254 | async function test3_FindDuringIndexing() { 255 | logInfo('Test 3: find returns partial results during indexing (not empty)'); 256 | 257 | const client = new MCPClientSimulator(); 258 | await client.start(); 259 | 260 | // Call find immediately (during indexing) - like Perplexity does 261 | const id = client.sendRequest('tools/call', { 262 | name: 'find', 263 | arguments: { description: 'list files' } 264 | }); 265 | 266 | const response = await client.waitForResponse(id, 10000); 267 | 268 | await client.stop(); 269 | 270 | if (response.error) { 271 | logError(`find failed: ${response.error.message}`); 272 | return false; 273 | } 274 | 275 | const text = response.result?.content?.[0]?.text || ''; 276 | 277 | // Should either: 278 | // 1. Return partial results with indexing message 279 | // 2. Return "indexing in progress" message 280 | // Should NOT return blank or "No tools found" without context 281 | 282 | if (text.includes('No tools found') && !text.includes('Indexing')) { 283 | logError('find returned empty without indexing context'); 284 | return false; 285 | } 286 | 287 | if (text.length === 0) { 288 | logError('find returned empty response'); 289 | return false; 290 | } 291 | 292 | const hasIndexingMessage = text.includes('Indexing in progress') || text.includes('indexing'); 293 | const hasResults = text.includes('**') || text.includes('tools') || text.includes('MCP'); 294 | 295 | if (!hasIndexingMessage && !hasResults) { 296 | logError('find response has neither indexing message nor results'); 297 | return false; 298 | } 299 | 300 | logSuccess(`find returned ${hasResults ? 'partial results' : 'indexing message'}`); 301 | return true; 302 | } 303 | 304 | async function test4_CacheProfileHashPersists() { 305 | logInfo('Test 4: Cache profileHash persists correctly'); 306 | 307 | // Clear cache first 308 | const metaPath = path.join(CACHE_DIR, `${TEST_PROFILE}-cache-meta.json`); 309 | const csvPath = path.join(CACHE_DIR, `${TEST_PROFILE}-tools.csv`); 310 | 311 | if (fs.existsSync(metaPath)) { 312 | fs.unlinkSync(metaPath); 313 | } 314 | if (fs.existsSync(csvPath)) { 315 | fs.unlinkSync(csvPath); 316 | } 317 | 318 | // Start server and let it create cache 319 | const client1 = new MCPClientSimulator(); 320 | await client1.start(); 321 | 322 | const id1 = client1.sendRequest('tools/call', { 323 | name: 'find', 324 | arguments: {} 325 | }); 326 | 327 | await client1.waitForResponse(id1, 10000); 328 | 329 | // Wait a bit for indexing to potentially complete 330 | await new Promise(resolve => setTimeout(resolve, 2000)); 331 | 332 | await client1.stop(); 333 | 334 | // Wait for cache to be finalized and written 335 | await new Promise(resolve => setTimeout(resolve, 1000)); 336 | 337 | // Check cache metadata 338 | if (!fs.existsSync(metaPath)) { 339 | logError('Cache metadata file not created'); 340 | logInfo(`Expected at: ${metaPath}`); 341 | 342 | // List what's in cache dir for debugging 343 | if (fs.existsSync(CACHE_DIR)) { 344 | const files = fs.readdirSync(CACHE_DIR); 345 | logInfo(`Files in cache dir: ${files.join(', ')}`); 346 | } 347 | 348 | return false; 349 | } 350 | 351 | const metadata = JSON.parse(fs.readFileSync(metaPath, 'utf-8')); 352 | 353 | if (!metadata.profileHash || metadata.profileHash === '') { 354 | logError(`profileHash is empty: "${metadata.profileHash}"`); 355 | return false; 356 | } 357 | 358 | logSuccess(`Cache profileHash saved: ${metadata.profileHash.substring(0, 16)}...`); 359 | return true; 360 | } 361 | 362 | async function test5_NoReindexingOnRestart() { 363 | logInfo('Test 5: Second startup uses cache (no re-indexing)'); 364 | 365 | const metaPath = path.join(CACHE_DIR, `${TEST_PROFILE}-cache-meta.json`); 366 | 367 | // Get initial cache state 368 | const metaBefore = JSON.parse(fs.readFileSync(metaPath, 'utf-8')); 369 | const hashBefore = metaBefore.profileHash; 370 | const lastUpdatedBefore = metaBefore.lastUpdated; 371 | 372 | // Wait a moment to ensure timestamp would change if re-indexed 373 | await new Promise(resolve => setTimeout(resolve, 1000)); 374 | 375 | // Start server again 376 | const client = new MCPClientSimulator(); 377 | await client.start(); 378 | 379 | const id = client.sendRequest('tools/call', { 380 | name: 'find', 381 | arguments: {} 382 | }); 383 | 384 | await client.waitForResponse(id, 10000); 385 | await client.stop(); 386 | 387 | // Wait for any potential cache updates 388 | await new Promise(resolve => setTimeout(resolve, 500)); 389 | 390 | // Check cache wasn't regenerated 391 | const metaAfter = JSON.parse(fs.readFileSync(metaPath, 'utf-8')); 392 | const hashAfter = metaAfter.profileHash; 393 | 394 | if (hashBefore !== hashAfter) { 395 | logError(`profileHash changed on restart (cache invalidated):\n Before: ${hashBefore}\n After: ${hashAfter}`); 396 | return false; 397 | } 398 | 399 | // Note: lastUpdated might change slightly due to timestamp updates, that's OK 400 | // The key is profileHash stays the same 401 | 402 | logSuccess('Cache persisted correctly (profileHash unchanged on restart)'); 403 | return true; 404 | } 405 | 406 | async function runAllTests() { 407 | console.log('\n' + '='.repeat(60)); 408 | console.log('🧪 NCP Integration Test Suite'); 409 | console.log(' Simulating Real AI Client Behavior'); 410 | console.log('='.repeat(60) + '\n'); 411 | 412 | // Setup test environment 413 | setupTestProfile(); 414 | 415 | const tests = [ 416 | test1_Initialize, 417 | test2_ToolsListDuringIndexing, 418 | test3_FindDuringIndexing, 419 | test4_CacheProfileHashPersists, 420 | test5_NoReindexingOnRestart 421 | ]; 422 | 423 | let passed = 0; 424 | let failed = 0; 425 | 426 | for (const test of tests) { 427 | try { 428 | const result = await test(); 429 | if (result) { 430 | passed++; 431 | } else { 432 | failed++; 433 | } 434 | } catch (error) { 435 | logError(`${test.name} threw error: ${error.message}`); 436 | failed++; 437 | } 438 | console.log(''); // Blank line between tests 439 | } 440 | 441 | console.log('='.repeat(60)); 442 | console.log(`📊 Results: ${passed} passed, ${failed} failed`); 443 | console.log('='.repeat(60) + '\n'); 444 | 445 | if (failed > 0) { 446 | console.log('❌ INTEGRATION TESTS FAILED - DO NOT RELEASE\n'); 447 | process.exit(1); 448 | } else { 449 | console.log('✅ ALL INTEGRATION TESTS PASSED - Safe to release\n'); 450 | process.exit(0); 451 | } 452 | } 453 | 454 | // Cleanup on exit 455 | process.on('exit', () => { 456 | // Clean up test profile cache if needed 457 | const metaPath = path.join(CACHE_DIR, `${TEST_PROFILE}-cache-meta.json`); 458 | if (fs.existsSync(metaPath)) { 459 | // Optionally clean up: fs.unlinkSync(metaPath); 460 | } 461 | }); 462 | 463 | // Run tests 464 | runAllTests().catch(error => { 465 | logError(`Test suite crashed: ${error.message}`); 466 | console.error(error); 467 | process.exit(1); 468 | }); 469 | ``` -------------------------------------------------------------------------------- /src/analytics/log-parser.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * NCP Analytics Log Parser 3 | * Parses real MCP session logs to extract performance and usage insights 4 | */ 5 | 6 | import { readFileSync, readdirSync, statSync } from 'fs'; 7 | import { join } from 'path'; 8 | import * as os from 'os'; 9 | 10 | export interface MCPSession { 11 | mcpName: string; 12 | startTime: Date; 13 | endTime?: Date; 14 | duration?: number; 15 | toolCount?: number; 16 | tools?: string[]; 17 | exitCode?: number; 18 | success: boolean; 19 | responseSize: number; 20 | errorMessages: string[]; 21 | } 22 | 23 | export interface AnalyticsReport { 24 | totalSessions: number; 25 | uniqueMCPs: number; 26 | timeRange: { start: Date; end: Date }; 27 | successRate: number; 28 | avgSessionDuration: number; 29 | totalResponseSize: number; 30 | topMCPsByUsage: Array<{ name: string; sessions: number; successRate: number }>; 31 | topMCPsByTools: Array<{ name: string; toolCount: number }>; 32 | performanceMetrics: { 33 | fastestMCPs: Array<{ name: string; avgDuration: number }>; 34 | slowestMCPs: Array<{ name: string; avgDuration: number }>; 35 | mostReliable: Array<{ name: string; successRate: number }>; 36 | leastReliable: Array<{ name: string; successRate: number }>; 37 | }; 38 | dailyUsage: Record<string, number>; 39 | hourlyUsage: Record<number, number>; 40 | } 41 | 42 | export class NCPLogParser { 43 | private logsDir: string; 44 | 45 | constructor() { 46 | // Always use global ~/.ncp/logs for analytics data 47 | // This ensures we analyze the real usage data, not local development data 48 | this.logsDir = join(os.homedir(), '.ncp', 'logs'); 49 | } 50 | 51 | /** 52 | * Parse a single log file to extract session data 53 | */ 54 | private parseLogFile(filePath: string): MCPSession[] { 55 | try { 56 | const content = readFileSync(filePath, 'utf-8'); 57 | const sessions: MCPSession[] = []; 58 | 59 | // Extract MCP name from filename: mcp-{name}-2025w39.log 60 | const fileName = filePath.split('/').pop() || ''; 61 | const mcpMatch = fileName.match(/mcp-(.+)-\d{4}w\d{2}\.log/); 62 | const mcpName = mcpMatch ? mcpMatch[1] : 'unknown'; 63 | 64 | // Split content into individual sessions 65 | const sessionBlocks = content.split(/--- MCP .+ Session Started: .+ ---/); 66 | 67 | for (let i = 1; i < sessionBlocks.length; i++) { 68 | const block = sessionBlocks[i]; 69 | const session = this.parseSessionBlock(mcpName, block); 70 | if (session) { 71 | sessions.push(session); 72 | } 73 | } 74 | 75 | return sessions; 76 | } catch (error) { 77 | console.error(`Error parsing log file ${filePath}:`, error); 78 | return []; 79 | } 80 | } 81 | 82 | /** 83 | * Parse individual session block 84 | */ 85 | private parseSessionBlock(mcpName: string, block: string): MCPSession | null { 86 | try { 87 | const lines = block.split('\n').filter(line => line.trim()); 88 | 89 | // Find session start time from the previous separator 90 | const sessionStartRegex = /--- MCP .+ Session Started: (.+) ---/; 91 | let startTime: Date | undefined; 92 | 93 | // Look for start time in the content before this block 94 | const startMatch = block.match(sessionStartRegex); 95 | if (startMatch) { 96 | startTime = new Date(startMatch[1]); 97 | } else { 98 | // Fallback: use first timestamp we can find 99 | const firstLine = lines[0]; 100 | if (firstLine) { 101 | startTime = new Date(); // Use current time as fallback 102 | } 103 | } 104 | 105 | if (!startTime) return null; 106 | 107 | let toolCount = 0; 108 | let tools: string[] = []; 109 | let exitCode: number | undefined; 110 | let responseSize = 0; 111 | let errorMessages: string[] = []; 112 | let endTime: Date | undefined; 113 | 114 | for (const line of lines) { 115 | // Extract tool information 116 | if (line.includes('Loaded MCP with') && line.includes('tools:')) { 117 | const toolMatch = line.match(/Loaded MCP with (\d+) tools: (.+)/); 118 | if (toolMatch) { 119 | toolCount = parseInt(toolMatch[1]); 120 | tools = toolMatch[2].split(', ').map(t => t.trim()); 121 | } 122 | } 123 | 124 | // Extract JSON responses and their size 125 | if (line.startsWith('[STDOUT]') && line.includes('{"result"')) { 126 | const jsonPart = line.substring('[STDOUT] '.length); 127 | responseSize += jsonPart.length; 128 | } 129 | 130 | // Extract errors 131 | if (line.includes('[STDERR]') && (line.includes('Error') || line.includes('Failed'))) { 132 | errorMessages.push(line); 133 | } 134 | 135 | // Extract exit code 136 | if (line.includes('[EXIT] Process exited with code')) { 137 | const exitMatch = line.match(/code (\d+)/); 138 | if (exitMatch) { 139 | exitCode = parseInt(exitMatch[1]); 140 | endTime = new Date(startTime.getTime() + 5000); // Estimate end time 141 | } 142 | } 143 | } 144 | 145 | // Calculate duration (estimated) 146 | const duration = endTime ? endTime.getTime() - startTime.getTime() : undefined; 147 | const success = exitCode === 0 || exitCode === undefined || (toolCount > 0 && responseSize > 0); 148 | 149 | return { 150 | mcpName, 151 | startTime, 152 | endTime, 153 | duration, 154 | toolCount: toolCount || undefined, 155 | tools: tools.length > 0 ? tools : undefined, 156 | exitCode, 157 | success, 158 | responseSize, 159 | errorMessages 160 | }; 161 | } catch (error) { 162 | return null; 163 | } 164 | } 165 | 166 | /** 167 | * Parse all log files and generate analytics report 168 | * @param options - Filter options for time range 169 | */ 170 | async parseAllLogs(options?: { 171 | from?: Date; 172 | to?: Date; 173 | period?: number; // days 174 | today?: boolean; 175 | }): Promise<AnalyticsReport> { 176 | const sessions: MCPSession[] = []; 177 | 178 | try { 179 | const logFiles = readdirSync(this.logsDir) 180 | .filter(file => file.endsWith('.log')) 181 | .map(file => join(this.logsDir, file)); 182 | 183 | console.log(`📊 Parsing ${logFiles.length} log files...`); 184 | 185 | // Calculate date range 186 | let fromDate: Date | undefined; 187 | let toDate: Date | undefined; 188 | 189 | if (options?.today) { 190 | // Today only 191 | fromDate = new Date(); 192 | fromDate.setHours(0, 0, 0, 0); 193 | toDate = new Date(); 194 | toDate.setHours(23, 59, 59, 999); 195 | } else if (options?.period) { 196 | // Last N days 197 | toDate = new Date(); 198 | fromDate = new Date(); 199 | fromDate.setDate(fromDate.getDate() - options.period); 200 | fromDate.setHours(0, 0, 0, 0); 201 | } else if (options?.from || options?.to) { 202 | // Custom range 203 | fromDate = options.from; 204 | toDate = options.to || new Date(); 205 | 206 | // If toDate is provided, set to end of that day 207 | if (options?.to) { 208 | toDate = new Date(options.to); 209 | toDate.setHours(23, 59, 59, 999); 210 | } 211 | 212 | // If fromDate is provided, set to start of that day 213 | if (options?.from) { 214 | fromDate = new Date(options.from); 215 | fromDate.setHours(0, 0, 0, 0); 216 | } 217 | } 218 | 219 | for (const logFile of logFiles) { 220 | const fileSessions = this.parseLogFile(logFile); 221 | 222 | // Filter sessions by date range if specified 223 | const filteredSessions = fromDate || toDate 224 | ? fileSessions.filter(session => { 225 | if (fromDate && session.startTime < fromDate) return false; 226 | if (toDate && session.startTime > toDate) return false; 227 | return true; 228 | }) 229 | : fileSessions; 230 | 231 | sessions.push(...filteredSessions); 232 | } 233 | 234 | if (fromDate || toDate) { 235 | const rangeDesc = options?.today 236 | ? 'today' 237 | : options?.period 238 | ? `last ${options.period} days` 239 | : `${fromDate?.toLocaleDateString() || 'start'} to ${toDate?.toLocaleDateString() || 'now'}`; 240 | console.log(`📅 Filtering for ${rangeDesc}: ${sessions.length} sessions`); 241 | } 242 | 243 | return this.generateReport(sessions); 244 | } catch (error) { 245 | console.error('Error reading logs directory:', error); 246 | return this.generateReport(sessions); 247 | } 248 | } 249 | 250 | /** 251 | * Generate comprehensive analytics report 252 | */ 253 | private generateReport(sessions: MCPSession[]): AnalyticsReport { 254 | if (sessions.length === 0) { 255 | return { 256 | totalSessions: 0, 257 | uniqueMCPs: 0, 258 | timeRange: { start: new Date(), end: new Date() }, 259 | successRate: 0, 260 | avgSessionDuration: 0, 261 | totalResponseSize: 0, 262 | topMCPsByUsage: [], 263 | topMCPsByTools: [], 264 | performanceMetrics: { 265 | fastestMCPs: [], 266 | slowestMCPs: [], 267 | mostReliable: [], 268 | leastReliable: [] 269 | }, 270 | dailyUsage: {}, 271 | hourlyUsage: {} 272 | }; 273 | } 274 | 275 | // Basic metrics 276 | const totalSessions = sessions.length; 277 | const uniqueMCPs = new Set(sessions.map(s => s.mcpName)).size; 278 | const successfulSessions = sessions.filter(s => s.success).length; 279 | const successRate = (successfulSessions / totalSessions) * 100; 280 | 281 | // Time range 282 | const sortedByTime = sessions.filter(s => s.startTime).sort((a, b) => a.startTime.getTime() - b.startTime.getTime()); 283 | const timeRange = { 284 | start: sortedByTime[0]?.startTime || new Date(), 285 | end: sortedByTime[sortedByTime.length - 1]?.startTime || new Date() 286 | }; 287 | 288 | // Duration metrics 289 | const sessionsWithDuration = sessions.filter(s => s.duration && s.duration > 0); 290 | const avgSessionDuration = sessionsWithDuration.length > 0 291 | ? sessionsWithDuration.reduce((sum, s) => sum + (s.duration || 0), 0) / sessionsWithDuration.length 292 | : 0; 293 | 294 | // Response size 295 | const totalResponseSize = sessions.reduce((sum, s) => sum + s.responseSize, 0); 296 | 297 | // MCP usage statistics 298 | const mcpStats = new Map<string, { sessions: number; successes: number; totalTools: number; durations: number[] }>(); 299 | 300 | for (const session of sessions) { 301 | const stats = mcpStats.get(session.mcpName) || { sessions: 0, successes: 0, totalTools: 0, durations: [] }; 302 | stats.sessions++; 303 | if (session.success) stats.successes++; 304 | if (session.toolCount) stats.totalTools = Math.max(stats.totalTools, session.toolCount); 305 | if (session.duration && session.duration > 0) stats.durations.push(session.duration); 306 | mcpStats.set(session.mcpName, stats); 307 | } 308 | 309 | // Top MCPs by usage 310 | const topMCPsByUsage = Array.from(mcpStats.entries()) 311 | .map(([name, stats]) => ({ 312 | name, 313 | sessions: stats.sessions, 314 | successRate: (stats.successes / stats.sessions) * 100 315 | })) 316 | .sort((a, b) => b.sessions - a.sessions) 317 | .slice(0, 10); 318 | 319 | // Top MCPs by tool count 320 | const topMCPsByTools = Array.from(mcpStats.entries()) 321 | .filter(([_, stats]) => stats.totalTools > 0) 322 | .map(([name, stats]) => ({ 323 | name, 324 | toolCount: stats.totalTools 325 | })) 326 | .sort((a, b) => b.toolCount - a.toolCount) 327 | .slice(0, 10); 328 | 329 | // Performance metrics 330 | const mcpPerformance = Array.from(mcpStats.entries()) 331 | .map(([name, stats]) => ({ 332 | name, 333 | avgDuration: stats.durations.length > 0 ? stats.durations.reduce((a, b) => a + b, 0) / stats.durations.length : 0, 334 | successRate: (stats.successes / stats.sessions) * 100 335 | })) 336 | .filter(m => m.avgDuration > 0); 337 | 338 | const fastestMCPs = mcpPerformance 339 | .sort((a, b) => a.avgDuration - b.avgDuration) 340 | .slice(0, 5); 341 | 342 | const slowestMCPs = mcpPerformance 343 | .sort((a, b) => b.avgDuration - a.avgDuration) 344 | .slice(0, 5); 345 | 346 | const mostReliable = Array.from(mcpStats.entries()) 347 | .map(([name, stats]) => ({ 348 | name, 349 | successRate: (stats.successes / stats.sessions) * 100 350 | })) 351 | .filter(m => mcpStats.get(m.name)!.sessions >= 3) // At least 3 sessions for reliability 352 | .sort((a, b) => b.successRate - a.successRate) 353 | .slice(0, 5); 354 | 355 | const leastReliable = Array.from(mcpStats.entries()) 356 | .map(([name, stats]) => ({ 357 | name, 358 | successRate: (stats.successes / stats.sessions) * 100 359 | })) 360 | .filter(m => mcpStats.get(m.name)!.sessions >= 3) 361 | .sort((a, b) => a.successRate - b.successRate) 362 | .slice(0, 5); 363 | 364 | // Daily usage 365 | const dailyUsage: Record<string, number> = {}; 366 | for (const session of sessions) { 367 | const day = session.startTime.toISOString().split('T')[0]; 368 | dailyUsage[day] = (dailyUsage[day] || 0) + 1; 369 | } 370 | 371 | // Hourly usage 372 | const hourlyUsage: Record<number, number> = {}; 373 | for (const session of sessions) { 374 | const hour = session.startTime.getHours(); 375 | hourlyUsage[hour] = (hourlyUsage[hour] || 0) + 1; 376 | } 377 | 378 | return { 379 | totalSessions, 380 | uniqueMCPs, 381 | timeRange, 382 | successRate, 383 | avgSessionDuration, 384 | totalResponseSize, 385 | topMCPsByUsage, 386 | topMCPsByTools, 387 | performanceMetrics: { 388 | fastestMCPs, 389 | slowestMCPs, 390 | mostReliable, 391 | leastReliable 392 | }, 393 | dailyUsage, 394 | hourlyUsage 395 | }; 396 | } 397 | } ``` -------------------------------------------------------------------------------- /test/ecosystem-discovery-validation-simple.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Simple Ecosystem Discovery Validation 3 | * Tests that NCP can find relevant tools from our realistic MCP ecosystem 4 | */ 5 | 6 | import { DiscoveryEngine } from '../src/discovery/engine.js'; 7 | 8 | describe('Simple Ecosystem Discovery Validation', () => { 9 | let engine: DiscoveryEngine; 10 | 11 | beforeAll(async () => { 12 | engine = new DiscoveryEngine(); 13 | await engine.initialize(); 14 | 15 | // Clear any existing cached tools to ensure clean test environment 16 | await engine['ragEngine'].clearCache(); 17 | 18 | // Create comprehensive ecosystem with 20 realistic tools 19 | const ecosystemTools = [ 20 | // Database Operations 21 | { name: 'query', description: 'Execute SQL queries to retrieve data from PostgreSQL database tables. Find records, search data, analyze information.', mcpName: 'postgres-test' }, 22 | { name: 'insert', description: 'Insert new records into PostgreSQL database tables. Store customer data, add new information, create records.', mcpName: 'postgres-test' }, 23 | { name: 'execute_cypher', description: 'Execute Cypher queries on Neo4j graph database. Query relationships, find patterns, analyze connections.', mcpName: 'neo4j-test' }, 24 | 25 | // Payment Processing 26 | { name: 'create_payment', description: 'Process credit card payments and charges from customers. Charge customer for order, process payment from customer.', mcpName: 'stripe-test' }, 27 | { name: 'refund_payment', description: 'Process refunds for previously charged payments. Refund cancelled subscription, return customer money.', mcpName: 'stripe-test' }, 28 | 29 | // Developer Tools 30 | { name: 'create_repository', description: 'Create a new GitHub repository with configuration options. Set up new project, initialize repository.', mcpName: 'github-test' }, 31 | { name: 'create_issue', description: 'Create GitHub issues for bug reports and feature requests. Report bugs, request features, track tasks.', mcpName: 'github-test' }, 32 | { name: 'commit_changes', description: 'Create Git commits to save changes to version history. Save progress, commit code changes, record modifications.', mcpName: 'git-test' }, 33 | { name: 'create_branch', description: 'Create new Git branches for feature development and parallel work. Start new features, create development branches.', mcpName: 'git-test' }, 34 | 35 | // File Operations 36 | { name: 'read_file', description: 'Read contents of files from local filesystem. Load configuration files, read text documents, access data files.', mcpName: 'filesystem-test' }, 37 | { name: 'write_file', description: 'Write content to files on local filesystem. Create configuration files, save data, generate reports.', mcpName: 'filesystem-test' }, 38 | { name: 'create_directory', description: 'Create new directories and folder structures. Organize files, set up project structure, create folder hierarchies.', mcpName: 'filesystem-test' }, 39 | 40 | // Web Automation 41 | { name: 'click_element', description: 'Click on web page elements using selectors. Click buttons, links, form elements.', mcpName: 'playwright-test' }, 42 | { name: 'take_screenshot', description: 'Capture screenshots of web pages for testing and documentation. Take page screenshots, save visual evidence.', mcpName: 'playwright-test' }, 43 | { name: 'fill_form_field', description: 'Fill form inputs and text fields on web pages. Enter text, complete forms, input data.', mcpName: 'playwright-test' }, 44 | 45 | // Cloud & Infrastructure 46 | { name: 'create_ec2_instance', description: 'Launch new EC2 virtual machine instances with configuration. Create servers, deploy applications to cloud.', mcpName: 'aws-test' }, 47 | { name: 'upload_to_s3', description: 'Upload files and objects to S3 storage buckets. Store files in cloud, backup data, host static content.', mcpName: 'aws-test' }, 48 | { name: 'run_container', description: 'Run Docker containers from images with configuration options. Deploy applications, start services.', mcpName: 'docker-test' }, 49 | { name: 'send_message', description: 'Send messages to Slack channels or direct messages. Share updates, notify teams, communicate with colleagues.', mcpName: 'slack-test' }, 50 | { name: 'web_search', description: 'Search the web using Brave Search API with privacy protection. Find information, research topics, get current data.', mcpName: 'brave-search-test' }, 51 | ]; 52 | 53 | // Group by MCP and index 54 | const toolsByMCP = new Map(); 55 | for (const tool of ecosystemTools) { 56 | if (!toolsByMCP.has(tool.mcpName)) { 57 | toolsByMCP.set(tool.mcpName, []); 58 | } 59 | toolsByMCP.get(tool.mcpName).push({ 60 | name: tool.name, 61 | description: tool.description 62 | }); 63 | } 64 | 65 | // Index each MCP 66 | for (const [mcpName, tools] of toolsByMCP) { 67 | await engine['ragEngine'].indexMCP(mcpName, tools); 68 | } 69 | }); 70 | 71 | describe('Domain-Specific Discovery', () => { 72 | it('finds database tools for data queries', async () => { 73 | const results = await engine.findRelevantTools('query customer data from database', 8); 74 | expect(results.length).toBeGreaterThan(0); 75 | 76 | const hasDbTool = results.some(t => 77 | (t.name.includes('postgres') && t.name.includes('query')) || 78 | (t.name.includes('neo4j') && t.name.includes('cypher')) 79 | ); 80 | expect(hasDbTool).toBeTruthy(); 81 | }); 82 | 83 | it('finds payment tools for financial operations', async () => { 84 | const results = await engine.findRelevantTools('process credit card payment', 8); 85 | expect(results.length).toBeGreaterThan(0); 86 | 87 | const hasPaymentTool = results.some(t => 88 | t.name.includes('stripe') && (t.name.includes('payment') || t.name.includes('create')) 89 | ); 90 | expect(hasPaymentTool).toBeTruthy(); 91 | }); 92 | 93 | it('finds version control tools for code management', async () => { 94 | const results = await engine.findRelevantTools('commit code changes', 8); 95 | expect(results.length).toBeGreaterThan(0); 96 | 97 | const hasGitTool = results.some(t => 98 | t.name.includes('git') && t.name.includes('commit') 99 | ); 100 | expect(hasGitTool).toBeTruthy(); 101 | }); 102 | 103 | it('finds file system tools for file operations', async () => { 104 | const results = await engine.findRelevantTools('save configuration to file', 8); 105 | expect(results.length).toBeGreaterThan(0); 106 | 107 | const hasFileTool = results.some(t => 108 | t.name.includes('filesystem') && t.name.includes('write') 109 | ); 110 | expect(hasFileTool).toBeTruthy(); 111 | }); 112 | 113 | it('finds web automation tools for browser tasks', async () => { 114 | const results = await engine.findRelevantTools('take screenshot of webpage', 8); 115 | expect(results.length).toBeGreaterThan(0); 116 | 117 | const hasWebTool = results.some(t => 118 | t.name.includes('playwright') && t.name.includes('screenshot') 119 | ); 120 | expect(hasWebTool).toBeTruthy(); 121 | }); 122 | 123 | it('finds cloud tools for infrastructure deployment', async () => { 124 | const results = await engine.findRelevantTools('deploy server to AWS cloud', 8); 125 | expect(results.length).toBeGreaterThan(0); 126 | 127 | // Debug: Log what tools are actually returned 128 | console.log('Cloud deployment query returned:', results.map(t => ({ name: t.name, confidence: t.confidence || 'N/A' }))); 129 | 130 | const hasCloudTool = results.some(t => 131 | t.name.includes('ec2') || t.name.includes('instance') || t.name.includes('container') 132 | ); 133 | if (!hasCloudTool) { 134 | console.log('Expected to find tools with ec2/instance/container but got:', results.map(t => t.name)); 135 | } 136 | expect(hasCloudTool).toBeTruthy(); 137 | }); 138 | }); 139 | 140 | describe('Cross-Domain Scenarios', () => { 141 | it('handles complex multi-domain queries', async () => { 142 | const results = await engine.findRelevantTools('build and deploy web application with database', 12); 143 | expect(results.length).toBeGreaterThan(3); 144 | 145 | // Should find tools from multiple domains - check for any relevant tools 146 | const hasDeploymentTools = results.some(r => 147 | r.name.includes('docker') || r.name.includes('aws') || 148 | r.name.includes('git') || r.name.includes('github') 149 | ); 150 | const hasDatabaseTools = results.some(r => 151 | r.name.includes('postgres') || r.name.includes('neo4j') 152 | ); 153 | const hasFileTools = results.some(r => 154 | r.name.includes('filesystem') || r.name.includes('file') 155 | ); 156 | 157 | // Should find tools from at least one relevant domain 158 | const foundRelevantTools = hasDeploymentTools || hasDatabaseTools || hasFileTools; 159 | expect(foundRelevantTools).toBeTruthy(); 160 | }); 161 | 162 | it('prioritizes relevant tools for specific contexts', async () => { 163 | const results = await engine.findRelevantTools('refund customer payment for cancelled order', 6); 164 | expect(results.length).toBeGreaterThan(0); 165 | 166 | // Refund should be prioritized over create payment 167 | const refundTool = results.find(t => t.name.includes('refund')); 168 | const createTool = results.find(t => t.name.includes('create_payment')); 169 | 170 | if (refundTool && createTool) { 171 | expect(results.indexOf(refundTool)).toBeLessThan(results.indexOf(createTool)); 172 | } else { 173 | expect(refundTool).toBeDefined(); // At minimum, refund tool should be found 174 | } 175 | }); 176 | }); 177 | 178 | describe('Ecosystem Scale Validation', () => { 179 | it('demonstrates improved specificity with diverse tool set', async () => { 180 | // Test that having diverse tools improves matching specificity 181 | const specificQuery = 'create GitHub issue for bug report'; 182 | const results = await engine.findRelevantTools(specificQuery, 6); 183 | 184 | expect(results.length).toBeGreaterThan(0); 185 | 186 | // Should find the specific GitHub issue tool 187 | const issueTool = results.find(t => 188 | t.name.includes('github') && t.name.includes('issue') 189 | ); 190 | expect(issueTool).toBeDefined(); 191 | }); 192 | 193 | it('maintains performance with ecosystem scale', async () => { 194 | const start = Date.now(); 195 | 196 | const results = await engine.findRelevantTools('analyze user data and generate report', 8); 197 | 198 | const duration = Date.now() - start; 199 | 200 | expect(results.length).toBeGreaterThan(0); 201 | expect(duration).toBeLessThan(1000); // Should complete under 1 second 202 | }); 203 | 204 | it('provides consistent results across similar queries', async () => { 205 | const query1 = 'store files in cloud storage'; 206 | const query2 = 'upload files to cloud bucket'; 207 | 208 | const results1 = await engine.findRelevantTools(query1, 5); 209 | const results2 = await engine.findRelevantTools(query2, 5); 210 | 211 | expect(results1.length).toBeGreaterThan(0); 212 | expect(results2.length).toBeGreaterThan(0); 213 | 214 | // Should both find S3 upload tool 215 | const hasS3_1 = results1.some(t => t.name.includes('s3') || t.name.includes('upload')); 216 | const hasS3_2 = results2.some(t => t.name.includes('s3') || t.name.includes('upload')); 217 | 218 | expect(hasS3_1).toBeTruthy(); 219 | expect(hasS3_2).toBeTruthy(); 220 | }); 221 | }); 222 | 223 | describe('Coverage Validation', () => { 224 | it('can discover tools from all major ecosystem domains', async () => { 225 | const domains = [ 226 | { name: 'Database', query: 'database query', expectPattern: ['query', 'cypher'] }, 227 | { name: 'Payment', query: 'payment processing', expectPattern: ['payment', 'create_payment'] }, 228 | { name: 'Version Control', query: 'git repository', expectPattern: ['repository', 'branch', 'commit'] }, 229 | { name: 'File System', query: 'file operations', expectPattern: ['file', 'read_file', 'write_file'] }, 230 | { name: 'Web Automation', query: 'browser automation', expectPattern: ['click', 'screenshot', 'fill'] }, 231 | { name: 'Cloud', query: 'cloud deployment', expectPattern: ['ec2', 'container', 's3'] }, 232 | { name: 'Communication', query: 'team messaging', expectPattern: ['message', 'send_message'] }, 233 | { name: 'Search', query: 'web search', expectPattern: ['search', 'web_search'] } 234 | ]; 235 | 236 | let successCount = 0; 237 | for (const domain of domains) { 238 | const results = await engine.findRelevantTools(domain.query, 8); 239 | 240 | if (results.length === 0) { 241 | console.log(`⚠️ ${domain.name} query "${domain.query}" returned no results`); 242 | continue; 243 | } 244 | 245 | const found = results.some(t => 246 | domain.expectPattern.some(pattern => t.name.includes(pattern)) 247 | ); 248 | 249 | if (found) { 250 | successCount++; 251 | } else { 252 | console.log(`❌ ${domain.name} query "${domain.query}" failed pattern matching:`); 253 | console.log(' Expected patterns:', domain.expectPattern); 254 | console.log(' Got tools:', results.map(t => t.name)); 255 | } 256 | } 257 | 258 | // Expect at least 80% of domains to work (4 out of 5) 259 | expect(successCount).toBeGreaterThanOrEqual(4); 260 | }); 261 | }); 262 | }); ``` -------------------------------------------------------------------------------- /src/cache/cache-patcher.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Cache Patcher for NCP 3 | * Provides incremental, MCP-by-MCP cache patching operations 4 | * Enables fast startup by avoiding full re-indexing 5 | */ 6 | 7 | import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; 8 | import { join } from 'path'; 9 | import { createHash } from 'crypto'; 10 | import { getCacheDirectory } from '../utils/ncp-paths.js'; 11 | import { logger } from '../utils/logger.js'; 12 | 13 | export interface Tool { 14 | name: string; 15 | description: string; 16 | inputSchema?: any; 17 | } 18 | 19 | export interface ToolMetadataCache { 20 | version: string; 21 | profileHash: string; // SHA256 of entire profile 22 | lastModified: number; 23 | mcps: { 24 | [mcpName: string]: { 25 | configHash: string; // SHA256 of command+args+env 26 | discoveredAt: number; 27 | tools: Array<{ 28 | name: string; 29 | description: string; 30 | inputSchema: any; 31 | }>; 32 | serverInfo: { 33 | name: string; 34 | version: string; 35 | description?: string; 36 | }; 37 | } 38 | } 39 | } 40 | 41 | export interface EmbeddingsCache { 42 | version: string; 43 | modelVersion: string; // all-MiniLM-L6-v2 44 | lastModified: number; 45 | vectors: { 46 | [toolId: string]: number[]; // toolId = "mcpName:toolName" 47 | }; 48 | metadata: { 49 | [toolId: string]: { 50 | mcpName: string; 51 | generatedAt: number; 52 | enhancedDescription: string; // Used for generation 53 | } 54 | } 55 | } 56 | 57 | export interface MCPConfig { 58 | command?: string; // Optional: for stdio transport 59 | args?: string[]; 60 | env?: Record<string, string>; 61 | url?: string; // Optional: for HTTP/SSE transport 62 | } 63 | 64 | export class CachePatcher { 65 | private cacheDir: string; 66 | private toolMetadataCachePath: string; 67 | private embeddingsCachePath: string; 68 | private embeddingsMetadataCachePath: string; 69 | 70 | constructor() { 71 | this.cacheDir = getCacheDirectory(); 72 | this.toolMetadataCachePath = join(this.cacheDir, 'all-tools.json'); 73 | this.embeddingsCachePath = join(this.cacheDir, 'embeddings.json'); 74 | this.embeddingsMetadataCachePath = join(this.cacheDir, 'embeddings-metadata.json'); 75 | 76 | // Ensure cache directory exists 77 | if (!existsSync(this.cacheDir)) { 78 | mkdirSync(this.cacheDir, { recursive: true }); 79 | } 80 | } 81 | 82 | /** 83 | * Generate SHA256 hash for MCP configuration 84 | */ 85 | generateConfigHash(config: MCPConfig): string { 86 | const hashInput = JSON.stringify({ 87 | command: config.command, 88 | args: config.args || [], 89 | env: config.env || {} 90 | }); 91 | return createHash('sha256').update(hashInput).digest('hex'); 92 | } 93 | 94 | /** 95 | * Generate SHA256 hash for entire profile 96 | */ 97 | generateProfileHash(profile: any): string { 98 | const hashInput = JSON.stringify(profile.mcpServers || {}); 99 | return createHash('sha256').update(hashInput).digest('hex'); 100 | } 101 | 102 | /** 103 | * Load cache with atomic file operations and error handling 104 | */ 105 | private async loadCache<T>(path: string, defaultValue: T): Promise<T> { 106 | try { 107 | if (!existsSync(path)) { 108 | logger.debug(`Cache file not found: ${path}, using default`); 109 | return defaultValue; 110 | } 111 | 112 | const content = readFileSync(path, 'utf-8'); 113 | const parsed = JSON.parse(content); 114 | logger.debug(`Loaded cache from ${path}`); 115 | return parsed as T; 116 | } catch (error: any) { 117 | logger.warn(`Failed to load cache from ${path}: ${error.message}, using default`); 118 | return defaultValue; 119 | } 120 | } 121 | 122 | /** 123 | * Save cache with atomic file operations to prevent corruption 124 | */ 125 | private async saveCache<T>(path: string, data: T): Promise<void> { 126 | try { 127 | const tmpPath = `${path}.tmp`; 128 | const content = JSON.stringify(data, null, 2); 129 | 130 | // Write to temporary file first 131 | writeFileSync(tmpPath, content, 'utf-8'); 132 | 133 | // Atomic replacement 134 | await this.atomicReplace(tmpPath, path); 135 | 136 | logger.debug(`Saved cache to ${path}`); 137 | } catch (error: any) { 138 | logger.error(`Failed to save cache to ${path}: ${error.message}`); 139 | throw error; 140 | } 141 | } 142 | 143 | /** 144 | * Atomic file replacement to prevent corruption 145 | */ 146 | private async atomicReplace(tmpPath: string, finalPath: string): Promise<void> { 147 | const fs = await import('fs/promises'); 148 | await fs.rename(tmpPath, finalPath); 149 | } 150 | 151 | /** 152 | * Load tool metadata cache 153 | */ 154 | async loadToolMetadataCache(): Promise<ToolMetadataCache> { 155 | const defaultCache: ToolMetadataCache = { 156 | version: '1.0.0', 157 | profileHash: '', 158 | lastModified: Date.now(), 159 | mcps: {} 160 | }; 161 | 162 | return await this.loadCache(this.toolMetadataCachePath, defaultCache); 163 | } 164 | 165 | /** 166 | * Save tool metadata cache 167 | */ 168 | async saveToolMetadataCache(cache: ToolMetadataCache): Promise<void> { 169 | cache.lastModified = Date.now(); 170 | await this.saveCache(this.toolMetadataCachePath, cache); 171 | } 172 | 173 | /** 174 | * Load embeddings cache 175 | */ 176 | async loadEmbeddingsCache(): Promise<EmbeddingsCache> { 177 | const defaultCache: EmbeddingsCache = { 178 | version: '1.0.0', 179 | modelVersion: 'all-MiniLM-L6-v2', 180 | lastModified: Date.now(), 181 | vectors: {}, 182 | metadata: {} 183 | }; 184 | 185 | return await this.loadCache(this.embeddingsCachePath, defaultCache); 186 | } 187 | 188 | /** 189 | * Save embeddings cache 190 | */ 191 | async saveEmbeddingsCache(cache: EmbeddingsCache): Promise<void> { 192 | cache.lastModified = Date.now(); 193 | await this.saveCache(this.embeddingsCachePath, cache); 194 | } 195 | 196 | /** 197 | * Patch tool metadata cache - Add MCP 198 | */ 199 | async patchAddMCP(mcpName: string, config: MCPConfig, tools: Tool[], serverInfo: any): Promise<void> { 200 | logger.info(`🔧 Patching tool metadata cache: adding ${mcpName}`); 201 | 202 | const cache = await this.loadToolMetadataCache(); 203 | const configHash = this.generateConfigHash(config); 204 | 205 | cache.mcps[mcpName] = { 206 | configHash, 207 | discoveredAt: Date.now(), 208 | tools: tools.map(tool => ({ 209 | name: tool.name, 210 | description: tool.description || 'No description available', 211 | inputSchema: tool.inputSchema || {} 212 | })), 213 | serverInfo: { 214 | name: serverInfo?.name || mcpName, 215 | version: serverInfo?.version || '1.0.0', 216 | description: serverInfo?.description 217 | } 218 | }; 219 | 220 | await this.saveToolMetadataCache(cache); 221 | logger.info(`✅ Added ${tools.length} tools from ${mcpName} to metadata cache`); 222 | } 223 | 224 | /** 225 | * Patch tool metadata cache - Remove MCP 226 | */ 227 | async patchRemoveMCP(mcpName: string): Promise<void> { 228 | logger.info(`🔧 Patching tool metadata cache: removing ${mcpName}`); 229 | 230 | const cache = await this.loadToolMetadataCache(); 231 | 232 | if (cache.mcps[mcpName]) { 233 | const toolCount = cache.mcps[mcpName].tools.length; 234 | delete cache.mcps[mcpName]; 235 | await this.saveToolMetadataCache(cache); 236 | logger.info(`✅ Removed ${toolCount} tools from ${mcpName} from metadata cache`); 237 | } else { 238 | logger.warn(`MCP ${mcpName} not found in metadata cache`); 239 | } 240 | } 241 | 242 | /** 243 | * Patch tool metadata cache - Update MCP 244 | */ 245 | async patchUpdateMCP(mcpName: string, config: MCPConfig, tools: Tool[], serverInfo: any): Promise<void> { 246 | logger.info(`🔧 Patching tool metadata cache: updating ${mcpName}`); 247 | 248 | // Remove then add for clean update 249 | await this.patchRemoveMCP(mcpName); 250 | await this.patchAddMCP(mcpName, config, tools, serverInfo); 251 | } 252 | 253 | /** 254 | * Patch embeddings cache - Add MCP tools 255 | */ 256 | async patchAddEmbeddings(mcpName: string, toolEmbeddings: Map<string, any>): Promise<void> { 257 | logger.info(`🔧 Patching embeddings cache: adding ${mcpName} vectors`); 258 | 259 | const cache = await this.loadEmbeddingsCache(); 260 | let addedCount = 0; 261 | 262 | for (const [toolId, embeddingData] of toolEmbeddings) { 263 | if (embeddingData && embeddingData.embedding) { 264 | // Convert Float32Array to regular array for JSON serialization 265 | cache.vectors[toolId] = Array.from(embeddingData.embedding); 266 | cache.metadata[toolId] = { 267 | mcpName, 268 | generatedAt: Date.now(), 269 | enhancedDescription: embeddingData.enhancedDescription || '' 270 | }; 271 | addedCount++; 272 | } 273 | } 274 | 275 | await this.saveEmbeddingsCache(cache); 276 | logger.info(`✅ Added ${addedCount} embeddings for ${mcpName}`); 277 | } 278 | 279 | /** 280 | * Patch embeddings cache - Remove MCP tools 281 | */ 282 | async patchRemoveEmbeddings(mcpName: string): Promise<void> { 283 | logger.info(`🔧 Patching embeddings cache: removing ${mcpName} vectors`); 284 | 285 | const cache = await this.loadEmbeddingsCache(); 286 | let removedCount = 0; 287 | 288 | // Remove all tool embeddings for this MCP 289 | const toolIdsToRemove = Object.keys(cache.metadata).filter( 290 | toolId => cache.metadata[toolId].mcpName === mcpName 291 | ); 292 | 293 | for (const toolId of toolIdsToRemove) { 294 | delete cache.vectors[toolId]; 295 | delete cache.metadata[toolId]; 296 | removedCount++; 297 | } 298 | 299 | await this.saveEmbeddingsCache(cache); 300 | logger.info(`✅ Removed ${removedCount} embeddings for ${mcpName}`); 301 | } 302 | 303 | /** 304 | * Update profile hash in tool metadata cache 305 | */ 306 | async updateProfileHash(profileHash: string): Promise<void> { 307 | const cache = await this.loadToolMetadataCache(); 308 | cache.profileHash = profileHash; 309 | await this.saveToolMetadataCache(cache); 310 | logger.debug(`Updated profile hash: ${profileHash.substring(0, 8)}...`); 311 | } 312 | 313 | /** 314 | * Validate if cache is current with profile 315 | */ 316 | async validateCacheWithProfile(currentProfileHash: string): Promise<boolean> { 317 | try { 318 | const cache = await this.loadToolMetadataCache(); 319 | 320 | // Handle empty or corrupt cache 321 | if (!cache || !cache.profileHash) { 322 | logger.info('Cache validation failed: no profile hash found'); 323 | return false; 324 | } 325 | 326 | // Handle version mismatches 327 | if (cache.version !== '1.0.0') { 328 | logger.info(`Cache validation failed: version mismatch (${cache.version} → 1.0.0)`); 329 | return false; 330 | } 331 | 332 | const isValid = cache.profileHash === currentProfileHash; 333 | 334 | if (!isValid) { 335 | logger.info(`Cache validation failed: profile changed (${cache.profileHash?.substring(0, 8)}... → ${currentProfileHash.substring(0, 8)}...)`); 336 | } else { 337 | logger.debug(`Cache validation passed: ${currentProfileHash.substring(0, 8)}...`); 338 | } 339 | 340 | return isValid; 341 | } catch (error: any) { 342 | logger.warn(`Cache validation error: ${error.message}`); 343 | return false; 344 | } 345 | } 346 | 347 | /** 348 | * Validate cache integrity and repair if needed 349 | */ 350 | async validateAndRepairCache(): Promise<{ valid: boolean; repaired: boolean }> { 351 | try { 352 | const stats = await this.getCacheStats(); 353 | 354 | if (!stats.toolMetadataExists) { 355 | logger.warn('Tool metadata cache missing'); 356 | return { valid: false, repaired: false }; 357 | } 358 | 359 | const cache = await this.loadToolMetadataCache(); 360 | 361 | // Check for corruption 362 | if (!cache.mcps || typeof cache.mcps !== 'object') { 363 | logger.warn('Cache corruption detected: invalid mcps structure'); 364 | return { valid: false, repaired: false }; 365 | } 366 | 367 | // Check for missing tools 368 | let hasMissingTools = false; 369 | for (const [mcpName, mcpData] of Object.entries(cache.mcps)) { 370 | if (!Array.isArray(mcpData.tools)) { 371 | logger.warn(`Cache corruption detected: invalid tools array for ${mcpName}`); 372 | hasMissingTools = true; 373 | } 374 | } 375 | 376 | if (hasMissingTools) { 377 | logger.warn('Cache has missing or invalid tool data'); 378 | return { valid: false, repaired: false }; 379 | } 380 | 381 | logger.debug('Cache integrity validation passed'); 382 | return { valid: true, repaired: false }; 383 | 384 | } catch (error: any) { 385 | logger.error(`Cache validation failed: ${error.message}`); 386 | return { valid: false, repaired: false }; 387 | } 388 | } 389 | 390 | /** 391 | * Get cache statistics 392 | */ 393 | async getCacheStats(): Promise<{ 394 | toolMetadataExists: boolean; 395 | embeddingsExists: boolean; 396 | mcpCount: number; 397 | toolCount: number; 398 | embeddingCount: number; 399 | lastModified: Date | null; 400 | }> { 401 | const toolMetadataExists = existsSync(this.toolMetadataCachePath); 402 | const embeddingsExists = existsSync(this.embeddingsCachePath); 403 | 404 | let mcpCount = 0; 405 | let toolCount = 0; 406 | let embeddingCount = 0; 407 | let lastModified: Date | null = null; 408 | 409 | if (toolMetadataExists) { 410 | try { 411 | const cache = await this.loadToolMetadataCache(); 412 | mcpCount = Object.keys(cache.mcps).length; 413 | toolCount = Object.values(cache.mcps).reduce((sum, mcp) => sum + mcp.tools.length, 0); 414 | lastModified = new Date(cache.lastModified); 415 | } catch (error) { 416 | // Ignore errors for stats 417 | } 418 | } 419 | 420 | if (embeddingsExists) { 421 | try { 422 | const cache = await this.loadEmbeddingsCache(); 423 | embeddingCount = Object.keys(cache.vectors).length; 424 | } catch (error) { 425 | // Ignore errors for stats 426 | } 427 | } 428 | 429 | return { 430 | toolMetadataExists, 431 | embeddingsExists, 432 | mcpCount, 433 | toolCount, 434 | embeddingCount, 435 | lastModified 436 | }; 437 | } 438 | } ``` -------------------------------------------------------------------------------- /test/tool-schema-parser.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Comprehensive Tests for ToolSchemaParser 3 | * Following ncp-oss3 patterns for 95%+ coverage 4 | */ 5 | 6 | import { describe, it, expect } from '@jest/globals'; 7 | import { ToolSchemaParser, ParameterInfo } from '../src/services/tool-schema-parser'; 8 | 9 | describe('ToolSchemaParser - Comprehensive Coverage', () => { 10 | 11 | const sampleSchema = { 12 | properties: { 13 | path: { 14 | type: 'string', 15 | description: 'File path to read' 16 | }, 17 | encoding: { 18 | type: 'string', 19 | description: 'File encoding (optional)' 20 | }, 21 | maxSize: { 22 | type: 'number', 23 | description: 'Maximum file size in bytes' 24 | }, 25 | recursive: { 26 | type: 'boolean', 27 | description: 'Whether to read recursively' 28 | } 29 | }, 30 | required: ['path', 'maxSize'] 31 | }; 32 | 33 | const emptySchema = {}; 34 | const noPropertiesSchema = { required: ['something'] }; 35 | const noRequiredSchema = { 36 | properties: { 37 | optional1: { type: 'string' }, 38 | optional2: { type: 'number' } 39 | } 40 | }; 41 | 42 | describe('🎯 Parameter Parsing - Core Functionality', () => { 43 | it('should parse complete schema with all parameter types', () => { 44 | const params = ToolSchemaParser.parseParameters(sampleSchema); 45 | 46 | expect(params).toHaveLength(4); 47 | 48 | // Check path parameter (required string) 49 | const pathParam = params.find(p => p.name === 'path'); 50 | expect(pathParam).toEqual({ 51 | name: 'path', 52 | type: 'string', 53 | required: true, 54 | description: 'File path to read' 55 | }); 56 | 57 | // Check encoding parameter (optional string) 58 | const encodingParam = params.find(p => p.name === 'encoding'); 59 | expect(encodingParam).toEqual({ 60 | name: 'encoding', 61 | type: 'string', 62 | required: false, 63 | description: 'File encoding (optional)' 64 | }); 65 | 66 | // Check maxSize parameter (required number) 67 | const maxSizeParam = params.find(p => p.name === 'maxSize'); 68 | expect(maxSizeParam).toEqual({ 69 | name: 'maxSize', 70 | type: 'number', 71 | required: true, 72 | description: 'Maximum file size in bytes' 73 | }); 74 | 75 | // Check recursive parameter (optional boolean) 76 | const recursiveParam = params.find(p => p.name === 'recursive'); 77 | expect(recursiveParam).toEqual({ 78 | name: 'recursive', 79 | type: 'boolean', 80 | required: false, 81 | description: 'Whether to read recursively' 82 | }); 83 | }); 84 | 85 | it('should handle schema with missing properties', () => { 86 | const params = ToolSchemaParser.parseParameters(noPropertiesSchema); 87 | expect(params).toEqual([]); 88 | }); 89 | 90 | it('should handle schema with no required array', () => { 91 | const params = ToolSchemaParser.parseParameters(noRequiredSchema); 92 | expect(params).toHaveLength(2); 93 | 94 | params.forEach(param => { 95 | expect(param.required).toBe(false); 96 | }); 97 | }); 98 | 99 | it('should handle properties without type information', () => { 100 | const schemaWithoutTypes = { 101 | properties: { 102 | mystery1: { description: 'Unknown type parameter' }, 103 | mystery2: { /* no type or description */ } 104 | }, 105 | required: ['mystery1'] 106 | }; 107 | 108 | const params = ToolSchemaParser.parseParameters(schemaWithoutTypes); 109 | expect(params).toHaveLength(2); 110 | 111 | const mystery1 = params.find(p => p.name === 'mystery1'); 112 | expect(mystery1).toEqual({ 113 | name: 'mystery1', 114 | type: 'unknown', 115 | required: true, 116 | description: 'Unknown type parameter' 117 | }); 118 | 119 | const mystery2 = params.find(p => p.name === 'mystery2'); 120 | expect(mystery2).toEqual({ 121 | name: 'mystery2', 122 | type: 'unknown', 123 | required: false, 124 | description: undefined 125 | }); 126 | }); 127 | }); 128 | 129 | describe('🎯 Edge Cases and Error Handling', () => { 130 | it('should handle null and undefined schemas', () => { 131 | expect(ToolSchemaParser.parseParameters(null)).toEqual([]); 132 | expect(ToolSchemaParser.parseParameters(undefined)).toEqual([]); 133 | }); 134 | 135 | it('should handle non-object schemas', () => { 136 | expect(ToolSchemaParser.parseParameters('string')).toEqual([]); 137 | expect(ToolSchemaParser.parseParameters(123)).toEqual([]); 138 | expect(ToolSchemaParser.parseParameters([])).toEqual([]); 139 | expect(ToolSchemaParser.parseParameters(true)).toEqual([]); 140 | }); 141 | 142 | it('should handle empty schema object', () => { 143 | expect(ToolSchemaParser.parseParameters(emptySchema)).toEqual([]); 144 | }); 145 | 146 | it('should handle schema with null/undefined properties', () => { 147 | const badSchema = { 148 | properties: null, 149 | required: undefined 150 | }; 151 | expect(ToolSchemaParser.parseParameters(badSchema)).toEqual([]); 152 | }); 153 | 154 | it('should handle schema with non-array required field', () => { 155 | const invalidRequiredSchema = { 156 | properties: { 157 | param1: { type: 'string' } 158 | }, 159 | required: 'not-an-array' 160 | }; 161 | const params = ToolSchemaParser.parseParameters(invalidRequiredSchema); 162 | expect(params).toHaveLength(1); 163 | expect(params[0].required).toBe(false); 164 | }); 165 | }); 166 | 167 | describe('🎯 Required Parameters Filtering', () => { 168 | it('should extract only required parameters', () => { 169 | const requiredParams = ToolSchemaParser.getRequiredParameters(sampleSchema); 170 | 171 | expect(requiredParams).toHaveLength(2); 172 | expect(requiredParams.map(p => p.name)).toEqual(['path', 'maxSize']); 173 | 174 | requiredParams.forEach(param => { 175 | expect(param.required).toBe(true); 176 | }); 177 | }); 178 | 179 | it('should return empty array for schema with no required parameters', () => { 180 | const requiredParams = ToolSchemaParser.getRequiredParameters(noRequiredSchema); 181 | expect(requiredParams).toEqual([]); 182 | }); 183 | 184 | it('should handle invalid schemas in getRequiredParameters', () => { 185 | expect(ToolSchemaParser.getRequiredParameters(null)).toEqual([]); 186 | expect(ToolSchemaParser.getRequiredParameters({})).toEqual([]); 187 | }); 188 | }); 189 | 190 | describe('🎯 Optional Parameters Filtering', () => { 191 | it('should extract only optional parameters', () => { 192 | const optionalParams = ToolSchemaParser.getOptionalParameters(sampleSchema); 193 | 194 | expect(optionalParams).toHaveLength(2); 195 | expect(optionalParams.map(p => p.name)).toEqual(['encoding', 'recursive']); 196 | 197 | optionalParams.forEach(param => { 198 | expect(param.required).toBe(false); 199 | }); 200 | }); 201 | 202 | it('should return all parameters when none are required', () => { 203 | const optionalParams = ToolSchemaParser.getOptionalParameters(noRequiredSchema); 204 | expect(optionalParams).toHaveLength(2); 205 | 206 | optionalParams.forEach(param => { 207 | expect(param.required).toBe(false); 208 | }); 209 | }); 210 | 211 | it('should handle invalid schemas in getOptionalParameters', () => { 212 | expect(ToolSchemaParser.getOptionalParameters(null)).toEqual([]); 213 | expect(ToolSchemaParser.getOptionalParameters({})).toEqual([]); 214 | }); 215 | }); 216 | 217 | describe('🎯 Required Parameters Detection', () => { 218 | it('should detect schemas with required parameters', () => { 219 | expect(ToolSchemaParser.hasRequiredParameters(sampleSchema)).toBe(true); 220 | }); 221 | 222 | it('should detect schemas without required parameters', () => { 223 | expect(ToolSchemaParser.hasRequiredParameters(noRequiredSchema)).toBe(false); 224 | expect(ToolSchemaParser.hasRequiredParameters(emptySchema)).toBe(false); 225 | }); 226 | 227 | it('should handle edge cases in hasRequiredParameters', () => { 228 | expect(ToolSchemaParser.hasRequiredParameters(null)).toBe(false); 229 | expect(ToolSchemaParser.hasRequiredParameters(undefined)).toBe(false); 230 | expect(ToolSchemaParser.hasRequiredParameters('string')).toBe(false); 231 | expect(ToolSchemaParser.hasRequiredParameters(123)).toBe(false); 232 | }); 233 | 234 | it('should handle schema with empty required array', () => { 235 | const emptyRequiredSchema = { 236 | properties: { param1: { type: 'string' } }, 237 | required: [] 238 | }; 239 | expect(ToolSchemaParser.hasRequiredParameters(emptyRequiredSchema)).toBe(false); 240 | }); 241 | 242 | it('should handle schema with non-array required field', () => { 243 | const invalidRequiredSchema = { 244 | properties: { param1: { type: 'string' } }, 245 | required: 'not-an-array' 246 | }; 247 | expect(ToolSchemaParser.hasRequiredParameters(invalidRequiredSchema)).toBe(false); 248 | }); 249 | }); 250 | 251 | describe('🎯 Parameter Counting', () => { 252 | it('should count all parameter types correctly', () => { 253 | const counts = ToolSchemaParser.countParameters(sampleSchema); 254 | 255 | expect(counts).toEqual({ 256 | total: 4, 257 | required: 2, 258 | optional: 2 259 | }); 260 | }); 261 | 262 | it('should count parameters in schema with no required fields', () => { 263 | const counts = ToolSchemaParser.countParameters(noRequiredSchema); 264 | 265 | expect(counts).toEqual({ 266 | total: 2, 267 | required: 0, 268 | optional: 2 269 | }); 270 | }); 271 | 272 | it('should count parameters in schema with all required fields', () => { 273 | const allRequiredSchema = { 274 | properties: { 275 | param1: { type: 'string' }, 276 | param2: { type: 'number' } 277 | }, 278 | required: ['param1', 'param2'] 279 | }; 280 | 281 | const counts = ToolSchemaParser.countParameters(allRequiredSchema); 282 | 283 | expect(counts).toEqual({ 284 | total: 2, 285 | required: 2, 286 | optional: 0 287 | }); 288 | }); 289 | 290 | it('should handle empty schemas in countParameters', () => { 291 | expect(ToolSchemaParser.countParameters(emptySchema)).toEqual({ 292 | total: 0, 293 | required: 0, 294 | optional: 0 295 | }); 296 | 297 | expect(ToolSchemaParser.countParameters(null)).toEqual({ 298 | total: 0, 299 | required: 0, 300 | optional: 0 301 | }); 302 | }); 303 | }); 304 | 305 | describe('🎯 Individual Parameter Lookup', () => { 306 | it('should find existing parameters by name', () => { 307 | const pathParam = ToolSchemaParser.getParameter(sampleSchema, 'path'); 308 | expect(pathParam).toEqual({ 309 | name: 'path', 310 | type: 'string', 311 | required: true, 312 | description: 'File path to read' 313 | }); 314 | 315 | const encodingParam = ToolSchemaParser.getParameter(sampleSchema, 'encoding'); 316 | expect(encodingParam).toEqual({ 317 | name: 'encoding', 318 | type: 'string', 319 | required: false, 320 | description: 'File encoding (optional)' 321 | }); 322 | }); 323 | 324 | it('should return undefined for non-existent parameters', () => { 325 | expect(ToolSchemaParser.getParameter(sampleSchema, 'nonexistent')).toBeUndefined(); 326 | expect(ToolSchemaParser.getParameter(sampleSchema, '')).toBeUndefined(); 327 | }); 328 | 329 | it('should handle invalid schemas in getParameter', () => { 330 | expect(ToolSchemaParser.getParameter(null, 'any')).toBeUndefined(); 331 | expect(ToolSchemaParser.getParameter({}, 'any')).toBeUndefined(); 332 | expect(ToolSchemaParser.getParameter('invalid', 'any')).toBeUndefined(); 333 | }); 334 | 335 | it('should handle case-sensitive parameter names', () => { 336 | expect(ToolSchemaParser.getParameter(sampleSchema, 'Path')).toBeUndefined(); 337 | expect(ToolSchemaParser.getParameter(sampleSchema, 'PATH')).toBeUndefined(); 338 | expect(ToolSchemaParser.getParameter(sampleSchema, 'path')).toBeDefined(); 339 | }); 340 | }); 341 | 342 | describe('🎯 Complex Schema Scenarios', () => { 343 | it('should handle nested object schemas', () => { 344 | const nestedSchema = { 345 | properties: { 346 | config: { 347 | type: 'object', 348 | description: 'Configuration object', 349 | properties: { 350 | nested: { type: 'string' } 351 | } 352 | } 353 | }, 354 | required: ['config'] 355 | }; 356 | 357 | const params = ToolSchemaParser.parseParameters(nestedSchema); 358 | expect(params).toHaveLength(1); 359 | expect(params[0]).toEqual({ 360 | name: 'config', 361 | type: 'object', 362 | required: true, 363 | description: 'Configuration object' 364 | }); 365 | }); 366 | 367 | it('should handle array type schemas', () => { 368 | const arraySchema = { 369 | properties: { 370 | items: { 371 | type: 'array', 372 | description: 'List of items', 373 | items: { type: 'string' } 374 | } 375 | }, 376 | required: ['items'] 377 | }; 378 | 379 | const params = ToolSchemaParser.parseParameters(arraySchema); 380 | expect(params[0]).toEqual({ 381 | name: 'items', 382 | type: 'array', 383 | required: true, 384 | description: 'List of items' 385 | }); 386 | }); 387 | 388 | it('should handle schemas with special characters in property names', () => { 389 | const specialSchema = { 390 | properties: { 391 | 'kebab-case': { type: 'string' }, 392 | 'snake_case': { type: 'number' }, 393 | 'dot.notation': { type: 'boolean' }, 394 | 'space name': { type: 'string' } 395 | }, 396 | required: ['kebab-case', 'space name'] 397 | }; 398 | 399 | const params = ToolSchemaParser.parseParameters(specialSchema); 400 | expect(params).toHaveLength(4); 401 | 402 | const kebabParam = params.find(p => p.name === 'kebab-case'); 403 | expect(kebabParam?.required).toBe(true); 404 | 405 | const spaceParam = params.find(p => p.name === 'space name'); 406 | expect(spaceParam?.required).toBe(true); 407 | 408 | const snakeParam = params.find(p => p.name === 'snake_case'); 409 | expect(snakeParam?.required).toBe(false); 410 | }); 411 | }); 412 | }); ``` -------------------------------------------------------------------------------- /docs/stories/06-official-registry.md: -------------------------------------------------------------------------------- ```markdown 1 | # 🌐 Story 6: Official Registry 2 | 3 | *How AI discovers 2,200+ MCPs without you lifting a finger* 4 | 5 | **Reading time:** 2 minutes 6 | 7 | --- 8 | 9 | ## 😤 The Pain 10 | 11 | You need a database MCP. Here's what you have to do today: 12 | 13 | **The Manual Discovery Process:** 14 | 15 | ``` 16 | Step 1: Google "MCP database" 17 | → Find blog post from 3 months ago 18 | → List is outdated 19 | 20 | Step 2: Visit Smithery.ai 21 | → Browse through categories 22 | → 2,200+ MCPs to wade through 23 | → No way to preview without installing 24 | 25 | Step 3: Find promising MCP 26 | → Click to GitHub repo 27 | → Read README (hopefully it's good) 28 | → Find npm package name 29 | → Hope it's maintained 30 | 31 | Step 4: Copy installation command 32 | → npm install -g @someone/mcp-postgres 33 | → Still not sure if it's the right one 34 | 35 | Step 5: Configure it 36 | → Add to config file 37 | → Restart Claude Desktop 38 | → Test it 39 | → Realize it's not what you needed 40 | 41 | Step 6: Remove and try another 42 | → Repeat steps 3-5 multiple times 43 | → 2 hours wasted 44 | ``` 45 | 46 | **Questions you can't easily answer:** 47 | - Which database MCPs are **official** vs community? 48 | - Which are **actively maintained**? 49 | - Which are **most popular**? 50 | - What **env vars** do they need? 51 | - How do I install them **correctly**? 52 | 53 | **You're on your own. Good luck.** 54 | 55 | --- 56 | 57 | ## 🌐 The Journey 58 | 59 | NCP connects to the **Official MCP Registry** - Anthropic's central directory of vetted MCPs. Your AI can search it directly. 60 | 61 | ### **Discovery Through Conversation:** 62 | 63 | **You:** "Find database MCPs" 64 | 65 | **AI:** [Calls `ncp:import` with discovery mode] 66 | 67 | ```typescript 68 | ncp:import({ 69 | from: "discovery", 70 | source: "database" 71 | }) 72 | ``` 73 | 74 | **NCP:** [Searches registry.modelcontextprotocol.io] 75 | 76 | **AI shows you:** 77 | 78 | ``` 79 | Found 8 database MCPs from official registry: 80 | 81 | 1. ⭐ @modelcontextprotocol/server-postgres 82 | PostgreSQL database integration 83 | Status: Official | Downloads: 1,240 84 | 85 | 2. 📦 @modelcontextprotocol/server-sqlite 86 | SQLite database integration 87 | Status: Official | Downloads: 890 88 | 89 | 3. 📦 @modelcontextprotocol/server-mongodb 90 | MongoDB database integration 91 | Status: Community | Downloads: 456 92 | 93 | 4. 📦 mcp-mysql-server 94 | MySQL database integration 95 | Status: Community | Downloads: 234 96 | 97 | [... 4 more ...] 98 | 99 | Which would you like to install? 100 | ``` 101 | 102 | **You:** "Install 1 and 2" 103 | 104 | **AI:** [Calls import with selection] 105 | 106 | ```typescript 107 | ncp:import({ 108 | from: "discovery", 109 | source: "database", 110 | selection: "1,2" 111 | }) 112 | ``` 113 | 114 | **NCP:** [Imports PostgreSQL and SQLite MCPs with correct configs] 115 | 116 | **Result:** 117 | ``` 118 | ✅ Installed @modelcontextprotocol/server-postgres 119 | ✅ Installed @modelcontextprotocol/server-sqlite 120 | 121 | Both MCPs ready to use! If they require credentials, use clipboard 122 | security pattern (Story 2) to configure API keys safely. 123 | ``` 124 | 125 | **Total time: 30 seconds.** (vs 2 hours manually) 126 | 127 | --- 128 | 129 | ## ✨ The Magic 130 | 131 | What you get with registry integration: 132 | 133 | ### **🔍 AI-Powered Discovery** 134 | - **Search by intent:** "Find file tools" not "grep filesystem npm" 135 | - **Semantic matching:** Registry understands what you need 136 | - **Natural language:** No technical keywords required 137 | - **Conversational:** Back-and-forth with AI to refine results 138 | 139 | ### **⭐ Curated Results** 140 | - **Official badge:** Shows Anthropic-maintained MCPs 141 | - **Download counts:** See what's popular and trusted 142 | - **Status indicators:** Official vs Community vs Experimental 143 | - **Version info:** Always get latest stable version 144 | 145 | ### **📦 One-Click Install** 146 | - **Select by number:** "Install 1, 3, and 5" 147 | - **Range selection:** "Install 1-5" 148 | - **Install all:** "Install *" 149 | - **Batch import:** Multiple MCPs installed in parallel 150 | 151 | ### **✅ Correct Configuration** 152 | - **Registry knows the command:** `npx` or `node` or custom 153 | - **Registry knows the args:** Package identifier, required flags 154 | - **Registry knows env vars:** Shows what credentials you need 155 | - **No guessing:** NCP gets it right the first time 156 | 157 | ### **🔒 Safe Credentials** 158 | - **Registry shows:** "This MCP needs GITHUB_TOKEN" 159 | - **You provide:** Via clipboard security pattern (Story 2) 160 | - **AI never sees:** Your actual token 161 | - **Works seamlessly:** Discovery + secure config in one flow 162 | 163 | --- 164 | 165 | ## 🔍 How It Works (The Technical Story) 166 | 167 | ### **Registry API:** 168 | 169 | ```typescript 170 | // NCP talks to official MCP Registry 171 | const REGISTRY_BASE = 'https://registry.modelcontextprotocol.io/v0'; 172 | 173 | // Search endpoint 174 | GET /v0/servers?limit=50 175 | → Returns: List of all MCPs with metadata 176 | 177 | // Details endpoint 178 | GET /v0/servers/{encoded_name} 179 | → Returns: Full details including env vars, packages, etc. 180 | ``` 181 | 182 | ### **Search Flow:** 183 | 184 | ```typescript 185 | // User: "Find database MCPs" 186 | // AI calls: ncp:import({ from: "discovery", source: "database" }) 187 | 188 | // Step 1: Search registry 189 | const results = await fetch(`${REGISTRY_BASE}/servers?limit=50`); 190 | const allServers = await results.json(); 191 | 192 | // Step 2: Filter by query 193 | const filtered = allServers.servers.filter(s => 194 | s.server.name.toLowerCase().includes('database') || 195 | s.server.description?.toLowerCase().includes('database') 196 | ); 197 | 198 | // Step 3: Format as numbered list 199 | const candidates = filtered.map((server, index) => ({ 200 | number: index + 1, 201 | name: server.server.name, 202 | displayName: extractShortName(server.server.name), 203 | description: server.server.description, 204 | status: server._meta?.['io.modelcontextprotocol.registry/official']?.status, 205 | downloads: getDownloadCount(server), // From registry metadata 206 | version: server.server.version 207 | })); 208 | 209 | // Return to AI for display 210 | ``` 211 | 212 | ### **Import Flow:** 213 | 214 | ```typescript 215 | // User: "Install 1 and 3" 216 | // AI calls: ncp:import({ from: "discovery", source: "database", selection: "1,3" }) 217 | 218 | // Step 1: Parse selection 219 | const selected = parseSelection("1,3", candidates); 220 | // Returns: [candidates[0], candidates[2]] 221 | 222 | // Step 2: Get detailed info for each 223 | for (const candidate of selected) { 224 | const details = await fetch(`${REGISTRY_BASE}/servers/${encodeURIComponent(candidate.name)}`); 225 | const server = await details.json(); 226 | 227 | // Extract install config 228 | const pkg = server.server.packages[0]; 229 | const config = { 230 | command: pkg.runtimeHint || 'npx', 231 | args: [pkg.identifier], 232 | env: {} // User provides via clipboard if needed 233 | }; 234 | 235 | // Import using internal add command 236 | await internalAdd(candidate.displayName, config); 237 | } 238 | ``` 239 | 240 | ### **Caching:** 241 | 242 | ```typescript 243 | // Registry responses cached for 5 minutes 244 | const CACHE_TTL = 5 * 60 * 1000; 245 | 246 | // First search: Hits network (~200ms) 247 | ncp:import({ from: "discovery", source: "database" }) 248 | 249 | // Repeat search within 5 min: Hits cache (0ms) 250 | ncp:import({ from: "discovery", source: "database" }) 251 | 252 | // After 5 min: Cache expires, fetches fresh data 253 | ``` 254 | 255 | --- 256 | 257 | ## 🎨 The Analogy That Makes It Click 258 | 259 | **Manual Discovery = Library Without Card Catalog** 📚 260 | 261 | ``` 262 | You walk into library with 2,200 books. 263 | No organization. No search system. No librarian. 264 | You wander the aisles hoping to find what you need. 265 | Read book spines one by one. 266 | Pull out books to check if they're relevant. 267 | 3 hours later: Found 2 books, not sure if they're the best. 268 | ``` 269 | 270 | **Registry Discovery = Amazon Search** 🔍 271 | 272 | ``` 273 | You open Amazon. 274 | Type: "database book" 275 | See: Reviews, ratings, bestsellers, "customers also bought" 276 | Filter: By rating, by relevance, by date 277 | Click: Buy recommended book 278 | 5 minutes later: Book on the way, confident it's what you need. 279 | ``` 280 | 281 | **Registry gives MCPs the search/discovery experience of modern marketplaces.** 282 | 283 | --- 284 | 285 | ## 🧪 See It Yourself 286 | 287 | Try this experiment: 288 | 289 | ### **Test 1: Search Registry** 290 | 291 | ```bash 292 | # Manual way (old) 293 | [Open browser] 294 | [Go to smithery.ai] 295 | [Search "filesystem"] 296 | [Read through results] 297 | [Copy npm command] 298 | [Run in terminal] 299 | [Total time: 5 minutes] 300 | 301 | # Registry way (new) 302 | You: "Find filesystem MCPs" 303 | AI: [Shows numbered list from registry] 304 | You: "Install 1" 305 | AI: [Installs in seconds] 306 | [Total time: 30 seconds] 307 | ``` 308 | 309 | ### **Test 2: Compare Official vs Community** 310 | 311 | ``` 312 | You: "Find GitHub MCPs" 313 | 314 | AI shows: 315 | 1. ⭐ @modelcontextprotocol/server-github [Official] 316 | 2. 📦 github-mcp-enhanced [Community] 317 | 3. 📦 mcp-github-toolkit [Community] 318 | 319 | You can see at a glance which is official/supported! 320 | ``` 321 | 322 | ### **Test 3: Batch Install** 323 | 324 | ``` 325 | You: "Find AI reasoning MCPs" 326 | 327 | AI shows: 328 | 1. sequential-thinking 329 | 2. memory 330 | 3. thinking-protocol 331 | 4. context-manager 332 | 333 | You: "Install all" 334 | AI: [Installs 1-4 in parallel] 335 | 336 | Done in seconds! 337 | ``` 338 | 339 | --- 340 | 341 | ## 🚀 Why This Changes Everything 342 | 343 | ### **Before Registry (Fragmented Discovery):** 344 | 345 | **The ecosystem was scattered:** 346 | - Some MCPs on Smithery.ai 347 | - Some on GitHub awesome lists 348 | - Some only documented in blog posts 349 | - No central source of truth 350 | - No quality indicators 351 | - No official vs community distinction 352 | 353 | **Finding MCPs was hard. Choosing the right one was harder.** 354 | 355 | ### **After Registry (Unified Discovery):** 356 | 357 | **The ecosystem is organized:** 358 | - ✅ All MCPs in central registry (registry.modelcontextprotocol.io) 359 | - ✅ Clear official vs community badges 360 | - ✅ Download counts show popularity 361 | - ✅ Correct install commands included 362 | - ✅ AI can search and install directly 363 | - ✅ One source of truth for all MCPs 364 | 365 | **Finding MCPs is easy. Choosing the right one is obvious.** 366 | 367 | --- 368 | 369 | ## 🎯 Selection Syntax 370 | 371 | NCP supports flexible selection formats: 372 | 373 | ```typescript 374 | // Individual numbers 375 | selection: "1,3,5" 376 | → Installs: #1, #3, #5 377 | 378 | // Ranges 379 | selection: "1-5" 380 | → Installs: #1, #2, #3, #4, #5 381 | 382 | // Mixed 383 | selection: "1,3,7-10" 384 | → Installs: #1, #3, #7, #8, #9, #10 385 | 386 | // All results 387 | selection: "*" 388 | → Installs: Everything shown 389 | 390 | // Just one 391 | selection: "1" 392 | → Installs: #1 only 393 | ``` 394 | 395 | **Natural syntax. No programming knowledge required.** 396 | 397 | --- 398 | 399 | ## 📊 Registry Metadata 400 | 401 | What registry provides per MCP: 402 | 403 | ```typescript 404 | { 405 | server: { 406 | name: "io.github.modelcontextprotocol/server-filesystem", 407 | description: "File system operations", 408 | version: "0.2.0", 409 | repository: { 410 | url: "https://github.com/modelcontextprotocol/servers", 411 | type: "git" 412 | }, 413 | packages: [{ 414 | identifier: "@modelcontextprotocol/server-filesystem", 415 | version: "0.2.0", 416 | runtimeHint: "npx", 417 | environmentVariables: [ 418 | { 419 | name: "ROOT_PATH", 420 | description: "Root directory for file operations", 421 | isRequired: true 422 | } 423 | ] 424 | }] 425 | }, 426 | _meta: { 427 | 'io.modelcontextprotocol.registry/official': { 428 | status: "official" // or "community" 429 | } 430 | } 431 | } 432 | ``` 433 | 434 | **Registry tells NCP exactly how to install and configure each MCP.** 435 | 436 | --- 437 | 438 | ## 🔒 Security Considerations 439 | 440 | **Q: Can malicious MCPs enter the registry?** 441 | 442 | **A: Registry has curation process:** 443 | 444 | 1. **Official MCPs:** Maintained by Anthropic, fully vetted 445 | 2. **Community MCPs:** User-submitted, reviewed before listing 446 | 3. **Each MCP shows status:** Official vs Community badge visible 447 | 4. **Source code linked:** GitHub repo always shown 448 | 5. **Download counts:** Popular = more eyes = more security 449 | 450 | **Best practices:** 451 | 452 | - ✅ Prefer official MCPs when available 453 | - ✅ Check GitHub repo before installing community MCPs 454 | - ✅ Review source code if handling sensitive data 455 | - ✅ Start with high-download-count MCPs (battle-tested) 456 | 457 | **Registry doesn't execute code. It's a directory. You're still in control of what runs.** 458 | 459 | --- 460 | 461 | ## 📚 Deep Dive 462 | 463 | Want the full technical implementation? 464 | 465 | - **Registry Client:** [src/services/registry-client.ts] 466 | - **Discovery Mode:** [src/internal-mcps/ncp-management.ts] (import tool) 467 | - **Selection Parser:** [Parse selection format] 468 | - **API Docs:** [https://registry.modelcontextprotocol.io/](https://registry.modelcontextprotocol.io/) 469 | 470 | --- 471 | 472 | ## 🔗 Complete the Journey 473 | 474 | **[← Back to Story 1: Dream and Discover](01-dream-and-discover.md)** 475 | 476 | You've now read all 6 core stories that make NCP special: 477 | 478 | 1. ✅ **Dream and Discover** - AI searches by intent, not by browsing tools 479 | 2. ✅ **Secrets in Plain Sight** - Clipboard handshake keeps credentials safe 480 | 3. ✅ **Sync and Forget** - Auto-imports Claude Desktop MCPs forever 481 | 4. ✅ **Double-Click Install** - .mcpb makes installation feel native 482 | 5. ✅ **Runtime Detective** - Adapts to your Node.js runtime automatically 483 | 6. ✅ **Official Registry** - Discovers 2,200+ MCPs through conversation 484 | 485 | **Together, these stories explain why NCP transforms how you work with MCPs.** 486 | 487 | --- 488 | 489 | ## 💬 Questions? 490 | 491 | **Q: How often is registry updated?** 492 | 493 | A: Registry is live. New MCPs appear as soon as they're approved. NCP caches results for 5 minutes, then fetches fresh data. 494 | 495 | **Q: Can I search for specific features?** 496 | 497 | A: Yes! Try: "Find MCPs with email capabilities" or "Find MCPs for web scraping". Semantic search works across name + description. 498 | 499 | **Q: What if registry is down?** 500 | 501 | A: NCP falls back gracefully. You can still use existing MCPs and install new ones manually via `ncp add`. 502 | 503 | **Q: Can I submit my MCP to registry?** 504 | 505 | A: Yes! Visit [registry.modelcontextprotocol.io](https://registry.modelcontextprotocol.io/) for submission guidelines. (Process managed by Anthropic) 506 | 507 | **Q: What about MCPs not in registry?** 508 | 509 | A: You can still install them manually: `ncp add myserver npx my-custom-mcp`. Registry is for discovery convenience, not a requirement. 510 | 511 | --- 512 | 513 | **[← Previous Story](05-runtime-detective.md)** | **[Back to Story Index](../README.md#the-six-stories)** 514 | 515 | --- 516 | 517 | ## 🎉 What's Next? 518 | 519 | Now that you understand how NCP works through these six stories, you're ready to: 520 | 521 | 1. **[Install NCP →](../README.md#installation)** - Get started in 30 seconds 522 | 2. **[Try the examples →](../README.md#test-drive)** - See it in action 523 | 3. **[Read technical docs →](../technical/)** - Deep dive into implementation 524 | 4. **[Contribute →](../../CONTRIBUTING.md)** - Help make NCP even better 525 | 526 | **Welcome to the NCP community!** 🚀 527 | ``` -------------------------------------------------------------------------------- /src/analytics/visual-formatter.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * NCP Visual Analytics Formatter 3 | * Enhanced terminal output with CLI charts and graphs 4 | */ 5 | 6 | import chalk from 'chalk'; 7 | import { AnalyticsReport } from './log-parser.js'; 8 | 9 | export class VisualAnalyticsFormatter { 10 | /** 11 | * Format analytics dashboard with visual charts 12 | */ 13 | static async formatVisualDashboard(report: AnalyticsReport): Promise<string> { 14 | const output: string[] = []; 15 | 16 | // Header with enhanced styling 17 | output.push(''); 18 | output.push(chalk.bold.cyan('🚀 NCP Impact Analytics Dashboard (Visual)')); 19 | output.push(chalk.dim('═'.repeat(60))); 20 | output.push(''); 21 | 22 | // Overview Section with Key Metrics 23 | output.push(chalk.bold.white('📊 KEY METRICS OVERVIEW')); 24 | output.push(''); 25 | 26 | const days = Math.ceil((report.timeRange.end.getTime() - report.timeRange.start.getTime()) / (1000 * 60 * 60 * 24)); 27 | const period = days <= 1 ? 'today' : `last ${days} days`; 28 | 29 | // Create metrics display with visual bars 30 | const metrics = [ 31 | { label: 'Total Sessions', value: report.totalSessions, unit: 'sessions', color: chalk.green }, 32 | { label: 'Unique MCPs', value: report.uniqueMCPs, unit: 'servers', color: chalk.cyan }, 33 | { label: 'Success Rate', value: Math.round(report.successRate), unit: '%', color: chalk.yellow }, 34 | { label: 'Response Data', value: Math.round(report.totalResponseSize / 1024 / 1024), unit: 'MB', color: chalk.blue } 35 | ]; 36 | 37 | for (const metric of metrics) { 38 | const bar = this.createHorizontalBar(metric.value, Math.max(...metrics.map(m => m.value)), 25); 39 | output.push(`${metric.color(metric.label.padEnd(15))}: ${bar} ${metric.color(metric.value.toLocaleString())} ${chalk.dim(metric.unit)}`); 40 | } 41 | output.push(''); 42 | 43 | // Usage Trends Chart 44 | if (Object.keys(report.dailyUsage).length > 3) { 45 | output.push(chalk.bold.white('📈 DAILY USAGE TRENDS')); 46 | output.push(''); 47 | 48 | const dailyData = Object.entries(report.dailyUsage) 49 | .sort(([a], [b]) => a.localeCompare(b)) 50 | .map(([_, usage]) => usage); 51 | 52 | if (dailyData.length > 1) { 53 | // Create simple ASCII line chart 54 | const chart = this.createLineChart(dailyData, 8, 40); 55 | output.push(chalk.green(chart)); 56 | output.push(chalk.dim(' └─ Sessions per day over time')); 57 | } 58 | output.push(''); 59 | } 60 | 61 | // Top MCPs Usage Chart 62 | if (report.topMCPsByUsage.length > 0) { 63 | output.push(chalk.bold.white('🔥 TOP MCP USAGE DISTRIBUTION')); 64 | output.push(''); 65 | 66 | const topMCPs = report.topMCPsByUsage.slice(0, 8); 67 | const maxSessions = Math.max(...topMCPs.map(mcp => mcp.sessions)); 68 | 69 | for (const mcp of topMCPs) { 70 | const percentage = ((mcp.sessions / report.totalSessions) * 100).toFixed(1); 71 | const bar = this.createColorfulBar(mcp.sessions, maxSessions, 30); 72 | const successIcon = mcp.successRate >= 95 ? '✅' : mcp.successRate >= 80 ? '⚠️' : '❌'; 73 | 74 | output.push(`${chalk.cyan(mcp.name.padEnd(20))} ${bar} ${chalk.white(mcp.sessions.toString().padStart(3))} ${chalk.dim(`(${percentage}%)`)} ${successIcon}`); 75 | } 76 | output.push(''); 77 | } 78 | 79 | // Performance Distribution 80 | if (report.performanceMetrics.fastestMCPs.length > 0) { 81 | output.push(chalk.bold.white('⚡ PERFORMANCE DISTRIBUTION')); 82 | output.push(''); 83 | 84 | // Create performance buckets 85 | const performanceData = report.performanceMetrics.fastestMCPs.concat(report.performanceMetrics.slowestMCPs); 86 | const durations = performanceData.map(mcp => mcp.avgDuration).filter(d => d > 0); 87 | 88 | if (durations.length > 3) { 89 | // Create performance distribution chart 90 | const chart = this.createLineChart(durations.slice(0, 20), 6, 35); 91 | output.push(chalk.yellow(chart)); 92 | output.push(chalk.dim(' └─ Response times across MCPs (ms)')); 93 | } 94 | output.push(''); 95 | } 96 | 97 | // Value Delivered Section with Visual Impact 98 | output.push(chalk.bold.white('💰 VALUE IMPACT VISUALIZATION (ESTIMATES)')); 99 | output.push(''); 100 | 101 | // Calculate savings 102 | const estimatedTokensWithoutNCP = report.totalSessions * report.uniqueMCPs * 100; 103 | const estimatedTokensWithNCP = report.totalSessions * 50; 104 | const tokenSavings = estimatedTokensWithoutNCP - estimatedTokensWithNCP; 105 | const costSavings = (tokenSavings / 1000) * 0.002; 106 | 107 | // Visual representation of savings 108 | const savingsData = [ 109 | { label: 'Without NCP', value: estimatedTokensWithoutNCP, color: chalk.red }, 110 | { label: 'With NCP', value: estimatedTokensWithNCP, color: chalk.green } 111 | ]; 112 | 113 | const maxTokens = Math.max(...savingsData.map(s => s.value)); 114 | for (const saving of savingsData) { 115 | const bar = this.createHorizontalBar(saving.value, maxTokens, 40); 116 | output.push(`${saving.label.padEnd(12)}: ${bar} ${saving.color((saving.value / 1000000).toFixed(1))}M tokens`); 117 | } 118 | 119 | output.push(''); 120 | output.push(`💎 ${chalk.bold.green((tokenSavings / 1000000).toFixed(1))}M tokens saved = ${chalk.bold.green('$' + costSavings.toFixed(2))} cost reduction`); 121 | output.push(`🧠 ${chalk.bold.green((((report.uniqueMCPs - 1) / report.uniqueMCPs) * 100).toFixed(1) + '%')} cognitive load reduction`); 122 | output.push(''); 123 | 124 | // Environmental Impact with Visual Scale 125 | output.push(chalk.bold.white('🌱 ENVIRONMENTAL IMPACT SCALE (ROUGH ESTIMATES)')); 126 | output.push(''); 127 | 128 | const sessionsWithoutNCP = report.totalSessions * report.uniqueMCPs; 129 | const computeReduction = sessionsWithoutNCP - report.totalSessions; 130 | const estimatedEnergyKWh = computeReduction * 0.0002; 131 | const estimatedCO2kg = estimatedEnergyKWh * 0.5; 132 | 133 | // Visual representation of environmental savings 134 | const envData = [ 135 | { label: 'Energy Saved', value: estimatedEnergyKWh, unit: 'kWh', icon: '⚡' }, 136 | { label: 'CO₂ Avoided', value: estimatedCO2kg, unit: 'kg', icon: '🌍' }, 137 | { label: 'Connections Saved', value: computeReduction / 1000, unit: 'k', icon: '🔌' } 138 | ]; 139 | 140 | const maxEnvValue = Math.max(...envData.map(e => e.value)); 141 | for (const env of envData) { 142 | const bar = this.createGreenBar(env.value, maxEnvValue, 25); 143 | output.push(`${env.icon} ${env.label.padEnd(18)}: ${bar} ${chalk.green(env.value.toFixed(1))} ${chalk.dim(env.unit)}`); 144 | } 145 | output.push(''); 146 | 147 | // Footer with enhanced tips 148 | output.push(chalk.bold.white('💡 INTERACTIVE COMMANDS')); 149 | output.push(''); 150 | output.push(chalk.dim(' 📊 ') + chalk.cyan('ncp analytics performance') + chalk.dim(' - Detailed performance metrics')); 151 | output.push(chalk.dim(' 📁 ') + chalk.cyan('ncp analytics export') + chalk.dim(' - Export data to CSV')); 152 | output.push(chalk.dim(' 🔄 ') + chalk.cyan('ncp analytics dashboard') + chalk.dim(' - Refresh this dashboard')); 153 | output.push(''); 154 | 155 | return output.join('\\n'); 156 | } 157 | 158 | /** 159 | * Create horizontal progress bar with custom styling 160 | */ 161 | private static createHorizontalBar(value: number, max: number, width: number): string { 162 | const percentage = max > 0 ? value / max : 0; 163 | const filled = Math.round(percentage * width); 164 | const empty = width - filled; 165 | 166 | const filledChar = '█'; 167 | const emptyChar = '░'; 168 | 169 | return chalk.green(filledChar.repeat(filled)) + chalk.dim(emptyChar.repeat(empty)); 170 | } 171 | 172 | /** 173 | * Create colorful bar with gradient effect 174 | */ 175 | private static createColorfulBar(value: number, max: number, width: number): string { 176 | const percentage = max > 0 ? value / max : 0; 177 | const filled = Math.round(percentage * width); 178 | const empty = width - filled; 179 | 180 | // Create gradient effect based on value 181 | let coloredBar = ''; 182 | for (let i = 0; i < filled; i++) { 183 | const progress = i / width; 184 | if (progress < 0.3) { 185 | coloredBar += chalk.red('█'); 186 | } else if (progress < 0.6) { 187 | coloredBar += chalk.yellow('█'); 188 | } else { 189 | coloredBar += chalk.green('█'); 190 | } 191 | } 192 | 193 | return coloredBar + chalk.dim('░'.repeat(empty)); 194 | } 195 | 196 | /** 197 | * Create green-themed bar for environmental metrics 198 | */ 199 | private static createGreenBar(value: number, max: number, width: number): string { 200 | const percentage = max > 0 ? value / max : 0; 201 | const filled = Math.round(percentage * width); 202 | const empty = width - filled; 203 | 204 | const filledBar = chalk.bgGreen.black('█'.repeat(filled)); 205 | const emptyBar = chalk.dim('░'.repeat(empty)); 206 | 207 | return filledBar + emptyBar; 208 | } 209 | 210 | /** 211 | * Format performance report with enhanced visuals 212 | */ 213 | static async formatVisualPerformance(report: AnalyticsReport): Promise<string> { 214 | const output: string[] = []; 215 | 216 | output.push(''); 217 | output.push(chalk.bold.cyan('⚡ NCP Performance Analytics (Visual)')); 218 | output.push(chalk.dim('═'.repeat(50))); 219 | output.push(''); 220 | 221 | // Performance Overview with Gauges 222 | output.push(chalk.bold.white('🎯 PERFORMANCE GAUGES')); 223 | output.push(''); 224 | 225 | const performanceMetrics = [ 226 | { label: 'Success Rate', value: report.successRate, max: 100, unit: '%', color: chalk.green }, 227 | { label: 'Avg Response', value: report.avgSessionDuration || 5000, max: 10000, unit: 'ms', color: chalk.yellow }, 228 | { label: 'MCPs Active', value: report.uniqueMCPs, max: 2000, unit: 'servers', color: chalk.cyan } 229 | ]; 230 | 231 | for (const metric of performanceMetrics) { 232 | const gauge = this.createGauge(metric.value, metric.max); 233 | output.push(`${metric.label.padEnd(15)}: ${gauge} ${metric.color(metric.value.toFixed(1))}${metric.unit}`); 234 | } 235 | output.push(''); 236 | 237 | // Performance Leaderboard with Visual Ranking 238 | if (report.performanceMetrics.fastestMCPs.length > 0) { 239 | output.push(chalk.bold.white('🏆 SPEED CHAMPIONS PODIUM')); 240 | output.push(''); 241 | 242 | const topPerformers = report.performanceMetrics.fastestMCPs.slice(0, 5); 243 | const medals = ['🥇', '🥈', '🥉', '🏅', '🎖️']; 244 | 245 | for (let i = 0; i < topPerformers.length; i++) { 246 | const mcp = topPerformers[i]; 247 | const medal = medals[i] || '⭐'; 248 | const speedBar = this.createSpeedBar(mcp.avgDuration, 10000); 249 | 250 | output.push(`${medal} ${chalk.cyan(mcp.name.padEnd(20))} ${speedBar} ${chalk.bold.green(mcp.avgDuration.toFixed(0))}ms`); 251 | } 252 | output.push(''); 253 | } 254 | 255 | // Reliability Champions 256 | if (report.performanceMetrics.mostReliable.length > 0) { 257 | output.push(chalk.bold.white('🛡️ RELIABILITY CHAMPIONS')); 258 | output.push(''); 259 | 260 | const reliablePerformers = report.performanceMetrics.mostReliable.slice(0, 5); 261 | 262 | for (let i = 0; i < reliablePerformers.length; i++) { 263 | const mcp = reliablePerformers[i]; 264 | const reliabilityBar = this.createReliabilityBar(mcp.successRate); 265 | const shield = mcp.successRate >= 99 ? '🛡️' : mcp.successRate >= 95 ? '🔰' : '⚡'; 266 | 267 | output.push(`${shield} ${chalk.cyan(mcp.name.padEnd(20))} ${reliabilityBar} ${chalk.bold.green(mcp.successRate.toFixed(1))}%`); 268 | } 269 | output.push(''); 270 | } 271 | 272 | return output.join('\\n'); 273 | } 274 | 275 | /** 276 | * Create gauge visualization 277 | */ 278 | private static createGauge(value: number, max: number): string { 279 | const percentage = Math.min(value / max, 1); 280 | const gaugeWidth = 20; 281 | const filled = Math.round(percentage * gaugeWidth); 282 | 283 | // Create gauge with different colors based on performance 284 | let gauge = '['; 285 | for (let i = 0; i < gaugeWidth; i++) { 286 | if (i < filled) { 287 | if (percentage > 0.8) gauge += chalk.green('█'); 288 | else if (percentage > 0.5) gauge += chalk.yellow('█'); 289 | else gauge += chalk.red('█'); 290 | } else { 291 | gauge += chalk.dim('░'); 292 | } 293 | } 294 | gauge += ']'; 295 | 296 | return gauge; 297 | } 298 | 299 | /** 300 | * Create speed bar (faster = more green) 301 | */ 302 | private static createSpeedBar(duration: number, maxDuration: number): string { 303 | const speed = Math.max(0, 1 - (duration / maxDuration)); // Invert: faster = higher score 304 | const barWidth = 15; 305 | const filled = Math.round(speed * barWidth); 306 | 307 | return chalk.green('█'.repeat(filled)) + chalk.dim('░'.repeat(barWidth - filled)); 308 | } 309 | 310 | /** 311 | * Create reliability bar 312 | */ 313 | private static createReliabilityBar(successRate: number): string { 314 | const barWidth = 15; 315 | const filled = Math.round((successRate / 100) * barWidth); 316 | 317 | return chalk.blue('█'.repeat(filled)) + chalk.dim('░'.repeat(barWidth - filled)); 318 | } 319 | 320 | /** 321 | * Create simple ASCII line chart 322 | */ 323 | private static createLineChart(data: number[], height: number, width: number): string { 324 | if (data.length === 0) return ''; 325 | 326 | const min = Math.min(...data); 327 | const max = Math.max(...data); 328 | const range = max - min || 1; 329 | 330 | const lines: string[] = []; 331 | 332 | // Create chart grid 333 | for (let row = 0; row < height; row++) { 334 | const threshold = max - (row / (height - 1)) * range; 335 | let line = ' '; 336 | 337 | for (let col = 0; col < Math.min(data.length, width); col++) { 338 | const value = data[col]; 339 | const prevValue = col > 0 ? data[col - 1] : value; 340 | 341 | // Determine character based on value relative to threshold 342 | if (value >= threshold) { 343 | // Different characters for trends 344 | if (col > 0) { 345 | if (value > prevValue) line += '╱'; // Rising 346 | else if (value < prevValue) line += '╲'; // Falling 347 | else line += '─'; // Flat 348 | } else { 349 | line += '●'; // Start point 350 | } 351 | } else { 352 | line += ' '; // Empty space 353 | } 354 | } 355 | lines.push(line); 356 | } 357 | 358 | // Add axis 359 | const axis = ' ' + '─'.repeat(Math.min(data.length, width)); 360 | lines.push(axis); 361 | 362 | return lines.join('\n'); 363 | } 364 | } ``` -------------------------------------------------------------------------------- /docs/guides/mcpb-installation.md: -------------------------------------------------------------------------------- ```markdown 1 | # One-Click Installation with .mcpb Files 2 | 3 | ## 🚀 Slim & Fast MCP-Only Bundle 4 | 5 | **The .mcpb installation is now optimized as a slim, MCP-only runtime:** 6 | 7 | ✅ **What it includes:** 8 | - NCP MCP server (126KB compressed, 462KB unpacked) 9 | - All orchestration, discovery, and RAG capabilities 10 | - Optimized for fast startup and low memory usage 11 | 12 | ❌ **What it excludes:** 13 | - CLI tools (`ncp add`, `ncp find`, `ncp list`, etc.) 14 | - CLI dependencies (Commander.js, Inquirer.js, etc.) 15 | - 13% smaller than full package, 16% less unpacked size 16 | 17 | **Configuration methods:** 18 | 1. **Manual JSON editing** (recommended for power users) 19 | 2. **Optional:** Install npm package separately for CLI tools 20 | 21 | **Why this design?** 22 | - .mcpb bundles use Claude Desktop's sandboxed Node.js runtime 23 | - This runtime is only available when Claude Desktop runs MCP servers 24 | - It's NOT in your system PATH, so CLI commands can't work 25 | - By excluding CLI code, we get faster startup and smaller bundle 26 | 27 | **Choose your workflow:** 28 | 29 | ### Option A: Manual Configuration (Slim bundle only) 30 | 1. Install .mcpb (fast, lightweight) 31 | 2. Edit `~/.ncp/profiles/all.json` manually 32 | 3. Perfect for automation, power users, production deployments 33 | 34 | ### Option B: CLI + .mcpb (Both installed) 35 | 1. Install .mcpb (Claude Desktop integration) 36 | 2. Install npm: `npm install -g @portel/ncp` (CLI tools) 37 | 3. Use CLI to configure, benefit from slim .mcpb runtime 38 | 39 | ## What is a .mcpb file? 40 | 41 | .mcpb (MCP Bundle) files are zip-based packages that bundle an entire MCP server with all its dependencies into a single installable file. Think of them like: 42 | - Chrome extensions (.crx) 43 | - VS Code extensions (.vsix) 44 | - But for MCP servers! 45 | 46 | ## Why .mcpb for NCP? 47 | 48 | Installing NCP traditionally requires: 49 | 1. Node.js installation 50 | 2. npm commands 51 | 3. Manual configuration editing 52 | 4. Understanding of file paths and environment variables 53 | 54 | **With .mcpb:** Download → Double-click → Done! ✨ 55 | 56 | **But remember:** You still need npm for CLI tools (see limitation above). 57 | 58 | ## Installation Steps 59 | 60 | ### For Claude Desktop Users (Auto-Import + Manual Configuration) 61 | 62 | 1. **Download the bundle:** 63 | - Go to [NCP Releases](https://github.com/portel-dev/ncp/releases/latest) 64 | - Download `ncp.mcpb` from the latest release 65 | 66 | 2. **Install:** 67 | - **macOS/Windows:** Double-click the downloaded `ncp.mcpb` file 68 | - Claude Desktop will show an installation dialog 69 | - Click "Install" 70 | 71 | 3. **Continuous auto-sync:** 72 | - **On every startup**, NCP automatically detects and imports NEW MCPs: 73 | - ✅ Scans `claude_desktop_config.json` for traditional MCPs 74 | - ✅ Scans Claude Extensions directory for .mcpb extensions 75 | - ✅ Compares with NCP profile to find missing MCPs 76 | - ✅ Auto-imports only the new ones using internal `add` command 77 | - You'll see: `✨ Auto-synced X new MCPs from Claude Desktop` 78 | - **Cache coherence maintained**: Using internal `add` ensures vector cache, discovery index, and all other caches stay in sync 79 | - No manual configuration needed! 80 | 81 | 4. **Add more MCPs later (manual configuration):** 82 | 83 | If you want to add additional MCPs after the initial import: 84 | 85 | ```bash 86 | # Create/edit the profile configuration 87 | mkdir -p ~/.ncp/profiles 88 | nano ~/.ncp/profiles/all.json 89 | ``` 90 | 91 | Add your MCP servers (example configuration): 92 | 93 | ```json 94 | { 95 | "mcpServers": { 96 | "filesystem": { 97 | "command": "npx", 98 | "args": ["-y", "@modelcontextprotocol/server-filesystem", "/Users/yourname"] 99 | }, 100 | "github": { 101 | "command": "npx", 102 | "args": ["-y", "@modelcontextprotocol/server-github"], 103 | "env": { 104 | "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_your_token_here" 105 | } 106 | }, 107 | "postgres": { 108 | "command": "npx", 109 | "args": ["-y", "@modelcontextprotocol/server-postgres"], 110 | "env": { 111 | "DATABASE_URL": "postgresql://user:pass@localhost:5432/dbname" 112 | } 113 | }, 114 | "brave-search": { 115 | "command": "npx", 116 | "args": ["-y", "@modelcontextprotocol/server-brave-search"], 117 | "env": { 118 | "BRAVE_API_KEY": "your_brave_api_key" 119 | } 120 | } 121 | } 122 | } 123 | ``` 124 | 125 | **Tips for manual configuration:** 126 | - Use the same format as Claude Desktop's `claude_desktop_config.json` 127 | - Environment variables go in `env` object 128 | - Paths should be absolute, not relative 129 | - Use `npx -y` to auto-install MCP packages on first use 130 | 131 | 4. **Restart Claude Desktop:** 132 | - Quit Claude Desktop completely 133 | - Reopen it 134 | - NCP will load and index your configured MCPs 135 | 136 | 5. **Verify:** 137 | - Ask Claude: "What MCP tools do you have?" 138 | - You should see NCP's `find` and `run` tools 139 | - Ask: "Find tools for searching files" 140 | - NCP will show tools from your configured MCPs 141 | 142 | ### For Other MCP Clients (Cursor, Cline, Continue) 143 | 144 | The .mcpb format is currently supported only by Claude Desktop. For other clients, use the manual installation method: 145 | 146 | ```bash 147 | # Install NCP via npm 148 | npm install -g @portel/ncp 149 | 150 | # Configure your client's config file manually 151 | # See README.md for client-specific configuration 152 | ``` 153 | 154 | ## What Gets Installed? 155 | 156 | The .mcpb bundle includes: 157 | - ✅ NCP compiled code (dist/) 158 | - ✅ All Node.js dependencies 159 | - ✅ Configuration manifest 160 | - ✅ Runtime environment setup 161 | 162 | **You don't need:** 163 | - ❌ Node.js pre-installed (Claude Desktop includes it) 164 | - ❌ Manual npm commands 165 | - ❌ Manual configuration file editing 166 | 167 | ## Troubleshooting 168 | 169 | ### "Cannot open file" error (macOS) 170 | 171 | macOS may block .mcpb files from unknown developers: 172 | 173 | **Solution:** 174 | 1. Right-click the `ncp.mcpb` file 175 | 2. Select "Open With" → "Claude Desktop" 176 | 3. If prompted, click "Open" to allow 177 | 178 | ### "Installation failed" error 179 | 180 | **Possible causes:** 181 | 1. Claude Desktop not updated to latest version 182 | - **Solution:** Update Claude Desktop to support .mcpb format 183 | 184 | 2. Corrupted download 185 | - **Solution:** Re-download the .mcpb file 186 | 187 | 3. Conflicting existing NCP installation 188 | - **Solution:** Remove existing NCP from Claude config first 189 | 190 | ### NCP not showing in tool list 191 | 192 | **Check:** 193 | 1. Restart Claude Desktop completely (Quit → Reopen) 194 | 2. Check Claude Desktop settings → MCPs → Verify NCP is listed 195 | 3. Ask Claude: "List your available tools" 196 | 197 | ## How We Build the .mcpb File 198 | 199 | For developers interested in how NCP creates the .mcpb bundle: 200 | 201 | ```bash 202 | # Build the bundle locally 203 | npm run build:mcpb 204 | 205 | # This runs: 206 | # 1. npm run build (compiles TypeScript) 207 | # 2. npx @anthropic-ai/mcpb pack (creates .mcpb from manifest.json) 208 | ``` 209 | 210 | The `manifest.json` describes: 211 | - NCP's capabilities 212 | - Entry point (dist/index.js) 213 | - Required tools 214 | - Environment variables 215 | - Node.js version requirements 216 | 217 | ## Updating NCP 218 | 219 | When a new version is released: 220 | 221 | 1. **Download new .mcpb** from latest release 222 | 2. **Double-click to install** - it will replace the old version 223 | 3. **Restart Claude Desktop** 224 | 225 | ## Comparison: .mcpb vs npm Installation 226 | 227 | | Aspect | .mcpb Installation | npm Installation | 228 | |--------|-------------------|------------------| 229 | | **Ease** | Double-click | Multiple commands | 230 | | **Prerequisites** | None (Claude Desktop has runtime) | Node.js 18+ | 231 | | **Time** | 10 seconds + manual config | 2-3 minutes with CLI | 232 | | **Bundle Size** | **126KB** (slim, MCP-only) | ~2.5MB (full package with CLI) | 233 | | **Startup Time** | ⚡ Faster (no CLI code loading) | Standard (includes CLI) | 234 | | **Memory Usage** | 💚 Lower (minimal footprint) | Standard (full features) | 235 | | **CLI Tools** | ❌ NO - Manual JSON editing only | ✅ YES - `ncp add`, `ncp find`, etc. | 236 | | **MCP Server** | ✅ YES - Works in Claude Desktop | ✅ YES - Works in all MCP clients | 237 | | **Configuration** | 📝 Manual JSON editing | 🔧 CLI commands or JSON | 238 | | **Updates** | Download new .mcpb | `npm update -g @portel/ncp` | 239 | | **Client Support** | Claude Desktop only | All MCP clients | 240 | | **Best for** | ✅ Power users, automation, production | ✅ General users, development | 241 | 242 | ## How Continuous Auto-Sync Works 243 | 244 | **On every startup**, NCP automatically syncs with Claude Desktop to detect new MCPs: 245 | 246 | ### Sync Process 247 | 248 | 1. **Scans Claude Desktop configuration:** 249 | - **JSON config:** Reads `~/Library/Application Support/Claude/claude_desktop_config.json` 250 | - **.mcpb extensions:** Scans `~/Library/Application Support/Claude/Claude Extensions/` 251 | 252 | 2. **Extracts MCP configurations:** 253 | - For JSON MCPs: Extracts command, args, env 254 | - For .mcpb extensions: Reads `manifest.json`, resolves `${__dirname}` paths 255 | 256 | 3. **Detects missing MCPs:** 257 | - Compares Claude Desktop MCPs vs NCP profile 258 | - Identifies MCPs that exist in Claude Desktop but NOT in NCP 259 | 260 | 4. **Imports missing MCPs using internal `add` command:** 261 | - For each missing MCP: `await this.addMCPToProfile('all', name, config)` 262 | - This ensures **cache coherence**: 263 | - ✅ Profile JSON gets updated 264 | - ✅ Cache invalidation triggers on next orchestrator init 265 | - ✅ Vector embeddings regenerate for new tools 266 | - ✅ Discovery index includes new MCPs 267 | - ✅ All caches stay in sync 268 | 269 | 5. **Skips existing MCPs:** 270 | - If MCP already exists in NCP → No action 271 | - Prevents duplicate imports and cache thrashing 272 | 273 | ### Example Auto-Sync Output 274 | 275 | **First startup (multiple MCPs found):** 276 | ``` 277 | ✨ Auto-synced 6 new MCPs from Claude Desktop: 278 | - 4 from claude_desktop_config.json 279 | - 2 from .mcpb extensions 280 | → Added to ~/.ncp/profiles/all.json 281 | ``` 282 | 283 | **Subsequent startup (1 new MCP detected):** 284 | ``` 285 | ✨ Auto-synced 1 new MCPs from Claude Desktop: 286 | - 1 from .mcpb extensions 287 | → Added to ~/.ncp/profiles/all.json 288 | ``` 289 | 290 | **Subsequent startup (no new MCPs):** 291 | ``` 292 | (No output - all Claude Desktop MCPs already in sync) 293 | ``` 294 | 295 | ### What Gets Imported 296 | 297 | **From `claude_desktop_config.json`:** 298 | ```json 299 | { 300 | "mcpServers": { 301 | "filesystem": { 302 | "command": "npx", 303 | "args": ["-y", "@modelcontextprotocol/server-filesystem", "/Users/name"] 304 | } 305 | } 306 | } 307 | ``` 308 | 309 | **From `.mcpb` extensions:** 310 | - Installed via double-click in Claude Desktop 311 | - Stored in `Claude Extensions/` directory 312 | - Automatically detected and imported with correct paths 313 | 314 | **Result in NCP:** 315 | ```json 316 | { 317 | "filesystem": { 318 | "command": "npx", 319 | "args": ["-y", "@modelcontextprotocol/server-filesystem", "/Users/name"], 320 | "_source": "json" 321 | }, 322 | "apple-mcp": { 323 | "command": "node", 324 | "args": ["/Users/.../Claude Extensions/local.dxt.../dist/index.js"], 325 | "_source": ".mcpb", 326 | "_extensionId": "local.dxt.dhravya-shah.apple-mcp", 327 | "_version": "1.0.0" 328 | } 329 | } 330 | ``` 331 | 332 | ## FAQ 333 | 334 | ### Q: Does NCP automatically sync with Claude Desktop? 335 | **A:** ✅ **YES!** On **every startup**, NCP automatically detects and imports NEW MCPs: 336 | - Scans `claude_desktop_config.json` for traditional MCPs 337 | - Scans Claude Extensions for .mcpb-installed extensions 338 | - Compares with NCP profile to find missing MCPs 339 | - Auto-imports only the new ones 340 | 341 | **Workflow example:** 342 | 1. Day 1: Install NCP → Auto-syncs 5 existing MCPs 343 | 2. Day 2: Install new .mcpb extension in Claude Desktop 344 | 3. Day 3: Restart Claude Desktop → NCP auto-syncs the new MCP 345 | 4. **Zero manual configuration** - NCP stays in sync automatically! 346 | 347 | ### Q: Can I use `ncp add` after .mcpb installation? 348 | **A:** ❌ **NO.** The .mcpb is a slim MCP-only bundle that excludes CLI code. You configure MCPs by editing `~/.ncp/profiles/all.json` manually. 349 | 350 | **If you want CLI tools:** Run `npm install -g @portel/ncp` separately. 351 | 352 | ### Q: How do I add MCPs without the CLI? 353 | **A:** Edit `~/.ncp/profiles/all.json` directly: 354 | 355 | ```bash 356 | nano ~/.ncp/profiles/all.json 357 | ``` 358 | 359 | Add your MCPs using the same format as Claude Desktop's config: 360 | 361 | ```json 362 | { 363 | "mcpServers": { 364 | "your-mcp-name": { 365 | "command": "npx", 366 | "args": ["-y", "@modelcontextprotocol/server-name"], 367 | "env": {} 368 | } 369 | } 370 | } 371 | ``` 372 | 373 | Restart Claude Desktop for changes to take effect. 374 | 375 | ### Q: Why is .mcpb smaller than npm? 376 | **A:** The .mcpb bundle (126KB) excludes all CLI code and dependencies: 377 | - ❌ No Commander.js, Inquirer.js, or other CLI libraries 378 | - ❌ No `dist/cli/` directory 379 | - ✅ Only MCP server, orchestrator, and discovery code 380 | 381 | This makes it 13% smaller and faster to load. 382 | 383 | ### Q: When should I use .mcpb vs npm? 384 | **A:** 385 | 386 | **Use .mcpb if:** 387 | - You're comfortable editing JSON configs manually 388 | - You want the smallest, fastest MCP runtime 389 | - You're deploying in production/automation 390 | - You only use Claude Desktop 391 | 392 | **Use npm if:** 393 | - You want CLI tools (`ncp add`, `ncp find`, etc.) 394 | - You use multiple MCP clients (Cursor, Cline, Continue) 395 | - You prefer commands over manual JSON editing 396 | - You want a complete solution 397 | 398 | **Both:** Install .mcpb for slim runtime + npm for CLI tools 399 | 400 | ### Q: Do I need Node.js installed for .mcpb? 401 | **A:** No, Claude Desktop includes Node.js runtime for .mcpb bundles. However, if you need the CLI tools (which you do for NCP), you'll need Node.js + npm anyway. 402 | 403 | ### Q: Can I use .mcpb with Cursor/Cline/Continue? 404 | **A:** Not yet. The .mcpb format is currently Claude Desktop-only. Use npm installation for other clients. 405 | 406 | ### Q: How do I uninstall? 407 | **A:** In Claude Desktop settings → MCPs → Find NCP → Click "Remove" 408 | 409 | ### Q: Can I customize NCP settings via .mcpb? 410 | **A:** Basic environment variables can be configured through Claude Desktop settings after installation. For advanced configuration, use npm installation instead. 411 | 412 | ### Q: Is .mcpb secure? 413 | **A:** .mcpb files are reviewed by Claude Desktop before installation. Always download from official NCP releases on GitHub. 414 | 415 | ## Future Plans 416 | 417 | - Support for other MCP clients (Cursor, Cline, Continue) 418 | - Auto-update mechanism 419 | - Configuration wizard within .mcpb 420 | - Multiple profile support 421 | 422 | ## More Information 423 | 424 | - [MCP Bundle Specification](https://github.com/anthropics/mcpb) 425 | - [Claude Desktop Extensions Documentation](https://www.anthropic.com/engineering/desktop-extensions) 426 | - [NCP GitHub Repository](https://github.com/portel-dev/ncp) 427 | ``` -------------------------------------------------------------------------------- /EXTENSION-CONFIG-DISCOVERY.md: -------------------------------------------------------------------------------- ```markdown 1 | # Extension User Configuration - Major Discovery! 🎉 2 | 3 | ## What We Found 4 | 5 | Claude Desktop extensions support a **`user_config`** field in `manifest.json` that enables: 6 | 7 | 1. ✅ **Declarative configuration** - Extensions declare what config they need 8 | 2. ✅ **Type-safe inputs** - String, number, boolean, directory, file 9 | 3. ✅ **Secure storage** - Sensitive values stored in OS keychain 10 | 4. ✅ **Runtime injection** - Values injected via `${user_config.KEY}` template literals 11 | 5. ✅ **Validation** - Required fields, min/max constraints, default values 12 | 13 | **This opens MASSIVE possibilities for NCP!** 14 | 15 | --- 16 | 17 | ## Complete Specification 18 | 19 | ### **Supported Configuration Types** 20 | 21 | | Type | Description | Example Use Case | 22 | |------|-------------|------------------| 23 | | `string` | Text input | API keys, URLs, usernames | 24 | | `number` | Numeric input | Port numbers, timeouts, limits | 25 | | `boolean` | Checkbox/toggle | Enable/disable features | 26 | | `directory` | Directory picker | Allowed paths, workspace folders | 27 | | `file` | File picker | Config files, credentials | 28 | 29 | ### **Configuration Properties** 30 | 31 | ```typescript 32 | interface UserConfigOption { 33 | type: 'string' | 'number' | 'boolean' | 'directory' | 'file'; 34 | title: string; // Display name in UI 35 | description?: string; // Help text 36 | required?: boolean; // Must be provided (default: false) 37 | default?: any; // Default value (supports variables) 38 | sensitive?: boolean; // Mask input + store in keychain 39 | multiple?: boolean; // Allow multiple selections (directory/file) 40 | min?: number; // Minimum value (number type) 41 | max?: number; // Maximum value (number type) 42 | } 43 | ``` 44 | 45 | ### **Variable Substitution** 46 | 47 | Supports these built-in variables: 48 | - `${HOME}` - User home directory 49 | - `${DESKTOP}` - Desktop folder 50 | - `${__dirname}` - Extension directory 51 | 52 | ### **Template Injection** 53 | 54 | Reference user config in `mcp_config`: 55 | ```json 56 | { 57 | "user_config": { 58 | "api_key": { "type": "string", "sensitive": true } 59 | }, 60 | "server": { 61 | "mcp_config": { 62 | "env": { 63 | "API_KEY": "${user_config.api_key}" // ← Injected at runtime 64 | } 65 | } 66 | } 67 | } 68 | ``` 69 | 70 | --- 71 | 72 | ## Real-World Examples 73 | 74 | ### **Example 1: GitHub Extension** 75 | 76 | ```json 77 | { 78 | "name": "github", 79 | "user_config": { 80 | "github_token": { 81 | "type": "string", 82 | "title": "GitHub Personal Access Token", 83 | "description": "Token with repo and workflow permissions", 84 | "sensitive": true, 85 | "required": true 86 | }, 87 | "default_owner": { 88 | "type": "string", 89 | "title": "Default Repository Owner", 90 | "description": "Your GitHub username or organization", 91 | "default": "" 92 | }, 93 | "max_search_results": { 94 | "type": "number", 95 | "title": "Maximum Search Results", 96 | "description": "Limit number of results returned", 97 | "default": 10, 98 | "min": 1, 99 | "max": 100 100 | } 101 | }, 102 | "server": { 103 | "mcp_config": { 104 | "command": "node", 105 | "args": ["${__dirname}/server/index.js"], 106 | "env": { 107 | "GITHUB_TOKEN": "${user_config.github_token}", 108 | "DEFAULT_OWNER": "${user_config.default_owner}", 109 | "MAX_RESULTS": "${user_config.max_search_results}" 110 | } 111 | } 112 | } 113 | } 114 | ``` 115 | 116 | ### **Example 2: Filesystem Extension** 117 | 118 | ```json 119 | { 120 | "name": "filesystem", 121 | "user_config": { 122 | "allowed_directories": { 123 | "type": "directory", 124 | "title": "Allowed Directories", 125 | "description": "Directories the server can access", 126 | "multiple": true, 127 | "required": true, 128 | "default": ["${HOME}/Documents", "${HOME}/Desktop"] 129 | }, 130 | "read_only": { 131 | "type": "boolean", 132 | "title": "Read-only Mode", 133 | "description": "Prevent write operations", 134 | "default": false 135 | } 136 | }, 137 | "server": { 138 | "mcp_config": { 139 | "command": "node", 140 | "args": ["${__dirname}/server/index.js"], 141 | "env": { 142 | "ALLOWED_DIRECTORIES": "${user_config.allowed_directories}", 143 | "READ_ONLY": "${user_config.read_only}" 144 | } 145 | } 146 | } 147 | } 148 | ``` 149 | 150 | ### **Example 3: Database Extension** 151 | 152 | ```json 153 | { 154 | "name": "postgresql", 155 | "user_config": { 156 | "connection_string": { 157 | "type": "string", 158 | "title": "PostgreSQL Connection String", 159 | "description": "Database connection URL", 160 | "sensitive": true, 161 | "required": true 162 | }, 163 | "max_connections": { 164 | "type": "number", 165 | "title": "Maximum Connections", 166 | "default": 10, 167 | "min": 1, 168 | "max": 50 169 | }, 170 | "ssl_enabled": { 171 | "type": "boolean", 172 | "title": "Use SSL", 173 | "default": true 174 | }, 175 | "ssl_cert_path": { 176 | "type": "file", 177 | "title": "SSL Certificate", 178 | "description": "Path to SSL certificate file" 179 | } 180 | }, 181 | "server": { 182 | "mcp_config": { 183 | "command": "node", 184 | "args": ["${__dirname}/server/index.js"], 185 | "env": { 186 | "DATABASE_URL": "${user_config.connection_string}", 187 | "MAX_CONNECTIONS": "${user_config.max_connections}", 188 | "SSL_ENABLED": "${user_config.ssl_enabled}", 189 | "SSL_CERT": "${user_config.ssl_cert_path}" 190 | } 191 | } 192 | } 193 | } 194 | ``` 195 | 196 | --- 197 | 198 | ## How Claude Desktop Handles This 199 | 200 | ### **1. Configuration UI** 201 | When user enables extension: 202 | - Claude Desktop reads `user_config` from manifest 203 | - Renders configuration dialog with appropriate inputs 204 | - Shows titles, descriptions, validation rules 205 | - Validates input before allowing extension to activate 206 | 207 | ### **2. Secure Storage** 208 | For sensitive fields: 209 | - Values stored in **OS keychain** (not in JSON config) 210 | - macOS: Keychain Access 211 | - Windows: Credential Manager 212 | - Linux: Secret Service API 213 | 214 | ### **3. Runtime Injection** 215 | When spawning MCP server: 216 | - Reads user config from secure storage 217 | - Replaces `${user_config.KEY}` with actual values 218 | - Injects into environment variables or args 219 | - MCP server receives final config 220 | 221 | --- 222 | 223 | ## HUGE Possibilities for NCP! 224 | 225 | ### **Current Problem** 226 | 227 | When NCP auto-imports extensions: 228 | ```json 229 | { 230 | "github": { 231 | "command": "node", 232 | "args": ["/path/to/extension/index.js"], 233 | "env": {} // ← EMPTY! No API key configured 234 | } 235 | } 236 | ``` 237 | 238 | Extension won't work without configuration! 239 | 240 | ### **Solution 1: Configuration Detection + Prompts** 241 | 242 | **Flow:** 243 | ``` 244 | 1. NCP auto-imports extension 245 | ↓ 246 | 2. Reads manifest.json → Detects user_config requirements 247 | ↓ 248 | 3. AI calls prompt: "configure_extension" 249 | Shows: "GitHub extension needs: GitHub Token (required)" 250 | ↓ 251 | 4. User copies config to clipboard: 252 | {"github_token": "ghp_..."} 253 | ↓ 254 | 5. User clicks YES 255 | ↓ 256 | 6. NCP reads clipboard (server-side) 257 | ↓ 258 | 7. NCP stores config securely 259 | ↓ 260 | 8. When spawning: Injects ${user_config.github_token} → env.GITHUB_TOKEN 261 | ↓ 262 | 9. Extension works perfectly! 263 | ``` 264 | 265 | ### **Solution 2: Batch Configuration via ncp:import** 266 | 267 | **Discovery mode with configuration:** 268 | ``` 269 | User: "Find GitHub MCPs" 270 | AI: Shows numbered list with config requirements 271 | 272 | 1. ⭐ server-github (requires: API Token) 273 | 2. ⭐ github-actions (requires: Token, Repo) 274 | 275 | User: "Import 1" 276 | AI: Shows prompt with clipboard instructions 277 | User: Copies {"github_token": "ghp_..."} 278 | User: Clicks YES 279 | AI: Imports with configuration 280 | ``` 281 | 282 | ### **Solution 3: Interactive Configuration via ncp:configure** 283 | 284 | New internal tool: 285 | ```typescript 286 | ncp:configure { 287 | mcp_name: "github", 288 | // User copies full config to clipboard before calling 289 | } 290 | ``` 291 | 292 | Shows what's needed, collects via clipboard, stores securely. 293 | 294 | --- 295 | 296 | ## Implementation Plan 297 | 298 | ### **Phase 1: Detection** 299 | 300 | **Add to client-importer:** 301 | ```typescript 302 | // When importing .mcpb extensions 303 | const manifest = JSON.parse(manifestContent); 304 | 305 | // Extract user_config requirements 306 | const userConfigSchema = manifest.user_config || {}; 307 | const userConfigRequired = Object.entries(userConfigSchema) 308 | .filter(([key, config]) => config.required) 309 | .map(([key, config]) => ({ 310 | key, 311 | title: config.title, 312 | type: config.type, 313 | sensitive: config.sensitive 314 | })); 315 | 316 | // Store in imported config 317 | mcpServers[mcpName] = { 318 | command, 319 | args, 320 | env: mcpConfig.env || {}, 321 | _source: '.mcpb', 322 | _userConfigSchema: userConfigSchema, // ← NEW 323 | _userConfigRequired: userConfigRequired, // ← NEW 324 | _userConfig: {} // Will be populated later 325 | }; 326 | ``` 327 | 328 | ### **Phase 2: Prompt Definition** 329 | 330 | **Add new prompt:** `configure_extension` 331 | 332 | ```typescript 333 | { 334 | name: 'configure_extension', 335 | description: 'Collect configuration for MCP extension', 336 | arguments: [ 337 | { name: 'mcp_name', description: 'Extension name', required: true }, 338 | { name: 'config_schema', description: 'Configuration requirements', required: true } 339 | ] 340 | } 341 | ``` 342 | 343 | **Prompt message:** 344 | ``` 345 | Extension "${mcp_name}" requires configuration: 346 | 347 | ${config_schema.map(field => ` 348 | • ${field.title} (${field.type}) 349 | ${field.description} 350 | ${field.required ? 'REQUIRED' : 'Optional'} 351 | ${field.sensitive ? '⚠️ Sensitive - will be stored securely' : ''} 352 | `).join('\n')} 353 | 354 | 📋 Copy configuration to clipboard in JSON format: 355 | { 356 | "${field.key}": "your_value" 357 | } 358 | 359 | Then click YES to save configuration. 360 | ``` 361 | 362 | ### **Phase 3: Storage** 363 | 364 | **Secure user config storage:** 365 | ```typescript 366 | // Separate file for user configs 367 | ~/.ncp/user-configs/{profile-name}.json 368 | 369 | { 370 | "github": { 371 | "github_token": "ghp_...", // Will move to OS keychain later 372 | "default_owner": "myorg" 373 | }, 374 | "filesystem": { 375 | "allowed_directories": ["/Users/me/Projects"] 376 | } 377 | } 378 | ``` 379 | 380 | **Later:** Integrate with OS keychain for sensitive values. 381 | 382 | ### **Phase 4: Runtime Injection** 383 | 384 | **Update orchestrator spawn logic:** 385 | ```typescript 386 | // Before spawning 387 | const userConfig = await getUserConfig(mcpName); 388 | const resolvedEnv = resolveTemplates(definition.config.env, { 389 | user_config: userConfig 390 | }); 391 | 392 | // Replace ${user_config.KEY} with actual values 393 | const transport = new StdioClientTransport({ 394 | command: resolvedCommand, 395 | args: resolvedArgs, 396 | env: resolvedEnv // ← Injected values 397 | }); 398 | ``` 399 | 400 | ### **Phase 5: New Internal Tools** 401 | 402 | **`ncp:configure`** - Configure extension 403 | ```typescript 404 | { 405 | mcp_name: string, 406 | // User copies config to clipboard before calling 407 | } 408 | ``` 409 | 410 | **`ncp:list` enhancement** - Show config status 411 | ``` 412 | ✓ github (configured) 413 | • github_token: ******* (from clipboard) 414 | • default_owner: myorg 415 | 416 | ⚠ filesystem (needs configuration) 417 | Required: allowed_directories 418 | ``` 419 | 420 | --- 421 | 422 | ## Benefits 423 | 424 | ### **For Users** 425 | 426 | ✅ **No manual config editing** - AI handles everything via prompts 427 | ✅ **Clipboard security** - Secrets never exposed to AI 428 | ✅ **Guided configuration** - Shows exactly what's needed 429 | ✅ **Validation** - Type checking, required fields, constraints 430 | ✅ **Works with disabled extensions** - NCP manages config independently 431 | 432 | ### **For Extensions** 433 | 434 | ✅ **Standard configuration** - Same schema as Claude Desktop 435 | ✅ **Compatibility** - Works when enabled OR disabled 436 | ✅ **Secure storage** - OS keychain integration (future) 437 | ✅ **Type safety** - Number/boolean/string/directory/file types 438 | 439 | ### **For NCP** 440 | 441 | ✅ **Complete workflow** - Discovery → Import → Configure → Run 442 | ✅ **Differentiation** - Only MCP manager with smart config handling 443 | ✅ **User experience** - Seamless AI-driven configuration 444 | ✅ **Clipboard pattern** - Extends to configuration (not just secrets) 445 | 446 | --- 447 | 448 | ## Example End-to-End Workflow 449 | 450 | ### **User Story: Install GitHub Extension** 451 | 452 | ``` 453 | User: "Find and install GitHub MCP" 454 | 455 | AI: [Calls ncp:import discovery mode] 456 | I found server-github in the registry. 457 | 458 | [Calls ncp:import with selection] 459 | Imported server-github. 460 | 461 | ⚠️ This extension requires configuration: 462 | • GitHub Personal Access Token (required, sensitive) 463 | • Default Repository Owner (optional) 464 | 465 | [Shows configure_extension prompt] 466 | 467 | Prompt: "Copy configuration to clipboard in this format: 468 | { 469 | "github_token": "ghp_your_token_here", 470 | "default_owner": "your_username" 471 | } 472 | 473 | Then click YES to save configuration." 474 | 475 | User: [Copies config to clipboard] 476 | User: [Clicks YES] 477 | 478 | AI: [NCP reads clipboard, stores config] 479 | ✅ GitHub extension configured and ready to use! 480 | 481 | User: "Create an issue in my repo" 482 | 483 | AI: [Calls ncp:run github:create_issue] 484 | [NCP injects github_token into env] 485 | [Extension works perfectly!] 486 | ``` 487 | 488 | --- 489 | 490 | ## Next Steps 491 | 492 | ### **Immediate (Can Do Now)** 493 | 494 | 1. ✅ Extract `user_config` from manifest.json during import 495 | 2. ✅ Store schema with imported MCP config 496 | 3. ✅ Show warnings when extensions need configuration 497 | 498 | ### **Short-term (Phase 1)** 499 | 500 | 1. Add `configure_extension` prompt 501 | 2. Implement clipboard-based config collection 502 | 3. Store user configs in separate file 503 | 4. Implement template replacement (`${user_config.KEY}`) 504 | 505 | ### **Medium-term (Phase 2)** 506 | 507 | 1. Add `ncp:configure` internal tool 508 | 2. Enhance `ncp:list` to show config status 509 | 3. Add validation (type checking, required fields) 510 | 4. Support default values and variable substitution 511 | 512 | ### **Long-term (Phase 3)** 513 | 514 | 1. OS keychain integration for sensitive values 515 | 2. Config migration between profiles 516 | 3. Config export/import 517 | 4. Config versioning and updates 518 | 519 | --- 520 | 521 | ## Summary 522 | 523 | **What we discovered:** 524 | - Extensions declare configuration needs via `user_config` in manifest.json 525 | - Claude Desktop handles UI, validation, secure storage, and injection 526 | - Template literals (`${user_config.KEY}`) replaced at runtime 527 | 528 | **What this enables for NCP:** 529 | - AI-driven configuration via prompts + clipboard security 530 | - Auto-detect configuration requirements 531 | - Secure storage separate from MCP config 532 | - Runtime injection when spawning extensions 533 | - Complete discovery → import → configure → run workflow 534 | 535 | **This is MASSIVE for the NCP user experience!** 🚀 536 | 537 | Users can now: 538 | 1. Discover MCPs via AI 539 | 2. Import with one command 540 | 3. Configure via clipboard (secure!) 541 | 4. Run immediately with full functionality 542 | 543 | No CLI, no manual JSON editing, no copy-paste of configs - everything through natural conversation! 544 | ```