From 9c08faa8d25de2adadef7db3e7b1015ab6003fdc Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 21 Feb 2026 05:14:43 +0000 Subject: [PATCH] feat: add apply command for declarative config and interactive setup wizard Apply reads YAML/JSON config files to sync servers, profiles, and projects to the daemon with create-or-update semantics. Setup provides an interactive wizard for configuring MCP servers with environment variables. Co-Authored-By: Claude Opus 4.6 --- src/cli/src/commands/apply.ts | 173 +++++++++++++++++++++++++++ src/cli/src/commands/setup.ts | 103 ++++++++++++++++ src/cli/src/index.ts | 34 ++++++ src/cli/tests/commands/apply.test.ts | 166 +++++++++++++++++++++++++ src/cli/tests/commands/setup.test.ts | 141 ++++++++++++++++++++++ 5 files changed, 617 insertions(+) create mode 100644 src/cli/src/commands/apply.ts create mode 100644 src/cli/src/commands/setup.ts create mode 100644 src/cli/tests/commands/apply.test.ts create mode 100644 src/cli/tests/commands/setup.test.ts diff --git a/src/cli/src/commands/apply.ts b/src/cli/src/commands/apply.ts new file mode 100644 index 0000000..57fb1b4 --- /dev/null +++ b/src/cli/src/commands/apply.ts @@ -0,0 +1,173 @@ +import { Command } from 'commander'; +import { readFileSync } from 'node:fs'; +import yaml from 'js-yaml'; +import { z } from 'zod'; +import type { ApiClient } from '../api-client.js'; + +const ServerSpecSchema = z.object({ + name: z.string().min(1), + description: z.string().default(''), + packageName: z.string().optional(), + dockerImage: z.string().optional(), + transport: z.enum(['STDIO', 'SSE', 'STREAMABLE_HTTP']).default('STDIO'), + repositoryUrl: z.string().url().optional(), + envTemplate: z.array(z.object({ + name: z.string(), + description: z.string().default(''), + isSecret: z.boolean().default(false), + })).default([]), +}); + +const ProfileSpecSchema = z.object({ + name: z.string().min(1), + server: z.string().min(1), + permissions: z.array(z.string()).default([]), + envOverrides: z.record(z.string()).default({}), +}); + +const ProjectSpecSchema = z.object({ + name: z.string().min(1), + description: z.string().default(''), + profiles: z.array(z.string()).default([]), +}); + +const ApplyConfigSchema = z.object({ + servers: z.array(ServerSpecSchema).default([]), + profiles: z.array(ProfileSpecSchema).default([]), + projects: z.array(ProjectSpecSchema).default([]), +}); + +export type ApplyConfig = z.infer; + +export interface ApplyCommandDeps { + client: ApiClient; + log: (...args: unknown[]) => void; +} + +export function createApplyCommand(deps: ApplyCommandDeps): Command { + const { client, log } = deps; + + return new Command('apply') + .description('Apply declarative configuration from a YAML or JSON file') + .argument('', 'Path to config file (.yaml, .yml, or .json)') + .option('--dry-run', 'Validate and show changes without applying') + .action(async (file: string, opts: { dryRun?: boolean }) => { + const config = loadConfigFile(file); + + if (opts.dryRun) { + log('Dry run - would apply:'); + if (config.servers.length > 0) log(` ${config.servers.length} server(s)`); + if (config.profiles.length > 0) log(` ${config.profiles.length} profile(s)`); + if (config.projects.length > 0) log(` ${config.projects.length} project(s)`); + return; + } + + await applyConfig(client, config, log); + }); +} + +function loadConfigFile(path: string): ApplyConfig { + const raw = readFileSync(path, 'utf-8'); + let parsed: unknown; + + if (path.endsWith('.json')) { + parsed = JSON.parse(raw); + } else { + parsed = yaml.load(raw); + } + + return ApplyConfigSchema.parse(parsed); +} + +async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args: unknown[]) => void): Promise { + // Apply servers first (profiles depend on servers) + for (const server of config.servers) { + try { + const existing = await findByName(client, 'servers', server.name); + if (existing) { + await client.put(`/api/v1/servers/${(existing as { id: string }).id}`, server); + log(`Updated server: ${server.name}`); + } else { + await client.post('/api/v1/servers', server); + log(`Created server: ${server.name}`); + } + } catch (err) { + log(`Error applying server '${server.name}': ${err instanceof Error ? err.message : err}`); + } + } + + // Apply profiles (need server IDs) + for (const profile of config.profiles) { + try { + const server = await findByName(client, 'servers', profile.server); + if (!server) { + log(`Skipping profile '${profile.name}': server '${profile.server}' not found`); + continue; + } + const serverId = (server as { id: string }).id; + + const existing = await findProfile(client, serverId, profile.name); + if (existing) { + await client.put(`/api/v1/profiles/${(existing as { id: string }).id}`, { + permissions: profile.permissions, + envOverrides: profile.envOverrides, + }); + log(`Updated profile: ${profile.name} (server: ${profile.server})`); + } else { + await client.post('/api/v1/profiles', { + name: profile.name, + serverId, + permissions: profile.permissions, + envOverrides: profile.envOverrides, + }); + log(`Created profile: ${profile.name} (server: ${profile.server})`); + } + } catch (err) { + log(`Error applying profile '${profile.name}': ${err instanceof Error ? err.message : err}`); + } + } + + // Apply projects + for (const project of config.projects) { + try { + const existing = await findByName(client, 'projects', project.name); + if (existing) { + await client.put(`/api/v1/projects/${(existing as { id: string }).id}`, { + description: project.description, + }); + log(`Updated project: ${project.name}`); + } else { + await client.post('/api/v1/projects', { + name: project.name, + description: project.description, + }); + log(`Created project: ${project.name}`); + } + } catch (err) { + log(`Error applying project '${project.name}': ${err instanceof Error ? err.message : err}`); + } + } +} + +async function findByName(client: ApiClient, resource: string, name: string): Promise { + try { + const items = await client.get>(`/api/v1/${resource}`); + return items.find((item) => item.name === name) ?? null; + } catch { + return null; + } +} + +async function findProfile(client: ApiClient, serverId: string, name: string): Promise { + try { + const profiles = await client.get>( + `/api/v1/profiles?serverId=${serverId}`, + ); + return profiles.find((p) => p.name === name) ?? null; + } catch { + return null; + } +} + +// Export for testing +export { loadConfigFile, applyConfig }; diff --git a/src/cli/src/commands/setup.ts b/src/cli/src/commands/setup.ts new file mode 100644 index 0000000..fc8b91c --- /dev/null +++ b/src/cli/src/commands/setup.ts @@ -0,0 +1,103 @@ +import { Command } from 'commander'; +import type { ApiClient } from '../api-client.js'; + +export interface SetupPromptDeps { + input: (message: string) => Promise; + password: (message: string) => Promise; + select: (message: string, choices: Array<{ name: string; value: T }>) => Promise; + confirm: (message: string) => Promise; +} + +export interface SetupCommandDeps { + client: ApiClient; + prompt: SetupPromptDeps; + log: (...args: unknown[]) => void; +} + +export function createSetupCommand(deps: SetupCommandDeps): Command { + const { client, prompt, log } = deps; + + return new Command('setup') + .description('Interactive wizard for configuring an MCP server') + .argument('[server-name]', 'Server name to set up (will prompt if not given)') + .action(async (serverName?: string) => { + log('MCP Server Setup Wizard\n'); + + // Step 1: Server name + const name = serverName ?? await prompt.input('Server name (lowercase, hyphens allowed):'); + if (!name) { + log('Setup cancelled.'); + return; + } + + // Step 2: Transport + const transport = await prompt.select('Transport type:', [ + { name: 'STDIO (command-line process)', value: 'STDIO' as const }, + { name: 'SSE (Server-Sent Events over HTTP)', value: 'SSE' as const }, + { name: 'Streamable HTTP', value: 'STREAMABLE_HTTP' as const }, + ]); + + // Step 3: Package or image + const packageName = await prompt.input('NPM package name (or leave empty):'); + const dockerImage = await prompt.input('Docker image (or leave empty):'); + + // Step 4: Description + const description = await prompt.input('Description:'); + + // Step 5: Create the server + const serverData: Record = { + name, + transport, + description, + }; + if (packageName) serverData.packageName = packageName; + if (dockerImage) serverData.dockerImage = dockerImage; + + let server: { id: string; name: string }; + try { + server = await client.post<{ id: string; name: string }>('/api/v1/servers', serverData); + log(`\nServer '${server.name}' created.`); + } catch (err) { + log(`\nFailed to create server: ${err instanceof Error ? err.message : err}`); + return; + } + + // Step 6: Create a profile with env vars + const createProfile = await prompt.confirm('Create a profile with environment variables?'); + if (!createProfile) { + log('\nSetup complete!'); + return; + } + + const profileName = await prompt.input('Profile name:') || 'default'; + + // Collect env vars + const envOverrides: Record = {}; + let addMore = true; + while (addMore) { + const envName = await prompt.input('Environment variable name (empty to finish):'); + if (!envName) break; + + const isSecret = await prompt.confirm(`Is '${envName}' a secret (e.g., API key)?`); + const envValue = isSecret + ? await prompt.password(`Value for ${envName}:`) + : await prompt.input(`Value for ${envName}:`); + + envOverrides[envName] = envValue; + addMore = await prompt.confirm('Add another environment variable?'); + } + + try { + await client.post('/api/v1/profiles', { + name: profileName, + serverId: server.id, + envOverrides, + }); + log(`Profile '${profileName}' created for server '${name}'.`); + } catch (err) { + log(`Failed to create profile: ${err instanceof Error ? err.message : err}`); + } + + log('\nSetup complete!'); + }); +} diff --git a/src/cli/src/index.ts b/src/cli/src/index.ts index f76c2bb..af3d6d5 100644 --- a/src/cli/src/index.ts +++ b/src/cli/src/index.ts @@ -6,6 +6,8 @@ import { createStatusCommand } from './commands/status.js'; import { createGetCommand } from './commands/get.js'; import { createDescribeCommand } from './commands/describe.js'; import { createInstanceCommands } from './commands/instances.js'; +import { createApplyCommand } from './commands/apply.js'; +import { createSetupCommand } from './commands/setup.js'; import { ApiClient } from './api-client.js'; import { loadConfig } from './config/index.js'; @@ -52,6 +54,38 @@ export function createProgram(): Command { log: (...args) => console.log(...args), })); + program.addCommand(createApplyCommand({ + client, + log: (...args) => console.log(...args), + })); + + program.addCommand(createSetupCommand({ + client, + prompt: { + async input(message) { + const { default: inquirer } = await import('inquirer'); + const { answer } = await inquirer.prompt([{ type: 'input', name: 'answer', message }]); + return answer as string; + }, + async password(message) { + const { default: inquirer } = await import('inquirer'); + const { answer } = await inquirer.prompt([{ type: 'password', name: 'answer', message }]); + return answer as string; + }, + async select(message, choices) { + const { default: inquirer } = await import('inquirer'); + const { answer } = await inquirer.prompt([{ type: 'list', name: 'answer', message, choices }]); + return answer; + }, + async confirm(message) { + const { default: inquirer } = await import('inquirer'); + const { answer } = await inquirer.prompt([{ type: 'confirm', name: 'answer', message }]); + return answer as boolean; + }, + }, + log: (...args) => console.log(...args), + })); + return program; } diff --git a/src/cli/tests/commands/apply.test.ts b/src/cli/tests/commands/apply.test.ts new file mode 100644 index 0000000..de1cc0f --- /dev/null +++ b/src/cli/tests/commands/apply.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { writeFileSync, mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { createApplyCommand } from '../../src/commands/apply.js'; +import type { ApiClient } from '../../src/api-client.js'; + +function mockClient(): ApiClient { + return { + get: vi.fn(async () => []), + post: vi.fn(async () => ({ id: 'new-id', name: 'test' })), + put: vi.fn(async () => ({ id: 'existing-id', name: 'test' })), + delete: vi.fn(async () => {}), + } as unknown as ApiClient; +} + +describe('apply command', () => { + let client: ReturnType; + let output: string[]; + let tmpDir: string; + const log = (...args: unknown[]) => output.push(args.map(String).join(' ')); + + beforeEach(() => { + client = mockClient(); + output = []; + tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-test-')); + }); + + it('applies servers from YAML file', async () => { + const configPath = join(tmpDir, 'config.yaml'); + writeFileSync(configPath, ` +servers: + - name: slack + description: Slack MCP server + transport: STDIO + packageName: "@anthropic/slack-mcp" +`); + + const cmd = createApplyCommand({ client, log }); + await cmd.parseAsync([configPath], { from: 'user' }); + + expect(client.post).toHaveBeenCalledWith('/api/v1/servers', expect.objectContaining({ name: 'slack' })); + expect(output.join('\n')).toContain('Created server: slack'); + + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('applies servers from JSON file', async () => { + const configPath = join(tmpDir, 'config.json'); + writeFileSync(configPath, JSON.stringify({ + servers: [{ name: 'github', transport: 'STDIO' }], + })); + + const cmd = createApplyCommand({ client, log }); + await cmd.parseAsync([configPath], { from: 'user' }); + + expect(client.post).toHaveBeenCalledWith('/api/v1/servers', expect.objectContaining({ name: 'github' })); + expect(output.join('\n')).toContain('Created server: github'); + + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('updates existing servers', async () => { + vi.mocked(client.get).mockResolvedValue([{ id: 'srv-1', name: 'slack' }]); + + const configPath = join(tmpDir, 'config.yaml'); + writeFileSync(configPath, ` +servers: + - name: slack + description: Updated description + transport: STDIO +`); + + const cmd = createApplyCommand({ client, log }); + await cmd.parseAsync([configPath], { from: 'user' }); + + expect(client.put).toHaveBeenCalledWith('/api/v1/servers/srv-1', expect.objectContaining({ name: 'slack' })); + expect(output.join('\n')).toContain('Updated server: slack'); + + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('supports dry-run mode', async () => { + const configPath = join(tmpDir, 'config.yaml'); + writeFileSync(configPath, ` +servers: + - name: test + transport: STDIO +profiles: + - name: default + server: test +`); + + const cmd = createApplyCommand({ client, log }); + await cmd.parseAsync([configPath, '--dry-run'], { from: 'user' }); + + expect(client.post).not.toHaveBeenCalled(); + expect(output.join('\n')).toContain('Dry run'); + expect(output.join('\n')).toContain('1 server(s)'); + expect(output.join('\n')).toContain('1 profile(s)'); + + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('applies profiles with server lookup', async () => { + vi.mocked(client.get).mockImplementation(async (url: string) => { + if (url === '/api/v1/servers') return [{ id: 'srv-1', name: 'slack' }]; + return []; + }); + + const configPath = join(tmpDir, 'config.yaml'); + writeFileSync(configPath, ` +profiles: + - name: default + server: slack + envOverrides: + SLACK_TOKEN: "xoxb-test" +`); + + const cmd = createApplyCommand({ client, log }); + await cmd.parseAsync([configPath], { from: 'user' }); + + expect(client.post).toHaveBeenCalledWith('/api/v1/profiles', expect.objectContaining({ + name: 'default', + serverId: 'srv-1', + envOverrides: { SLACK_TOKEN: 'xoxb-test' }, + })); + expect(output.join('\n')).toContain('Created profile: default'); + + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('skips profiles when server not found', async () => { + const configPath = join(tmpDir, 'config.yaml'); + writeFileSync(configPath, ` +profiles: + - name: default + server: nonexistent +`); + + const cmd = createApplyCommand({ client, log }); + await cmd.parseAsync([configPath], { from: 'user' }); + + expect(client.post).not.toHaveBeenCalled(); + expect(output.join('\n')).toContain("Skipping profile 'default'"); + + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('applies projects', async () => { + const configPath = join(tmpDir, 'config.yaml'); + writeFileSync(configPath, ` +projects: + - name: my-project + description: A test project +`); + + const cmd = createApplyCommand({ client, log }); + await cmd.parseAsync([configPath], { from: 'user' }); + + expect(client.post).toHaveBeenCalledWith('/api/v1/projects', expect.objectContaining({ name: 'my-project' })); + expect(output.join('\n')).toContain('Created project: my-project'); + + rmSync(tmpDir, { recursive: true, force: true }); + }); +}); diff --git a/src/cli/tests/commands/setup.test.ts b/src/cli/tests/commands/setup.test.ts new file mode 100644 index 0000000..2c7c15e --- /dev/null +++ b/src/cli/tests/commands/setup.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createSetupCommand } from '../../src/commands/setup.js'; +import type { ApiClient } from '../../src/api-client.js'; +import type { SetupPromptDeps } from '../../src/commands/setup.js'; + +function mockClient(): ApiClient { + return { + get: vi.fn(async () => []), + post: vi.fn(async () => ({ id: 'new-id', name: 'test' })), + put: vi.fn(async () => ({})), + delete: vi.fn(async () => {}), + } as unknown as ApiClient; +} + +function mockPrompt(answers: Record): SetupPromptDeps { + const answersQueue = { ...answers }; + return { + input: vi.fn(async (message: string) => { + for (const [key, val] of Object.entries(answersQueue)) { + if (message.toLowerCase().includes(key.toLowerCase()) && typeof val === 'string') { + delete answersQueue[key]; + return val; + } + } + return ''; + }), + password: vi.fn(async () => 'secret-value'), + select: vi.fn(async () => 'STDIO') as SetupPromptDeps['select'], + confirm: vi.fn(async (message: string) => { + if (message.includes('profile')) return true; + if (message.includes('secret')) return false; + if (message.includes('another')) return false; + return false; + }), + }; +} + +describe('setup command', () => { + let client: ReturnType; + let output: string[]; + const log = (...args: unknown[]) => output.push(args.map(String).join(' ')); + + beforeEach(() => { + client = mockClient(); + output = []; + }); + + it('creates server with prompted values', async () => { + const prompt = mockPrompt({ + 'transport': 'STDIO', + 'npm package': '@anthropic/slack-mcp', + 'docker image': '', + 'description': 'Slack server', + 'profile name': 'default', + 'environment variable name': '', + }); + + const cmd = createSetupCommand({ client, prompt, log }); + await cmd.parseAsync(['slack'], { from: 'user' }); + + expect(client.post).toHaveBeenCalledWith('/api/v1/servers', expect.objectContaining({ + name: 'slack', + transport: 'STDIO', + })); + expect(output.join('\n')).toContain("Server 'test' created"); + }); + + it('creates profile with env vars', async () => { + vi.mocked(client.post) + .mockResolvedValueOnce({ id: 'srv-1', name: 'slack' }) // server create + .mockResolvedValueOnce({ id: 'prof-1', name: 'default' }); // profile create + + const prompt = mockPrompt({ + 'transport': 'STDIO', + 'npm package': '', + 'docker image': '', + 'description': '', + 'profile name': 'default', + }); + // Override confirm to create profile and add one env var + let confirmCallCount = 0; + vi.mocked(prompt.confirm).mockImplementation(async (msg: string) => { + confirmCallCount++; + if (msg.includes('profile')) return true; + if (msg.includes('secret')) return true; + if (msg.includes('another')) return false; + return false; + }); + // Override input to provide env var name then empty to stop + let inputCallCount = 0; + vi.mocked(prompt.input).mockImplementation(async (msg: string) => { + inputCallCount++; + if (msg.includes('Profile name')) return 'default'; + if (msg.includes('variable name') && inputCallCount <= 8) return 'API_KEY'; + if (msg.includes('variable name')) return ''; + return ''; + }); + + const cmd = createSetupCommand({ client, prompt, log }); + await cmd.parseAsync(['slack'], { from: 'user' }); + + expect(client.post).toHaveBeenCalledTimes(2); + const profileCall = vi.mocked(client.post).mock.calls[1]; + expect(profileCall?.[0]).toBe('/api/v1/profiles'); + expect(profileCall?.[1]).toEqual(expect.objectContaining({ + name: 'default', + serverId: 'srv-1', + })); + }); + + it('exits if server creation fails', async () => { + vi.mocked(client.post).mockRejectedValue(new Error('conflict')); + + const prompt = mockPrompt({ + 'npm package': '', + 'docker image': '', + 'description': '', + }); + + const cmd = createSetupCommand({ client, prompt, log }); + await cmd.parseAsync(['slack'], { from: 'user' }); + + expect(output.join('\n')).toContain('Failed to create server'); + expect(client.post).toHaveBeenCalledTimes(1); // Only server create, no profile + }); + + it('skips profile creation when declined', async () => { + const prompt = mockPrompt({ + 'npm package': '', + 'docker image': '', + 'description': '', + }); + vi.mocked(prompt.confirm).mockResolvedValue(false); + + const cmd = createSetupCommand({ client, prompt, log }); + await cmd.parseAsync(['test-server'], { from: 'user' }); + + expect(client.post).toHaveBeenCalledTimes(1); // Only server create + expect(output.join('\n')).toContain('Setup complete'); + }); +});