feat: add get and describe commands with API client

kubectl-style get (table/json/yaml) and describe commands for servers,
profiles, projects, instances. ApiClient for daemon communication.
118 CLI tests passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michal
2026-02-21 04:55:45 +00:00
parent d1390313a3
commit 1b8b886995
7 changed files with 566 additions and 0 deletions

91
src/cli/src/api-client.ts Normal file
View File

@@ -0,0 +1,91 @@
import http from 'node:http';
export interface ApiClientOptions {
baseUrl: string;
timeout?: number;
}
export interface ApiResponse<T = unknown> {
status: number;
data: T;
}
export class ApiError extends Error {
constructor(
public readonly status: number,
public readonly body: string,
) {
super(`API error ${status}: ${body}`);
this.name = 'ApiError';
}
}
function request<T>(method: string, url: string, timeout: number, body?: unknown): Promise<ApiResponse<T>> {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const opts: http.RequestOptions = {
hostname: parsed.hostname,
port: parsed.port,
path: parsed.pathname + parsed.search,
method,
timeout,
headers: { 'Content-Type': 'application/json' },
};
const req = http.request(opts, (res) => {
const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => chunks.push(chunk));
res.on('end', () => {
const raw = Buffer.concat(chunks).toString('utf-8');
const status = res.statusCode ?? 0;
if (status >= 400) {
reject(new ApiError(status, raw));
return;
}
try {
resolve({ status, data: JSON.parse(raw) as T });
} catch {
resolve({ status, data: raw as unknown as T });
}
});
});
req.on('error', reject);
req.on('timeout', () => {
req.destroy();
reject(new Error(`Request to ${url} timed out`));
});
if (body !== undefined) {
req.write(JSON.stringify(body));
}
req.end();
});
}
export class ApiClient {
private baseUrl: string;
private timeout: number;
constructor(opts: ApiClientOptions) {
this.baseUrl = opts.baseUrl.replace(/\/$/, '');
this.timeout = opts.timeout ?? 10000;
}
async get<T = unknown>(path: string): Promise<T> {
const res = await request<T>('GET', `${this.baseUrl}${path}`, this.timeout);
return res.data;
}
async post<T = unknown>(path: string, body?: unknown): Promise<T> {
const res = await request<T>('POST', `${this.baseUrl}${path}`, this.timeout, body);
return res.data;
}
async put<T = unknown>(path: string, body?: unknown): Promise<T> {
const res = await request<T>('PUT', `${this.baseUrl}${path}`, this.timeout, body);
return res.data;
}
async delete(path: string): Promise<void> {
await request('DELETE', `${this.baseUrl}${path}`, this.timeout);
}
}

View File

@@ -0,0 +1,74 @@
import { Command } from 'commander';
import { formatJson, formatYaml } from '../formatters/output.js';
export interface DescribeCommandDeps {
fetchResource: (resource: string, id: string) => Promise<unknown>;
log: (...args: string[]) => void;
}
const RESOURCE_ALIASES: Record<string, string> = {
server: 'servers',
srv: 'servers',
profile: 'profiles',
prof: 'profiles',
project: 'projects',
proj: 'projects',
instance: 'instances',
inst: 'instances',
};
function resolveResource(name: string): string {
const lower = name.toLowerCase();
return RESOURCE_ALIASES[lower] ?? lower;
}
function formatDetail(obj: Record<string, unknown>, indent = 0): string {
const pad = ' '.repeat(indent);
const lines: string[] = [];
for (const [key, value] of Object.entries(obj)) {
if (value === null || value === undefined) {
lines.push(`${pad}${key}: -`);
} else if (Array.isArray(value)) {
if (value.length === 0) {
lines.push(`${pad}${key}: []`);
} else if (typeof value[0] === 'object') {
lines.push(`${pad}${key}:`);
for (const item of value) {
lines.push(`${pad} - ${JSON.stringify(item)}`);
}
} else {
lines.push(`${pad}${key}: ${value.join(', ')}`);
}
} else if (typeof value === 'object') {
lines.push(`${pad}${key}:`);
lines.push(formatDetail(value as Record<string, unknown>, indent + 1));
} else {
lines.push(`${pad}${key}: ${String(value)}`);
}
}
return lines.join('\n');
}
export function createDescribeCommand(deps: DescribeCommandDeps): Command {
return new Command('describe')
.description('Show detailed information about a resource')
.argument('<resource>', 'resource type (server, profile, project, instance)')
.argument('<id>', 'resource ID')
.option('-o, --output <format>', 'output format (detail, json, yaml)', 'detail')
.action(async (resourceArg: string, id: string, opts: { output: string }) => {
const resource = resolveResource(resourceArg);
const item = await deps.fetchResource(resource, id);
if (opts.output === 'json') {
deps.log(formatJson(item));
} else if (opts.output === 'yaml') {
deps.log(formatYaml(item));
} else {
const typeName = resource.replace(/s$/, '').charAt(0).toUpperCase() + resource.replace(/s$/, '').slice(1);
deps.log(`--- ${typeName} ---`);
deps.log(formatDetail(item as Record<string, unknown>));
}
});
}

