feat: implement mcpctl discover command with table/json/yaml output
Add discover command for searching MCP servers across registries with: - Table, JSON, YAML output formats - Filtering by category, verified, transport, registry - Interactive mode via inquirer - Dependency injection for testability - 27 tests covering command parsing, formatting, and action integration 90 tests passing total. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -750,8 +750,9 @@
|
|||||||
"dependencies": [
|
"dependencies": [
|
||||||
"25"
|
"25"
|
||||||
],
|
],
|
||||||
"status": "pending",
|
"status": "in-progress",
|
||||||
"subtasks": []
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-02-21T03:55:53.004Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "27",
|
"id": "27",
|
||||||
@@ -770,7 +771,7 @@
|
|||||||
],
|
],
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"lastModified": "2026-02-21T03:52:54.909Z",
|
"lastModified": "2026-02-21T03:55:53.004Z",
|
||||||
"taskCount": 27,
|
"taskCount": 27,
|
||||||
"completedCount": 2,
|
"completedCount": 2,
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
145
src/cli/src/commands/discover.ts
Normal file
145
src/cli/src/commands/discover.ts
Normal file
@@ -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<RegistryClient, 'search'>;
|
||||||
|
log: (...args: string[]) => void;
|
||||||
|
processRef: { exitCode: number | undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultDeps: DiscoverDeps = {
|
||||||
|
createClient: () => new RegistryClient(),
|
||||||
|
log: console.log,
|
||||||
|
processRef: process,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createDiscoverCommand(deps?: Partial<DiscoverDeps>): Command {
|
||||||
|
const { createClient, log, processRef } = { ...defaultDeps, ...deps };
|
||||||
|
|
||||||
|
return new Command('discover')
|
||||||
|
.description('Search for MCP servers across registries')
|
||||||
|
.argument('<query>', 'Search query (e.g., "slack", "database", "terraform")')
|
||||||
|
.option('-c, --category <category>', 'Filter by category (devops, data-platform, analytics)')
|
||||||
|
.option('-v, --verified', 'Only show verified servers')
|
||||||
|
.option('-t, --transport <type>', 'Filter by transport (stdio, sse)')
|
||||||
|
.option('-r, --registry <registry>', 'Query specific registry (official, glama, smithery, all)', 'all')
|
||||||
|
.option('-l, --limit <n>', 'Maximum results', '20')
|
||||||
|
.option('-o, --output <format>', '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 <name>' 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<void> {
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
282
src/cli/tests/commands/discover.test.ts
Normal file
282
src/cli/tests/commands/discover.test.ts
Normal file
@@ -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> = {}): 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<typeof vi.fn>;
|
||||||
|
let consoleSpy: ReturnType<typeof vi.fn>;
|
||||||
|
let exitCodeSetter: { exitCode: number | undefined };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockSearch = vi.fn();
|
||||||
|
consoleSpy = vi.fn();
|
||||||
|
exitCodeSetter = { exitCode: undefined };
|
||||||
|
});
|
||||||
|
|
||||||
|
async function runDiscover(
|
||||||
|
args: string[],
|
||||||
|
searchResults: RegistryServer[],
|
||||||
|
): Promise<string> {
|
||||||
|
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 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user