import { readFileSync, existsSync } from 'fs';
import { createInterface } from 'readline';
import chalk from 'chalk';
import clipboardy from 'clipboardy';
import { ProfileManager } from '../profiles/profile-manager.js';
import { OutputFormatter } from '../services/output-formatter.js';
import { ErrorHandler } from '../services/error-handler.js';
import { formatCommandDisplay } from '../utils/security.js';
import { TextUtils } from '../utils/text-utils.js';
import { UIMessages } from './ui-messages.js';
import { logger } from '../utils/logger.js';
import { getRuntimeForExtension } from './runtime-detector.js';
interface MCPConfig {
command?: string; // Optional: for stdio transport
args?: string[];
env?: Record<string, string>;
url?: string; // Optional: for HTTP/SSE transport
}
interface MCPImportData {
[mcpName: string]: MCPConfig;
}
export class ConfigManager {
private profileManager: ProfileManager;
constructor() {
this.profileManager = new ProfileManager();
}
/**
* Show the location of NCP config files
*/
async showConfigLocations(): Promise<void> {
await this.profileManager.initialize();
const configDir = this.profileManager.getConfigPath();
console.log(chalk.blue('π NCP Configuration:'));
console.log(` Profiles Directory: ${configDir}`);
if (existsSync(configDir)) {
console.log(chalk.green(' β Config directory exists'));
// List existing profiles
const profiles = this.profileManager.listProfiles();
if (profiles.length > 0) {
console.log(` π Found ${profiles.length} profiles:`);
profiles.forEach(profile => {
const profilePath = this.profileManager.getProfilePath(profile);
console.log(` β’ ${profile}: ${profilePath}`);
});
} else {
console.log(chalk.yellow(' No profiles created yet'));
}
} else {
console.log(chalk.yellow(' β Config directory will be created on first use'));
}
}
/**
* Open existing config directory in default editor/explorer
*/
async editConfig(): Promise<void> {
await this.profileManager.initialize();
const configDir = this.profileManager.getConfigPath();
if (!existsSync(configDir)) {
console.log(chalk.yellow('β Config directory does not exist yet. Use "ncp config --import" to create it.'));
return;
}
const profiles = this.profileManager.listProfiles();
if (profiles.length === 0) {
console.log(chalk.yellow('β No profile files exist yet. Use "ncp config --import" to create them.'));
return;
}
// Just show the config location and files
console.log(chalk.green('β Configuration location:'));
console.log(OutputFormatter.info(`Config directory: ${configDir}`));
console.log(OutputFormatter.info(`Profile files:`));
profiles.forEach(profile => {
console.log(OutputFormatter.bullet(`${profile}.json`));
});
console.log('');
console.log(chalk.dim('π‘ You can edit these files directly with your preferred editor'))
}
/**
* Import MCP configurations using interactive editor
*
* β οΈ CRITICAL: Default profile MUST be 'all' - DO NOT CHANGE!
*
* The 'all' profile is the universal profile where MCPs are imported by default.
* This matches the behavior of `ncp add` and auto-import functionality.
*
* Changing this to 'default' or any other name will break:
* - User expectations (CLI help says "default: all")
* - Consistency with `ncp add` command
* - Auto-import from Claude Desktop
*
* If you change this, you WILL introduce bugs. Keep it as 'all'.
*/
async importConfig(filePath?: string, profileName: string = 'all', dryRun: boolean = false): Promise<void> {
if (filePath) {
// Import from file
await this.importFromFile(filePath, profileName, dryRun);
} else {
// Interactive import with editor
await this.importInteractive(profileName, dryRun);
}
}
/**
* Validate current configuration
*/
async validateConfig(): Promise<void> {
await this.profileManager.initialize();
const configDir = this.profileManager.getConfigPath();
if (!existsSync(configDir)) {
console.log(chalk.yellow('β No config directory found. Nothing to validate.'));
return;
}
const profiles = this.profileManager.listProfiles();
if (profiles.length === 0) {
console.log(chalk.yellow('β No profile files found. Nothing to validate.'));
return;
}
let totalMCPs = 0;
let issues: string[] = [];
let validProfiles = 0;
for (const profileName of profiles) {
try {
const profilePath = this.profileManager.getProfilePath(profileName);
const profileContent = readFileSync(profilePath, 'utf-8');
const profile = JSON.parse(profileContent);
// Validate profile structure
if (!profile.name) {
issues.push(`Profile "${profileName}" missing name field`);
}
if (!profile.mcpServers || typeof profile.mcpServers !== 'object') {
issues.push(`Profile "${profileName}" missing or invalid mcpServers field`);
continue;
}
// Validate each MCP in this profile
for (const [mcpName, mcpConfig] of Object.entries(profile.mcpServers)) {
totalMCPs++;
const config = mcpConfig as MCPConfig;
if (!config.command) {
issues.push(`MCP "${mcpName}" in profile "${profileName}" missing command`);
}
if (config.args && !Array.isArray(config.args)) {
issues.push(`MCP "${mcpName}" in profile "${profileName}" has invalid args (must be array)`);
}
if (config.env && typeof config.env !== 'object') {
issues.push(`MCP "${mcpName}" in profile "${profileName}" has invalid env (must be object)`);
}
}
validProfiles++;
} catch (error: any) {
issues.push(`Profile "${profileName}" has invalid JSON: ${error.message}`);
}
}
if (issues.length === 0) {
console.log(chalk.green(`β Configuration is valid`));
console.log(chalk.blue(` Found ${totalMCPs} MCP servers across ${validProfiles} profiles`));
} else {
console.log(chalk.red(`β Configuration has ${issues.length} issues:`));
issues.forEach(issue => {
console.log(chalk.red(` β’ ${issue}`));
});
}
}
/**
* Import from a JSON file
*/
private async importFromFile(filePath: string, profileName: string, dryRun: boolean): Promise<void> {
// Expand tilde to home directory
const { homedir } = await import('os');
const { basename, dirname, join } = await import('path');
const { copyFile, mkdir, access } = await import('fs/promises');
const expandedPath = filePath.startsWith('~') ?
filePath.replace('~', homedir()) :
filePath;
if (!existsSync(expandedPath)) {
throw new Error(`Configuration file not found at: ${filePath}\n\nPlease check that the file exists and the path is correct.`);
}
// Check if this is a Photon file
if (expandedPath.endsWith('.photon.ts')) {
try {
const fileName = basename(expandedPath);
const baseName = fileName.replace('.photon.ts', '');
// Create micromcps directory
const microDir = join(homedir(), '.ncp', 'micromcps');
await mkdir(microDir, { recursive: true });
const destFile = join(microDir, fileName);
const destSchema = join(microDir, `${baseName}.micro.schema.json`);
if (dryRun) {
console.log(chalk.blue('\nπ Dry-run mode: Would import Photon:'));
console.log(chalk.dim(` Source: ${expandedPath}`));
console.log(chalk.dim(` Destination: ${destFile}`));
return;
}
// Copy .photon.ts file
await copyFile(expandedPath, destFile);
console.log(chalk.green(`β
Copied ${fileName}`));
// Check for optional schema file
let schemaImported = false;
const sourceDir = dirname(expandedPath);
const sourceSchema = join(sourceDir, `${baseName}.micro.schema.json`);
try {
await access(sourceSchema);
await copyFile(sourceSchema, destSchema);
schemaImported = true;
console.log(chalk.green(`β
Copied ${baseName}.micro.schema.json`));
} catch {
// Schema is optional
}
console.log(chalk.green(`\n${UIMessages.photonImportedFile(baseName)}`));
console.log(chalk.dim(`π Location: ${destFile}`));
if (schemaImported) {
console.log(chalk.dim(`π Schema: ${destSchema}`));
}
console.log(chalk.blue(`\n${UIMessages.photonUsage(baseName)}`));
console.log(chalk.blue(UIMessages.photonDiscovery(baseName)));
return;
} catch (error: any) {
const errorResult = ErrorHandler.handle(error, ErrorHandler.fileOperation('import Photon', filePath));
console.log(ErrorHandler.formatForConsole(errorResult));
return;
}
}
// Handle JSON config files
try {
const content = readFileSync(expandedPath, 'utf-8');
const parsedData = JSON.parse(content);
// Clean the data to handle Claude Desktop format and remove unwanted entries
const mcpData = this.cleanImportData(parsedData);
await this.processImportData(mcpData, profileName, dryRun);
} catch (error: any) {
const errorResult = ErrorHandler.handle(error, ErrorHandler.fileOperation('import', filePath));
console.log(ErrorHandler.formatForConsole(errorResult));
}
}
/**
* Interactive import - clipboard-first approach
*/
private async importInteractive(profileName: string, dryRun: boolean): Promise<void> {
console.log(chalk.blue('π NCP Config Import'));
console.log('');
try {
// Try to read from clipboard
let clipboardContent = '';
try {
clipboardContent = await clipboardy.read();
} catch (clipboardError) {
console.log(chalk.red('β Could not access system clipboard'));
console.log(chalk.yellow('π‘ Copy your MCP configuration JSON first, then run this command again'));
console.log(chalk.yellow('π‘ Or use: ncp config import <file> to import from a file'));
return;
}
// Check if clipboard has content
if (!clipboardContent.trim()) {
console.log(chalk.red('β Clipboard is empty'));
console.log(chalk.yellow('π‘ Copy your MCP configuration JSON first, then run this command again'));
console.log(chalk.yellow('π‘ Or use: ncp config import <file> to import from a file'));
console.log('');
console.log(chalk.dim('Common config file locations:'));
console.log(chalk.dim(' Claude Desktop (macOS): ~/Library/Application Support/Claude/claude_desktop_config.json'));
console.log(chalk.dim(' Claude Desktop (Windows): %APPDATA%\\Claude\\claude_desktop_config.json'));
return;
}
// Detect format: TypeScript (Photon) vs JSON (config)
const trimmed = clipboardContent.trim();
const isMicroMCP = trimmed.includes('export class') &&
(trimmed.includes('implements Photon') || trimmed.includes('@tool'));
if (isMicroMCP) {
// Handle Photon TypeScript code
await this.importMicroMCPFromClipboard(trimmed);
return;
}
// Display clipboard content in a highlighted box
console.log(chalk.blue('π Clipboard content detected:'));
this.displayJsonInBox(clipboardContent);
console.log('');
// Try to parse clipboard content as JSON
let parsedData: any;
try {
parsedData = JSON.parse(clipboardContent);
} catch (jsonError) {
console.log(chalk.red('β Invalid JSON format in clipboard'));
console.log(chalk.yellow('π‘ Please ensure your clipboard contains valid JSON or Photon TypeScript code'));
return;
}
// Check if it's a direct MCP config (has "command" property at root level)
const isDirectConfig = parsedData.command && typeof parsedData === 'object' && !Array.isArray(parsedData);
let mcpData: any;
let mcpNames: string[];
if (isDirectConfig) {
// Handle direct MCP configuration
console.log(chalk.green('β
Single MCP configuration detected'));
// Prompt for name
console.log('');
console.log(chalk.blue('β What should we name this MCP server?'));
console.log(chalk.gray(' (e.g., \'filesystem\', \'web-search\', \'github\')'));
const mcpName = await this.promptForMCPName(parsedData.command);
mcpData = { [mcpName]: parsedData };
mcpNames = [mcpName];
} else {
// Handle key-value format (multiple MCPs or client config)
mcpData = this.cleanImportData(parsedData);
mcpNames = Object.keys(mcpData).filter(key => {
if (key.startsWith('//')) return false;
const config = mcpData[key];
return config && typeof config === 'object' && config.command;
});
if (mcpNames.length > 0) {
console.log(chalk.green(`β
${mcpNames.length} MCP configuration(s) detected`));
} else {
console.log(chalk.red('β No valid MCP configurations found'));
console.log(chalk.yellow('π‘ Expected JSON with MCP server configurations'));
console.log(chalk.dim(' Example: {"server": {"command": "npx", "args": ["..."]}}'));
return;
}
}
console.log('');
await this.processImportData(mcpData, profileName, dryRun);
} catch (error: any) {
console.log('');
const errorResult = ErrorHandler.handle(error, ErrorHandler.createContext('config', 'import', undefined, ['Check the JSON format', 'Ensure the clipboard contains valid MCP configuration']));
console.log(ErrorHandler.formatForConsole(errorResult));
}
}
/**
* Display JSON content in a highlighted box
*/
private displayJsonInBox(jsonContent: string): void {
// Pretty format the JSON for display
let formattedJson: string;
try {
const parsed = JSON.parse(jsonContent);
formattedJson = JSON.stringify(parsed, null, 2);
} catch {
// If parsing fails, use original content
formattedJson = jsonContent;
}
// Split into lines and add box borders
const lines = formattedJson.split('\n');
const maxLength = Math.max(...lines.map(line => line.length), 20);
const boxWidth = Math.min(maxLength + 4, 80); // Limit box width to 80 chars
// Top border
console.log(chalk.gray('β' + 'β'.repeat(boxWidth - 2) + 'β'));
// Content lines (truncate if too long)
lines.slice(0, 20).forEach(line => { // Limit to 20 lines
let displayLine = line;
if (line.length > boxWidth - 4) {
displayLine = line.substring(0, boxWidth - 7) + '...';
}
const padding = ' '.repeat(Math.max(0, boxWidth - displayLine.length - 4));
console.log(chalk.gray('β ') + chalk.cyan(displayLine) + padding + chalk.gray(' β'));
});
// Show truncation indicator if there are more lines
if (lines.length > 20) {
const truncatedMsg = `... (${lines.length - 20} more lines)`;
const padding = ' '.repeat(Math.max(0, boxWidth - truncatedMsg.length - 4));
console.log(chalk.gray('β ') + chalk.dim(truncatedMsg) + padding + chalk.gray(' β'));
}
// Bottom border
console.log(chalk.gray('β' + 'β'.repeat(boxWidth - 2) + 'β'));
}
/**
* Import Photon from clipboard containing TypeScript code
*/
private async importMicroMCPFromClipboard(tsContent: string): Promise<void> {
const { basename, join } = await import('path');
const { homedir } = await import('os');
const { writeFile, mkdir } = await import('fs/promises');
// Extract class name from: export class CalculatorMCP
const classMatch = tsContent.match(/export\s+class\s+(\w+)/);
if (!classMatch) {
console.log(chalk.red('β Could not detect Photon class name'));
console.log(chalk.yellow('π‘ Expected "export class <Name>MCP" in clipboard'));
return;
}
// 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 = join(homedir(), '.ncp', 'micromcps');
await mkdir(microDir, { recursive: true });
const destFile = join(microDir, `${baseName}.photon.ts`);
// Write TypeScript code to file
await writeFile(destFile, tsContent, 'utf8');
console.log(chalk.green(`\n${UIMessages.photonImportedClipboard(baseName)}`));
console.log(chalk.dim(`π Location: ${destFile}`));
console.log(chalk.dim(`π Class: ${className}`));
console.log(chalk.blue(`\n${UIMessages.photonUsage(baseName)}`));
console.log(chalk.blue(UIMessages.photonDiscovery(baseName)));
}
/**
* Process and import MCP data
*/
private async processImportData(mcpData: MCPImportData, profileName: string, dryRun: boolean): Promise<void> {
await this.profileManager.initialize();
const mcpNames = Object.keys(mcpData).filter(key => !key.startsWith('//'));
if (mcpNames.length === 0) {
console.log(chalk.yellow('β No MCP configurations found to import'));
return;
}
if (dryRun) {
console.log('\n' + chalk.blue(`π₯ Would import ${mcpNames.length} MCP server(s):`));
console.log('');
mcpNames.forEach((name, index) => {
const config = mcpData[name];
const isLast = index === mcpNames.length - 1;
const connector = isLast ? 'βββ' : 'βββ';
const indent = isLast ? ' ' : 'β ';
// MCP name (no indent - root level)
console.log(chalk.gray(`${connector} `) + chalk.cyan(name));
// Command line or URL with reverse colors (like ncp list)
const fullCommand = config.url
? `HTTP/SSE: ${config.url}`
: formatCommandDisplay(config.command || '', config.args);
const maxWidth = process.stdout.columns ? process.stdout.columns - 4 : 80;
const wrappedLines = TextUtils.wrapTextWithBackground(fullCommand, maxWidth, chalk.gray(`${indent} `), (text: string) => chalk.bgGray.black(text));
console.log(wrappedLines);
// Environment variables if present
if (config.env && Object.keys(config.env).length > 0) {
const envCount = Object.keys(config.env).length;
console.log(chalk.gray(`${indent} `) + chalk.yellow(`${envCount} environment variable${envCount > 1 ? 's' : ''}`));
}
if (!isLast) console.log(chalk.gray('β'));
});
console.log('');
console.log(chalk.dim('π‘ Run without --dry-run to perform the import'));
return;
}
// Actually import the MCPs
const successful: Array<{name: string, config: MCPConfig}> = [];
const failed: Array<{name: string, error: string}> = [];
for (const mcpName of mcpNames) {
try {
const config = mcpData[mcpName];
await this.profileManager.addMCPToProfile(profileName, mcpName, config);
successful.push({ name: mcpName, config });
} catch (error: any) {
failed.push({ name: mcpName, error: error.message });
}
}
// Import phase completed, now validate what actually works
if (successful.length > 0) {
console.log(''); // Add newline before spinner starts
// Show loading animation during validation
const spinner = this.createSpinner(`β
Validating ${successful.length} imported MCP server(s)...`);
spinner.start();
const discoveryResult = await this.discoverImportedMCPs(successful.map(s => s.name));
// Clear spinner and show final result
spinner.stop();
process.stdout.write('\r\x1b[K'); // Clear the line
// Show successfully working MCPs
if (discoveryResult.successful.length > 0) {
console.log(chalk.green(`β
Successfully imported ${discoveryResult.successful.length} MCP server(s):`));
console.log('');
// Show profile header like ncp list
console.log(`π¦ ${chalk.bold.white('all')} ${chalk.dim(`(${discoveryResult.successful.length} MCPs)`)}`);
// Show in ncp list format with rich data from fresh cache
await this.displayImportedMCPs(discoveryResult.successful);
}
// Show MCPs that failed with actual error messages
if (discoveryResult.failed.length > 0) {
console.log(chalk.red(`β ${discoveryResult.failed.length} MCP(s) failed to connect:`));
discoveryResult.failed.forEach(({ name, error }) => {
console.log(chalk.red(` β’ ${name}: `) + chalk.dim(error));
});
console.log('');
}
}
if (failed.length > 0) {
console.log(chalk.red(`β Failed to import ${failed.length} server(s):`));
failed.forEach(({ name, error }) => {
console.log(` ${chalk.red('β’')} ${chalk.bold(name)} β ${chalk.dim(error)}`);
});
console.log('');
}
if (successful.length > 0) {
console.log(chalk.dim('π‘ Next steps:'));
console.log(chalk.dim(' β’') + ' Test discovery: ' + chalk.cyan('ncp find "file tools"'));
console.log(chalk.dim(' β’') + ' List all MCPs: ' + chalk.cyan('ncp list'));
console.log(chalk.dim(' β’') + ' Update your AI client config to use NCP');
}
}
/**
* Run discovery for imported MCPs to populate cache and check which ones work
* @returns Object with successful and failed MCPs with error details
*/
private async discoverImportedMCPs(importedMcpNames: string[]): Promise<{successful: string[], failed: Array<{name: string, error: string}>}> {
const successful: string[] = [];
const failed: Array<{name: string, error: string}> = [];
try {
// Import health monitor to get real error messages
const { healthMonitor } = await import('./health-monitor.js');
// Get the imported MCP configurations for direct health checks
const profileManager = new ProfileManager();
await profileManager.initialize();
const profile = await profileManager.getProfile('all');
if (!profile) {
throw new Error('Profile not found');
}
// Perform direct health checks on imported MCPs
for (const mcpName of importedMcpNames) {
const mcpConfig = profile.mcpServers[mcpName];
if (!mcpConfig) {
failed.push({
name: mcpName,
error: 'MCP configuration not found in profile'
});
continue;
}
try {
// Skip health check for HTTP/SSE MCPs (they use different connection method)
if (!mcpConfig.command && mcpConfig.url) {
logger.debug(`Skipping health check for HTTP/SSE MCP: ${mcpName}`);
continue;
}
// Direct health check using the health monitor
const health = await healthMonitor.checkMCPHealth(
mcpName,
mcpConfig.command || '',
mcpConfig.args || [],
mcpConfig.env
);
if (health.status === 'healthy') {
successful.push(mcpName);
} else {
failed.push({
name: mcpName,
error: health.lastError || health.disabledReason || 'Health check failed'
});
}
} catch (error) {
failed.push({
name: mcpName,
error: `Health check error: ${error instanceof Error ? error.message : 'Unknown error'}`
});
}
}
// If we have successful MCPs, run discovery to populate cache for display
if (successful.length > 0) {
try {
const { NCPOrchestrator } = await import('../orchestrator/ncp-orchestrator.js');
const orchestrator = new NCPOrchestrator();
await orchestrator.initialize();
await orchestrator.find('', 1000, false);
await orchestrator.cleanup();
} catch (error) {
// Discovery failure doesn't affect health check results, just cache population
console.log('Cache population failed, but health checks completed');
}
}
} catch (error) {
// If the entire process fails, all are considered failed
for (const mcpName of importedMcpNames) {
failed.push({
name: mcpName,
error: `Discovery failed: ${error instanceof Error ? error.message : 'Unknown error'}`
});
}
}
return { successful, failed };
}
/**
* Display imported MCPs in ncp list style with rich data (descriptions, versions, tool counts)
*/
private async displayImportedMCPs(importedMcpNames: string[]): Promise<void> {
// Load cache data for rich display
const mcpDescriptions: Record<string, string> = {};
const mcpToolCounts: Record<string, number> = {};
const mcpVersions: Record<string, string> = {};
await this.loadMCPInfoFromCache(mcpDescriptions, mcpToolCounts, mcpVersions);
// Get the imported MCPs' configurations
const profiles = this.profileManager.listProfiles();
const allMcps: Record<string, MCPConfig> = {};
// Collect all MCPs from all profiles to get the config
for (const profileName of profiles) {
try {
const profileConfig = await this.profileManager.getProfile(profileName);
if (profileConfig?.mcpServers) {
Object.assign(allMcps, profileConfig.mcpServers);
}
} catch (error) {
// Skip invalid profiles
}
}
// Filter to only show imported MCPs
const filteredMcps: Record<string, MCPConfig> = {};
for (const mcpName of importedMcpNames) {
if (allMcps[mcpName]) {
filteredMcps[mcpName] = allMcps[mcpName];
}
}
if (Object.keys(filteredMcps).length === 0) {
console.log(chalk.yellow('β No imported MCPs found to display'));
return;
}
// Display without the "all" header - just show imported MCPs directly
const mcpEntries = Object.entries(filteredMcps);
mcpEntries.forEach(([mcpName, config], index) => {
const isLast = index === mcpEntries.length - 1;
const connector = isLast ? 'βββ' : 'βββ';
const indent = isLast ? ' ' : 'β ';
// MCP name with tool count and version (like ncp list) - handle case variations
const capitalizedName = mcpName.charAt(0).toUpperCase() + mcpName.slice(1);
const toolCount = mcpToolCounts[mcpName] ?? mcpToolCounts[capitalizedName];
const versionPart = (mcpVersions[mcpName] ?? mcpVersions[capitalizedName]) ?
chalk.magenta(`v${mcpVersions[mcpName] ?? mcpVersions[capitalizedName]}`) : '';
const toolPart = toolCount !== undefined ? chalk.green(`${toolCount} ${toolCount === 1 ? 'tool' : 'tools'}`) : '';
let nameDisplay = chalk.bold.cyanBright(mcpName);
// Format: (v1.0.0 | 4 tools) with version first, all inside parentheses - like ncp list
const badge = versionPart && toolPart ? chalk.dim(` (${versionPart} | ${toolPart})`) :
versionPart ? chalk.dim(` (${versionPart})`) :
toolPart ? chalk.dim(` (${toolPart})`) : '';
nameDisplay += badge;
// Indent properly under the profile (like ncp list)
console.log(` ${connector} ${nameDisplay}`);
// Description if available (depth >= 1)
const description = mcpDescriptions[mcpName];
if (description && description.toLowerCase() !== mcpName.toLowerCase()) {
console.log(` ${indent} ${chalk.white(description)}`);
}
// Command or URL with reverse colors (depth >= 2)
const commandText = config.url
? `HTTP/SSE: ${config.url}`
: formatCommandDisplay(config.command || '', config.args);
const maxWidth = process.stdout.columns ? process.stdout.columns - 6 : 80;
const wrappedLines = TextUtils.wrapTextWithBackground(commandText, maxWidth, ` ${indent} `, (text: string) => chalk.bgGray.black(text));
console.log(wrappedLines);
if (!isLast) console.log(` β`);
});
console.log('');
}
/**
* Load MCP info from cache (copied from CLI list command)
*/
private async loadMCPInfoFromCache(
mcpDescriptions: Record<string, string>,
mcpToolCounts: Record<string, number>,
mcpVersions: Record<string, string>
): Promise<boolean> {
try {
const { readFileSync, existsSync } = await import('fs');
const { join } = await import('path');
const { homedir } = await import('os');
const cacheDir = join(homedir(), '.ncp', 'cache');
const cachePath = join(cacheDir, 'all-tools.json');
if (!existsSync(cachePath)) {
return false; // No cache available
}
const cacheContent = readFileSync(cachePath, 'utf-8');
const cache = JSON.parse(cacheContent);
// Extract server info and tool counts from cache
for (const [mcpName, mcpData] of Object.entries(cache.mcps || {})) {
const data = mcpData as any;
// Extract server description (without version)
if (data.serverInfo?.description && data.serverInfo.description !== mcpName) {
mcpDescriptions[mcpName] = data.serverInfo.description;
} else if (data.serverInfo?.title) {
mcpDescriptions[mcpName] = data.serverInfo.title;
}
// Extract version separately
if (data.serverInfo?.version && data.serverInfo.version !== 'unknown') {
mcpVersions[mcpName] = data.serverInfo.version;
}
// Count tools
if (data.tools && Array.isArray(data.tools)) {
mcpToolCounts[mcpName] = data.tools.length;
}
}
return true;
} catch (error) {
// No cache available - just show basic info
return false;
}
}
/**
* Create a simple spinner for loading animation
*/
private createSpinner(message: string) {
const frames = ['β ', 'β ', 'β Ή', 'β Έ', 'β Ό', 'β ΄', 'β ¦', 'β §', 'β ', 'β '];
let i = 0;
let intervalId: NodeJS.Timeout;
return {
start: () => {
intervalId = setInterval(() => {
process.stdout.write(`\r${chalk.dim(frames[i % frames.length])} ${message}`);
i++;
}, 100);
},
stop: () => {
if (intervalId) {
clearInterval(intervalId);
}
}
};
}
/**
* Clean template comments and example data from import
*/
private cleanImportData(data: any): MCPImportData {
const cleaned: MCPImportData = {};
// Check if this is a Claude Desktop config format with mcpServers wrapper
if (data.mcpServers && typeof data.mcpServers === 'object') {
data = data.mcpServers;
}
for (const [key, value] of Object.entries(data)) {
// Skip template comments and example sections
if (key.startsWith('//') || key.includes('Example') || key.includes('Your MCPs')) {
continue;
}
// Skip NCP entries themselves to avoid circular references
if (key.toLowerCase().startsWith('ncp')) {
continue;
}
// Validate that value is a valid MCP config object
if (value && typeof value === 'object' && !Array.isArray(value)) {
const mcpConfig = value as any;
// Normalize config format (Claude Desktop β NCP format)
const normalizedConfig = this.normalizeConfig(mcpConfig);
// Must have either command (stdio) or url (HTTP/SSE) to be valid
const hasCommand = normalizedConfig.command && typeof normalizedConfig.command === 'string';
const hasUrl = normalizedConfig.url && typeof normalizedConfig.url === 'string';
if (hasCommand || hasUrl) {
cleaned[key] = normalizedConfig as MCPConfig;
}
}
}
return cleaned;
}
/**
* Normalize MCP config from various formats to NCP format
*/
private normalizeConfig(config: any): any {
const normalized = { ...config };
// Remove redundant 'type' field (we detect by presence of url/command)
delete normalized.type;
// Convert 'headers' to 'auth' format
if (config.headers && typeof config.headers === 'object') {
const headers = config.headers as Record<string, string>;
// Extract Authorization header
const authHeader = headers['Authorization'] || headers['authorization'];
if (authHeader) {
// Parse Bearer token
if (authHeader.startsWith('Bearer ')) {
normalized.auth = {
type: 'bearer',
token: authHeader.substring(7)
};
}
// Parse Basic auth
else if (authHeader.startsWith('Basic ')) {
const decoded = Buffer.from(authHeader.substring(6), 'base64').toString('utf-8');
const [username, password] = decoded.split(':');
normalized.auth = {
type: 'basic',
username,
password
};
}
}
// Remove headers field after conversion
delete normalized.headers;
}
// Apply runtime resolution for command (npx β npx.cmd on Windows)
if (normalized.command && typeof normalized.command === 'string') {
normalized.command = getRuntimeForExtension(normalized.command);
}
return normalized;
}
/**
* Prompt user for MCP name with smart suggestions
*/
private async promptForMCPName(command: string): Promise<string> {
const rl = createInterface({
input: process.stdin,
output: process.stdout
});
// Generate smart suggestion based on command
const suggestion = this.generateMCPNameSuggestion(command);
return new Promise((resolve) => {
const prompt = suggestion
? `β€ MCP name [${chalk.cyan(suggestion)}]: `
: `β€ MCP name: `;
rl.question(prompt, (answer) => {
rl.close();
const finalName = answer.trim() || suggestion || 'unnamed-mcp';
console.log(chalk.green(` β
Using name: '${finalName}'`));
resolve(finalName);
});
});
}
/**
* Generate smart MCP name suggestions based on command
*/
private generateMCPNameSuggestion(command: string): string {
// Remove common prefixes and suffixes
let suggestion = command
.replace(/^mcp-/, '') // Remove "mcp-" prefix
.replace(/-server$/, '') // Remove "-server" suffix
.replace(/-mcp$/, '') // Remove "-mcp" suffix
.replace(/^@[\w-]+\//, '') // Remove npm scope like "@org/"
.toLowerCase();
// Handle common patterns
const patterns: Record<string, string> = {
'filesystem': 'filesystem',
'file': 'filesystem',
'web-search': 'web',
'search': 'web-search',
'github': 'github',
'git': 'git',
'database': 'database',
'db': 'database',
'shell': 'shell',
'terminal': 'shell'
};
return patterns[suggestion] || suggestion || 'mcp-server';
}
}