122
src/cli/src/commands/get.ts Normal file
View File

@@ -0,0 +1,122 @@
import { Command } from 'commander';
import { formatTable } from '../formatters/table.js';
import { formatJson, formatYaml } from '../formatters/output.js';
import type { Column } from '../formatters/table.js';
export interface GetCommandDeps {
fetchResource: (resource: string, id?: string) => Promise<unknown[]>;
log: (...args: string[]) => void;
}
interface ServerRow {
id: string;
name: string;
transport: string;
packageName: string | null;
dockerImage: string | null;
}
interface ProfileRow {
id: string;
name: string;
serverId: string;
}
interface ProjectRow {
id: string;
name: string;
description: string;
ownerId: string;
}
interface InstanceRow {
id: string;
serverId: string;
status: string;
containerId: string | null;
port: number | null;
}
const RESOURCE_ALIASES: Record<string, string> = {
server: 'servers',
srv: 'servers',
profile: 'profiles',
prof: 'profiles',
project: 'projects',
proj: 'projects',
instance: 'instances',
inst: 'instances',
};
function resolveResource(name: string): string {
const lower = name.toLowerCase();
return RESOURCE_ALIASES[lower] ?? lower;
}
const serverColumns: Column<ServerRow>[] = [
{ header: 'NAME', key: 'name' },
{ header: 'TRANSPORT', key: 'transport', width: 16 },
{ header: 'PACKAGE', key: (r) => r.packageName ?? '-' },
{ header: 'IMAGE', key: (r) => r.dockerImage ?? '-' },
{ header: 'ID', key: 'id' },
];
const profileColumns: Column<ProfileRow>[] = [
{ header: 'NAME', key: 'name' },
{ header: 'SERVER ID', key: 'serverId' },
{ header: 'ID', key: 'id' },
];
const projectColumns: Column<ProjectRow>[] = [
{ header: 'NAME', key: 'name' },
{ header: 'DESCRIPTION', key: 'description', width: 40 },
{ header: 'OWNER', key: 'ownerId' },
{ header: 'ID', key: 'id' },
];
const instanceColumns: Column<InstanceRow>[] = [
{ header: 'STATUS', key: 'status', width: 10 },
{ header: 'SERVER ID', key: 'serverId' },
{ header: 'PORT', key: (r) => r.port != null ? String(r.port) : '-', width: 6 },
{ header: 'CONTAINER', key: (r) => r.containerId ? r.containerId.slice(0, 12) : '-', width: 14 },
{ header: 'ID', key: 'id' },
];
function getColumnsForResource(resource: string): Column<Record<string, unknown>>[] {
switch (resource) {
case 'servers':
return serverColumns as unknown as Column<Record<string, unknown>>[];
case 'profiles':
return profileColumns as unknown as Column<Record<string, unknown>>[];
case 'projects':
return projectColumns as unknown as Column<Record<string, unknown>>[];
case 'instances':
return instanceColumns as unknown as Column<Record<string, unknown>>[];
default:
return [
{ header: 'ID', key: 'id' as keyof Record<string, unknown> },
{ header: 'NAME', key: 'name' as keyof Record<string, unknown> },
];
}
}
export function createGetCommand(deps: GetCommandDeps): Command {
return new Command('get')
.description('List resources (servers, profiles, projects, instances)')
.argument('<resource>', 'resource type (servers, profiles, projects, instances)')
.argument('[id]', 'specific resource ID')
.option('-o, --output <format>', 'output format (table, json, yaml)', 'table')
.action(async (resourceArg: string, id: string | undefined, opts: { output: string }) => {
const resource = resolveResource(resourceArg);
const items = await deps.fetchResource(resource, id);
if (opts.output === 'json') {
deps.log(formatJson(items.length === 1 ? items[0] : items));
} else if (opts.output === 'yaml') {
deps.log(formatYaml(items.length === 1 ? items[0] : items));
} else {
const columns = getColumnsForResource(resource);
deps.log(formatTable(items as Record<string, unknown>[], columns));
}
});
}

View File

