Skip to content

Commit 38ac261

Browse files
committed
feat(mcp): implement interactive installation wizard for MCP servers
- Updated the `install` command to support an interactive wizard for guided server installation when no server name is provided. - Enhanced the `buildInstallCommand` to include an optional `interactive` flag, allowing users to launch the wizard. - Introduced the `InstallWizard` class to manage the interactive installation process, including server selection and configuration. - Added utility functions for rendering server details and managing keyboard input during the wizard. - Implemented comprehensive tests for the `InstallWizard` to ensure functionality and reliability. - Updated documentation to reflect the new interactive installation feature and its usage.
1 parent 749b5f3 commit 38ac261

28 files changed

+3330
-80
lines changed

src/commands/mcp/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,8 @@ export function setupMcpCommands(yargs: Argv): Argv {
5252
},
5353
})
5454
.command({
55-
command: 'install <serverName>',
56-
describe: 'Install an MCP server from the registry',
55+
command: 'install [serverName]',
56+
describe: 'Install an MCP server from the registry (interactive wizard if no serverName)',
5757
builder: buildInstallCommand,
5858
handler: async (argv) => {
5959
const { installCommand } = await import('./install.js');

src/commands/mcp/install.ts

Lines changed: 268 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,28 @@
1+
import { MCPRegistryClient } from '@src/domains/registry/mcpRegistryClient.js';
12
import { createServerInstallationService, getProgressTrackingService } from '@src/domains/server-management/index.js';
23
import { GlobalOptions } from '@src/globalOptions.js';
34
import logger from '@src/logger/logger.js';
45

6+
import boxen from 'boxen';
7+
import chalk from 'chalk';
58
import type { Argv } from 'yargs';
69

7-
import { backupConfig, initializeConfigContext, reloadMcpConfig, serverExists } from './utils/configUtils.js';
10+
import {
11+
backupConfig,
12+
getAllServers,
13+
initializeConfigContext,
14+
reloadMcpConfig,
15+
serverExists,
16+
} from './utils/configUtils.js';
17+
import { InstallWizard } from './utils/installWizard.js';
818
import { generateOperationId, parseServerNameVersion, validateVersion } from './utils/serverUtils.js';
919

1020
export interface InstallCommandArgs extends GlobalOptions {
11-
serverName: string;
21+
serverName?: string;
1222
force?: boolean;
1323
dryRun?: boolean;
1424
verbose?: boolean;
25+
interactive?: boolean;
1526
}
1627

1728
/**
@@ -22,7 +33,7 @@ export function buildInstallCommand(yargs: Argv) {
2233
.positional('serverName', {
2334
describe: 'Server name or name@version to install',
2435
type: 'string',
25-
demandOption: true,
36+
demandOption: false,
2637
})
2738
.option('force', {
2839
describe: 'Force installation even if already exists',
@@ -34,15 +45,23 @@ export function buildInstallCommand(yargs: Argv) {
3445
type: 'boolean',
3546
default: false,
3647
})
48+
.option('interactive', {
49+
describe: 'Launch interactive wizard for guided installation',
50+
type: 'boolean',
51+
default: false,
52+
alias: 'i',
53+
})
3754
.option('verbose', {
3855
describe: 'Detailed output',
3956
type: 'boolean',
4057
default: false,
4158
alias: 'v',
4259
})
4360
.example([
61+
['$0 mcp install', 'Launch interactive installation wizard'],
4462
['$0 mcp install filesystem', 'Install latest version of filesystem server'],
4563
['$0 mcp install [email protected]', 'Install specific version'],
64+
['$0 mcp install filesystem --interactive', 'Install with interactive configuration'],
4665
['$0 mcp install filesystem --force', 'Force reinstallation'],
4766
['$0 mcp install filesystem --dry-run', 'Preview installation'],
4867
]);
@@ -60,6 +79,7 @@ export async function installCommand(argv: InstallCommandArgs): Promise<void> {
6079
force = false,
6180
dryRun = false,
6281
verbose = false,
82+
interactive = false,
6383
} = argv;
6484

6585
// Initialize configuration context
@@ -69,6 +89,12 @@ export async function installCommand(argv: InstallCommandArgs): Promise<void> {
6989
logger.info('Starting installation process...');
7090
}
7191

92+
// Launch interactive wizard if no server name provided or --interactive flag set
93+
if (!inputServerName || interactive) {
94+
await runInteractiveInstallation(argv);
95+
return;
96+
}
97+
7298
// Parse server name and version
7399
const { name: registryServerId, version } = parseServerNameVersion(inputServerName);
74100

@@ -268,3 +294,242 @@ function deriveLocalServerName(registryId: string): string {
268294
logger.debug(`Derived local server name '${sanitized}' from registry ID '${registryId}'`);
269295
return sanitized;
270296
}
297+
298+
/**
299+
* Run interactive installation workflow
300+
*/
301+
async function runInteractiveInstallation(argv: InstallCommandArgs): Promise<void> {
302+
const {
303+
serverName: initialServerId,
304+
config: configPath,
305+
'config-dir': configDir,
306+
force = false,
307+
dryRun = false,
308+
verbose = false,
309+
} = argv;
310+
311+
// Initialize configuration context
312+
initializeConfigContext(configPath, configDir);
313+
314+
// Create registry client
315+
const registryClient = new MCPRegistryClient({
316+
baseUrl: 'https://registry.modelcontextprotocol.io',
317+
timeout: 30000,
318+
cache: {
319+
defaultTtl: 300,
320+
maxSize: 100,
321+
cleanupInterval: 60000,
322+
},
323+
});
324+
325+
// Create wizard
326+
const wizard = new InstallWizard(registryClient);
327+
328+
// Get existing server names for conflict detection
329+
const getExistingNames = () => Object.keys(getAllServers());
330+
331+
// Run wizard loop (supports installing multiple servers)
332+
let continueInstalling = true;
333+
let currentServerId = initialServerId;
334+
335+
try {
336+
while (continueInstalling) {
337+
const existingNames = getExistingNames();
338+
const wizardResult = await wizard.run(currentServerId, existingNames);
339+
340+
if (wizardResult.cancelled) {
341+
console.log('\n❌ Installation cancelled.\n');
342+
wizard.cleanup();
343+
process.exit(0);
344+
}
345+
346+
// Perform installation with collected configuration
347+
try {
348+
const registryServerId = wizardResult.serverId;
349+
const version = wizardResult.version;
350+
const serverName = wizardResult.localName || deriveLocalServerName(registryServerId);
351+
352+
// Use forceOverride from wizard if user selected override option
353+
const shouldForce = force || wizardResult.forceOverride || false;
354+
355+
// Check if server already exists (early check)
356+
const serverAlreadyExists = serverExists(serverName);
357+
if (serverAlreadyExists && !shouldForce) {
358+
console.error(`\n❌ Server '${serverName}' already exists. Use --force to reinstall.\n`);
359+
wizard.cleanup();
360+
if (wizardResult.installAnother) {
361+
currentServerId = undefined;
362+
continue;
363+
}
364+
process.exit(1);
365+
}
366+
367+
// Dry run mode
368+
if (dryRun) {
369+
console.log('🔍 Dry run mode - no changes will be made\n');
370+
console.log(`Would install: ${serverName}${version ? `@${version}` : ''}`);
371+
console.log(`From registry: https://registry.modelcontextprotocol.io\n`);
372+
if (wizardResult.installAnother) {
373+
currentServerId = undefined;
374+
continue;
375+
}
376+
wizard.cleanup();
377+
process.exit(0);
378+
}
379+
380+
// Create operation ID for tracking
381+
const operationId = generateOperationId();
382+
const progressTracker = getProgressTrackingService();
383+
384+
// Helper function to show step indicator
385+
const showStepIndicator = (currentStep: number, skipClear = false) => {
386+
if (!skipClear) {
387+
console.clear();
388+
}
389+
const steps = ['Search', 'Select', 'Configure', 'Confirm', 'Install'];
390+
const stepBar = steps
391+
.map((step, index) => {
392+
const num = index + 1;
393+
if (num < currentStep) {
394+
return chalk.green(`✓ ${step}`);
395+
} else if (num === currentStep) {
396+
return chalk.cyan.bold(`► ${step}`);
397+
} else {
398+
return chalk.gray(`○ ${step}`);
399+
}
400+
})
401+
.join(' → ');
402+
console.log(boxen(stepBar, { padding: { left: 2, right: 2, top: 0, bottom: 0 }, borderStyle: 'round' }));
403+
console.log('');
404+
};
405+
406+
// Show Install step indicator (clear screen before starting)
407+
showStepIndicator(5, false);
408+
409+
// Start progress tracking
410+
progressTracker.startOperation(operationId, 'install', 5);
411+
412+
try {
413+
// Get installation service
414+
const installationService = createServerInstallationService();
415+
416+
// Update progress: Validating
417+
console.log(chalk.cyan('⏳ Validating server...'));
418+
progressTracker.updateProgress(operationId, 1, 'Validating server', `Checking registry for ${serverName}`);
419+
420+
// Create backup if replacing existing server
421+
let backupPath: string | undefined;
422+
if (serverAlreadyExists) {
423+
console.log(chalk.cyan('⏳ Creating backup...'));
424+
progressTracker.updateProgress(operationId, 2, 'Creating backup', `Backing up existing configuration`);
425+
backupPath = backupConfig();
426+
console.log(chalk.gray(` Backup created: ${backupPath}`));
427+
logger.info(`Backup created: ${backupPath}`);
428+
429+
// Remove the existing server before reinstalling to prevent duplicates
430+
const { removeServer } = await import('./utils/configUtils.js');
431+
const removed = removeServer(serverName);
432+
if (removed) {
433+
console.log(chalk.gray(` Removed existing server '${serverName}'`));
434+
if (verbose) {
435+
logger.info(`Removed existing server '${serverName}' before reinstalling`);
436+
}
437+
}
438+
}
439+
440+
// Update progress: Installing
441+
console.log(chalk.cyan(`⏳ Installing ${serverName}${version ? `@${version}` : ''}...`));
442+
progressTracker.updateProgress(
443+
operationId,
444+
3,
445+
'Installing server',
446+
`Installing ${serverName}${version ? `@${version}` : ''}`,
447+
);
448+
449+
// Perform installation
450+
const result = await installationService.installServer(registryServerId, version, {
451+
force: shouldForce,
452+
verbose,
453+
localServerName: serverName,
454+
tags: wizardResult.tags,
455+
env: wizardResult.env,
456+
args: wizardResult.args,
457+
});
458+
459+
// Update progress: Finalizing
460+
console.log(chalk.cyan('⏳ Finalizing...'));
461+
progressTracker.updateProgress(operationId, 4, 'Finalizing', 'Verifying configuration');
462+
463+
// Update progress: Reloading
464+
console.log(chalk.cyan('⏳ Reloading configuration...'));
465+
progressTracker.updateProgress(operationId, 5, 'Reloading configuration', 'Applying changes');
466+
467+
// Reload MCP configuration
468+
reloadMcpConfig();
469+
470+
// Complete the operation
471+
const duration = result.installedAt ? Date.now() - result.installedAt.getTime() : 0;
472+
progressTracker.completeOperation(operationId, {
473+
success: true,
474+
operationId,
475+
duration,
476+
message: `Successfully installed ${serverName}`,
477+
});
478+
479+
// Show completed step indicator with all steps marked as done (don't clear logs)
480+
console.log('');
481+
showStepIndicator(6, true); // 6 means all steps are completed, true = skip clear
482+
483+
// Report success
484+
console.log(
485+
chalk.green.bold(`✅ Successfully installed server '${serverName}'${version ? ` version ${version}` : ''}`),
486+
);
487+
if (backupPath) {
488+
console.log(chalk.gray(`📁 Backup created: ${backupPath}`));
489+
}
490+
if (result.warnings.length > 0) {
491+
console.log(chalk.yellow('\n⚠️ Warnings:'));
492+
result.warnings.forEach((warning) => console.log(chalk.yellow(` • ${warning}`)));
493+
}
494+
} catch (error) {
495+
progressTracker.failOperation(operationId, error as Error);
496+
throw error;
497+
}
498+
} catch (error) {
499+
const errorMessage = error instanceof Error ? error.message : String(error);
500+
console.error(`\n❌ Installation failed: ${errorMessage}\n`);
501+
if (error instanceof Error && error.stack) {
502+
logger.error('Installation error stack:', error.stack);
503+
}
504+
505+
if (wizardResult.installAnother) {
506+
const continueAfterError = await wizard.run(undefined, getExistingNames());
507+
if (continueAfterError.cancelled) {
508+
wizard.cleanup();
509+
process.exit(1);
510+
}
511+
currentServerId = undefined;
512+
continue;
513+
}
514+
515+
wizard.cleanup();
516+
process.exit(1);
517+
}
518+
519+
// Check if user wants to install another
520+
if (wizardResult.installAnother) {
521+
currentServerId = undefined;
522+
continueInstalling = true;
523+
} else {
524+
continueInstalling = false;
525+
}
526+
}
527+
} finally {
528+
// Always cleanup wizard resources
529+
wizard.cleanup();
530+
}
531+
532+
// Explicitly exit after successful completion to prevent hanging
533+
// This ensures stdin doesn't keep the process alive
534+
process.exit(0);
535+
}

0 commit comments

Comments
 (0)