diff --git a/.taskmaster/tasks/tasks.json b/.taskmaster/tasks/tasks.json index ef8024a..758c7a7 100644 --- a/.taskmaster/tasks/tasks.json +++ b/.taskmaster/tasks/tasks.json @@ -750,8 +750,9 @@ "dependencies": [ "25" ], - "status": "pending", - "subtasks": [] + "status": "in-progress", + "subtasks": [], + "updatedAt": "2026-02-21T03:55:53.004Z" }, { "id": "27", @@ -770,7 +771,7 @@ ], "metadata": { "version": "1.0.0", - "lastModified": "2026-02-21T03:52:54.909Z", + "lastModified": "2026-02-21T03:55:53.004Z", "taskCount": 27, "completedCount": 2, "tags": [ diff --git a/src/cli/src/commands/discover.ts b/src/cli/src/commands/discover.ts new file mode 100644 index 0000000..02e1c08 --- /dev/null +++ b/src/cli/src/commands/discover.ts @@ -0,0 +1,145 @@ +import { Command } from 'commander'; +import chalk from 'chalk'; +import yaml from 'js-yaml'; +import { RegistryClient, type SearchOptions, type RegistryServer, type RegistryName } from '../registry/index.js'; + +export interface DiscoverDeps { + createClient: () => Pick; + log: (...args: string[]) => void; + processRef: { exitCode: number | undefined }; +} + +const defaultDeps: DiscoverDeps = { + createClient: () => new RegistryClient(), + log: console.log, + processRef: process, +}; + +export function createDiscoverCommand(deps?: Partial): Command { + const { createClient, log, processRef } = { ...defaultDeps, ...deps }; + + return new Command('discover') + .description('Search for MCP servers across registries') + .argument('', 'Search query (e.g., "slack", "database", "terraform")') + .option('-c, --category ', 'Filter by category (devops, data-platform, analytics)') + .option('-v, --verified', 'Only show verified servers') + .option('-t, --transport ', 'Filter by transport (stdio, sse)') + .option('-r, --registry ', 'Query specific registry (official, glama, smithery, all)', 'all') + .option('-l, --limit ', 'Maximum results', '20') + .option('-o, --output ', 'Output format (table, json, yaml)', 'table') + .option('-i, --interactive', 'Interactive browsing mode') + .action(async (query: string, options: { + category?: string; + verified?: boolean; + transport?: string; + registry: string; + limit: string; + output: string; + interactive?: boolean; + }) => { + const client = createClient(); + + const searchOpts: SearchOptions = { + query, + limit: parseInt(options.limit, 10), + verified: options.verified, + transport: options.transport as SearchOptions['transport'], + category: options.category, + registries: options.registry === 'all' + ? undefined + : [options.registry as RegistryName], + }; + + const results = await client.search(searchOpts); + + if (results.length === 0) { + log('No servers found matching your query.'); + processRef.exitCode = 2; + return; + } + + if (options.interactive) { + await runInteractiveMode(results, log); + } else { + switch (options.output) { + case 'json': + log(formatJson(results)); + break; + case 'yaml': + log(formatYaml(results)); + break; + default: + log(printTable(results)); + } + } + }); +} + +export function printTable(results: RegistryServer[]): string { + const lines: string[] = []; + + lines.push( + 'NAME'.padEnd(30) + + 'DESCRIPTION'.padEnd(50) + + 'PACKAGE'.padEnd(35) + + 'TRANSPORT VERIFIED POPULARITY', + ); + lines.push('-'.repeat(140)); + + for (const s of results) { + const pkg = s.packages.npm ?? s.packages.pypi ?? s.packages.docker ?? '-'; + const verified = s.verified ? chalk.green('Y') : '-'; + lines.push( + s.name.slice(0, 28).padEnd(30) + + s.description.slice(0, 48).padEnd(50) + + pkg.slice(0, 33).padEnd(35) + + s.transport.padEnd(11) + + String(verified).padEnd(10) + + String(s.popularityScore), + ); + } + + lines.push(''); + lines.push("Run 'mcpctl install ' to set up a server."); + + return lines.join('\n'); +} + +export function formatJson(results: RegistryServer[]): string { + return JSON.stringify(results, null, 2); +} + +export function formatYaml(results: RegistryServer[]): string { + return yaml.dump(results, { lineWidth: -1 }); +} + +async function runInteractiveMode( + results: RegistryServer[], + log: (...args: string[]) => void, +): Promise { + const inquirer = await import('inquirer'); + + const { selected } = await inquirer.default.prompt([{ + type: 'list', + name: 'selected', + message: 'Select an MCP server:', + choices: results.map((s) => ({ + name: `${s.name} - ${s.description.slice(0, 60)}`, + value: s, + })), + }]); + + const { action } = await inquirer.default.prompt([{ + type: 'list', + name: 'action', + message: `What would you like to do with ${selected.name}?`, + choices: [ + { name: 'View details', value: 'details' }, + { name: 'Cancel', value: 'cancel' }, + ], + }]); + + if (action === 'details') { + log(JSON.stringify(selected, null, 2)); + } +} diff --git a/src/cli/tests/commands/discover.test.ts b/src/cli/tests/commands/discover.test.ts new file mode 100644 index 0000000..cf1211c --- /dev/null +++ b/src/cli/tests/commands/discover.test.ts @@ -0,0 +1,282 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + createDiscoverCommand, + printTable, + formatJson, + formatYaml, +} from '../../src/commands/discover.js'; +import type { RegistryServer } from '../../src/registry/types.js'; + +function makeServer(overrides: Partial = {}): RegistryServer { + return { + name: 'test-server', + description: 'A test MCP server for testing', + packages: { npm: '@test/mcp-server' }, + envTemplate: [], + transport: 'stdio', + popularityScore: 42, + verified: true, + sourceRegistry: 'official', + ...overrides, + }; +} + +function makeServers(count: number): RegistryServer[] { + return Array.from({ length: count }, (_, i) => + makeServer({ + name: `server-${i}`, + description: `Description for server ${i}`, + packages: { npm: `@test/server-${i}` }, + popularityScore: count - i, + verified: i % 2 === 0, + sourceRegistry: (['official', 'glama', 'smithery'] as const)[i % 3], + }), + ); +} + +describe('discover command', () => { + describe('createDiscoverCommand', () => { + it('creates a command with correct name and description', () => { + const cmd = createDiscoverCommand(); + expect(cmd.name()).toBe('discover'); + expect(cmd.description()).toContain('Search'); + }); + + it('accepts a required query argument', () => { + const cmd = createDiscoverCommand(); + // Commander registers arguments internally + const args = cmd.registeredArguments; + expect(args.length).toBe(1); + expect(args[0].required).toBe(true); + }); + + it('has all expected options', () => { + const cmd = createDiscoverCommand(); + const optionNames = cmd.options.map((o) => o.long); + expect(optionNames).toContain('--category'); + expect(optionNames).toContain('--verified'); + expect(optionNames).toContain('--transport'); + expect(optionNames).toContain('--registry'); + expect(optionNames).toContain('--limit'); + expect(optionNames).toContain('--output'); + expect(optionNames).toContain('--interactive'); + }); + + it('has correct defaults for options', () => { + const cmd = createDiscoverCommand(); + const findOption = (name: string) => + cmd.options.find((o) => o.long === name); + expect(findOption('--registry')?.defaultValue).toBe('all'); + expect(findOption('--limit')?.defaultValue).toBe('20'); + expect(findOption('--output')?.defaultValue).toBe('table'); + }); + }); + + describe('printTable', () => { + it('formats servers as a table with header', () => { + const servers = [makeServer()]; + const output = printTable(servers); + + expect(output).toContain('NAME'); + expect(output).toContain('DESCRIPTION'); + expect(output).toContain('PACKAGE'); + expect(output).toContain('TRANSPORT'); + expect(output).toContain('test-server'); + expect(output).toContain('@test/mcp-server'); + }); + + it('shows verified status', () => { + const verified = makeServer({ verified: true }); + const unverified = makeServer({ name: 'other', verified: false }); + const output = printTable([verified, unverified]); + + // Should contain both entries + expect(output).toContain('test-server'); + expect(output).toContain('other'); + }); + + it('truncates long names and descriptions', () => { + const server = makeServer({ + name: 'a'.repeat(50), + description: 'b'.repeat(80), + }); + const output = printTable([server]); + const lines = output.split('\n'); + // Data lines should not exceed reasonable width + const dataLine = lines.find((l) => l.includes('aaa')); + expect(dataLine).toBeDefined(); + // Name truncated at 28 chars + expect(dataLine!.indexOf('aaa')).toBeLessThan(30); + }); + + it('handles servers with no npm package', () => { + const server = makeServer({ packages: { docker: 'test/img' } }); + const output = printTable([server]); + expect(output).toContain('test/img'); + }); + + it('handles servers with no packages at all', () => { + const server = makeServer({ packages: {} }); + const output = printTable([server]); + expect(output).toContain('-'); + }); + + it('shows footer with install hint', () => { + const output = printTable([makeServer()]); + expect(output).toContain('mcpctl install'); + }); + + it('handles empty results', () => { + const output = printTable([]); + // Should still show header + expect(output).toContain('NAME'); + }); + }); + + describe('formatJson', () => { + it('returns valid JSON', () => { + const servers = makeServers(3); + const output = formatJson(servers); + const parsed = JSON.parse(output); + expect(parsed).toHaveLength(3); + }); + + it('preserves all fields', () => { + const server = makeServer({ repositoryUrl: 'https://github.com/test/test' }); + const output = formatJson([server]); + const parsed = JSON.parse(output); + expect(parsed[0].name).toBe('test-server'); + expect(parsed[0].repositoryUrl).toBe('https://github.com/test/test'); + expect(parsed[0].packages.npm).toBe('@test/mcp-server'); + }); + + it('is pretty-printed with 2-space indentation', () => { + const output = formatJson([makeServer()]); + expect(output).toContain('\n'); + expect(output).toContain(' '); + }); + }); + + describe('formatYaml', () => { + it('returns valid YAML', () => { + const servers = makeServers(2); + const output = formatYaml(servers); + // YAML arrays start with - + expect(output).toContain('- name:'); + }); + + it('includes all server fields', () => { + const output = formatYaml([makeServer()]); + expect(output).toContain('name: test-server'); + expect(output).toContain('description:'); + expect(output).toContain('transport: stdio'); + }); + }); + + describe('action integration', () => { + let mockSearch: ReturnType; + let consoleSpy: ReturnType; + let exitCodeSetter: { exitCode: number | undefined }; + + beforeEach(() => { + mockSearch = vi.fn(); + consoleSpy = vi.fn(); + exitCodeSetter = { exitCode: undefined }; + }); + + async function runDiscover( + args: string[], + searchResults: RegistryServer[], + ): Promise { + mockSearch.mockResolvedValue(searchResults); + const output: string[] = []; + consoleSpy.mockImplementation((...msgs: string[]) => output.push(msgs.join(' '))); + + const cmd = createDiscoverCommand({ + createClient: () => ({ search: mockSearch } as any), + log: consoleSpy, + processRef: exitCodeSetter as any, + }); + + // Commander needs parent program to parse properly + const { Command } = await import('commander'); + const program = new Command(); + program.addCommand(cmd); + await program.parseAsync(['node', 'mcpctl', 'discover', ...args]); + + return output.join('\n'); + } + + it('passes query to client search', async () => { + await runDiscover(['slack'], [makeServer()]); + expect(mockSearch).toHaveBeenCalledWith( + expect.objectContaining({ query: 'slack' }), + ); + }); + + it('passes verified filter when --verified is set', async () => { + await runDiscover(['slack', '--verified'], [makeServer()]); + expect(mockSearch).toHaveBeenCalledWith( + expect.objectContaining({ verified: true }), + ); + }); + + it('passes transport filter', async () => { + await runDiscover(['slack', '--transport', 'sse'], [makeServer()]); + expect(mockSearch).toHaveBeenCalledWith( + expect.objectContaining({ transport: 'sse' }), + ); + }); + + it('passes category filter', async () => { + await runDiscover(['slack', '--category', 'devops'], [makeServer()]); + expect(mockSearch).toHaveBeenCalledWith( + expect.objectContaining({ category: 'devops' }), + ); + }); + + it('passes specific registry', async () => { + await runDiscover(['slack', '--registry', 'glama'], [makeServer()]); + expect(mockSearch).toHaveBeenCalledWith( + expect.objectContaining({ registries: ['glama'] }), + ); + }); + + it('passes limit as number', async () => { + await runDiscover(['slack', '--limit', '5'], [makeServer()]); + expect(mockSearch).toHaveBeenCalledWith( + expect.objectContaining({ limit: 5 }), + ); + }); + + it('outputs table format by default', async () => { + const output = await runDiscover(['slack'], [makeServer()]); + expect(output).toContain('NAME'); + expect(output).toContain('test-server'); + }); + + it('outputs JSON when --output json', async () => { + const output = await runDiscover(['slack', '--output', 'json'], [makeServer()]); + const parsed = JSON.parse(output); + expect(parsed[0].name).toBe('test-server'); + }); + + it('outputs YAML when --output yaml', async () => { + const output = await runDiscover(['slack', '--output', 'yaml'], [makeServer()]); + expect(output).toContain('name: test-server'); + }); + + it('sets exit code 2 when no results', async () => { + const output = await runDiscover(['nonexistent'], []); + expect(output).toContain('No servers found'); + expect(exitCodeSetter.exitCode).toBe(2); + }); + + it('does not set registries when --registry all', async () => { + await runDiscover(['slack', '--registry', 'all'], [makeServer()]); + expect(mockSearch).toHaveBeenCalledWith( + expect.objectContaining({ registries: undefined }), + ); + }); + }); +});