@@ -3,6 +3,10 @@ import { Command } from 'commander';
import { APP_NAME, APP_VERSION } from '@mcpctl/shared'; import { APP_NAME, APP_VERSION } from '@mcpctl/shared';
import { createConfigCommand } from './commands/config.js'; import { createConfigCommand } from './commands/config.js';
import { createStatusCommand } from './commands/status.js'; import { createStatusCommand } from './commands/status.js';
import { createGetCommand } from './commands/get.js';
import { createDescribeCommand } from './commands/describe.js';
import { ApiClient } from './api-client.js';
import { loadConfig } from './config/index.js';
export function createProgram(): Command { export function createProgram(): Command {
const program = new Command() const program = new Command()
@@ -15,6 +19,33 @@ export function createProgram(): Command {
program.addCommand(createConfigCommand()); program.addCommand(createConfigCommand());
program.addCommand(createStatusCommand()); program.addCommand(createStatusCommand());
// Create API-backed commands
const config = loadConfig();
const daemonUrl = program.opts().daemonUrl ?? config.daemonUrl;
const client = new ApiClient({ baseUrl: daemonUrl });
const fetchResource = async (resource: string, id?: string): Promise<unknown[]> => {
if (id) {
const item = await client.get(`/api/v1/${resource}/${id}`);
return [item];
}
return client.get<unknown[]>(`/api/v1/${resource}`);
};
const fetchSingleResource = async (resource: string, id: string): Promise<unknown> => {
return client.get(`/api/v1/${resource}/${id}`);
};
program.addCommand(createGetCommand({
fetchResource,
log: (...args) => console.log(...args),
}));
program.addCommand(createDescribeCommand({
fetchResource: fetchSingleResource,
log: (...args) => console.log(...args),
}));
return program; return program;
} }

View File

@@ -0,0 +1,77 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import http from 'node:http';
import { ApiClient, ApiError } from '../src/api-client.js';
let server: http.Server;
let port: number;
beforeAll(async () => {
server = http.createServer((req, res) => {
if (req.url === '/api/v1/servers' && req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify([{ id: 'srv-1', name: 'slack' }]));
} else if (req.url === '/api/v1/servers/srv-1' && req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ id: 'srv-1', name: 'slack', transport: 'STDIO' }));
} else if (req.url === '/api/v1/servers' && req.method === 'POST') {
const chunks: Buffer[] = [];
req.on('data', (c: Buffer) => chunks.push(c));
req.on('end', () => {
const body = JSON.parse(Buffer.concat(chunks).toString());
res.writeHead(201, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ id: 'srv-new', ...body }));
});
} else if (req.url === '/api/v1/missing' && req.method === 'GET') {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Not found' }));
} else {
res.writeHead(404);
res.end();
}
});
await new Promise<void>((resolve) => {
server.listen(0, () => {
const addr = server.address();
if (addr && typeof addr === 'object') {
port = addr.port;
}
resolve();
});
});
});
afterAll(() => {
server.close();
});
describe('ApiClient', () => {
it('performs GET request for list', async () => {
const client = new ApiClient({ baseUrl: `http://localhost:${port}` });
const result = await client.get<Array<{ id: string; name: string }>>('/api/v1/servers');
expect(result).toEqual([{ id: 'srv-1', name: 'slack' }]);
});
it('performs GET request for single item', async () => {
const client = new ApiClient({ baseUrl: `http://localhost:${port}` });
const result = await client.get<{ id: string; name: string }>('/api/v1/servers/srv-1');
expect(result.name).toBe('slack');
});
it('performs POST request', async () => {
const client = new ApiClient({ baseUrl: `http://localhost:${port}` });
const result = await client.post<{ id: string; name: string }>('/api/v1/servers', { name: 'github' });
expect(result.id).toBe('srv-new');
expect(result.name).toBe('github');
});
it('throws ApiError on 404', async () => {
const client = new ApiClient({ baseUrl: `http://localhost:${port}` });
await expect(client.get('/api/v1/missing')).rejects.toThrow(ApiError);
});
it('throws on connection error', async () => {
const client = new ApiClient({ baseUrl: 'http://localhost:1' });
await expect(client.get('/anything')).rejects.toThrow();
});
});

View File

