Files
mcpctl/.taskmaster/tasks/task_023.md
2026-02-21 03:10:39 +00:00

22 KiB

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:

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:

// 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:

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 ' 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:

// 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:

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:

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:

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:

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:

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:

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 { console.log('Install not implemented yet'); }