597 lines
22 KiB
Markdown
597 lines
22 KiB
Markdown
|
|
# Task ID: 23
|
||
|
|
|
||
|
|
**Title:** Implement mcpctl discover Command
|
||
|
|
|
||
|
|
**Status:** pending
|
||
|
|
|
||
|
|
**Dependencies:** 22
|
||
|
|
|
||
|
|
**Priority:** medium
|
||
|
|
|
||
|
|
**Description:** Create the `mcpctl discover` CLI command that lets users search for MCP servers across all configured registries with filtering, multiple output formats, and an interactive browsing mode.
|
||
|
|
|
||
|
|
**Details:**
|
||
|
|
|
||
|
|
Create src/cli/src/commands/discover.ts:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
import { Command } from 'commander';
|
||
|
|
import { RegistryClient } from '../registry/client';
|
||
|
|
import { formatTable, formatJson, formatYaml } from '../utils/output';
|
||
|
|
import inquirer from 'inquirer';
|
||
|
|
|
||
|
|
export function createDiscoverCommand(): Command {
|
||
|
|
const cmd = new Command('discover')
|
||
|
|
.description('Search for MCP servers across registries')
|
||
|
|
.argument('<query>', 'Search query (e.g., "slack", "database", "terraform")')
|
||
|
|
.option('--category <category>', 'Filter by category (devops, data-platform, analytics, security)')
|
||
|
|
.option('--verified', 'Only show verified servers')
|
||
|
|
.option('--transport <type>', 'Filter by transport (stdio, sse)', undefined)
|
||
|
|
.option('--registry <source>', 'Search specific registry (official, glama, smithery, all)', 'all')
|
||
|
|
.option('--limit <n>', 'Maximum results to show', '20')
|
||
|
|
.option('--output <format>', 'Output format (table, json, yaml)', 'table')
|
||
|
|
.option('--interactive', 'Interactive browsing mode')
|
||
|
|
.action(async (query, options) => {
|
||
|
|
await discoverAction(query, options);
|
||
|
|
});
|
||
|
|
return cmd;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**Table Output Format:**
|
||
|
|
```
|
||
|
|
┌─────────────────┬────────────────────────────────┬───────────────────────┬───────────┬──────────┬────────────┐
|
||
|
|
│ NAME │ DESCRIPTION │ PACKAGE │ TRANSPORT │ VERIFIED │ POPULARITY │
|
||
|
|
├─────────────────┼────────────────────────────────┼───────────────────────┼───────────┼──────────┼────────────┤
|
||
|
|
│ slack-mcp │ Slack workspace integration... │ @anthropic/slack-mcp │ stdio │ ✓ │ ★★★★☆ │
|
||
|
|
│ slack-tools │ Send messages, manage chan... │ slack-mcp-server │ stdio │ │ ★★★☆☆ │
|
||
|
|
└─────────────────┴────────────────────────────────┴───────────────────────┴───────────┴──────────┴────────────┘
|
||
|
|
|
||
|
|
Run 'mcpctl install <name>' to set up a server
|
||
|
|
```
|
||
|
|
|
||
|
|
**Implementation Details:**
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// discover-action.ts
|
||
|
|
import chalk from 'chalk';
|
||
|
|
import Table from 'cli-table3';
|
||
|
|
|
||
|
|
const CATEGORIES = ['devops', 'data-platform', 'analytics', 'security', 'productivity', 'development'] as const;
|
||
|
|
|
||
|
|
interface DiscoverOptions {
|
||
|
|
category?: string;
|
||
|
|
verified?: boolean;
|
||
|
|
transport?: 'stdio' | 'sse';
|
||
|
|
registry?: 'official' | 'glama' | 'smithery' | 'all';
|
||
|
|
limit?: string;
|
||
|
|
output?: 'table' | 'json' | 'yaml';
|
||
|
|
interactive?: boolean;
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function discoverAction(query: string, options: DiscoverOptions): Promise<void> {
|
||
|
|
const client = new RegistryClient();
|
||
|
|
|
||
|
|
const searchOptions = {
|
||
|
|
query,
|
||
|
|
limit: parseInt(options.limit ?? '20', 10),
|
||
|
|
registries: options.registry === 'all'
|
||
|
|
? ['official', 'glama', 'smithery']
|
||
|
|
: [options.registry],
|
||
|
|
verified: options.verified,
|
||
|
|
transport: options.transport,
|
||
|
|
category: options.category,
|
||
|
|
};
|
||
|
|
|
||
|
|
const results = await client.search(searchOptions);
|
||
|
|
|
||
|
|
if (results.length === 0) {
|
||
|
|
console.log(chalk.yellow('No MCP servers found matching your query.'));
|
||
|
|
console.log(chalk.dim('Try a different search term or remove filters.'));
|
||
|
|
process.exit(2); // Exit code 2 = no results
|
||
|
|
}
|
||
|
|
|
||
|
|
if (options.interactive) {
|
||
|
|
await interactiveMode(results);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
switch (options.output) {
|
||
|
|
case 'json':
|
||
|
|
console.log(JSON.stringify(results, null, 2));
|
||
|
|
break;
|
||
|
|
case 'yaml':
|
||
|
|
console.log(formatYaml(results));
|
||
|
|
break;
|
||
|
|
default:
|
||
|
|
printTable(results);
|
||
|
|
console.log(chalk.cyan("\nRun 'mcpctl install <name>' to set up a server"));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function printTable(servers: RegistryServer[]): void {
|
||
|
|
const table = new Table({
|
||
|
|
head: ['NAME', 'DESCRIPTION', 'PACKAGE', 'TRANSPORT', 'VERIFIED', 'POPULARITY'],
|
||
|
|
colWidths: [18, 35, 25, 10, 9, 12],
|
||
|
|
wordWrap: true,
|
||
|
|
});
|
||
|
|
|
||
|
|
for (const server of servers) {
|
||
|
|
table.push([
|
||
|
|
server.name,
|
||
|
|
truncate(server.description, 32),
|
||
|
|
server.packages.npm ?? server.packages.pypi ?? '-',
|
||
|
|
server.transport,
|
||
|
|
server.verified ? chalk.green('✓') : '',
|
||
|
|
popularityStars(server.popularityScore),
|
||
|
|
]);
|
||
|
|
}
|
||
|
|
|
||
|
|
console.log(table.toString());
|
||
|
|
}
|
||
|
|
|
||
|
|
function popularityStars(score: number): string {
|
||
|
|
const stars = Math.round(score / 20); // 0-100 -> 0-5 stars
|
||
|
|
return '★'.repeat(stars) + '☆'.repeat(5 - stars);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**Interactive Mode with Inquirer:**
|
||
|
|
```typescript
|
||
|
|
async function interactiveMode(servers: RegistryServer[]): Promise<void> {
|
||
|
|
const { selected } = await inquirer.prompt([
|
||
|
|
{
|
||
|
|
type: 'list',
|
||
|
|
name: 'selected',
|
||
|
|
message: 'Select an MCP server to install:',
|
||
|
|
choices: servers.map(s => ({
|
||
|
|
name: `${s.name} - ${truncate(s.description, 50)} ${s.verified ? '✓' : ''}`,
|
||
|
|
value: s.name,
|
||
|
|
})),
|
||
|
|
pageSize: 15,
|
||
|
|
},
|
||
|
|
]);
|
||
|
|
|
||
|
|
const { confirm } = await inquirer.prompt([
|
||
|
|
{
|
||
|
|
type: 'confirm',
|
||
|
|
name: 'confirm',
|
||
|
|
message: `Install ${selected}?`,
|
||
|
|
default: true,
|
||
|
|
},
|
||
|
|
]);
|
||
|
|
|
||
|
|
if (confirm) {
|
||
|
|
// Trigger install command
|
||
|
|
const installCmd = await import('./install');
|
||
|
|
await installCmd.installAction(selected, {});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**Exit Codes for Scripting:**
|
||
|
|
- 0: Success, results found
|
||
|
|
- 1: Error (network, API, etc.)
|
||
|
|
- 2: No results found
|
||
|
|
|
||
|
|
**Category Inference for Data Analyst Tools:**
|
||
|
|
Include categories relevant to BI/analytics:
|
||
|
|
- 'data-platform': BigQuery, Snowflake, Databricks, dbt
|
||
|
|
- 'analytics': Tableau, Looker, Metabase
|
||
|
|
- 'database': PostgreSQL, MySQL, MongoDB tools
|
||
|
|
|
||
|
|
**Test Strategy:**
|
||
|
|
|
||
|
|
TDD approach - write tests BEFORE implementation:
|
||
|
|
|
||
|
|
1. **Command parsing tests:**
|
||
|
|
- Test all option combinations parse correctly
|
||
|
|
- Test query argument is required
|
||
|
|
- Test invalid transport value rejected
|
||
|
|
- Test invalid registry value rejected
|
||
|
|
- Test limit parsed as integer
|
||
|
|
|
||
|
|
2. **Output formatting tests:**
|
||
|
|
- Test table format with varying description lengths
|
||
|
|
- Test table truncation at specified width
|
||
|
|
- Test JSON output is valid JSON array
|
||
|
|
- Test YAML output is valid YAML
|
||
|
|
- Test popularity score to stars conversion (0-100 -> 0-5 stars)
|
||
|
|
- Test verified badge displays correctly
|
||
|
|
|
||
|
|
3. **Interactive mode tests (mock inquirer):**
|
||
|
|
- Test server list displayed as choices
|
||
|
|
- Test selection triggers install confirmation
|
||
|
|
- Test cancel does not trigger install
|
||
|
|
- Test pagination with >15 results
|
||
|
|
|
||
|
|
4. **Exit code tests:**
|
||
|
|
- Test exit(0) when results found
|
||
|
|
- Test exit(1) on registry client error
|
||
|
|
- Test exit(2) when no results match
|
||
|
|
|
||
|
|
5. **Integration tests:**
|
||
|
|
- Test full command execution with mocked RegistryClient
|
||
|
|
- Test --verified filter reduces results
|
||
|
|
- Test --category filter applies correctly
|
||
|
|
- Test --registry limits to single source
|
||
|
|
|
||
|
|
6. **Filter combination tests:**
|
||
|
|
- Test verified + transport + category combined
|
||
|
|
- Test filters with no matches returns empty
|
||
|
|
|
||
|
|
Run: `pnpm --filter @mcpctl/cli test:run -- --coverage commands/discover`
|
||
|
|
|
||
|
|
## Subtasks
|
||
|
|
|
||
|
|
### 23.1. Write TDD Test Suites for Command Parsing, Option Validation, and Exit Codes
|
||
|
|
|
||
|
|
**Status:** pending
|
||
|
|
**Dependencies:** None
|
||
|
|
|
||
|
|
Create comprehensive Vitest test suites for the discover command's argument parsing, option validation, and exit code behavior BEFORE implementation, following the project's TDD approach.
|
||
|
|
|
||
|
|
**Details:**
|
||
|
|
|
||
|
|
Create src/cli/tests/unit/commands/discover.test.ts with the following test categories:
|
||
|
|
|
||
|
|
**Command Parsing Tests:**
|
||
|
|
- Test 'mcpctl discover' without query argument shows error and exits with code 2 (invalid arguments)
|
||
|
|
- Test 'mcpctl discover slack' parses query correctly as 'slack'
|
||
|
|
- Test 'mcpctl discover "database tools"' handles quoted multi-word queries
|
||
|
|
- Test query argument is accessible in action handler
|
||
|
|
|
||
|
|
**Option Validation Tests:**
|
||
|
|
- Test --category accepts valid values: 'devops', 'data-platform', 'analytics', 'security', 'productivity', 'development'
|
||
|
|
- Test --category with invalid value shows error listing valid options
|
||
|
|
- Test --verified flag sets verified=true in options
|
||
|
|
- Test --transport accepts 'stdio' and 'sse' only, rejects invalid values
|
||
|
|
- Test --registry accepts 'official', 'glama', 'smithery', 'all' (default), rejects others
|
||
|
|
- Test --limit parses as integer (e.g., '20' -> 20)
|
||
|
|
- Test --limit with non-numeric value shows validation error
|
||
|
|
- Test --output accepts 'table', 'json', 'yaml', rejects others
|
||
|
|
- Test --interactive flag sets interactive=true
|
||
|
|
|
||
|
|
**Default Values Tests:**
|
||
|
|
- Test --registry defaults to 'all' when not specified
|
||
|
|
- Test --limit defaults to '20' when not specified
|
||
|
|
- Test --output defaults to 'table' when not specified
|
||
|
|
|
||
|
|
**Exit Code Tests:**
|
||
|
|
- Test exit code 0 when results are found
|
||
|
|
- Test exit code 1 on RegistryClient errors (network, API failures)
|
||
|
|
- Test exit code 2 when no results match query/filters
|
||
|
|
|
||
|
|
**Filter Combination Tests:**
|
||
|
|
- Test --verified + --category + --transport combined correctly
|
||
|
|
- Test all filters with empty results returns exit code 2
|
||
|
|
|
||
|
|
Create src/cli/tests/fixtures/mock-registry-client.ts with MockRegistryClient class that returns configurable results or throws configurable errors for testing. Use vitest mock functions to capture calls to verify correct option passing.
|
||
|
|
|
||
|
|
All tests should initially fail (TDD red phase) as the discover command doesn't exist yet.
|
||
|
|
|
||
|
|
### 23.2. Write TDD Test Suites for Output Formatters with Security Sanitization
|
||
|
|
|
||
|
|
**Status:** pending
|
||
|
|
**Dependencies:** 23.1
|
||
|
|
|
||
|
|
Create comprehensive Vitest test suites for all three output formats (table, JSON, YAML), popularity star rendering, description truncation, and critical security tests for terminal escape sequence sanitization.
|
||
|
|
|
||
|
|
**Details:**
|
||
|
|
|
||
|
|
Create src/cli/tests/unit/commands/discover-output.test.ts with the following test categories:
|
||
|
|
|
||
|
|
**Table Output Tests:**
|
||
|
|
- Test table header contains: NAME, DESCRIPTION, PACKAGE, TRANSPORT, VERIFIED, POPULARITY
|
||
|
|
- Test table column widths match spec: 18, 35, 25, 10, 9, 12
|
||
|
|
- Test word wrapping works for long descriptions
|
||
|
|
- Test description truncation at 32 characters with ellipsis
|
||
|
|
- Test verified=true shows green checkmark (chalk.green('✓'))
|
||
|
|
- Test verified=false shows empty string
|
||
|
|
- Test footer shows "Run 'mcpctl install <name>' to set up a server"
|
||
|
|
- Test empty results array shows yellow 'No MCP servers found' message
|
||
|
|
|
||
|
|
**Popularity Stars Tests (popularityStars function):**
|
||
|
|
- Test score 0 returns '☆☆☆☆☆' (0 filled stars)
|
||
|
|
- Test score 20 returns '★☆☆☆☆' (1 filled star)
|
||
|
|
- Test score 50 returns '★★★☆☆' (2.5 rounds to 3 stars - verify rounding)
|
||
|
|
- Test score 100 returns '★★★★★' (5 filled stars)
|
||
|
|
- Test intermediate values: 10->1, 30->2, 60->3, 80->4
|
||
|
|
|
||
|
|
**JSON Output Tests:**
|
||
|
|
- Test JSON output is valid JSON (passes JSON.parse())
|
||
|
|
- Test JSON output is pretty-printed with 2-space indentation
|
||
|
|
- Test JSON array contains all RegistryServer fields
|
||
|
|
- Test JSON is jq-parseable: 'echo output | jq .[]' works
|
||
|
|
- Test --output json does NOT print footer message
|
||
|
|
|
||
|
|
**YAML Output Tests:**
|
||
|
|
- Test YAML output is valid YAML (passes yaml.load())
|
||
|
|
- Test YAML output uses formatYaml utility from utils/output
|
||
|
|
- Test --output yaml does NOT print footer message
|
||
|
|
|
||
|
|
**SECURITY - Terminal Escape Sequence Sanitization Tests:**
|
||
|
|
- Test description containing ANSI codes '\x1b[31mRED\x1b[0m' is sanitized
|
||
|
|
- Test description containing '\033[1mBOLD\033[0m' is sanitized
|
||
|
|
- Test name containing escape sequences is sanitized
|
||
|
|
- Test package name containing escape sequences is sanitized
|
||
|
|
- Test sanitization removes all \x1b[ and \033[ patterns
|
||
|
|
- Test sanitization preserves normal text content
|
||
|
|
- Test prevents cursor movement codes (\x1b[2J screen clear, etc.)
|
||
|
|
|
||
|
|
**Truncate Function Tests:**
|
||
|
|
- Test truncate('short', 32) returns 'short' unchanged
|
||
|
|
- Test truncate('exactly 32 characters string!!!', 32) returns unchanged
|
||
|
|
- Test truncate('this is a very long description that exceeds limit', 32) returns 'this is a very long description...' (29 chars + '...')
|
||
|
|
|
||
|
|
Create src/cli/tests/fixtures/mock-servers.ts with sample RegistryServer objects including edge cases: very long descriptions, special characters, potential injection strings, missing optional fields (packages.pypi undefined).
|
||
|
|
|
||
|
|
### 23.3. Implement discover Command Definition and Action Handler with Sanitization
|
||
|
|
|
||
|
|
**Status:** pending
|
||
|
|
**Dependencies:** 23.1, 23.2
|
||
|
|
|
||
|
|
Implement the discover command using Commander.js following the project's command registration pattern, with the discoverAction handler that orchestrates RegistryClient calls, applies filters, handles errors, and sets correct exit codes.
|
||
|
|
|
||
|
|
**Details:**
|
||
|
|
|
||
|
|
Create src/cli/src/commands/discover.ts implementing the CommandModule interface from the project's command registry pattern:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// discover.ts
|
||
|
|
import { Command } from 'commander';
|
||
|
|
import { RegistryClient } from '../registry/client';
|
||
|
|
import { sanitizeTerminalOutput } from '../utils/sanitize';
|
||
|
|
import { DiscoverOptions, CATEGORIES } from './discover-types';
|
||
|
|
import { printResults } from './discover-output';
|
||
|
|
|
||
|
|
const VALID_TRANSPORTS = ['stdio', 'sse'] as const;
|
||
|
|
const VALID_REGISTRIES = ['official', 'glama', 'smithery', 'all'] as const;
|
||
|
|
const VALID_OUTPUT_FORMATS = ['table', 'json', 'yaml'] as const;
|
||
|
|
|
||
|
|
export function createDiscoverCommand(): Command {
|
||
|
|
const cmd = new Command('discover')
|
||
|
|
.description('Search for MCP servers across registries')
|
||
|
|
.argument('<query>', 'Search query (e.g., "slack", "database", "terraform")')
|
||
|
|
.option('--category <category>', `Filter by category (${CATEGORIES.join(', ')})`)
|
||
|
|
.option('--verified', 'Only show verified servers')
|
||
|
|
.option('--transport <type>', 'Filter by transport (stdio, sse)')
|
||
|
|
.option('--registry <source>', 'Search specific registry (official, glama, smithery, all)', 'all')
|
||
|
|
.option('--limit <n>', 'Maximum results to show', '20')
|
||
|
|
.option('--output <format>', 'Output format (table, json, yaml)', 'table')
|
||
|
|
.option('--interactive', 'Interactive browsing mode')
|
||
|
|
.action(async (query, options) => {
|
||
|
|
await discoverAction(query, options);
|
||
|
|
});
|
||
|
|
return cmd;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Create src/cli/src/commands/discover-action.ts:
|
||
|
|
```typescript
|
||
|
|
export async function discoverAction(query: string, options: DiscoverOptions): Promise<void> {
|
||
|
|
// 1. Validate options (transport, registry, output, category)
|
||
|
|
// 2. Parse limit as integer with validation
|
||
|
|
// 3. Build SearchOptions for RegistryClient
|
||
|
|
// 4. Call client.search() wrapped in try/catch
|
||
|
|
// 5. Handle empty results -> exit code 2
|
||
|
|
// 6. Handle network/API errors -> exit code 1 with structured logging
|
||
|
|
// 7. Sanitize all string fields in results (prevent terminal injection)
|
||
|
|
// 8. Delegate to printResults() or interactiveMode() based on options
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Create src/cli/src/utils/sanitize.ts:
|
||
|
|
```typescript
|
||
|
|
export function sanitizeTerminalOutput(text: string): string {
|
||
|
|
// Remove ANSI escape sequences: \x1b[...m, \033[...m
|
||
|
|
// Remove cursor control sequences
|
||
|
|
// Preserve legitimate text content
|
||
|
|
return text
|
||
|
|
.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
|
||
|
|
.replace(/\033\[[0-9;]*[a-zA-Z]/g, '')
|
||
|
|
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '');
|
||
|
|
}
|
||
|
|
|
||
|
|
export function sanitizeServerResult(server: RegistryServer): RegistryServer {
|
||
|
|
return {
|
||
|
|
...server,
|
||
|
|
name: sanitizeTerminalOutput(server.name),
|
||
|
|
description: sanitizeTerminalOutput(server.description),
|
||
|
|
// sanitize other user-facing string fields
|
||
|
|
};
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Create src/cli/src/commands/discover-types.ts with TypeScript interfaces and constants.
|
||
|
|
|
||
|
|
Register discover command via CommandRegistry following existing patterns in src/cli/src/commands/.
|
||
|
|
|
||
|
|
### 23.4. Implement Output Formatters: Table with cli-table3, JSON, and YAML
|
||
|
|
|
||
|
|
**Status:** pending
|
||
|
|
**Dependencies:** 23.2, 23.3
|
||
|
|
|
||
|
|
Implement the three output format handlers (table, JSON, YAML) including the popularity stars renderer, description truncation, verified badge display, and footer message. Table uses cli-table3 with specified column widths.
|
||
|
|
|
||
|
|
**Details:**
|
||
|
|
|
||
|
|
Create src/cli/src/commands/discover-output.ts:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
import chalk from 'chalk';
|
||
|
|
import Table from 'cli-table3';
|
||
|
|
import { RegistryServer } from '../registry/types';
|
||
|
|
import { formatYaml } from '../utils/output';
|
||
|
|
|
||
|
|
export function printResults(servers: RegistryServer[], format: 'table' | 'json' | 'yaml'): void {
|
||
|
|
switch (format) {
|
||
|
|
case 'json':
|
||
|
|
printJsonOutput(servers);
|
||
|
|
break;
|
||
|
|
case 'yaml':
|
||
|
|
printYamlOutput(servers);
|
||
|
|
break;
|
||
|
|
default:
|
||
|
|
printTableOutput(servers);
|
||
|
|
console.log(chalk.cyan("\nRun 'mcpctl install <name>' to set up a server"));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function printTableOutput(servers: RegistryServer[]): void {
|
||
|
|
const table = new Table({
|
||
|
|
head: ['NAME', 'DESCRIPTION', 'PACKAGE', 'TRANSPORT', 'VERIFIED', 'POPULARITY'],
|
||
|
|
colWidths: [18, 35, 25, 10, 9, 12],
|
||
|
|
wordWrap: true,
|
||
|
|
style: { head: ['cyan'] }
|
||
|
|
});
|
||
|
|
|
||
|
|
for (const server of servers) {
|
||
|
|
table.push([
|
||
|
|
server.name,
|
||
|
|
truncate(server.description, 32),
|
||
|
|
getPackageName(server.packages),
|
||
|
|
server.transport,
|
||
|
|
server.verified ? chalk.green('✓') : '',
|
||
|
|
popularityStars(server.popularityScore),
|
||
|
|
]);
|
||
|
|
}
|
||
|
|
|
||
|
|
console.log(table.toString());
|
||
|
|
}
|
||
|
|
|
||
|
|
function printJsonOutput(servers: RegistryServer[]): void {
|
||
|
|
console.log(JSON.stringify(servers, null, 2));
|
||
|
|
}
|
||
|
|
|
||
|
|
function printYamlOutput(servers: RegistryServer[]): void {
|
||
|
|
console.log(formatYaml(servers));
|
||
|
|
}
|
||
|
|
|
||
|
|
export function truncate(text: string, maxLength: number): string {
|
||
|
|
if (text.length <= maxLength) return text;
|
||
|
|
return text.slice(0, maxLength - 3) + '...';
|
||
|
|
}
|
||
|
|
|
||
|
|
export function popularityStars(score: number): string {
|
||
|
|
const stars = Math.round(score / 20); // 0-100 -> 0-5 stars
|
||
|
|
return '★'.repeat(stars) + '☆'.repeat(5 - stars);
|
||
|
|
}
|
||
|
|
|
||
|
|
function getPackageName(packages: RegistryServer['packages']): string {
|
||
|
|
return packages.npm ?? packages.pypi ?? packages.docker ?? '-';
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Create src/cli/src/commands/discover-no-results.ts for handling empty results:
|
||
|
|
```typescript
|
||
|
|
export function printNoResults(): void {
|
||
|
|
console.log(chalk.yellow('No MCP servers found matching your query.'));
|
||
|
|
console.log(chalk.dim('Try a different search term or remove filters.'));
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Ensure formatYaml utility exists in src/cli/src/utils/output.ts (may need to create if not existing from Task 7). Install cli-table3 dependency: 'pnpm --filter @mcpctl/cli add cli-table3'.
|
||
|
|
|
||
|
|
**Data Analyst/BI Category Support:**
|
||
|
|
Ensure CATEGORIES constant includes categories relevant to data analysts:
|
||
|
|
- 'data-platform': BigQuery, Snowflake, Databricks, dbt
|
||
|
|
- 'analytics': Tableau, Looker, Metabase, Power BI
|
||
|
|
- 'database': PostgreSQL, MySQL, MongoDB connectors
|
||
|
|
- 'visualization': Grafana, Superset integrations
|
||
|
|
|
||
|
|
This supports the Data Analyst persona requirement from the task context.
|
||
|
|
|
||
|
|
### 23.5. Implement Interactive Mode with Inquirer and Install Integration
|
||
|
|
|
||
|
|
**Status:** pending
|
||
|
|
**Dependencies:** 23.3, 23.4
|
||
|
|
|
||
|
|
Implement the interactive browsing mode using Inquirer.js that allows users to scroll through results, select a server, confirm installation, and trigger the install command. Include graceful handling of user cancellation.
|
||
|
|
|
||
|
|
**Details:**
|
||
|
|
|
||
|
|
Create src/cli/src/commands/discover-interactive.ts:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
import inquirer from 'inquirer';
|
||
|
|
import chalk from 'chalk';
|
||
|
|
import { RegistryServer } from '../registry/types';
|
||
|
|
import { truncate } from './discover-output';
|
||
|
|
|
||
|
|
export async function interactiveMode(servers: RegistryServer[]): Promise<void> {
|
||
|
|
// Step 1: Display server selection list
|
||
|
|
const { selected } = await inquirer.prompt([
|
||
|
|
{
|
||
|
|
type: 'list',
|
||
|
|
name: 'selected',
|
||
|
|
message: 'Select an MCP server to install:',
|
||
|
|
choices: servers.map(s => ({
|
||
|
|
name: formatChoice(s),
|
||
|
|
value: s.name,
|
||
|
|
})),
|
||
|
|
pageSize: 15, // Show 15 items before scrolling
|
||
|
|
},
|
||
|
|
]);
|
||
|
|
|
||
|
|
// Step 2: Show server details and confirm installation
|
||
|
|
const selectedServer = servers.find(s => s.name === selected);
|
||
|
|
if (selectedServer) {
|
||
|
|
console.log(chalk.dim('\nSelected server details:'));
|
||
|
|
console.log(chalk.dim(` Description: ${selectedServer.description}`));
|
||
|
|
console.log(chalk.dim(` Package: ${selectedServer.packages.npm ?? selectedServer.packages.pypi ?? '-'}`));
|
||
|
|
console.log(chalk.dim(` Transport: ${selectedServer.transport}`));
|
||
|
|
}
|
||
|
|
|
||
|
|
const { confirm } = await inquirer.prompt([
|
||
|
|
{
|
||
|
|
type: 'confirm',
|
||
|
|
name: 'confirm',
|
||
|
|
message: `Install ${selected}?`,
|
||
|
|
default: true,
|
||
|
|
},
|
||
|
|
]);
|
||
|
|
|
||
|
|
if (confirm) {
|
||
|
|
// Dynamically import install command to avoid circular dependencies
|
||
|
|
const { installAction } = await import('./install');
|
||
|
|
await installAction([selected], {}); // Pass as array per install command spec
|
||
|
|
} else {
|
||
|
|
console.log(chalk.dim('Installation cancelled.'));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatChoice(server: RegistryServer): string {
|
||
|
|
const verifiedBadge = server.verified ? chalk.green(' ✓') : '';
|
||
|
|
const description = truncate(server.description, 50);
|
||
|
|
return `${server.name} - ${description}${verifiedBadge}`;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Create src/cli/tests/unit/commands/discover-interactive.test.ts with mock inquirer tests:
|
||
|
|
- Test server list displayed as scrollable choices
|
||
|
|
- Test selection triggers install confirmation prompt
|
||
|
|
- Test confirm=true triggers installAction with correct server name
|
||
|
|
- Test confirm=false outputs 'Installation cancelled' and exits gracefully
|
||
|
|
- Test pagination works with >15 results (pageSize check)
|
||
|
|
- Test Ctrl+C cancellation is handled gracefully (inquirer throws on SIGINT)
|
||
|
|
- Test formatChoice includes verified badge for verified servers
|
||
|
|
- Test formatChoice truncates long descriptions correctly
|
||
|
|
|
||
|
|
Update src/cli/src/commands/discover-action.ts to call interactiveMode when options.interactive is true:
|
||
|
|
```typescript
|
||
|
|
if (options.interactive) {
|
||
|
|
await interactiveMode(sanitizedResults);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**Error Handling:**
|
||
|
|
- Wrap inquirer prompts in try/catch to handle Ctrl+C gracefully
|
||
|
|
- Exit with code 0 on user cancellation (not an error)
|
||
|
|
- Log structured message on cancellation for SRE observability
|
||
|
|
|
||
|
|
**Integration with Install Command:**
|
||
|
|
- The install command (Task 24) may not exist yet - create a stub if needed
|
||
|
|
- src/cli/src/commands/install.ts stub: export async function installAction(servers: string[], options: {}): Promise<void> { console.log('Install not implemented yet'); }
|