Skip to main content
Glama
portel-dev

NCP - Natural Context Provider

by portel-dev
ncp-management.ts72.2 kB
/** * NCP Management Internal MCP * * Provides tools for managing NCP configuration: * - add: Add MCP(s) with smart detection (single/bulk/file/clipboard/URL) * - remove: Remove MCP * - list: List configured MCPs * - export: Export configuration */ import * as path from 'path'; import { InternalMCP, InternalTool, InternalToolResult, ElicitationCapable } from './types.js'; import ProfileManager from '../profiles/profile-manager.js'; import { logger } from '../utils/logger.js'; import { getNcpBaseDirectory } from '../utils/ncp-paths.js'; import { RegistryMCPCandidate } from '../services/registry-client.js'; import { UnifiedRegistryClient } from '../services/unified-registry-client.js'; import { collectCredentials, detectRequiredEnvVars, collectHTTPCredentials, collectBulkCredentials, elicitMultiSelect, detectHTTPCredentials } from '../utils/elicitation-helper.js'; import { showConfirmDialog } from '../utils/native-dialog.js'; import { UIMessages } from '../utils/ui-messages.js'; import { CLIIndexer } from '../services/cli-indexer.js'; export class NCPManagementMCP implements InternalMCP { name = 'mcp'; description = 'MCP configuration management tools (built-in)'; private profileManager: ProfileManager | null = null; private elicitationServer: ElicitationCapable | null = null; tools: InternalTool[] = [ { name: 'add', description: 'Add MCP server(s) to NCP with smart auto-detection of input type. Supports multiple modes: (1) SINGLE: Add one MCP from registry or manual config (e.g., "github", "canva"). (2) BULK: Pipe-separated names for multiple MCPs (e.g., "gmail | slack | github"). (3) FILE IMPORT: Path to config file or Photon (e.g., "~/backup.json", "./calculator.photon.ts"). (4) URL: HTTP/SSE server URL (e.g., "https://mcp.example.com") or Photon file URL (e.g., "https://example.com/calc.photon.ts"). (5) CLIPBOARD: Use special value "clipboard" to import from clipboard (auto-detects JSON config or TypeScript Photon). For uncertain names, use "find" method first to discover available MCPs.', inputSchema: { type: 'object', properties: { mcp_name: { type: 'string', description: 'REQUIRED. Smart parameter that auto-detects mode: (1) Single name: "github" → registry lookup or manual. (2) Pipe-separated: "gmail | slack" → bulk install from registry. (3) File path: "~/config.json" (JSON config) or "./calculator.photon.ts" (Photon) → import from file. (4) URL: "https://mcp.example.com" (HTTP/SSE server) or "https://example.com/calc.photon.ts" (Photon download). (5) "clipboard" → import from clipboard (auto-detects JSON or TypeScript). Pre-fill based on user intent, or use "find" method to discover MCP names first.' }, command: { type: 'string', description: 'Optional. Command for stdio servers (e.g., "npx", "python"). Only for manual single-MCP configuration when mcp_name is a simple name.' }, args: { type: 'array', items: { type: 'string' }, description: 'Optional. Command arguments for stdio servers. Only for manual single-MCP configuration.' }, url: { type: 'string', description: 'Optional. URL for HTTP/SSE servers. Only for manual single-MCP configuration when mcp_name is a simple name (overrides auto-detected URL mode).' }, profile: { type: 'string', description: 'Target profile name (default: "all")', default: 'all' } }, required: ['mcp_name'] } }, { name: 'remove', description: 'Remove an MCP server from NCP configuration. First use "find" or "list" to identify the exact mcp_name, then call this tool. User confirmation required (automatic popup).', inputSchema: { type: 'object', properties: { mcp_name: { type: 'string', description: 'REQUIRED. Name of the MCP server to remove. Pre-fill if extractable from user intent, otherwise use "find" or "list" method to get the exact name first.' }, profile: { type: 'string', description: 'Profile to remove from (default: "all")', default: 'all' } }, required: ['mcp_name'] } }, { name: 'list', description: 'List all configured MCP servers in a profile', inputSchema: { type: 'object', properties: { profile: { type: 'string', description: 'Profile name to list (default: "all")', default: 'all' } } } }, { name: 'export', description: 'Export current NCP configuration. Use clipboard for security (no chat history), response for transparency.', inputSchema: { type: 'object', properties: { to: { type: 'string', enum: ['clipboard', 'response', 'file'], default: 'clipboard', description: 'Export destination: clipboard (silent, secure), response (visible to AI), or file' }, destination: { type: 'string', description: 'File path (only for to=file)' }, profile: { type: 'string', description: 'Profile to export (default: "all")', default: 'all' } } } }, { name: 'doctor', description: 'Run diagnostics on NCP system health and MCP configurations. Read-only - reports issues without making changes. Matches CLI: ncp doctor', inputSchema: { type: 'object', properties: { mcp_name: { type: 'string', description: 'Check specific MCP only (optional - omit to check all)' }, profile: { type: 'string', description: 'Profile to check (default: "all")', default: 'all' } } } } ]; /** * Set the ProfileManager instance * Called by orchestrator after initialization */ setProfileManager(profileManager: ProfileManager): void { this.profileManager = profileManager; } /** * Set the elicitation server for user interaction * Called by MCP server after initialization */ setElicitationServer(server: ElicitationCapable): void { this.elicitationServer = server; } async executeTool(toolName: string, parameters: any): Promise<InternalToolResult> { if (!this.profileManager) { return { success: false, error: 'ProfileManager not initialized. Please try again.' }; } try { switch (toolName) { case 'add': return await this.handleAdd(parameters); case 'remove': return await this.handleRemove(parameters); case 'list': return await this.handleList(parameters); case 'export': return await this.handleExport(parameters); case 'doctor': return await this.handleDoctor(parameters); default: return { success: false, error: `Unknown tool: ${toolName}. Available tools: add, remove, list, export, doctor` }; } } catch (error: any) { logger.error(`Internal MCP tool execution failed: ${error.message}`); return { success: false, error: error.message || 'Tool execution failed' }; } } private async handleAdd(params: any): Promise<InternalToolResult> { if (!params?.mcp_name) { return { success: false, error: 'Missing required parameter: mcp_name. Use "find" method to discover MCPs first, then extract the mcp_name from results.' }; } let mcpName = params.mcp_name; let command = params.command; let commandArgs = params.args || []; let url = params.url; const profile = params.profile || 'all'; // === SMART AUTO-DETECTION (matching CLI behavior) === // 1. Special value: "clipboard" → import from clipboard if (mcpName.toLowerCase() === 'clipboard') { return await this.importFromClipboard(); } // 2. Pipe-delimited bulk add → discovery mode with multiple queries if (mcpName.includes('|') && !command && !url) { return await this.importFromDiscovery(mcpName); } // 3. File path detection → import from file // 3a. Photon file (.photon.ts) if (!command && !url && mcpName.endsWith('.photon.ts')) { return await this.importMicroMCPFromFile(mcpName, profile); } // 3b. JSON config file (.json or paths) if (!command && !url && ( mcpName.endsWith('.json') || mcpName.startsWith('/') || mcpName.startsWith('~/') || mcpName.startsWith('./') )) { return await this.importFromFile(mcpName); } // 4. HTTP URL detection if (!command && !url && ( mcpName.startsWith('http://') || mcpName.startsWith('https://') )) { // 4a. Check if it's a .photon.ts file URL → download Photon if (mcpName.endsWith('.photon.ts')) { return await this.downloadMicroMCPFromURL(mcpName, profile); } // 4b. Otherwise → HTTP/SSE MCP server URL try { // Extract name from URL (domain-based) const urlObj = new URL(mcpName); const generatedName = urlObj.hostname .replace(/\./g, '-') .replace(/^www-/, ''); // Set url from the URL string and update mcpName to generated name url = mcpName; mcpName = generatedName; logger.info(`Auto-detected HTTP URL: ${url}, using name: ${mcpName}`); } catch (error: any) { return { success: false, error: `Invalid URL format: ${mcpName}. Error: ${error.message}` }; } // Continue with single add logic below (will be HTTP transport) } // 5. CLI tool detection → index CLI tool for discovery if (!command && !url && mcpName.startsWith('cli:')) { const cliToolName = mcpName.substring(4); // Remove "cli:" prefix return await this.addCliTool(cliToolName, profile); } // 6. Otherwise → single add mode (registry or manual) // Continue with existing logic below... // === END SMART AUTO-DETECTION === // Validate transport type parameters const hasCommand = !!command; const hasUrl = !!url; if (hasCommand && hasUrl) { return { success: false, error: 'Cannot specify both command and url. Use command for stdio servers, url for HTTP/SSE servers.' }; } // Auto-detect mode: no command or url provided if (!hasCommand && !hasUrl) { try { // Try to fetch provider from registry const { fetchProvider } = await import('../registry/provider-registry.js'); const provider = await fetchProvider(mcpName); if (!provider) { return { success: false, error: `Provider "${mcpName}" not found in registry. Either:\n` + `1. Use a known provider name (e.g., "canva", "github", "notion")\n` + `2. Provide file path for Photon: "${mcpName}.photon.ts" or "~/.ncp/micromcps/${mcpName}.photon.ts"\n` + `3. Provide manual configuration with command (for stdio) or url (for HTTP/SSE)\n\n` + `💡 Tip: MicroMCPs in ~/.ncp/micromcps/ are auto-discovered on NCP startup` }; } // Handle Photon installation (different flow) if (provider._meta?.isMicroMCP && provider._meta?.sourceUrl) { return await this.installMicroMCP(mcpName, provider, profile); } // Use provider metadata to build config const transport = provider.recommended || 'stdio'; if (transport === 'stdio' && provider.stdio) { command = provider.stdio.command; commandArgs = provider.stdio.args || []; } else if (transport === 'http' && provider.http) { url = provider.http.url; } else { return { success: false, error: `Provider "${mcpName}" found but missing ${transport} configuration. Please provide manual config.` }; } logger.info(`Auto-detected provider "${mcpName}": ${transport} transport`); } catch (error: any) { return { success: false, error: `Failed to fetch provider: ${error.message}. Provide manual configuration instead.` }; } } const transportType = url ? 'http' : 'stdio'; // Validate stdio command before proceeding (security check) if (transportType === 'stdio') { const validationError = this.validateMCPCommand(command!, commandArgs); if (validationError) { return { success: false, error: `❌ Security validation failed: ${validationError}\n\n` + `For security, NCP validates all MCP commands before installation.\n` + `If you believe this is a legitimate MCP, please review the command and ensure it doesn't contain dangerous characters.` }; } } // ===== CONFIRM BEFORE ADD (Server-side enforcement) ===== if (this.elicitationServer) { // Build confirmation message based on transport type let confirmationMessage: string; if (transportType === 'http') { confirmationMessage = `⚠️ CONFIRM MCP INSTALLATION Adding new HTTP/SSE MCP server: ${mcpName} Profile: ${profile} URL: ${url} ⚠️ This will allow the MCP server to access your system through HTTP/SSE. Only proceed if you trust this server. Do you want to install this MCP?`; } else { const argsStr = commandArgs.length > 0 ? ` ${commandArgs.join(' ')}` : ''; confirmationMessage = `⚠️ CONFIRM MCP INSTALLATION Adding new MCP server: ${mcpName} Profile: ${profile} Command: ${command}${argsStr} ⚠️ Installing MCPs can execute arbitrary code on your system. Only proceed if you trust this MCP server. Do you want to install this MCP?`; } let approved = false; try { // Try elicitation first (works with supporting MCP clients) const result = await this.elicitationServer.elicitInput({ message: confirmationMessage, requestedSchema: { type: 'object', properties: { action: { type: 'string', enum: ['approve', 'cancel'], description: 'Choose: approve (install MCP) or cancel (don\'t install)' } }, required: ['action'] } }); approved = !(result.action === 'decline' || result.action === 'cancel' || (result.action === 'accept' && result.content?.action === 'cancel')); } catch (error: any) { // Check if client doesn't support elicitation (error -32601: Method not found) const isMethodNotFound = error.code === -32601 || (error.message && error.message.includes('Method not found')); if (isMethodNotFound) { logger.warn('Elicitation not supported by client, falling back to native OS dialog'); // Fallback to native OS dialog try { const result = await showConfirmDialog( 'NCP: Confirm MCP Installation', confirmationMessage, 'Approve', 'Cancel' ); // Handle timeout with retry instruction if (result.timedOut && result.stillPending) { return { success: false, error: `⏳ Waiting for user confirmation...\n\n` + `A confirmation dialog is still open on your system. Please:\n` + `1. Check for a dialog box asking to approve MCP installation\n` + `2. Click "Approve" or "Cancel" in that dialog\n` + `3. Retry this operation (I'll check if you already responded)\n\n` + `💡 If you already clicked Approve, just retry this exact same operation and it will proceed.` }; } approved = result.approved; } catch (nativeError: any) { // Dialog system failed (missing zenity, PowerShell error, etc.) // This is our limitation, not user's choice - provide manual installation instructions logger.error(`Native dialog failed: ${nativeError.message}`); const profilePath = await this.profileManager!.getProfilePath(profile); // Build config based on transport type let configToAdd: any; let cliCommand: string; if (transportType === 'http') { configToAdd = { url }; cliCommand = `Manual config edit required (HTTP URLs not supported via CLI)`; } else { const argsStr = commandArgs.length > 0 ? commandArgs.join(' ') : ''; configToAdd = { command, args: commandArgs }; cliCommand = `ncp add ${mcpName} ${command} ${argsStr}`; } // Check if running as extension with global CLI disabled const isExtension = process.env.NCP_MODE === 'extension'; const globalCliEnabled = process.env.NCP_ENABLE_GLOBAL_CLI === 'true'; let errorMessage = `⚠️ Cannot show confirmation dialog: ${nativeError.message}\n\n` + `For security, NCP requires user confirmation before installing MCPs.\n\n`; // If extension without global CLI, suggest enabling it first if (isExtension && !globalCliEnabled) { errorMessage += `📌 EASIEST OPTION: Enable the global NCP command\n\n` + `1. Edit your Claude Desktop extension settings (.dxt file)\n` + `2. Set: "enableGlobalCLI": true\n` + `3. Restart Claude Desktop\n` + `4. Use command: ${cliCommand}\n\n` + `────────────────────────────────────────\n\n`; } const credentialHint = transportType === 'http' ? `💡 If this MCP requires authentication, add the "auth" field in the config.` : `💡 If this MCP requires API keys/credentials, add them to the "env" field in the config.`; errorMessage += `📝 OR: Install manually by editing configuration\n\n` + `1. Open your profile configuration file:\n` + ` ${profilePath}\n\n` + `2. Add this to the "mcpServers" section:\n` + ` "${mcpName}": ${JSON.stringify(configToAdd, null, 2).split('\n').join('\n ')}\n\n` + `3. Save the file\n\n` + `4. Restart NCP or your MCP client\n\n` + `⚙️ Full reference: ${cliCommand}\n\n` + credentialHint; return { success: false, error: errorMessage }; } } else { // Other elicitation error logger.error(`Elicitation error: ${error.message}`); throw error; } } if (!approved) { return { success: false, error: `⛔ Installation cancelled by user. MCP "${mcpName}" was not added.` }; } } // ===== END CONFIRM BEFORE ADD ===== // Build config and collect credentials based on transport type let config: any; let credentialInfo = ''; if (transportType === 'http') { // HTTP/SSE server configuration config = { url }; // Try to collect HTTP credentials (bearer tokens) if needed if (this.elicitationServer) { try { const httpAuth = await collectHTTPCredentials(this.elicitationServer, mcpName, url); if (httpAuth) { config.auth = httpAuth; credentialInfo = ' with authentication'; logger.info(`Collected HTTP authentication for "${mcpName}"`); } else { logger.info(`No authentication required for HTTP MCP "${mcpName}"`); } } catch (error: any) { // User cancelled credential collection if (error.message && error.message.includes('cancelled')) { return { success: false, error: 'User cancelled credential collection' }; } // Other errors - log but continue without auth (might be public endpoint) logger.warn(`Failed to collect HTTP credentials: ${error.message}`); } } } else { // stdio server configuration config = { command, args: commandArgs }; // Detect if this MCP needs environment variables const requiredCredentials = detectRequiredEnvVars(mcpName); if (requiredCredentials.length > 0 && this.elicitationServer) { // Use elicitation to collect credentials one by one logger.info(`MCP "${mcpName}" requires ${requiredCredentials.length} credentials`); const credentials = await collectCredentials( this.elicitationServer, requiredCredentials.map(c => ({ ...c, required: true })) ); if (credentials === null) { return { success: false, error: 'User cancelled credential collection' }; } // Add collected credentials to config if (Object.keys(credentials).length > 0) { config.env = credentials; credentialInfo = ` with ${Object.keys(credentials).length} credential(s)`; logger.info(`Collected ${Object.keys(credentials).length} credentials for "${mcpName}"`); } } } // Add MCP to profile await this.profileManager!.addMCPToProfile(profile, mcpName, config); // Try to get tools from the newly added MCP let toolsList = ''; try { const tools = await this.getToolsFromMCP(mcpName, config, transportType); if (tools && tools.length > 0) { toolsList = `\n\n**Available tools** (${tools.length} total):\n`; // Show first 10 tools const displayTools = tools.slice(0, 10); displayTools.forEach(tool => { toolsList += ` • \`${tool.name}\` - ${tool.description || 'No description'}\n`; }); if (tools.length > 10) { toolsList += ` ... and ${tools.length - 10} more\n`; } } } catch (error: any) { // Failed to get tools - non-fatal, just skip the tools list logger.warn(`Could not retrieve tools from "${mcpName}": ${error.message}`); } // Build success message based on transport type let successMessage: string; if (transportType === 'http') { const authInfo = config.auth ? ` (${config.auth.type} auth)` : ' (no auth)'; successMessage = `✅ HTTP/SSE MCP server "${mcpName}" added to profile "${profile}"${credentialInfo}\n\n` + `URL: ${url}${authInfo}${toolsList}`; } else { successMessage = `✅ MCP server "${mcpName}" added to profile "${profile}"${credentialInfo}\n\n` + `Command: ${command} ${config.args?.join(' ') || ''}${toolsList}`; } logger.info(`Added MCP "${mcpName}" to profile "${profile}" (${transportType})${credentialInfo}`); return { success: true, content: successMessage }; } private async handleRemove(params: any): Promise<InternalToolResult> { if (!params?.mcp_name) { return { success: false, error: 'Missing required parameter: mcp_name. Use "list" or "find" method to identify MCPs first, then extract the exact mcp_name.' }; } const mcpName = params.mcp_name; const profile = params.profile || 'all'; // ===== CONFIRM BEFORE REMOVE (Server-side enforcement) ===== if (this.elicitationServer) { const confirmationMessage = `⚠️ CONFIRM MCP REMOVAL Removing MCP server: ${mcpName} Profile: ${profile} This will remove the MCP configuration. You can always add it back later. Do you want to remove this MCP?`; let approved = false; try { // Try elicitation first (works with supporting MCP clients) const result = await this.elicitationServer.elicitInput({ message: confirmationMessage, requestedSchema: { type: 'object', properties: { action: { type: 'string', enum: ['approve', 'cancel'], description: 'Choose: approve (remove MCP) or cancel (keep it)' } }, required: ['action'] } }); approved = !(result.action === 'decline' || result.action === 'cancel' || (result.action === 'accept' && result.content?.action === 'cancel')); } catch (error: any) { // Check if client doesn't support elicitation (error -32601: Method not found) const isMethodNotFound = error.code === -32601 || (error.message && error.message.includes('Method not found')); if (isMethodNotFound) { logger.warn('Elicitation not supported by client, falling back to native OS dialog'); // Fallback to native OS dialog try { const result = await showConfirmDialog( 'NCP: Confirm MCP Removal', confirmationMessage, 'Approve', 'Cancel' ); // Handle timeout with retry instruction if (result.timedOut && result.stillPending) { return { success: false, error: `⏳ Waiting for user confirmation...\n\n` + `A confirmation dialog is still open on your system. Please:\n` + `1. Check for a dialog box asking to approve MCP removal\n` + `2. Click "Approve" or "Cancel" in that dialog\n` + `3. Retry this operation (I'll check if you already responded)\n\n` + `💡 If you already clicked Approve, just retry this exact same operation and it will proceed.` }; } approved = result.approved; } catch (nativeError: any) { // Dialog system failed (missing zenity, PowerShell error, etc.) // This is our limitation, not user's choice - provide manual removal instructions logger.error(`Native dialog failed: ${nativeError.message}`); const profilePath = await this.profileManager!.getProfilePath(profile); // Check if running as extension with global CLI disabled const isExtension = process.env.NCP_MODE === 'extension'; const globalCliEnabled = process.env.NCP_ENABLE_GLOBAL_CLI === 'true'; let errorMessage = `⚠️ Cannot show confirmation dialog: ${nativeError.message}\n\n` + `For security, NCP requires user confirmation before removing MCPs.\n\n`; // If extension without global CLI, suggest enabling it first if (isExtension && !globalCliEnabled) { errorMessage += `📌 EASIEST OPTION: Enable the global NCP command\n\n` + `1. Edit your Claude Desktop extension settings (.dxt file)\n` + `2. Set: "enableGlobalCLI": true\n` + `3. Restart Claude Desktop\n` + `4. Use command: ncp remove ${mcpName}\n\n` + `────────────────────────────────────────\n\n`; } errorMessage += `📝 OR: Remove manually by editing configuration\n\n` + `1. Open your profile configuration file:\n` + ` ${profilePath}\n\n` + `2. Find and delete the "${mcpName}" entry from the "mcpServers" section\n\n` + `3. Save the file\n\n` + `4. Restart NCP or your MCP client\n\n` + `💡 Make sure to preserve valid JSON format (watch for trailing commas).`; return { success: false, error: errorMessage }; } } else { // Other elicitation error logger.error(`Elicitation error: ${error.message}`); throw error; } } if (!approved) { return { success: false, error: `⛔ Removal cancelled by user. MCP "${mcpName}" was not removed.` }; } } // ===== END CONFIRM BEFORE REMOVE ===== // Remove MCP from profile await this.profileManager!.removeMCPFromProfile(profile, mcpName); const successMessage = `✅ MCP server "${mcpName}" removed from profile "${profile}"\n\n` + `The change will take effect after NCP is restarted.`; logger.info(`Removed MCP "${mcpName}" from profile "${profile}"`); return { success: true, content: successMessage }; } private async handleList(params: any): Promise<InternalToolResult> { const profile = params?.profile || 'all'; const mcps = await this.profileManager!.getProfileMCPs(profile); if (!mcps || Object.keys(mcps).length === 0) { return { success: true, content: `No MCPs configured in profile "${profile}"` }; } const mcpList = Object.entries(mcps) .map(([name, config]) => { const isRemote = 'url' in config; const transportBadge = isRemote ? '🌐' : '💻'; if (isRemote) { // HTTP/SSE server const authType = config.auth?.type || 'none'; return `${transportBadge} ${name}\n URL: ${config.url}\n Auth: ${authType}`; } else { // stdio server const argsStr = config.args?.join(' ') || ''; const envKeys = config.env ? Object.keys(config.env).join(', ') : ''; const envInfo = envKeys ? `\n Environment: ${envKeys}` : ''; return `${transportBadge} ${name}\n Command: ${config.command} ${argsStr}${envInfo}`; } }) .join('\n\n'); const successMessage = `📋 Configured MCPs in profile "${profile}":\n\n${mcpList}\n\n💡 Badges: 💻=stdio 🌐=HTTP/SSE`; return { success: true, content: successMessage }; } private async importFromClipboard(): Promise<InternalToolResult> { try { const clipboardy = await import('clipboardy'); const clipboardContent = await clipboardy.default.read(); if (!clipboardContent || clipboardContent.trim().length === 0) { return { success: false, error: 'Clipboard is empty. Copy a valid MCP configuration JSON or Photon TypeScript code.' }; } const trimmed = clipboardContent.trim(); // Detect format: TypeScript (Photon) vs JSON (config) // TypeScript indicators: contains "export class", "implements Photon", etc. const isMicroMCP = trimmed.includes('export class') && (trimmed.includes('implements Photon') || trimmed.includes('@tool')); if (isMicroMCP) { // Import as Photon TypeScript code return await this.importMicroMCPFromClipboard(trimmed); } // Try to parse as JSON config const config = JSON.parse(trimmed); // Validate and import if (!config.mcpServers || typeof config.mcpServers !== 'object') { return { success: false, error: 'Invalid config format. Expected: {"mcpServers": {...}} or Photon TypeScript code' }; } let imported = 0; for (const [name, mcpConfig] of Object.entries(config.mcpServers)) { if (typeof mcpConfig === 'object' && mcpConfig !== null && 'command' in mcpConfig) { await this.profileManager!.addMCPToProfile('all', name, mcpConfig as any); imported++; } } return { success: true, content: `✅ Imported ${imported} MCPs from clipboard` }; } catch (error: any) { return { success: false, error: `Failed to import from clipboard: ${error.message}` }; } } /** * Import Photon from clipboard containing TypeScript code */ private async importMicroMCPFromClipboard(tsContent: string): Promise<InternalToolResult> { try { const fs = await import('fs/promises'); const path = await import('path'); const os = await import('os'); // Try to extract class name from: export class CalculatorMCP const classMatch = tsContent.match(/export\s+class\s+(\w+)/); if (!classMatch) { return { success: false, error: 'Could not detect Photon class name. Expected "export class <Name>MCP"' }; } // Extract base name (e.g., "CalculatorMCP" → "calculator") const className = classMatch[1]; const baseName = className .replace(/MCP$/, '') // Remove "MCP" suffix .replace(/([A-Z])/g, (match, p1, offset) => offset > 0 ? '-' + p1.toLowerCase() : p1.toLowerCase()) .replace(/^-/, ''); // Remove leading dash // Create destination directory const microDir = path.join(getNcpBaseDirectory(), 'micromcps'); await fs.mkdir(microDir, { recursive: true }); const destFile = path.join(microDir, `${baseName}.photon.ts`); // Write TypeScript code to file await fs.writeFile(destFile, tsContent, 'utf8'); logger.info(`Saved Photon from clipboard: ${baseName}.photon.ts`); return { success: true, content: [ { type: 'text', text: `${UIMessages.photonImportedClipboard(baseName)}\n\n` + `📍 Location: ${destFile}\n` + `📝 Class: ${className}\n` + `\n${UIMessages.photonUsage(baseName)}` + `\n${UIMessages.photonDiscovery(baseName)}` } ] }; } catch (error: any) { logger.error(`Failed to import Photon from clipboard: ${error.message}`); return { success: false, error: `Failed to import Photon from clipboard: ${error.message}` }; } } private async importFromFile(filePath: string): Promise<InternalToolResult> { try { const fs = await import('fs/promises'); const path = await import('path'); // Expand ~ to home directory const expandedPath = filePath.startsWith('~') ? path.join(process.env.HOME || process.env.USERPROFILE || '', filePath.slice(1)) : filePath; const content = await fs.readFile(expandedPath, 'utf-8'); const config = JSON.parse(content); // Validate and import if (!config.mcpServers || typeof config.mcpServers !== 'object') { return { success: false, error: 'Invalid config format. Expected: {"mcpServers": {...}}' }; } let imported = 0; for (const [name, mcpConfig] of Object.entries(config.mcpServers)) { if (typeof mcpConfig === 'object' && mcpConfig !== null && 'command' in mcpConfig) { await this.profileManager!.addMCPToProfile('all', name, mcpConfig as any); imported++; } } return { success: true, content: `✅ Imported ${imported} MCPs from ${filePath}` }; } catch (error: any) { return { success: false, error: `Failed to import from file: ${error.message}` }; } } private async importFromDiscovery(queryString: string): Promise<InternalToolResult> { try { const registryClient = new UnifiedRegistryClient(); // Check for pipe-delimited multi-query const queries = queryString.includes('|') ? queryString.split('|').map((q: string) => q.trim()).filter((q: string) => q.length > 0) : [queryString]; // Search for all queries in parallel const searchResults = await Promise.all( queries.map(query => registryClient.searchForSelection(query)) ); // Flatten and deduplicate candidates const allCandidates = searchResults.flat(); const uniqueCandidates = Array.from( new Map(allCandidates.map(c => [c.name, c])).values() ); if (uniqueCandidates.length === 0) { return { success: false, error: queries.length > 1 ? `No MCPs found for queries: "${queries.join('", "')}". Try different search terms.` : `No MCPs found for query: "${queryString}". Try a different search term.` }; } // If elicitation server available, show interactive multi-select dialog let selectedCandidates = uniqueCandidates; if (this.elicitationServer) { const options = uniqueCandidates.map(c => { // Build label with security indicators const trustBadge = c.isTrusted ? '✓ ' : ''; const isMicroMCP = c._meta?.isMicroMCP; const transportBadge = isMicroMCP ? '📦' : (c.transport === 'stdio' ? '💻' : '🌐'); const envInfo = c.envVars?.length ? ` (${c.envVars.length} env vars)` : ''; const transportInfo = isMicroMCP ? ' [Photon]' : (c.transport !== 'stdio' ? ` [${c.transport.toUpperCase()}]` : ''); return { value: c.name, label: `${trustBadge}${transportBadge} ${c.displayName}${transportInfo}${envInfo}` }; }); const unverifiedCount = uniqueCandidates.filter(c => !c.repository?.url).length; const queryDesc = queries.length > 1 ? `"${queries.join('", "')}"` : `"${queryString}"`; let message = `Select MCPs to import from ${queryDesc}:\n\n`; if (unverifiedCount > 0) { message += `⚠️ WARNING: ${unverifiedCount} server${unverifiedCount !== 1 ? 's have' : ' has'} no repository.\n` + `Only select MCPs from sources you trust.\n\n`; } message += `Badges: ✓=Trusted 💻=stdio 🌐=HTTP/SSE 📦=Photon`; const selected = await elicitMultiSelect( this.elicitationServer, 'mcps', options, message ); if (selected.length === 0) { return { success: false, error: 'No MCPs selected for import' }; } // Filter to only selected candidates selectedCandidates = uniqueCandidates.filter(c => selected.includes(c.name)); } // If no elicitation server, install all candidates directly (bulk import use case) if (selectedCandidates.length === 0) { return { success: false, error: `No valid MCPs selected for import.` }; } // Import each selected MCP (without credentials first) let imported = 0; const importedNames: string[] = []; const errors: string[] = []; const importedMCPs: Array<{ name: string; displayName: string; transport: 'stdio' | 'http' | 'sse'; details: any; }> = []; for (const candidate of selectedCandidates) { try { // Get detailed info including env vars const details = await registryClient.getDetailedInfo(candidate.name); // Check if this is a Photon (different installation flow) if (details._meta?.isMicroMCP && details._meta?.sourceUrl) { const result = await this.installMicroMCP(candidate.name, details, 'all'); if (result.success) { imported++; importedNames.push(candidate.displayName); logger.info(`Installed Photon ${candidate.displayName} from registry`); } else { errors.push(`${candidate.displayName}: ${result.error}`); } continue; // Skip regular MCP installation } // Build config based on transport type (without credentials) let config: any; if (details.transport === 'stdio') { // stdio server config = { command: details.command!, args: details.args || [], env: {} }; } else { // HTTP/SSE server config = { url: details.url!, auth: { type: 'bearer' } }; } await this.profileManager!.addMCPToProfile('all', candidate.displayName, config); imported++; importedNames.push(candidate.displayName); importedMCPs.push({ name: candidate.name, displayName: candidate.displayName, transport: details.transport, details }); logger.info(`Imported ${candidate.displayName} from registry (${details.transport})`); } catch (error: any) { errors.push(`${candidate.displayName}: ${error.message}`); logger.error(`Failed to import ${candidate.displayName}: ${error.message}`); } } // After importing, collect credentials in bulk if elicitation available let credentialsConfigured = 0; if (imported > 0 && this.elicitationServer) { try { // Build credential requirements for all imported MCPs const bulkCredentials: Record<string, Array<{ envVarName: string; displayName: string; example?: string; required?: boolean; transport?: 'stdio' | 'http'; }>> = {}; for (const mcp of importedMCPs) { const credentials = []; if (mcp.transport === 'stdio') { // Detect environment variables for stdio servers const envVars = detectRequiredEnvVars(mcp.name); for (const envVar of envVars) { credentials.push({ envVarName: envVar.envVarName, displayName: envVar.displayName, example: envVar.example, required: true, // All detected env vars are required transport: 'stdio' as const }); } } else { // Detect auth requirements for HTTP servers const httpCreds = detectHTTPCredentials(mcp.name, mcp.details.url); for (const httpCred of httpCreds) { credentials.push({ envVarName: 'AUTH_TOKEN', displayName: httpCred.displayName, example: httpCred.example, required: true, transport: 'http' as const }); } } if (credentials.length > 0) { bulkCredentials[mcp.displayName] = credentials; } } // If any MCPs need credentials, show consolidated form if (Object.keys(bulkCredentials).length > 0) { const collected = await collectBulkCredentials(this.elicitationServer, bulkCredentials); if (collected && Object.keys(collected).length > 0) { // Update each MCP config with collected credentials for (const mcp of importedMCPs) { try { const mcpCreds: Record<string, string> = {}; let hasCredentials = false; // Extract credentials for this MCP from collected data for (const [key, value] of Object.entries(collected)) { if (key.startsWith(`${mcp.displayName}:`)) { const envVarName = key.split(':')[1]; mcpCreds[envVarName] = value; hasCredentials = true; } } if (hasCredentials) { // Get current config const currentConfig = await this.profileManager!.getProfileMCPs('all'); if (!currentConfig) { logger.warn(`Could not retrieve profile config for ${mcp.displayName}`); continue; } const mcpConfig = currentConfig[mcp.displayName]; if (mcpConfig) { if (mcp.transport === 'stdio') { // Update env vars for stdio mcpConfig.env = { ...mcpConfig.env, ...mcpCreds }; } else { // Update auth token for HTTP if (mcpCreds.AUTH_TOKEN) { mcpConfig.auth = { type: 'bearer', token: mcpCreds.AUTH_TOKEN }; } } // Save updated config await this.profileManager!.addMCPToProfile('all', mcp.displayName, mcpConfig); credentialsConfigured++; logger.info(`Updated credentials for ${mcp.displayName}`); } } } catch (error: any) { logger.warn(`Failed to update credentials for ${mcp.displayName}: ${error.message}`); } } } } } catch (error: any) { logger.warn(`Failed to collect bulk credentials: ${error.message}`); } } const queryDesc = queries.length > 1 ? `${queries.length} queries ("${queries.join('", "')}")` : `"${queryString}"`; let message = `✅ Imported ${imported}/${selectedCandidates.length} MCPs from ${queryDesc}:\n\n`; message += importedNames.map(name => ` ✓ ${name}`).join('\n'); if (errors.length > 0) { message += `\n\n❌ Failed to import ${errors.length} MCPs:\n`; message += errors.map(e => ` ✗ ${e}`).join('\n'); } // Add credential status to message if (credentialsConfigured > 0) { message += `\n\n🔑 Configured credentials for ${credentialsConfigured}/${imported} MCPs`; if (credentialsConfigured < imported) { message += `\n💡 ${imported - credentialsConfigured} MCP(s) still need credentials. Use ncp:list to see configs.`; } } else if (imported > 0 && this.elicitationServer) { message += `\n\n💡 Note: Credentials not configured. MCPs may require API keys/tokens to function.`; } else if (imported > 0) { message += `\n\n💡 Note: MCPs imported without credentials. Use ncp:list to see configs, or manually add credentials.`; } return { success: imported > 0, content: message }; } catch (error: any) { return { success: false, error: `Failed to import from registry: ${error.message}` }; } } private async handleExport(params: any): Promise<InternalToolResult> { const to = params?.to || 'clipboard'; const destination = params?.destination; const profile = params?.profile || 'all'; try { const mcps = await this.profileManager!.getProfileMCPs(profile); if (!mcps || Object.keys(mcps).length === 0) { return { success: false, error: `No MCPs to export from profile "${profile}"` }; } const exportConfig = { mcpServers: mcps }; const jsonContent = JSON.stringify(exportConfig, null, 2); switch (to) { case 'clipboard': { // Write to clipboard (security pattern - no chat history) try { const clipboardy = await import('clipboardy'); await clipboardy.default.write(jsonContent); return { success: true, content: `✅ Copied ${Object.keys(mcps).length} MCPs from profile "${profile}" to clipboard\n\n` + `Use ncp:import with from=clipboard to restore later.` }; } catch (error: any) { // Clipboard failed - fallback to response mode logger.warn(`Clipboard write failed: ${error.message}, falling back to response mode`); return { success: true, content: `⚠️ Clipboard unavailable. Showing configuration instead:\n\n` + `\`\`\`json\n${jsonContent}\n\`\`\`\n\n` + `Please copy manually.` }; } } case 'response': { // Return JSON in response (visible to AI, for transparency) return { success: true, content: `✅ Exported ${Object.keys(mcps).length} MCPs from profile "${profile}"\n\n` + `📋 Configuration:\n\n` + `\`\`\`json\n${jsonContent}\n\`\`\`\n\n` + `💡 You can:\n` + `• Copy and paste into another MCP client's config\n` + `• Save to a file for backup\n` + `• Share with your team\n` + `• Use with 'ncp import' to restore later` }; } case 'file': { if (!destination) { return { success: false, error: 'destination parameter required when to=file' }; } const fs = await import('fs/promises'); const path = await import('path'); // Expand ~ to home directory const expandedPath = destination.startsWith('~') ? path.join(process.env.HOME || process.env.USERPROFILE || '', destination.slice(1)) : destination; await fs.writeFile(expandedPath, jsonContent, 'utf-8'); return { success: true, content: `✅ Exported ${Object.keys(mcps).length} MCPs to ${destination}` }; } default: return { success: false, error: `Invalid to parameter: ${to}. Use: clipboard, response, or file` }; } } catch (error: any) { return { success: false, error: `Failed to export: ${error.message}` }; } } /** * Get tools from a newly added MCP by temporarily connecting to it * Returns array of tools or null if connection fails */ private async getToolsFromMCP( mcpName: string, config: any, transportType: 'stdio' | 'http' ): Promise<Array<{ name: string; description?: string }> | null> { const timeoutMs = 5000; // 5 second timeout if (transportType === 'stdio') { // Stdio transport - use MCP SDK client const { Client } = await import('@modelcontextprotocol/sdk/client/index.js'); const { StdioClientTransport } = await import('@modelcontextprotocol/sdk/client/stdio.js'); let transport: any = null; let client: any = null; try { // Validate command for security (prevent command injection) const validationError = this.validateMCPCommand(config.command, config.args || []); if (validationError) { logger.warn(`Command validation failed for "${mcpName}": ${validationError}`); return null; } // Create transport transport = new StdioClientTransport({ command: config.command, args: config.args || [], env: { ...process.env, ...(config.env || {}) }, stderr: 'ignore' }); client = new Client( { name: 'ncp-tool-discovery', version: '1.0.0' }, { capabilities: {} } ); // Connect with timeout await Promise.race([ client.connect(transport), new Promise((_, reject) => setTimeout(() => reject(new Error('Connection timeout')), timeoutMs) ) ]); // Get tools list const response = await Promise.race([ client.listTools(), new Promise((_, reject) => setTimeout(() => reject(new Error('listTools timeout')), timeoutMs) ) ]) as any; return response.tools.map((t: any) => ({ name: t.name, description: t.description || '' })); } catch (error: any) { logger.warn(`Failed to get tools from stdio MCP "${mcpName}": ${error.message}`); return null; } finally { // Clean up try { if (client) await client.close(); } catch (e) { /* ignore */ } try { if (transport) await transport.close(); } catch (e) { /* ignore */ } } } else { // HTTP/SSE transport - make HTTP request try { const url = config.url; const headers: Record<string, string> = { 'Content-Type': 'application/json' }; // Add auth if configured if (config.auth?.type === 'bearer' && config.auth.token) { headers['Authorization'] = `Bearer ${config.auth.token}`; } const response = await Promise.race([ fetch(url, { method: 'POST', headers, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }) }), new Promise((_, reject) => setTimeout(() => reject(new Error('HTTP timeout')), timeoutMs) ) ]) as Response; if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const data = await response.json() as any; if (data.error) { throw new Error(data.error.message || 'Unknown error'); } return data.result.tools.map((t: any) => ({ name: t.name, description: t.description || '' })); } catch (error: any) { logger.warn(`Failed to get tools from HTTP MCP "${mcpName}": ${error.message}`); return null; } } } private async handleDoctor(params: any): Promise<InternalToolResult> { const mcpName = params?.mcp_name; const profile = params?.profile || 'all'; const issues: string[] = []; const warnings: string[] = []; let checksPerformed = 0; // 1. Check Node.js version checksPerformed++; const nodeVersion = process.version; const majorVersion = parseInt(nodeVersion.slice(1).split('.')[0]); if (majorVersion < 18) { issues.push(`Node.js ${nodeVersion} is outdated (minimum: v18.0.0)`); } // 2. Check config directory checksPerformed++; const configPath = this.profileManager!.getConfigPath(); try { const fs = await import('fs/promises'); await fs.access(configPath); } catch (error) { issues.push(`Config directory not found: ${configPath}`); } // 3. Check profile exists and is valid JSON checksPerformed++; try { const profilePath = await this.profileManager!.getProfilePath(profile); const fs = await import('fs/promises'); const content = await fs.readFile(profilePath, 'utf-8'); JSON.parse(content); // Validate JSON } catch (error: any) { if (error.code === 'ENOENT') { issues.push(`Profile "${profile}" not found`); } else if (error instanceof SyntaxError) { issues.push(`Profile "${profile}" contains invalid JSON`); } else { issues.push(`Failed to read profile "${profile}": ${error.message}`); } } // 4. Check MCPs in profile checksPerformed++; let mcpsChecked = 0; let mcpIssues = 0; try { const mcps = await this.profileManager!.getProfileMCPs(profile); if (!mcps || Object.keys(mcps).length === 0) { warnings.push(`No MCPs configured in profile "${profile}"`); } else { for (const [name, config] of Object.entries(mcps)) { // If specific MCP requested, only check that one if (mcpName && name !== mcpName) continue; mcpsChecked++; // Basic validation if (!config.command && !config.url) { issues.push(`MCP "${name}" missing both command and URL`); mcpIssues++; continue; } // Validate stdio config if (config.command) { const validationError = this.validateMCPCommand(config.command, config.args || []); if (validationError) { issues.push(`MCP "${name}" has invalid command: ${validationError}`); mcpIssues++; } } // Validate HTTP config if (config.url) { try { new URL(config.url); // Validate URL format } catch (error) { issues.push(`MCP "${name}" has invalid URL: ${config.url}`); mcpIssues++; } } } } } catch (error: any) { issues.push(`Failed to check MCPs: ${error.message}`); } // Build diagnostic report const totalIssues = issues.length; const status = totalIssues === 0 ? '✅ Healthy' : `⚠️ ${totalIssues} issue(s) found`; let report = `🩺 **NCP Doctor - Diagnostics Report**\n\n`; report += `**Status:** ${status}\n`; report += `**Profile:** ${profile}\n`; report += `**Checks Performed:** ${checksPerformed}\n`; if (mcpsChecked > 0) { report += `**MCPs Checked:** ${mcpsChecked}${mcpIssues > 0 ? ` (${mcpIssues} with issues)` : ''}\n`; } report += `\n`; // System info report += `**System Information:**\n`; report += ` • Node.js: ${nodeVersion}\n`; report += ` • Platform: ${process.platform}\n`; report += ` • Architecture: ${process.arch}\n`; report += `\n`; // Issues if (totalIssues > 0) { report += `**Issues Found:**\n`; issues.forEach((issue, i) => { report += ` ${i + 1}. ❌ ${issue}\n`; }); report += `\n`; } // Warnings if (warnings.length > 0) { report += `**Warnings:**\n`; warnings.forEach((warning, i) => { report += ` ${i + 1}. ⚠️ ${warning}\n`; }); report += `\n`; } if (totalIssues === 0 && warnings.length === 0) { report += `✅ All checks passed! NCP is healthy.\n`; } else if (totalIssues > 0) { report += `💡 **Note:** This is a read-only diagnostic. To fix issues, use the CLI:\n`; report += ` • Run: \`ncp doctor --fix\` (requires user confirmation)\n`; } return { success: totalIssues === 0, content: report, error: totalIssues > 0 ? `${totalIssues} issue(s) found in NCP configuration` : undefined }; } /** * Install Photon from registry * Downloads .photon.ts file and optional schema, saves to ~/.ncp/micromcps/ */ private async installMicroMCP(name: string, provider: any, profile: string): Promise<InternalToolResult> { try { const fs = await import('fs/promises'); const path = await import('path'); const os = await import('os'); // Create micromcps directory const microDir = path.join(getNcpBaseDirectory(), 'micromcps'); await fs.mkdir(microDir, { recursive: true }); const microFile = path.join(microDir, `${name}.photon.ts`); const schemaFile = path.join(microDir, `${name}.micro.schema.json`); logger.info(`Installing Photon "${name}" from ${provider._meta.sourceUrl}`); // Download .photon.ts file const sourceResponse = await fetch(provider._meta.sourceUrl); if (!sourceResponse.ok) { throw new Error(`Failed to download Photon source: ${sourceResponse.statusText}`); } const sourceContent = await sourceResponse.text(); await fs.writeFile(microFile, sourceContent, 'utf8'); // Download schema if available let schemaDownloaded = false; if (provider._meta.schemaUrl) { try { const schemaResponse = await fetch(provider._meta.schemaUrl); if (schemaResponse.ok) { const schemaContent = await schemaResponse.text(); await fs.writeFile(schemaFile, schemaContent, 'utf8'); schemaDownloaded = true; logger.info(`Downloaded schema for "${name}"`); } } catch (error: any) { logger.warn(`Failed to download schema: ${error.message}`); } } // MicroMCPs are auto-loaded from ~/.ncp/micromcps/ directory // No need to track in profile - they'll be discovered on next startup return { success: true, content: [ { type: 'text', text: `${UIMessages.photonInstalled(name)}\n\n` + `📍 Location: ${microFile}\n` + (schemaDownloaded ? `📋 Schema: ${schemaFile}\n` : '') + `\n${UIMessages.photonUsage(name)}` + `\n${UIMessages.photonDiscovery(name)}` } ] }; } catch (error: any) { logger.error(`Failed to install Photon: ${error.message}`); return { success: false, error: `Failed to install Photon: ${error.message}` }; } } /** * Import Photon from a local .photon.ts file * Copies the file (and optional schema) to ~/.ncp/micromcps/ */ private async importMicroMCPFromFile(filePath: string, profile: string): Promise<InternalToolResult> { try { const fs = await import('fs/promises'); const path = await import('path'); const os = await import('os'); // Resolve path (handle ~/, ./, absolute) let resolvedPath = filePath; if (filePath.startsWith('~/')) { resolvedPath = path.join(os.homedir(), filePath.substring(2)); } else if (filePath.startsWith('./')) { resolvedPath = path.resolve(process.cwd(), filePath); } // Check if file exists try { await fs.access(resolvedPath); } catch { return { success: false, error: `File not found: ${resolvedPath}` }; } // Extract base name from filename (e.g., "calculator.photon.ts" → "calculator") const fileName = path.basename(resolvedPath); if (!fileName.endsWith('.photon.ts')) { return { success: false, error: `Invalid Photon file. Expected .photon.ts extension, got: ${fileName}` }; } const baseName = fileName.replace('.photon.ts', ''); // Create destination directory const microDir = path.join(getNcpBaseDirectory(), 'micromcps'); await fs.mkdir(microDir, { recursive: true }); const destFile = path.join(microDir, fileName); const destSchema = path.join(microDir, `${baseName}.micro.schema.json`); // Copy .photon.ts file await fs.copyFile(resolvedPath, destFile); logger.info(`Copied Photon file: ${fileName}`); // Check for optional schema file in same directory let schemaImported = false; const sourceDir = path.dirname(resolvedPath); const sourceSchema = path.join(sourceDir, `${baseName}.micro.schema.json`); try { await fs.access(sourceSchema); await fs.copyFile(sourceSchema, destSchema); schemaImported = true; logger.info(`Copied schema file: ${baseName}.micro.schema.json`); } catch { // Schema file is optional logger.debug(`No schema file found for ${baseName}`); } return { success: true, content: [ { type: 'text', text: `${UIMessages.photonImportedFile(baseName)}\n\n` + `📍 Location: ${destFile}\n` + (schemaImported ? `📋 Schema: ${destSchema}\n` : '') + `\n${UIMessages.photonUsage(baseName)}` + `\n${UIMessages.photonDiscovery(baseName)}` } ] }; } catch (error: any) { logger.error(`Failed to import Photon from file: ${error.message}`); return { success: false, error: `Failed to import Photon: ${error.message}` }; } } /** * Download Photon from a URL * Downloads .photon.ts file and optional schema from HTTP(S) URL */ private async downloadMicroMCPFromURL(fileUrl: string, profile: string): Promise<InternalToolResult> { try { const fs = await import('fs/promises'); const path = await import('path'); const os = await import('os'); logger.info(`Downloading Photon from URL: ${fileUrl}`); // Download .photon.ts file const response = await fetch(fileUrl); if (!response.ok) { throw new Error(`Failed to download: ${response.statusText}`); } const tsContent = await response.text(); // Extract filename from URL const urlPath = new URL(fileUrl).pathname; const fileName = path.basename(urlPath); const baseName = fileName.replace('.photon.ts', ''); // Create destination directory const microDir = path.join(getNcpBaseDirectory(), 'micromcps'); await fs.mkdir(microDir, { recursive: true }); const destFile = path.join(microDir, fileName); const destSchema = path.join(microDir, `${baseName}.micro.schema.json`); // Save the .photon.ts file await fs.writeFile(destFile, tsContent, 'utf8'); logger.info(`Saved Photon: ${fileName}`); // Try to download optional schema file let schemaDownloaded = false; const schemaUrl = fileUrl.replace('.photon.ts', '.micro.schema.json'); try { const schemaResponse = await fetch(schemaUrl); if (schemaResponse.ok) { const schemaContent = await schemaResponse.text(); await fs.writeFile(destSchema, schemaContent, 'utf8'); schemaDownloaded = true; logger.info(`Downloaded schema for "${baseName}"`); } } catch (error: any) { logger.debug(`Schema not available at ${schemaUrl}`); } return { success: true, content: [ { type: 'text', text: `${UIMessages.photonDownloaded(baseName)}\n\n` + `📍 Location: ${destFile}\n` + (schemaDownloaded ? `📋 Schema: ${destSchema}\n` : '') + `\n${UIMessages.photonUsage(baseName)}` + `\n${UIMessages.photonDiscovery(baseName)}` } ] }; } catch (error: any) { logger.error(`Failed to download Photon from URL: ${error.message}`); return { success: false, error: `Failed to download Photon: ${error.message}` }; } } /** * Add CLI tool for discovery * Indexes CLI tool operations so they appear in find() results */ private async addCliTool(cliToolName: string, profile: string): Promise<InternalToolResult> { try { logger.info(`Adding CLI tool: ${cliToolName}`); const indexer = new CLIIndexer(); // Check if tool is available const { CLIParser } = await import('../services/cli-parser.js'); const parser = new CLIParser(); const isAvailable = await parser.isCliAvailable(cliToolName); if (!isAvailable) { return { success: false, error: `❌ CLI tool "${cliToolName}" not found on system.\n\n` + `Please install ${cliToolName} first, then try again.\n` + `For example: brew install ${cliToolName} (macOS) or apt install ${cliToolName} (Linux)` }; } // Index the tool const operationCount = await indexer.indexCliTool({ baseCommand: cliToolName }); // Add to profile so orchestrator loads it // Use a special marker to indicate this is a CLI tool (not a real MCP) await this.profileManager!.addMCPToProfile(profile, cliToolName, { command: cliToolName, args: [], env: { NCP_CLI_TOOL: 'true' } // Marker for CLI tools }); logger.info(`Added ${cliToolName} to profile ${profile}`); return { success: true, content: `✅ Added CLI tool: ${cliToolName}\n` + ` Indexed ${operationCount} operations for discovery\n\n` + `You can now use find() to discover ${cliToolName} operations:\n` + ` find("${cliToolName}")\n` + ` find("convert video") # For ffmpeg\n\n` + `Note: CLI tools are indexed for discovery only. The AI will execute them via shell commands.` }; } catch (error: any) { logger.error(`Failed to add CLI tool ${cliToolName}:`, error); return { success: false, error: `Failed to add CLI tool: ${error.message}` }; } } /** * Validate MCP command for security * Prevents command injection by checking against allowlist and validating format * * @returns null if valid, error message if invalid */ private validateMCPCommand(command: string, args: string[]): string | null { // Validate command is a string and not empty if (!command || typeof command !== 'string') { return 'Command must be a non-empty string'; } // Allowlist of safe command names (base name only, not full path) const SAFE_COMMANDS = [ 'node', 'npx', 'npm', 'pnpm', 'yarn', 'bun', 'deno', // Node.js runtimes 'python', 'python3', 'pip', 'pipx', 'uv', // Python 'docker', 'podman', // Containers 'bash', 'sh', 'zsh', // Shells (for wrapper scripts) 'go', 'cargo', 'rustc', // Other runtimes 'java', 'javac' // Java ]; // Extract base command name (handle paths) const baseCommand = path.basename(command); // Check if base command is in allowlist if (!SAFE_COMMANDS.includes(baseCommand)) { // Not in allowlist - log warning but allow (user may have custom MCPs) logger.warn(`MCP command "${baseCommand}" not in known safe commands list. Proceeding with caution.`); } // Check for shell metacharacters and control characters that could be used for injection // Include newlines, carriage returns, and other control characters const DANGEROUS_CHARS = /[;&|`$()<>\n\r\t\0]/; if (DANGEROUS_CHARS.test(command)) { return `Command contains dangerous shell metacharacters or control characters: ${command}`; } // Special validation for shell commands that can execute arbitrary code const SHELL_COMMANDS = ['bash', 'sh', 'zsh']; if (SHELL_COMMANDS.includes(baseCommand)) { // Check if args contain -c flag which allows arbitrary command execution for (const arg of args) { if (arg === '-c' || arg.startsWith('-c=')) { return `Shell command with -c flag is not allowed for security reasons: ${command}`; } } } // Validate args don't contain injection attempts for (const arg of args) { if (typeof arg !== 'string') { return `All arguments must be strings, got: ${typeof arg}`; } // Check for dangerous patterns in args (same as command for consistency) // Allow common arg patterns like --flag, -f, @package, ./path if (DANGEROUS_CHARS.test(arg)) { return `Argument contains dangerous characters: ${arg}`; } } // Path traversal check - ensure command doesn't escape upward if (command.includes('../')) { return 'Command contains path traversal (../)'; } return null; // Valid } }

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