@@ -0,0 +1,85 @@
import { describe, it, expect, vi } from 'vitest';
import { createDescribeCommand } from '../../src/commands/describe.js';
import type { DescribeCommandDeps } from '../../src/commands/describe.js';
function makeDeps(item: unknown = {}): DescribeCommandDeps & { output: string[] } {
const output: string[] = [];
return {
output,
fetchResource: vi.fn(async () => item),
log: (...args: string[]) => output.push(args.join(' ')),
};
}
describe('describe command', () => {
it('shows detailed server info', async () => {
const deps = makeDeps({
id: 'srv-1',
name: 'slack',
transport: 'STDIO',
packageName: '@slack/mcp',
dockerImage: null,
envTemplate: [],
});
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'server', 'srv-1']);
expect(deps.fetchResource).toHaveBeenCalledWith('servers', 'srv-1');
const text = deps.output.join('\n');
expect(text).toContain('--- Server ---');
expect(text).toContain('name: slack');
expect(text).toContain('transport: STDIO');
expect(text).toContain('dockerImage: -');
});
it('resolves resource aliases', async () => {
const deps = makeDeps({ id: 'p1' });
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'prof', 'p1']);
expect(deps.fetchResource).toHaveBeenCalledWith('profiles', 'p1');
});
it('outputs JSON format', async () => {
const deps = makeDeps({ id: 'srv-1', name: 'slack' });
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'server', 'srv-1', '-o', 'json']);
const parsed = JSON.parse(deps.output[0] ?? '');
expect(parsed.name).toBe('slack');
});
it('outputs YAML format', async () => {
const deps = makeDeps({ id: 'srv-1', name: 'slack' });
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'server', 'srv-1', '-o', 'yaml']);
expect(deps.output[0]).toContain('name: slack');
});
it('formats nested objects', async () => {
const deps = makeDeps({
id: 'srv-1',
name: 'slack',
metadata: { version: '1.0', nested: { deep: true } },
});
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'server', 'srv-1']);
const text = deps.output.join('\n');
expect(text).toContain('metadata:');
expect(text).toContain('version: 1.0');
});
it('formats arrays correctly', async () => {
const deps = makeDeps({
id: 'srv-1',
permissions: ['read', 'write'],
envTemplate: [],
});
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'server', 'srv-1']);
const text = deps.output.join('\n');
expect(text).toContain('permissions: read, write');
expect(text).toContain('envTemplate: []');
});
});

View File

@@ -0,0 +1,86 @@
import { describe, it, expect, vi } from 'vitest';
import { createGetCommand } from '../../src/commands/get.js';
import type { GetCommandDeps } from '../../src/commands/get.js';
function makeDeps(items: unknown[] = []): GetCommandDeps & { output: string[] } {
const output: string[] = [];
return {
output,
fetchResource: vi.fn(async () => items),
log: (...args: string[]) => output.push(args.join(' ')),
};
}
describe('get command', () => {
it('lists servers in table format', async () => {
const deps = makeDeps([
{ id: 'srv-1', name: 'slack', transport: 'STDIO', packageName: '@slack/mcp', dockerImage: null },
{ id: 'srv-2', name: 'github', transport: 'SSE', packageName: null, dockerImage: 'ghcr.io/github-mcp' },
]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'servers']);
expect(deps.fetchResource).toHaveBeenCalledWith('servers', undefined);
expect(deps.output[0]).toContain('NAME');
expect(deps.output[0]).toContain('TRANSPORT');
expect(deps.output.join('\n')).toContain('slack');
expect(deps.output.join('\n')).toContain('github');
});
it('resolves resource aliases', async () => {
const deps = makeDeps([]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'srv']);
expect(deps.fetchResource).toHaveBeenCalledWith('servers', undefined);
});
it('passes ID when provided', async () => {
const deps = makeDeps([{ id: 'srv-1', name: 'slack' }]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'servers', 'srv-1']);
expect(deps.fetchResource).toHaveBeenCalledWith('servers', 'srv-1');
});
it('outputs JSON format', async () => {
const deps = makeDeps([{ id: 'srv-1', name: 'slack' }]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'servers', '-o', 'json']);
const parsed = JSON.parse(deps.output[0] ?? '');
expect(parsed).toEqual({ id: 'srv-1', name: 'slack' });
});
it('outputs YAML format', async () => {
const deps = makeDeps([{ id: 'srv-1', name: 'slack' }]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'servers', '-o', 'yaml']);
expect(deps.output[0]).toContain('name: slack');
});
it('lists profiles with correct columns', async () => {
const deps = makeDeps([
{ id: 'p1', name: 'default', serverId: 'srv-1' },
]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'profiles']);
expect(deps.output[0]).toContain('NAME');
expect(deps.output[0]).toContain('SERVER ID');
});
it('lists instances with correct columns', async () => {
const deps = makeDeps([
{ id: 'inst-1', serverId: 'srv-1', status: 'RUNNING', containerId: 'abc123def456', port: 3000 },
]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'instances']);
expect(deps.output[0]).toContain('STATUS');
expect(deps.output.join('\n')).toContain('RUNNING');
});
it('shows no results message for empty list', async () => {
const deps = makeDeps([]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'servers']);
expect(deps.output[0]).toContain('No results');
});
});