Skip to main content
Glama
portel-dev

NCP - Natural Context Provider

by portel-dev
mcp-client-simulation.test.cjs15.1 kB
#!/usr/bin/env node /** * Integration Test: MCP Client Simulation * * Simulates real AI client behavior (Claude Desktop, Perplexity) to catch bugs * that unit tests miss. This should be run before EVERY release. * * Tests: * 1. Server responds to initialize immediately * 2. tools/list returns tools < 250ms even during indexing * 3. find returns partial results during indexing (not empty) * 4. Cache profileHash persists across restarts * 5. Second startup uses cache (no re-indexing) */ const { spawn, spawnSync } = require('child_process'); const fs = require('fs'); const path = require('path'); const os = require('os'); // Test configuration // Use local .ncp directory for integration tests to avoid conflicts with user's global config const NCP_DIR = path.join(process.cwd(), '.ncp'); const PROFILES_DIR = path.join(NCP_DIR, 'profiles'); const CACHE_DIR = path.join(NCP_DIR, 'cache'); const TEST_PROFILE = 'integration-test'; const TIMEOUT_MS = 10000; // Ensure test profile exists function setupTestProfile() { // Create .ncp directory structure if (!fs.existsSync(PROFILES_DIR)) { fs.mkdirSync(PROFILES_DIR, { recursive: true }); } if (!fs.existsSync(CACHE_DIR)) { fs.mkdirSync(CACHE_DIR, { recursive: true }); } // Create minimal test profile with filesystem MCP // filesystem MCP requires allowed directories as arguments const profilePath = path.join(PROFILES_DIR, `${TEST_PROFILE}.json`); const testProfile = { name: TEST_PROFILE, // IMPORTANT: profile.name must match filename for ProfileManager to load it correctly description: 'Integration test profile', mcpServers: { filesystem: { command: 'npx', args: ['@modelcontextprotocol/server-filesystem', '/tmp'] } }, metadata: { created: new Date().toISOString(), modified: new Date().toISOString() } }; fs.writeFileSync(profilePath, JSON.stringify(testProfile, null, 2)); logInfo(`Created test profile at ${profilePath}`); } // ANSI colors for output const colors = { green: '\x1b[32m', red: '\x1b[31m', yellow: '\x1b[33m', blue: '\x1b[34m', reset: '\x1b[0m' }; function log(emoji, message, color = 'reset') { console.log(`${emoji} ${colors[color]}${message}${colors.reset}`); } function logError(message) { log('❌', `FAIL: ${message}`, 'red'); } function logSuccess(message) { log('✓', message, 'green'); } function logInfo(message) { log('ℹ️', message, 'blue'); } class MCPClientSimulator { constructor() { this.ncp = null; this.responses = []; this.responseBuffer = ''; this.requestId = 0; } start() { return new Promise((resolve, reject) => { logInfo('Starting NCP MCP server...'); this.ncp = spawn('node', ['dist/index.js', '--profile', TEST_PROFILE], { stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, NCP_MODE: 'mcp', NO_COLOR: 'true', // Disable colors in output NCP_DEBUG: 'true' // Enable debug logging } }); this.ncp.stdout.on('data', (data) => { this.responseBuffer += data.toString(); const lines = this.responseBuffer.split('\n'); lines.slice(0, -1).forEach(line => { if (line.trim()) { try { const response = JSON.parse(line); this.responses.push(response); } catch (e) { // Ignore non-JSON lines (logs, etc.) } } }); this.responseBuffer = lines[lines.length - 1]; }); this.ncp.stderr.on('data', (data) => { // Collect stderr for debugging const msg = data.toString(); if (msg.includes('[DEBUG]')) { console.log(msg.trim()); } }); this.ncp.on('error', reject); // Give it a moment to start setTimeout(resolve, 100); }); } sendRequest(method, params = {}) { this.requestId++; const request = { jsonrpc: '2.0', id: this.requestId, method, params }; this.ncp.stdin.write(JSON.stringify(request) + '\n'); return this.requestId; } waitForResponse(id, timeoutMs = 5000) { return new Promise((resolve, reject) => { const startTime = Date.now(); const checkResponse = () => { const response = this.responses.find(r => r.id === id); if (response) { resolve(response); return; } if (Date.now() - startTime > timeoutMs) { reject(new Error(`Timeout waiting for response to request ${id}`)); return; } setTimeout(checkResponse, 10); }; checkResponse(); }); } async stop() { if (this.ncp) { // Close stdin to trigger graceful shutdown (allows cache finalization) this.ncp.stdin.end(); // Wait for process to exit gracefully await new Promise(resolve => { this.ncp.once('exit', resolve); // Fallback: force kill after 2 seconds if it doesn't exit setTimeout(() => { if (!this.ncp.killed) { this.ncp.kill(); resolve(); } }, 2000); }); } } } async function test1_Initialize() { logInfo('Test 1: Initialize request responds immediately'); const client = new MCPClientSimulator(); await client.start(); const startTime = Date.now(); const id = client.sendRequest('initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test-client', version: '1.0.0' } }); const response = await client.waitForResponse(id); const duration = Date.now() - startTime; await client.stop(); if (response.error) { logError(`Initialize failed: ${response.error.message}`); return false; } // Allow 1500ms on Windows/macOS CI runners (slower than local dev) const maxDuration = process.platform === 'win32' || process.env.CI ? 1500 : 1000; if (duration > maxDuration) { logError(`Initialize took ${duration}ms (should be < ${maxDuration}ms)`); return false; } if (!response.result?.protocolVersion) { logError('Initialize response missing protocolVersion'); return false; } logSuccess(`Initialize responded in ${duration}ms`); return true; } async function test2_ToolsListDuringIndexing() { logInfo('Test 2: tools/list responds < 250ms even during indexing'); const client = new MCPClientSimulator(); await client.start(); // Send initialize first (required by MCP protocol) const initId = client.sendRequest('initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test-client', version: '1.0.0' } }); await client.waitForResponse(initId); // Call tools/list immediately after initialize (during indexing) const startTime = Date.now(); const id = client.sendRequest('tools/list'); const response = await client.waitForResponse(id); const duration = Date.now() - startTime; await client.stop(); if (response.error) { logError(`tools/list failed: ${response.error.message}`); return false; } if (duration > 250) { logError(`tools/list took ${duration}ms (should be < 250ms)`); return false; } if (!response.result?.tools || response.result.tools.length === 0) { logError('tools/list returned no tools'); return false; } const toolNames = response.result.tools.map(t => t.name); // Code mode is enabled by default, so we expect 'find' and 'code' // If code mode were disabled, we'd expect 'find' and 'run' if (!toolNames.includes('find') || (!toolNames.includes('code') && !toolNames.includes('run'))) { logError(`tools/list missing required tools. Got: ${toolNames.join(', ')}`); return false; } logSuccess(`tools/list responded in ${duration}ms with ${response.result.tools.length} tools`); return true; } async function test3_FindDuringIndexing() { logInfo('Test 3: find returns partial results during indexing (not empty)'); const client = new MCPClientSimulator(); await client.start(); // Call find immediately (during indexing) - like Perplexity does const id = client.sendRequest('tools/call', { name: 'find', arguments: { description: 'list files' } }); const response = await client.waitForResponse(id, 10000); await client.stop(); if (response.error) { logError(`find failed: ${response.error.message}`); return false; } const text = response.result?.content?.[0]?.text || ''; // Should either: // 1. Return partial results with indexing message // 2. Return "indexing in progress" message // Should NOT return blank or "No tools found" without context if (text.includes('No tools found') && !text.includes('Indexing')) { logError('find returned empty without indexing context'); return false; } if (text.length === 0) { logError('find returned empty response'); return false; } const hasIndexingMessage = text.includes('Indexing in progress') || text.includes('indexing'); const hasResults = text.includes('**') || text.includes('tools') || text.includes('MCP'); if (!hasIndexingMessage && !hasResults) { logError('find response has neither indexing message nor results'); return false; } logSuccess(`find returned ${hasResults ? 'partial results' : 'indexing message'}`); return true; } async function test4_CacheProfileHashPersists() { logInfo('Test 4: Cache profileHash persists correctly'); // Clear cache first const metaPath = path.join(CACHE_DIR, `${TEST_PROFILE}-cache-meta.json`); const csvPath = path.join(CACHE_DIR, `${TEST_PROFILE}-tools.csv`); if (fs.existsSync(metaPath)) { fs.unlinkSync(metaPath); } if (fs.existsSync(csvPath)) { fs.unlinkSync(csvPath); } // Start server and let it create cache const client1 = new MCPClientSimulator(); await client1.start(); const id1 = client1.sendRequest('tools/call', { name: 'find', arguments: {} }); await client1.waitForResponse(id1, 10000); // Wait a bit for indexing to potentially complete await new Promise(resolve => setTimeout(resolve, 2000)); await client1.stop(); // Wait for cache to be finalized and written await new Promise(resolve => setTimeout(resolve, 1000)); // Check cache metadata if (!fs.existsSync(metaPath)) { logError('Cache metadata file not created'); logInfo(`Expected at: ${metaPath}`); // List what's in cache dir for debugging if (fs.existsSync(CACHE_DIR)) { const files = fs.readdirSync(CACHE_DIR); logInfo(`Files in cache dir: ${files.join(', ')}`); } return false; } const metadata = JSON.parse(fs.readFileSync(metaPath, 'utf-8')); if (!metadata.profileHash || metadata.profileHash === '') { logError(`profileHash is empty: "${metadata.profileHash}"`); return false; } logSuccess(`Cache profileHash saved: ${metadata.profileHash.substring(0, 16)}...`); return true; } async function test5_NoReindexingOnRestart() { logInfo('Test 5: Second startup uses cache (no re-indexing)'); const metaPath = path.join(CACHE_DIR, `${TEST_PROFILE}-cache-meta.json`); // Get initial cache state const metaBefore = JSON.parse(fs.readFileSync(metaPath, 'utf-8')); const hashBefore = metaBefore.profileHash; const lastUpdatedBefore = metaBefore.lastUpdated; // Wait a moment to ensure timestamp would change if re-indexed await new Promise(resolve => setTimeout(resolve, 1000)); // Start server again const client = new MCPClientSimulator(); await client.start(); const id = client.sendRequest('tools/call', { name: 'find', arguments: {} }); await client.waitForResponse(id, 10000); await client.stop(); // Wait for any potential cache updates await new Promise(resolve => setTimeout(resolve, 500)); // Check cache wasn't regenerated const metaAfter = JSON.parse(fs.readFileSync(metaPath, 'utf-8')); const hashAfter = metaAfter.profileHash; if (hashBefore !== hashAfter) { logError(`profileHash changed on restart (cache invalidated):\n Before: ${hashBefore}\n After: ${hashAfter}`); return false; } // Note: lastUpdated might change slightly due to timestamp updates, that's OK // The key is profileHash stays the same logSuccess('Cache persisted correctly (profileHash unchanged on restart)'); return true; } async function test6_CredentialsJsonOutput() { logInfo('Test 6: credentials --json returns well-formed output'); const result = spawnSync('node', ['dist/index.js', 'credentials', '--json'], { env: { ...process.env, NO_COLOR: 'true' }, encoding: 'utf-8' }); if (result.status !== 0) { logError(`credentials --json exited with code ${result.status}`); if (result.stderr) { logInfo(`stderr:\n${result.stderr}`); } return false; } const raw = result.stdout.trim(); if (!raw) { logError('credentials --json produced empty output'); return false; } try { const parsed = JSON.parse(raw); if (!parsed || !Array.isArray(parsed.secureStore) || !Array.isArray(parsed.vault)) { logError('credentials --json missing secureStore or vault arrays'); return false; } } catch (error) { logError(`credentials --json output was not valid JSON: ${error.message}`); logInfo(`Output:\n${raw}`); return false; } logSuccess('credentials --json produced valid JSON payload'); return true; } async function runAllTests() { console.log('\n' + '='.repeat(60)); console.log('🧪 NCP Integration Test Suite'); console.log(' Simulating Real AI Client Behavior'); console.log('='.repeat(60) + '\n'); // Setup test environment setupTestProfile(); const tests = [ test1_Initialize, test2_ToolsListDuringIndexing, test3_FindDuringIndexing, test4_CacheProfileHashPersists, test5_NoReindexingOnRestart, test6_CredentialsJsonOutput ]; let passed = 0; let failed = 0; for (const test of tests) { try { const result = await test(); if (result) { passed++; } else { failed++; } } catch (error) { logError(`${test.name} threw error: ${error.message}`); failed++; } console.log(''); // Blank line between tests } console.log('='.repeat(60)); console.log(`📊 Results: ${passed} passed, ${failed} failed`); console.log('='.repeat(60) + '\n'); if (failed > 0) { console.log('❌ INTEGRATION TESTS FAILED - DO NOT RELEASE\n'); process.exit(1); } else { console.log('✅ ALL INTEGRATION TESTS PASSED - Safe to release\n'); process.exit(0); } } // Cleanup on exit process.on('exit', () => { // Clean up test profile cache if needed const metaPath = path.join(CACHE_DIR, `${TEST_PROFILE}-cache-meta.json`); if (fs.existsSync(metaPath)) { // Optionally clean up: fs.unlinkSync(metaPath); } }); // Run tests runAllTests().catch(error => { logError(`Test suite crashed: ${error.message}`); console.error(error); process.exit(1); });

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/portel-dev/ncp'

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