diff --git a/src/cli/src/commands/instances.ts b/src/cli/src/commands/instances.ts new file mode 100644 index 0000000..1bcb55d --- /dev/null +++ b/src/cli/src/commands/instances.ts @@ -0,0 +1,123 @@ +import { Command } from 'commander'; +import type { ApiClient } from '../api-client.js'; + +interface Instance { + id: string; + serverId: string; + status: string; + containerId: string | null; + port: number | null; + createdAt: string; +} + +export interface InstanceCommandDeps { + client: ApiClient; + log: (...args: unknown[]) => void; +} + +export function createInstanceCommands(deps: InstanceCommandDeps): Command { + const { client, log } = deps; + const cmd = new Command('instance') + .alias('instances') + .alias('inst') + .description('Manage MCP server instances'); + + cmd + .command('list') + .alias('ls') + .description('List running instances') + .option('-s, --server ', 'Filter by server ID') + .option('-o, --output ', 'Output format (table, json)', 'table') + .action(async (opts: { server?: string; output: string }) => { + let url = '/api/v1/instances'; + if (opts.server) { + url += `?serverId=${encodeURIComponent(opts.server)}`; + } + const instances = await client.get(url); + if (opts.output === 'json') { + log(JSON.stringify(instances, null, 2)); + return; + } + if (instances.length === 0) { + log('No instances found.'); + return; + } + log('ID\tSERVER\tSTATUS\tPORT\tCONTAINER'); + for (const inst of instances) { + const cid = inst.containerId ? inst.containerId.slice(0, 12) : '-'; + const port = inst.port ?? '-'; + log(`${inst.id}\t${inst.serverId}\t${inst.status}\t${port}\t${cid}`); + } + }); + + cmd + .command('start ') + .description('Start a new MCP server instance') + .option('-p, --port ', 'Host port to bind') + .option('-o, --output ', 'Output format (table, json)', 'table') + .action(async (serverId: string, opts: { port?: string; output: string }) => { + const body: Record = { serverId }; + if (opts.port !== undefined) { + body.hostPort = parseInt(opts.port, 10); + } + const instance = await client.post('/api/v1/instances', body); + if (opts.output === 'json') { + log(JSON.stringify(instance, null, 2)); + return; + } + log(`Instance ${instance.id} started (status: ${instance.status})`); + }); + + cmd + .command('stop ') + .description('Stop a running instance') + .action(async (id: string) => { + const instance = await client.post(`/api/v1/instances/${id}/stop`); + log(`Instance ${id} stopped (status: ${instance.status})`); + }); + + cmd + .command('restart ') + .description('Restart an instance (stop, remove, start fresh)') + .action(async (id: string) => { + const instance = await client.post(`/api/v1/instances/${id}/restart`); + log(`Instance restarted as ${instance.id} (status: ${instance.status})`); + }); + + cmd + .command('remove ') + .alias('rm') + .description('Remove an instance and its container') + .action(async (id: string) => { + await client.delete(`/api/v1/instances/${id}`); + log(`Instance ${id} removed.`); + }); + + cmd + .command('logs ') + .description('Get logs from an instance') + .option('-t, --tail ', 'Number of lines to show') + .action(async (id: string, opts: { tail?: string }) => { + let url = `/api/v1/instances/${id}/logs`; + if (opts.tail) { + url += `?tail=${opts.tail}`; + } + const logs = await client.get<{ stdout: string; stderr: string }>(url); + if (logs.stdout) { + log(logs.stdout); + } + if (logs.stderr) { + process.stderr.write(logs.stderr); + } + }); + + cmd + .command('inspect ') + .description('Get detailed container info for an instance') + .action(async (id: string) => { + const info = await client.get(`/api/v1/instances/${id}/inspect`); + log(JSON.stringify(info, null, 2)); + }); + + return cmd; +} diff --git a/src/cli/src/index.ts b/src/cli/src/index.ts index 345a623..f76c2bb 100644 --- a/src/cli/src/index.ts +++ b/src/cli/src/index.ts @@ -5,6 +5,7 @@ import { createConfigCommand } from './commands/config.js'; 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 { ApiClient } from './api-client.js'; import { loadConfig } from './config/index.js'; @@ -46,6 +47,11 @@ export function createProgram(): Command { log: (...args) => console.log(...args), })); + program.addCommand(createInstanceCommands({ + client, + log: (...args) => console.log(...args), + })); + return program; } diff --git a/src/cli/tests/commands/instances.test.ts b/src/cli/tests/commands/instances.test.ts new file mode 100644 index 0000000..9820af1 --- /dev/null +++ b/src/cli/tests/commands/instances.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createInstanceCommands } from '../../src/commands/instances.js'; +import type { ApiClient } from '../../src/api-client.js'; + +function mockClient(): ApiClient { + return { + get: vi.fn(async () => []), + post: vi.fn(async () => ({})), + put: vi.fn(async () => ({})), + delete: vi.fn(async () => {}), + } as unknown as ApiClient; +} + +describe('instance commands', () => { + let client: ReturnType; + let output: string[]; + const log = (...args: unknown[]) => output.push(args.map(String).join(' ')); + + beforeEach(() => { + client = mockClient(); + output = []; + }); + + describe('list', () => { + it('shows no instances message when empty', async () => { + const cmd = createInstanceCommands({ client, log }); + await cmd.parseAsync(['list'], { from: 'user' }); + expect(output.join('\n')).toContain('No instances found'); + }); + + it('shows instance table', async () => { + vi.mocked(client.get).mockResolvedValue([ + { id: 'inst-1', serverId: 'srv-1', status: 'RUNNING', containerId: 'ctr-abc123def', port: 3000, createdAt: '2025-01-01' }, + ]); + const cmd = createInstanceCommands({ client, log }); + await cmd.parseAsync(['list'], { from: 'user' }); + expect(output.join('\n')).toContain('inst-1'); + expect(output.join('\n')).toContain('RUNNING'); + }); + + it('filters by server', async () => { + const cmd = createInstanceCommands({ client, log }); + await cmd.parseAsync(['list', '-s', 'srv-1'], { from: 'user' }); + expect(client.get).toHaveBeenCalledWith(expect.stringContaining('serverId=srv-1')); + }); + + it('outputs json', async () => { + vi.mocked(client.get).mockResolvedValue([{ id: 'inst-1' }]); + const cmd = createInstanceCommands({ client, log }); + await cmd.parseAsync(['list', '-o', 'json'], { from: 'user' }); + expect(output[0]).toContain('"id"'); + }); + }); + + describe('start', () => { + it('starts an instance', async () => { + vi.mocked(client.post).mockResolvedValue({ id: 'inst-new', status: 'RUNNING' }); + const cmd = createInstanceCommands({ client, log }); + await cmd.parseAsync(['start', 'srv-1'], { from: 'user' }); + expect(client.post).toHaveBeenCalledWith('/api/v1/instances', { serverId: 'srv-1' }); + expect(output.join('\n')).toContain('started'); + }); + + it('passes host port', async () => { + vi.mocked(client.post).mockResolvedValue({ id: 'inst-new', status: 'RUNNING' }); + const cmd = createInstanceCommands({ client, log }); + await cmd.parseAsync(['start', 'srv-1', '-p', '8080'], { from: 'user' }); + expect(client.post).toHaveBeenCalledWith('/api/v1/instances', { serverId: 'srv-1', hostPort: 8080 }); + }); + }); + + describe('stop', () => { + it('stops an instance', async () => { + vi.mocked(client.post).mockResolvedValue({ id: 'inst-1', status: 'STOPPED' }); + const cmd = createInstanceCommands({ client, log }); + await cmd.parseAsync(['stop', 'inst-1'], { from: 'user' }); + expect(client.post).toHaveBeenCalledWith('/api/v1/instances/inst-1/stop'); + expect(output.join('\n')).toContain('stopped'); + }); + }); + + describe('restart', () => { + it('restarts an instance', async () => { + vi.mocked(client.post).mockResolvedValue({ id: 'inst-2', status: 'RUNNING' }); + const cmd = createInstanceCommands({ client, log }); + await cmd.parseAsync(['restart', 'inst-1'], { from: 'user' }); + expect(client.post).toHaveBeenCalledWith('/api/v1/instances/inst-1/restart'); + expect(output.join('\n')).toContain('restarted'); + }); + }); + + describe('remove', () => { + it('removes an instance', async () => { + const cmd = createInstanceCommands({ client, log }); + await cmd.parseAsync(['remove', 'inst-1'], { from: 'user' }); + expect(client.delete).toHaveBeenCalledWith('/api/v1/instances/inst-1'); + expect(output.join('\n')).toContain('removed'); + }); + }); + + describe('logs', () => { + it('shows logs', async () => { + vi.mocked(client.get).mockResolvedValue({ stdout: 'hello world\n', stderr: '' }); + const cmd = createInstanceCommands({ client, log }); + await cmd.parseAsync(['logs', 'inst-1'], { from: 'user' }); + expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/logs'); + expect(output.join('\n')).toContain('hello world'); + }); + + it('passes tail option', async () => { + vi.mocked(client.get).mockResolvedValue({ stdout: '', stderr: '' }); + const cmd = createInstanceCommands({ client, log }); + await cmd.parseAsync(['logs', 'inst-1', '-t', '50'], { from: 'user' }); + expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/logs?tail=50'); + }); + }); + + describe('inspect', () => { + it('shows container info as json', async () => { + vi.mocked(client.get).mockResolvedValue({ containerId: 'ctr-abc', state: 'running' }); + const cmd = createInstanceCommands({ client, log }); + await cmd.parseAsync(['inspect', 'inst-1'], { from: 'user' }); + expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/inspect'); + expect(output[0]).toContain('ctr-abc'); + }); + }); +}); diff --git a/src/mcpd/src/routes/instances.ts b/src/mcpd/src/routes/instances.ts index a410f35..944b4a5 100644 --- a/src/mcpd/src/routes/instances.ts +++ b/src/mcpd/src/routes/instances.ts @@ -31,6 +31,14 @@ export function registerInstanceRoutes(app: FastifyInstance, service: InstanceSe return service.stop(request.params.id); }); + app.post<{ Params: { id: string } }>('/api/v1/instances/:id/restart', async (request) => { + return service.restart(request.params.id); + }); + + app.get<{ Params: { id: string } }>('/api/v1/instances/:id/inspect', async (request) => { + return service.inspect(request.params.id); + }); + app.delete<{ Params: { id: string } }>('/api/v1/instances/:id', async (request, reply) => { await service.remove(request.params.id); reply.code(204); diff --git a/src/mcpd/src/services/index.ts b/src/mcpd/src/services/index.ts index b9f94c1..dfa7f15 100644 --- a/src/mcpd/src/services/index.ts +++ b/src/mcpd/src/services/index.ts @@ -1,7 +1,7 @@ export { McpServerService, NotFoundError, ConflictError } from './mcp-server.service.js'; export { McpProfileService } from './mcp-profile.service.js'; export { ProjectService } from './project.service.js'; -export { InstanceService } from './instance.service.js'; +export { InstanceService, InvalidStateError } from './instance.service.js'; export { generateMcpConfig } from './mcp-config-generator.js'; export type { McpConfig, McpConfigServer, ProfileWithServer } from './mcp-config-generator.js'; export type { McpOrchestrator, ContainerSpec, ContainerInfo, ContainerLogs } from './orchestrator.js'; diff --git a/src/mcpd/src/services/instance.service.ts b/src/mcpd/src/services/instance.service.ts index db07133..3eb25b4 100644 --- a/src/mcpd/src/services/instance.service.ts +++ b/src/mcpd/src/services/instance.service.ts @@ -1,8 +1,16 @@ import type { McpInstance } from '@prisma/client'; import type { IMcpInstanceRepository, IMcpServerRepository } from '../repositories/interfaces.js'; -import type { McpOrchestrator, ContainerSpec } from './orchestrator.js'; +import type { McpOrchestrator, ContainerSpec, ContainerInfo } from './orchestrator.js'; import { NotFoundError } from './mcp-server.service.js'; +export class InvalidStateError extends Error { + readonly statusCode = 409; + constructor(message: string) { + super(message); + this.name = 'InvalidStateError'; + } +} + export class InstanceService { constructor( private instanceRepo: IMcpInstanceRepository, @@ -71,6 +79,9 @@ export class InstanceService { async stop(id: string): Promise { const instance = await this.getById(id); + if (instance.status === 'STOPPED') { + throw new InvalidStateError(`Instance '${id}' is already stopped`); + } if (!instance.containerId) { return this.instanceRepo.updateStatus(id, 'STOPPED'); } @@ -87,6 +98,37 @@ export class InstanceService { } } + async restart(id: string): Promise { + const instance = await this.getById(id); + + // Stop if running + if (instance.containerId && (instance.status === 'RUNNING' || instance.status === 'STARTING')) { + try { + await this.orchestrator.stopContainer(instance.containerId); + } catch { + // Container may already be stopped + } + try { + await this.orchestrator.removeContainer(instance.containerId, true); + } catch { + // Container may already be gone + } + } + + await this.instanceRepo.delete(id); + + // Start a fresh instance for the same server + return this.start(instance.serverId); + } + + async inspect(id: string): Promise { + const instance = await this.getById(id); + if (!instance.containerId) { + throw new InvalidStateError(`Instance '${id}' has no container`); + } + return this.orchestrator.inspectContainer(instance.containerId); + } + async remove(id: string): Promise { const instance = await this.getById(id); diff --git a/src/mcpd/tests/instance-service.test.ts b/src/mcpd/tests/instance-service.test.ts index 0dad241..102e02a 100644 --- a/src/mcpd/tests/instance-service.test.ts +++ b/src/mcpd/tests/instance-service.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { InstanceService } from '../src/services/instance.service.js'; +import { InstanceService, InvalidStateError } from '../src/services/instance.service.js'; import { NotFoundError } from '../src/services/mcp-server.service.js'; import type { IMcpInstanceRepository, IMcpServerRepository } from '../src/repositories/interfaces.js'; import type { McpOrchestrator } from '../src/services/orchestrator.js'; @@ -195,6 +195,83 @@ describe('InstanceService', () => { expect(orchestrator.stopContainer).not.toHaveBeenCalled(); expect(instanceRepo.updateStatus).toHaveBeenCalledWith('inst-1', 'STOPPED'); }); + + it('throws InvalidStateError when already stopped', async () => { + vi.mocked(instanceRepo.findById).mockResolvedValue({ + id: 'inst-1', containerId: 'ctr-abc', status: 'STOPPED', + serverId: 'srv-1', port: null, metadata: {}, + version: 1, createdAt: new Date(), updatedAt: new Date(), + }); + + await expect(service.stop('inst-1')).rejects.toThrow(InvalidStateError); + }); + }); + + describe('restart', () => { + it('stops, removes, and starts a new instance', async () => { + vi.mocked(instanceRepo.findById).mockResolvedValue({ + id: 'inst-1', containerId: 'ctr-abc', status: 'RUNNING', + serverId: 'srv-1', port: 3000, metadata: {}, + version: 1, createdAt: new Date(), updatedAt: new Date(), + }); + vi.mocked(serverRepo.findById).mockResolvedValue({ + id: 'srv-1', name: 'slack', dockerImage: 'slack:latest', + packageName: null, transport: 'STDIO', description: '', repositoryUrl: null, + envTemplate: [], version: 1, createdAt: new Date(), updatedAt: new Date(), + }); + + const result = await service.restart('inst-1'); + + expect(orchestrator.stopContainer).toHaveBeenCalledWith('ctr-abc'); + expect(orchestrator.removeContainer).toHaveBeenCalledWith('ctr-abc', true); + expect(instanceRepo.delete).toHaveBeenCalledWith('inst-1'); + expect(instanceRepo.create).toHaveBeenCalled(); + expect(result.status).toBe('RUNNING'); + }); + + it('handles restart when container already stopped', async () => { + vi.mocked(instanceRepo.findById).mockResolvedValue({ + id: 'inst-1', containerId: 'ctr-abc', status: 'STOPPED', + serverId: 'srv-1', port: null, metadata: {}, + version: 1, createdAt: new Date(), updatedAt: new Date(), + }); + vi.mocked(serverRepo.findById).mockResolvedValue({ + id: 'srv-1', name: 'slack', dockerImage: 'slack:latest', + packageName: null, transport: 'STDIO', description: '', repositoryUrl: null, + envTemplate: [], version: 1, createdAt: new Date(), updatedAt: new Date(), + }); + + const result = await service.restart('inst-1'); + + // Should not try to stop an already-stopped container + expect(orchestrator.stopContainer).not.toHaveBeenCalled(); + expect(instanceRepo.delete).toHaveBeenCalledWith('inst-1'); + expect(result.status).toBe('RUNNING'); + }); + }); + + describe('inspect', () => { + it('returns container info', async () => { + vi.mocked(instanceRepo.findById).mockResolvedValue({ + id: 'inst-1', containerId: 'ctr-abc', status: 'RUNNING', + serverId: 'srv-1', port: 3000, metadata: {}, + version: 1, createdAt: new Date(), updatedAt: new Date(), + }); + + const result = await service.inspect('inst-1'); + expect(orchestrator.inspectContainer).toHaveBeenCalledWith('ctr-abc'); + expect(result.containerId).toBe('ctr-abc123'); + }); + + it('throws InvalidStateError when no container', async () => { + vi.mocked(instanceRepo.findById).mockResolvedValue({ + id: 'inst-1', containerId: null, status: 'ERROR', + serverId: 'srv-1', port: null, metadata: {}, + version: 1, createdAt: new Date(), updatedAt: new Date(), + }); + + await expect(service.inspect('inst-1')).rejects.toThrow(InvalidStateError); + }); }); describe('remove', () => {