From 467357c2c6aea238200a84639f5b3d730b25377b Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 22 Feb 2026 13:30:46 +0000 Subject: [PATCH] feat: kubectl-style CLI + Deployment/Pod model for servers/instances MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server = Deployment (defines what to run + desired replicas) Instance = Pod (ephemeral, auto-created by reconciliation) Backend: - Add replicas field to McpServer schema - Add reconcile() to InstanceService (scales instances to match replicas) - Remove manual start/stop/restart - instances are auto-managed - Cascade: deleting server stops all containers then cascades DB - Server create/update auto-triggers reconciliation CLI: - Add top-level delete command (servers, instances, profiles, projects) - Add top-level logs command - Remove instance compound command (use get/delete/logs instead) - Clean up project command (list/show/delete → top-level get/describe/delete) - Enhance describe for instances with container inspect info - Add replicas to apply command's ServerSpec Co-Authored-By: Claude Opus 4.6 --- src/cli/src/commands/apply.ts | 1 + src/cli/src/commands/delete.ts | 54 +++ src/cli/src/commands/describe.ts | 15 +- src/cli/src/commands/instances.ts | 123 ------- src/cli/src/commands/logs.ts | 29 ++ src/cli/src/commands/project.ts | 59 +-- src/cli/src/index.ts | 11 +- src/cli/tests/commands/instances.test.ts | 152 +++----- src/cli/tests/commands/project.test.ts | 47 --- src/cli/tests/e2e/cli-commands.test.ts | 27 +- src/db/prisma/schema.prisma | 1 + src/mcpd/src/main.ts | 5 +- .../src/repositories/mcp-server.repository.ts | 2 + src/mcpd/src/routes/instances.ts | 33 +- src/mcpd/src/routes/mcp-servers.ts | 14 +- src/mcpd/src/services/instance.service.ts | 181 ++++++---- src/mcpd/src/services/mcp-server.service.ts | 12 + src/mcpd/src/validation/mcp-server.schema.ts | 2 + src/mcpd/tests/instance-service.test.ts | 341 ++++++++---------- src/mcpd/tests/mcp-server-flow.test.ts | 166 ++++----- src/mcpd/tests/mcp-server-routes.test.ts | 127 +++++-- 21 files changed, 638 insertions(+), 764 deletions(-) create mode 100644 src/cli/src/commands/delete.ts delete mode 100644 src/cli/src/commands/instances.ts create mode 100644 src/cli/src/commands/logs.ts diff --git a/src/cli/src/commands/apply.ts b/src/cli/src/commands/apply.ts index 8a7007f..e731022 100644 --- a/src/cli/src/commands/apply.ts +++ b/src/cli/src/commands/apply.ts @@ -14,6 +14,7 @@ const ServerSpecSchema = z.object({ externalUrl: z.string().url().optional(), command: z.array(z.string()).optional(), containerPort: z.number().int().min(1).max(65535).optional(), + replicas: z.number().int().min(0).max(10).default(1), envTemplate: z.array(z.object({ name: z.string(), description: z.string().default(''), diff --git a/src/cli/src/commands/delete.ts b/src/cli/src/commands/delete.ts new file mode 100644 index 0000000..719f6d4 --- /dev/null +++ b/src/cli/src/commands/delete.ts @@ -0,0 +1,54 @@ +import { Command } from 'commander'; +import type { ApiClient } from '../api-client.js'; + +const RESOURCE_ALIASES: Record = { + 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; +} + +export interface DeleteCommandDeps { + client: ApiClient; + log: (...args: unknown[]) => void; +} + +export function createDeleteCommand(deps: DeleteCommandDeps): Command { + const { client, log } = deps; + + return new Command('delete') + .description('Delete a resource (server, instance, profile, project)') + .argument('', 'resource type') + .argument('', 'resource ID or name') + .action(async (resourceArg: string, idOrName: string) => { + const resource = resolveResource(resourceArg); + + // Try to resolve name → ID for servers + let id = idOrName; + if (resource === 'servers' && !idOrName.match(/^c[a-z0-9]{24}/)) { + try { + const servers = await client.get>(`/api/v1/${resource}`); + const match = servers.find((s) => s.name === idOrName); + if (match) { + id = match.id; + } + } catch { + // Fall through with original id + } + } + + await client.delete(`/api/v1/${resource}/${id}`); + + const singular = resource.replace(/s$/, ''); + log(`${singular} '${idOrName}' deleted.`); + }); +} diff --git a/src/cli/src/commands/describe.ts b/src/cli/src/commands/describe.ts index a3a16e5..675c348 100644 --- a/src/cli/src/commands/describe.ts +++ b/src/cli/src/commands/describe.ts @@ -3,6 +3,7 @@ import { formatJson, formatYaml } from '../formatters/output.js'; export interface DescribeCommandDeps { fetchResource: (resource: string, id: string) => Promise; + fetchInspect?: (id: string) => Promise; log: (...args: string[]) => void; } @@ -59,7 +60,17 @@ export function createDescribeCommand(deps: DescribeCommandDeps): Command { .option('-o, --output ', '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); + const item = await deps.fetchResource(resource, id) as Record; + + // Enrich instances with container inspect data + if (resource === 'instances' && deps.fetchInspect && item.containerId) { + try { + const inspect = await deps.fetchInspect(id); + item.containerInspect = inspect; + } catch { + // Container may not be available + } + } if (opts.output === 'json') { deps.log(formatJson(item)); @@ -68,7 +79,7 @@ export function createDescribeCommand(deps: DescribeCommandDeps): Command { } else { const typeName = resource.replace(/s$/, '').charAt(0).toUpperCase() + resource.replace(/s$/, '').slice(1); deps.log(`--- ${typeName} ---`); - deps.log(formatDetail(item as Record)); + deps.log(formatDetail(item)); } }); } diff --git a/src/cli/src/commands/instances.ts b/src/cli/src/commands/instances.ts deleted file mode 100644 index 1bcb55d..0000000 --- a/src/cli/src/commands/instances.ts +++ /dev/null @@ -1,123 +0,0 @@ -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/commands/logs.ts b/src/cli/src/commands/logs.ts new file mode 100644 index 0000000..5b50083 --- /dev/null +++ b/src/cli/src/commands/logs.ts @@ -0,0 +1,29 @@ +import { Command } from 'commander'; +import type { ApiClient } from '../api-client.js'; + +export interface LogsCommandDeps { + client: ApiClient; + log: (...args: unknown[]) => void; +} + +export function createLogsCommand(deps: LogsCommandDeps): Command { + const { client, log } = deps; + + return new Command('logs') + .description('Get logs from an MCP server instance') + .argument('', 'Instance ID') + .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); + } + }); +} diff --git a/src/cli/src/commands/project.ts b/src/cli/src/commands/project.ts index 275a111..49ae894 100644 --- a/src/cli/src/commands/project.ts +++ b/src/cli/src/commands/project.ts @@ -24,30 +24,8 @@ export function createProjectCommand(deps: ProjectCommandDeps): Command { const { client, log } = deps; const cmd = new Command('project') - .alias('projects') .alias('proj') - .description('Manage mcpctl projects'); - - cmd - .command('list') - .alias('ls') - .description('List all projects') - .option('-o, --output ', 'Output format (table, json)', 'table') - .action(async (opts: { output: string }) => { - const projects = await client.get('/api/v1/projects'); - if (opts.output === 'json') { - log(JSON.stringify(projects, null, 2)); - return; - } - if (projects.length === 0) { - log('No projects found.'); - return; - } - log('ID\tNAME\tDESCRIPTION'); - for (const p of projects) { - log(`${p.id}\t${p.name}\t${p.description || '-'}`); - } - }); + .description('Project-specific actions (use "get projects" to list, "delete project" to remove)'); cmd .command('create ') @@ -61,41 +39,6 @@ export function createProjectCommand(deps: ProjectCommandDeps): Command { log(`Project '${project.name}' created (id: ${project.id})`); }); - cmd - .command('delete ') - .alias('rm') - .description('Delete a project') - .action(async (id: string) => { - await client.delete(`/api/v1/projects/${id}`); - log(`Project '${id}' deleted.`); - }); - - cmd - .command('show ') - .description('Show project details') - .action(async (id: string) => { - const project = await client.get(`/api/v1/projects/${id}`); - log(`Name: ${project.name}`); - log(`ID: ${project.id}`); - log(`Description: ${project.description || '-'}`); - log(`Owner: ${project.ownerId}`); - log(`Created: ${project.createdAt}`); - - try { - const profiles = await client.get(`/api/v1/projects/${id}/profiles`); - if (profiles.length > 0) { - log('\nProfiles:'); - for (const p of profiles) { - log(` - ${p.name} (id: ${p.id})`); - } - } else { - log('\nNo profiles assigned.'); - } - } catch { - // Profiles endpoint may not be available - } - }); - cmd .command('profiles ') .description('List profiles assigned to a project') diff --git a/src/cli/src/index.ts b/src/cli/src/index.ts index 2b58926..793cfe6 100644 --- a/src/cli/src/index.ts +++ b/src/cli/src/index.ts @@ -5,7 +5,8 @@ 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 { createDeleteCommand } from './commands/delete.js'; +import { createLogsCommand } from './commands/logs.js'; import { createApplyCommand } from './commands/apply.js'; import { createSetupCommand } from './commands/setup.js'; import { createClaudeCommand } from './commands/claude.js'; @@ -64,10 +65,16 @@ export function createProgram(): Command { program.addCommand(createDescribeCommand({ fetchResource: fetchSingleResource, + fetchInspect: async (id: string) => client.get(`/api/v1/instances/${id}/inspect`), log: (...args) => console.log(...args), })); - program.addCommand(createInstanceCommands({ + program.addCommand(createDeleteCommand({ + client, + log: (...args) => console.log(...args), + })); + + program.addCommand(createLogsCommand({ client, log: (...args) => console.log(...args), })); diff --git a/src/cli/tests/commands/instances.test.ts b/src/cli/tests/commands/instances.test.ts index 9820af1..8080119 100644 --- a/src/cli/tests/commands/instances.test.ts +++ b/src/cli/tests/commands/instances.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { createInstanceCommands } from '../../src/commands/instances.js'; +import { createDeleteCommand } from '../../src/commands/delete.js'; +import { createLogsCommand } from '../../src/commands/logs.js'; import type { ApiClient } from '../../src/api-client.js'; function mockClient(): ApiClient { @@ -11,7 +12,7 @@ function mockClient(): ApiClient { } as unknown as ApiClient; } -describe('instance commands', () => { +describe('delete command', () => { let client: ReturnType; let output: string[]; const log = (...args: unknown[]) => output.push(args.map(String).join(' ')); @@ -21,107 +22,70 @@ describe('instance commands', () => { 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"'); - }); + it('deletes an instance by ID', async () => { + const cmd = createDeleteCommand({ client, log }); + await cmd.parseAsync(['instance', 'inst-1'], { from: 'user' }); + expect(client.delete).toHaveBeenCalledWith('/api/v1/instances/inst-1'); + expect(output.join('\n')).toContain('deleted'); }); - 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 }); - }); + it('deletes a server by ID', async () => { + const cmd = createDeleteCommand({ client, log }); + await cmd.parseAsync(['server', 'srv-1'], { from: 'user' }); + expect(client.delete).toHaveBeenCalledWith('/api/v1/servers/srv-1'); + expect(output.join('\n')).toContain('deleted'); }); - 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'); - }); + it('resolves server name to ID', async () => { + vi.mocked(client.get).mockResolvedValue([ + { id: 'srv-abc', name: 'ha-mcp' }, + ]); + const cmd = createDeleteCommand({ client, log }); + await cmd.parseAsync(['server', 'ha-mcp'], { from: 'user' }); + expect(client.delete).toHaveBeenCalledWith('/api/v1/servers/srv-abc'); }); - 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'); - }); + it('deletes a profile', async () => { + const cmd = createDeleteCommand({ client, log }); + await cmd.parseAsync(['profile', 'prof-1'], { from: 'user' }); + expect(client.delete).toHaveBeenCalledWith('/api/v1/profiles/prof-1'); }); - 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'); - }); + it('deletes a project', async () => { + const cmd = createDeleteCommand({ client, log }); + await cmd.parseAsync(['project', 'proj-1'], { from: 'user' }); + expect(client.delete).toHaveBeenCalledWith('/api/v1/projects/proj-1'); }); - 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'); - }); + it('accepts resource aliases', async () => { + const cmd = createDeleteCommand({ client, log }); + await cmd.parseAsync(['srv', 'srv-1'], { from: 'user' }); + expect(client.delete).toHaveBeenCalledWith('/api/v1/servers/srv-1'); + }); +}); + +describe('logs command', () => { + let client: ReturnType; + let output: string[]; + const log = (...args: unknown[]) => output.push(args.map(String).join(' ')); + + beforeEach(() => { + client = mockClient(); + output = []; + }); + + it('shows logs', async () => { + vi.mocked(client.get).mockResolvedValue({ stdout: 'hello world\n', stderr: '' }); + const cmd = createLogsCommand({ client, log }); + await cmd.parseAsync(['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 = createLogsCommand({ client, log }); + await cmd.parseAsync(['inst-1', '-t', '50'], { from: 'user' }); + expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/logs?tail=50'); }); }); diff --git a/src/cli/tests/commands/project.test.ts b/src/cli/tests/commands/project.test.ts index bcc93c0..9e665a5 100644 --- a/src/cli/tests/commands/project.test.ts +++ b/src/cli/tests/commands/project.test.ts @@ -21,31 +21,6 @@ describe('project command', () => { output = []; }); - describe('list', () => { - it('shows no projects message when empty', async () => { - const cmd = createProjectCommand({ client, log }); - await cmd.parseAsync(['list'], { from: 'user' }); - expect(output.join('\n')).toContain('No projects found'); - }); - - it('shows project table', async () => { - vi.mocked(client.get).mockResolvedValue([ - { id: 'proj-1', name: 'dev', description: 'Dev project', ownerId: 'user-1', createdAt: '2025-01-01' }, - ]); - const cmd = createProjectCommand({ client, log }); - await cmd.parseAsync(['list'], { from: 'user' }); - expect(output.join('\n')).toContain('proj-1'); - expect(output.join('\n')).toContain('dev'); - }); - - it('outputs json', async () => { - vi.mocked(client.get).mockResolvedValue([{ id: 'proj-1', name: 'dev' }]); - const cmd = createProjectCommand({ client, log }); - await cmd.parseAsync(['list', '-o', 'json'], { from: 'user' }); - expect(output[0]).toContain('"id"'); - }); - }); - describe('create', () => { it('creates a project', async () => { const cmd = createProjectCommand({ client, log }); @@ -58,28 +33,6 @@ describe('project command', () => { }); }); - describe('delete', () => { - it('deletes a project', async () => { - const cmd = createProjectCommand({ client, log }); - await cmd.parseAsync(['delete', 'proj-1'], { from: 'user' }); - expect(client.delete).toHaveBeenCalledWith('/api/v1/projects/proj-1'); - expect(output.join('\n')).toContain('deleted'); - }); - }); - - describe('show', () => { - it('shows project details', async () => { - vi.mocked(client.get).mockImplementation(async (url: string) => { - if (url.endsWith('/profiles')) return []; - return { id: 'proj-1', name: 'dev', description: 'Dev project', ownerId: 'user-1', createdAt: '2025-01-01' }; - }); - const cmd = createProjectCommand({ client, log }); - await cmd.parseAsync(['show', 'proj-1'], { from: 'user' }); - expect(output.join('\n')).toContain('Name: dev'); - expect(output.join('\n')).toContain('ID: proj-1'); - }); - }); - describe('profiles', () => { it('lists profiles for a project', async () => { vi.mocked(client.get).mockResolvedValue([ diff --git a/src/cli/tests/e2e/cli-commands.test.ts b/src/cli/tests/e2e/cli-commands.test.ts index 48f7f73..1a604e6 100644 --- a/src/cli/tests/e2e/cli-commands.test.ts +++ b/src/cli/tests/e2e/cli-commands.test.ts @@ -16,26 +16,18 @@ describe('CLI command registration (e2e)', () => { expect(commandNames).toContain('logout'); expect(commandNames).toContain('get'); expect(commandNames).toContain('describe'); - expect(commandNames).toContain('instance'); + expect(commandNames).toContain('delete'); + expect(commandNames).toContain('logs'); expect(commandNames).toContain('apply'); expect(commandNames).toContain('setup'); expect(commandNames).toContain('claude'); expect(commandNames).toContain('project'); }); - it('instance command has lifecycle subcommands', () => { + it('instance command is removed (use get/delete/logs instead)', () => { const program = createProgram(); - const instance = program.commands.find((c) => c.name() === 'instance'); - expect(instance).toBeDefined(); - - const subcommands = instance!.commands.map((c) => c.name()); - expect(subcommands).toContain('list'); - expect(subcommands).toContain('start'); - expect(subcommands).toContain('stop'); - expect(subcommands).toContain('restart'); - expect(subcommands).toContain('remove'); - expect(subcommands).toContain('logs'); - expect(subcommands).toContain('inspect'); + const commandNames = program.commands.map((c) => c.name()); + expect(commandNames).not.toContain('instance'); }); it('claude command has config management subcommands', () => { @@ -50,18 +42,19 @@ describe('CLI command registration (e2e)', () => { expect(subcommands).toContain('remove'); }); - it('project command has CRUD subcommands', () => { + it('project command has action subcommands only', () => { const program = createProgram(); const project = program.commands.find((c) => c.name() === 'project'); expect(project).toBeDefined(); const subcommands = project!.commands.map((c) => c.name()); - expect(subcommands).toContain('list'); expect(subcommands).toContain('create'); - expect(subcommands).toContain('delete'); - expect(subcommands).toContain('show'); expect(subcommands).toContain('profiles'); expect(subcommands).toContain('set-profiles'); + // list, show, delete are now top-level (get, describe, delete) + expect(subcommands).not.toContain('list'); + expect(subcommands).not.toContain('show'); + expect(subcommands).not.toContain('delete'); }); it('displays version', () => { diff --git a/src/db/prisma/schema.prisma b/src/db/prisma/schema.prisma index e5f5657..2619340 100644 --- a/src/db/prisma/schema.prisma +++ b/src/db/prisma/schema.prisma @@ -60,6 +60,7 @@ model McpServer { externalUrl String? command Json? containerPort Int? + replicas Int @default(1) envTemplate Json @default("[]") version Int @default(1) createdAt DateTime @default(now()) diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index 5fc53a7..4e23e84 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -60,8 +60,9 @@ async function main(): Promise { // Services const serverService = new McpServerService(serverRepo); - const profileService = new McpProfileService(profileRepo, serverRepo); const instanceService = new InstanceService(instanceRepo, serverRepo, orchestrator); + serverService.setInstanceService(instanceService); + const profileService = new McpProfileService(profileRepo, serverRepo); const projectService = new ProjectService(projectRepo, profileRepo, serverRepo); const auditLogService = new AuditLogService(auditLogRepo); const metricsCollector = new MetricsCollector(); @@ -86,7 +87,7 @@ async function main(): Promise { }); // Routes - registerMcpServerRoutes(app, serverService); + registerMcpServerRoutes(app, serverService, instanceService); registerMcpProfileRoutes(app, profileService); registerInstanceRoutes(app, instanceService); registerProjectRoutes(app, projectService); diff --git a/src/mcpd/src/repositories/mcp-server.repository.ts b/src/mcpd/src/repositories/mcp-server.repository.ts index 893e0a0..9c0fceb 100644 --- a/src/mcpd/src/repositories/mcp-server.repository.ts +++ b/src/mcpd/src/repositories/mcp-server.repository.ts @@ -29,6 +29,7 @@ export class McpServerRepository implements IMcpServerRepository { externalUrl: data.externalUrl ?? null, command: data.command ?? Prisma.DbNull, containerPort: data.containerPort ?? null, + replicas: data.replicas, envTemplate: data.envTemplate, }, }); @@ -44,6 +45,7 @@ export class McpServerRepository implements IMcpServerRepository { if (data.externalUrl !== undefined) updateData['externalUrl'] = data.externalUrl; if (data.command !== undefined) updateData['command'] = data.command; if (data.containerPort !== undefined) updateData['containerPort'] = data.containerPort; + if (data.replicas !== undefined) updateData['replicas'] = data.replicas; if (data.envTemplate !== undefined) updateData['envTemplate'] = data.envTemplate; return this.prisma.mcpServer.update({ where: { id }, data: updateData }); diff --git a/src/mcpd/src/routes/instances.ts b/src/mcpd/src/routes/instances.ts index 944b4a5..2921569 100644 --- a/src/mcpd/src/routes/instances.ts +++ b/src/mcpd/src/routes/instances.ts @@ -10,40 +10,17 @@ export function registerInstanceRoutes(app: FastifyInstance, service: InstanceSe return service.getById(request.params.id); }); - app.post<{ Body: { serverId: string; env?: Record; hostPort?: number } }>( - '/api/v1/instances', - async (request, reply) => { - const { serverId } = request.body; - const opts: { env?: Record; hostPort?: number } = {}; - if (request.body.env) { - opts.env = request.body.env; - } - if (request.body.hostPort !== undefined) { - opts.hostPort = request.body.hostPort; - } - const instance = await service.start(serverId, opts); - reply.code(201); - return instance; - }, - ); - - app.post<{ Params: { id: string } }>('/api/v1/instances/:id/stop', async (request) => { - 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.delete<{ Params: { id: string } }>('/api/v1/instances/:id', async (request, reply) => { + const { serverId } = await service.remove(request.params.id); + // Reconcile: server will auto-create a replacement if replicas > 0 + await service.reconcile(serverId); + reply.code(204); }); 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); - }); - app.get<{ Params: { id: string }; Querystring: { tail?: string } }>( '/api/v1/instances/:id/logs', async (request) => { diff --git a/src/mcpd/src/routes/mcp-servers.ts b/src/mcpd/src/routes/mcp-servers.ts index fe6273c..9da2162 100644 --- a/src/mcpd/src/routes/mcp-servers.ts +++ b/src/mcpd/src/routes/mcp-servers.ts @@ -1,7 +1,12 @@ import type { FastifyInstance } from 'fastify'; import type { McpServerService } from '../services/mcp-server.service.js'; +import type { InstanceService } from '../services/instance.service.js'; -export function registerMcpServerRoutes(app: FastifyInstance, service: McpServerService): void { +export function registerMcpServerRoutes( + app: FastifyInstance, + service: McpServerService, + instanceService: InstanceService, +): void { app.get('/api/v1/servers', async () => { return service.list(); }); @@ -12,12 +17,17 @@ export function registerMcpServerRoutes(app: FastifyInstance, service: McpServer app.post('/api/v1/servers', async (request, reply) => { const server = await service.create(request.body); + // Auto-reconcile: create instances to match replicas + await instanceService.reconcile(server.id); reply.code(201); return server; }); app.put<{ Params: { id: string } }>('/api/v1/servers/:id', async (request) => { - return service.update(request.params.id, request.body); + const server = await service.update(request.params.id, request.body); + // Re-reconcile after update (replicas may have changed) + await instanceService.reconcile(server.id); + return server; }); app.delete<{ Params: { id: string } }>('/api/v1/servers/:id', async (request, reply) => { diff --git a/src/mcpd/src/services/instance.service.ts b/src/mcpd/src/services/instance.service.ts index f156b4b..db7a6ba 100644 --- a/src/mcpd/src/services/instance.service.ts +++ b/src/mcpd/src/services/instance.service.ts @@ -28,7 +28,103 @@ export class InstanceService { return instance; } - async start(serverId: string, opts?: { env?: Record; hostPort?: number }): Promise { + /** + * Reconcile instances for a server to match desired replica count. + * - If fewer running instances than replicas: start new ones + * - If more running instances than replicas: remove excess (oldest first) + */ + async reconcile(serverId: string): Promise { + const server = await this.serverRepo.findById(serverId); + if (!server) throw new NotFoundError(`McpServer '${serverId}' not found`); + + const instances = await this.instanceRepo.findAll(serverId); + const active = instances.filter((i) => i.status === 'RUNNING' || i.status === 'STARTING'); + const desired = server.replicas; + + if (active.length < desired) { + // Scale up + const toStart = desired - active.length; + for (let i = 0; i < toStart; i++) { + await this.startOne(serverId); + } + } else if (active.length > desired) { + // Scale down — remove oldest first + const excess = active + .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()) + .slice(0, active.length - desired); + for (const inst of excess) { + await this.removeOne(inst); + } + } + + return this.instanceRepo.findAll(serverId); + } + + /** + * Remove an instance (stop container + delete DB record). + * Does NOT reconcile — caller should reconcile after if needed. + */ + async remove(id: string): Promise<{ serverId: string }> { + const instance = await this.getById(id); + + if (instance.containerId) { + 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); + return { serverId: instance.serverId }; + } + + /** + * Remove all instances for a server (used before server deletion). + * Stops all containers so Prisma cascade only cleans up DB records. + */ + async removeAllForServer(serverId: string): Promise { + const instances = await this.instanceRepo.findAll(serverId); + for (const inst of instances) { + if (inst.containerId) { + try { + await this.orchestrator.stopContainer(inst.containerId); + } catch { + // best-effort + } + try { + await this.orchestrator.removeContainer(inst.containerId, true); + } catch { + // best-effort + } + } + } + } + + 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 getLogs(id: string, opts?: { tail?: number }): Promise<{ stdout: string; stderr: string }> { + const instance = await this.getById(id); + if (!instance.containerId) { + return { stdout: '', stderr: '' }; + } + + return this.orchestrator.getContainerLogs(instance.containerId, opts); + } + + /** Start a single instance for a server. */ + private async startOne(serverId: string): Promise { const server = await this.serverRepo.findById(serverId); if (!server) throw new NotFoundError(`McpServer '${serverId}' not found`); @@ -43,7 +139,6 @@ export class InstanceService { const image = server.dockerImage ?? server.packageName ?? server.name; - // Create DB record first in STARTING state let instance = await this.instanceRepo.create({ serverId, status: 'STARTING', @@ -53,7 +148,7 @@ export class InstanceService { const spec: ContainerSpec = { image, name: `mcpctl-${server.name}-${instance.id}`, - hostPort: opts?.hostPort ?? null, + hostPort: null, labels: { 'mcpctl.server-id': serverId, 'mcpctl.instance-id': instance.id, @@ -66,9 +161,6 @@ export class InstanceService { if (command) { spec.command = command; } - if (opts?.env) { - spec.env = opts.env; - } const containerInfo = await this.orchestrator.createContainer(spec); @@ -81,7 +173,6 @@ export class InstanceService { instance = await this.instanceRepo.updateStatus(instance.id, 'RUNNING', updateFields); } catch (err) { - // Mark as ERROR if container creation fails instance = await this.instanceRepo.updateStatus(instance.id, 'ERROR', { metadata: { error: err instanceof Error ? err.message : String(err) }, }); @@ -90,78 +181,16 @@ export class InstanceService { return instance; } - 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'); - } - - await this.instanceRepo.updateStatus(id, 'STOPPING'); - - try { - await this.orchestrator.stopContainer(instance.containerId); - return await this.instanceRepo.updateStatus(id, 'STOPPED'); - } catch (err) { - return await this.instanceRepo.updateStatus(id, 'ERROR', { - metadata: { error: err instanceof Error ? err.message : String(err) }, - }); - } - } - - 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); - + /** Stop and remove a single instance. */ + private async removeOne(instance: McpInstance): Promise { if (instance.containerId) { + try { + await this.orchestrator.stopContainer(instance.containerId); + } catch { /* best-effort */ } try { await this.orchestrator.removeContainer(instance.containerId, true); - } catch { - // Container may already be gone, proceed with DB cleanup - } + } catch { /* best-effort */ } } - - await this.instanceRepo.delete(id); - } - - async getLogs(id: string, opts?: { tail?: number }): Promise<{ stdout: string; stderr: string }> { - const instance = await this.getById(id); - if (!instance.containerId) { - return { stdout: '', stderr: '' }; - } - - return this.orchestrator.getContainerLogs(instance.containerId, opts); + await this.instanceRepo.delete(instance.id); } } diff --git a/src/mcpd/src/services/mcp-server.service.ts b/src/mcpd/src/services/mcp-server.service.ts index 4640dda..ab8e29b 100644 --- a/src/mcpd/src/services/mcp-server.service.ts +++ b/src/mcpd/src/services/mcp-server.service.ts @@ -1,10 +1,18 @@ import type { McpServer } from '@prisma/client'; import type { IMcpServerRepository } from '../repositories/interfaces.js'; +import type { InstanceService } from './instance.service.js'; import { CreateMcpServerSchema, UpdateMcpServerSchema } from '../validation/mcp-server.schema.js'; export class McpServerService { + private instanceService: InstanceService | null = null; + constructor(private readonly repo: IMcpServerRepository) {} + /** Set after construction to avoid circular dependency. */ + setInstanceService(instanceService: InstanceService): void { + this.instanceService = instanceService; + } + async list(): Promise { return this.repo.findAll(); } @@ -48,6 +56,10 @@ export class McpServerService { async delete(id: string): Promise { // Verify exists await this.getById(id); + // Stop all containers before DB cascade + if (this.instanceService) { + await this.instanceService.removeAllForServer(id); + } await this.repo.delete(id); } } diff --git a/src/mcpd/src/validation/mcp-server.schema.ts b/src/mcpd/src/validation/mcp-server.schema.ts index f6c87cc..4f94633 100644 --- a/src/mcpd/src/validation/mcp-server.schema.ts +++ b/src/mcpd/src/validation/mcp-server.schema.ts @@ -17,6 +17,7 @@ export const CreateMcpServerSchema = z.object({ externalUrl: z.string().url().optional(), command: z.array(z.string()).optional(), containerPort: z.number().int().min(1).max(65535).optional(), + replicas: z.number().int().min(0).max(10).default(1), envTemplate: z.array(EnvTemplateEntrySchema).default([]), }); @@ -29,6 +30,7 @@ export const UpdateMcpServerSchema = z.object({ externalUrl: z.string().url().nullable().optional(), command: z.array(z.string()).nullable().optional(), containerPort: z.number().int().min(1).max(65535).nullable().optional(), + replicas: z.number().int().min(0).max(10).optional(), envTemplate: z.array(EnvTemplateEntrySchema).optional(), }); diff --git a/src/mcpd/tests/instance-service.test.ts b/src/mcpd/tests/instance-service.test.ts index 102e02a..e1957e2 100644 --- a/src/mcpd/tests/instance-service.test.ts +++ b/src/mcpd/tests/instance-service.test.ts @@ -3,6 +3,7 @@ import { InstanceService, InvalidStateError } from '../src/services/instance.ser 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'; +import type { McpInstance } from '@prisma/client'; function mockInstanceRepo(): IMcpInstanceRepository { return { @@ -69,6 +70,41 @@ function mockOrchestrator(): McpOrchestrator { }; } +function makeServer(overrides: Partial<{ id: string; name: string; replicas: number; dockerImage: string | null; externalUrl: string | null; transport: string; command: unknown; containerPort: number | null }> = {}) { + return { + id: overrides.id ?? 'srv-1', + name: overrides.name ?? 'slack', + dockerImage: overrides.dockerImage ?? 'ghcr.io/slack-mcp:latest', + packageName: null, + transport: overrides.transport ?? 'STDIO', + description: '', + repositoryUrl: null, + externalUrl: overrides.externalUrl ?? null, + command: overrides.command ?? null, + containerPort: overrides.containerPort ?? null, + replicas: overrides.replicas ?? 1, + envTemplate: [], + version: 1, + createdAt: new Date(), + updatedAt: new Date(), + }; +} + +function makeInstance(overrides: Partial = {}): McpInstance { + return { + id: 'inst-1', + serverId: 'srv-1', + containerId: overrides.containerId ?? 'ctr-abc', + status: overrides.status ?? 'RUNNING', + port: overrides.port ?? 3000, + metadata: overrides.metadata ?? {}, + version: 1, + createdAt: overrides.createdAt ?? new Date(), + updatedAt: new Date(), + ...overrides, + } as McpInstance; +} + describe('InstanceService', () => { let instanceRepo: ReturnType; let serverRepo: ReturnType; @@ -101,199 +137,98 @@ describe('InstanceService', () => { }); it('returns instance when found', async () => { - vi.mocked(instanceRepo.findById).mockResolvedValue({ id: 'inst-1' } as never); + vi.mocked(instanceRepo.findById).mockResolvedValue(makeInstance({ id: 'inst-1' })); const result = await service.getById('inst-1'); expect(result.id).toBe('inst-1'); }); }); - describe('start', () => { + describe('reconcile', () => { + it('starts instances when below desired replicas', async () => { + vi.mocked(serverRepo.findById).mockResolvedValue(makeServer({ replicas: 2 })); + vi.mocked(instanceRepo.findAll).mockResolvedValue([]); + + await service.reconcile('srv-1'); + + // Should create 2 instances + expect(instanceRepo.create).toHaveBeenCalledTimes(2); + }); + + it('does nothing when at desired replicas', async () => { + vi.mocked(serverRepo.findById).mockResolvedValue(makeServer({ replicas: 1 })); + vi.mocked(instanceRepo.findAll).mockResolvedValue([makeInstance({ status: 'RUNNING' })]); + + await service.reconcile('srv-1'); + + expect(instanceRepo.create).not.toHaveBeenCalled(); + expect(instanceRepo.delete).not.toHaveBeenCalled(); + }); + + it('removes excess instances when above desired replicas', async () => { + vi.mocked(serverRepo.findById).mockResolvedValue(makeServer({ replicas: 1 })); + vi.mocked(instanceRepo.findAll).mockResolvedValue([ + makeInstance({ id: 'inst-old', createdAt: new Date('2025-01-01') }), + makeInstance({ id: 'inst-new', createdAt: new Date('2025-06-01') }), + ]); + + await service.reconcile('srv-1'); + + // Should remove the oldest one + expect(orchestrator.stopContainer).toHaveBeenCalledTimes(1); + expect(instanceRepo.delete).toHaveBeenCalledWith('inst-old'); + }); + + it('creates external instances without Docker', async () => { + vi.mocked(serverRepo.findById).mockResolvedValue( + makeServer({ replicas: 1, externalUrl: 'http://localhost:8086/mcp', dockerImage: null }), + ); + vi.mocked(instanceRepo.findAll).mockResolvedValue([]); + + await service.reconcile('srv-1'); + + expect(instanceRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ status: 'RUNNING', metadata: expect.objectContaining({ external: true }) }), + ); + expect(orchestrator.createContainer).not.toHaveBeenCalled(); + }); + + it('handles replicas: 0 by removing all instances', async () => { + vi.mocked(serverRepo.findById).mockResolvedValue(makeServer({ replicas: 0 })); + vi.mocked(instanceRepo.findAll).mockResolvedValue([makeInstance()]); + + await service.reconcile('srv-1'); + + expect(instanceRepo.delete).toHaveBeenCalledTimes(1); + }); + it('throws NotFoundError for unknown server', async () => { - await expect(service.start('missing')).rejects.toThrow(NotFoundError); - }); - - it('creates instance and starts container', async () => { - vi.mocked(serverRepo.findById).mockResolvedValue({ - id: 'srv-1', name: 'slack', dockerImage: 'ghcr.io/slack-mcp:latest', - packageName: null, transport: 'STDIO', description: '', repositoryUrl: null, - envTemplate: [], version: 1, createdAt: new Date(), updatedAt: new Date(), - }); - - const result = await service.start('srv-1'); - - expect(instanceRepo.create).toHaveBeenCalledWith({ - serverId: 'srv-1', - status: 'STARTING', - }); - expect(orchestrator.createContainer).toHaveBeenCalled(); - expect(instanceRepo.updateStatus).toHaveBeenCalledWith( - 'inst-1', 'RUNNING', - expect.objectContaining({ containerId: 'ctr-abc123' }), - ); - expect(result.status).toBe('RUNNING'); - }); - - it('marks instance as ERROR on container failure', async () => { - vi.mocked(serverRepo.findById).mockResolvedValue({ - id: 'srv-1', name: 'slack', dockerImage: 'ghcr.io/slack-mcp:latest', - packageName: null, transport: 'STDIO', description: '', repositoryUrl: null, - envTemplate: [], version: 1, createdAt: new Date(), updatedAt: new Date(), - }); - vi.mocked(orchestrator.createContainer).mockRejectedValue(new Error('Docker unavailable')); - - const result = await service.start('srv-1'); - - expect(instanceRepo.updateStatus).toHaveBeenCalledWith( - 'inst-1', 'ERROR', - expect.objectContaining({ metadata: { error: 'Docker unavailable' } }), - ); - expect(result.status).toBe('ERROR'); - }); - - it('uses dockerImage for container spec', async () => { - vi.mocked(serverRepo.findById).mockResolvedValue({ - id: 'srv-1', name: 'slack', dockerImage: 'myregistry.com/slack:v1', - packageName: '@slack/mcp', transport: 'SSE', description: '', repositoryUrl: null, - envTemplate: [], version: 1, createdAt: new Date(), updatedAt: new Date(), - }); - - await service.start('srv-1'); - - const spec = vi.mocked(orchestrator.createContainer).mock.calls[0]?.[0]; - expect(spec?.image).toBe('myregistry.com/slack:v1'); - expect(spec?.containerPort).toBe(3000); // SSE transport - }); - }); - - describe('stop', () => { - it('throws NotFoundError for missing instance', async () => { - await expect(service.stop('missing')).rejects.toThrow(NotFoundError); - }); - - it('stops a running container', 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(), - }); - - await service.stop('inst-1'); - - expect(orchestrator.stopContainer).toHaveBeenCalledWith('ctr-abc'); - expect(instanceRepo.updateStatus).toHaveBeenCalledWith('inst-1', 'STOPPED'); - }); - - it('handles stop without containerId', 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 service.stop('inst-1'); - - 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); + await expect(service.reconcile('missing')).rejects.toThrow(NotFoundError); }); }); describe('remove', () => { - it('removes container and DB record', 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(), - }); + it('stops container and deletes DB record', async () => { + vi.mocked(instanceRepo.findById).mockResolvedValue(makeInstance({ containerId: 'ctr-abc' })); + + const result = await service.remove('inst-1'); + + expect(orchestrator.stopContainer).toHaveBeenCalledWith('ctr-abc'); + expect(orchestrator.removeContainer).toHaveBeenCalledWith('ctr-abc', true); + expect(instanceRepo.delete).toHaveBeenCalledWith('inst-1'); + expect(result.serverId).toBe('srv-1'); + }); + + it('deletes DB record for external instance (no container)', async () => { + vi.mocked(instanceRepo.findById).mockResolvedValue(makeInstance({ containerId: null })); await service.remove('inst-1'); - expect(orchestrator.removeContainer).toHaveBeenCalledWith('ctr-abc', true); + expect(orchestrator.stopContainer).not.toHaveBeenCalled(); expect(instanceRepo.delete).toHaveBeenCalledWith('inst-1'); }); - it('removes DB record even if container is already gone', 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(), - }); + it('deletes DB record even if container is already gone', async () => { + vi.mocked(instanceRepo.findById).mockResolvedValue(makeInstance({ containerId: 'ctr-abc' })); vi.mocked(orchestrator.removeContainer).mockRejectedValue(new Error('No such container')); await service.remove('inst-1'); @@ -302,24 +237,56 @@ describe('InstanceService', () => { }); }); + describe('removeAllForServer', () => { + it('stops all containers for a server', async () => { + vi.mocked(instanceRepo.findAll).mockResolvedValue([ + makeInstance({ id: 'inst-1', containerId: 'ctr-1' }), + makeInstance({ id: 'inst-2', containerId: 'ctr-2' }), + ]); + + await service.removeAllForServer('srv-1'); + + expect(orchestrator.stopContainer).toHaveBeenCalledTimes(2); + expect(orchestrator.removeContainer).toHaveBeenCalledTimes(2); + }); + + it('skips external instances with no container', async () => { + vi.mocked(instanceRepo.findAll).mockResolvedValue([ + makeInstance({ id: 'inst-1', containerId: null }), + ]); + + await service.removeAllForServer('srv-1'); + + expect(orchestrator.stopContainer).not.toHaveBeenCalled(); + }); + }); + + describe('inspect', () => { + it('returns container info', async () => { + vi.mocked(instanceRepo.findById).mockResolvedValue(makeInstance({ containerId: 'ctr-abc' })); + + 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(makeInstance({ containerId: null })); + + await expect(service.inspect('inst-1')).rejects.toThrow(InvalidStateError); + }); + }); + describe('getLogs', () => { it('returns empty logs for instance without 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(), - }); + vi.mocked(instanceRepo.findById).mockResolvedValue(makeInstance({ containerId: null })); const result = await service.getLogs('inst-1'); expect(result).toEqual({ stdout: '', stderr: '' }); }); it('returns container logs', 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(instanceRepo.findById).mockResolvedValue(makeInstance({ containerId: 'ctr-abc' })); const result = await service.getLogs('inst-1', { tail: 50 }); diff --git a/src/mcpd/tests/mcp-server-flow.test.ts b/src/mcpd/tests/mcp-server-flow.test.ts index 0ae6d11..17738b2 100644 --- a/src/mcpd/tests/mcp-server-flow.test.ts +++ b/src/mcpd/tests/mcp-server-flow.test.ts @@ -43,6 +43,7 @@ function createInMemoryServerRepo(): IMcpServerRepository { externalUrl: data.externalUrl ?? null, command: data.command ?? null, containerPort: data.containerPort ?? null, + replicas: data.replicas ?? 1, envTemplate: data.envTemplate ?? [], version: 1, createdAt: new Date(), @@ -279,10 +280,11 @@ async function buildTestApp(deps: { const serverService = new McpServerService(deps.serverRepo); const instanceService = new InstanceService(deps.instanceRepo, deps.serverRepo, deps.orchestrator); + serverService.setInstanceService(instanceService); const proxyService = new McpProxyService(deps.instanceRepo, deps.serverRepo); const auditLogService = new AuditLogService(deps.auditLogRepo); - registerMcpServerRoutes(app, serverService); + registerMcpServerRoutes(app, serverService, instanceService); registerInstanceRoutes(app, instanceService); registerMcpProxyRoutes(app, { mcpProxyService: proxyService, @@ -334,8 +336,8 @@ describe('MCP server full flow', () => { if (app) await app.close(); }); - it('registers server, starts virtual instance, and proxies tools/list', async () => { - // 1. Register external MCP server + it('registers server (auto-creates instance via reconcile), and proxies tools/list', async () => { + // 1. Register external MCP server (replicas defaults to 1 → auto-creates instance) const createRes = await app.inject({ method: 'POST', url: '/api/v1/servers', @@ -363,17 +365,16 @@ describe('MCP server full flow', () => { expect(servers).toHaveLength(1); expect(servers[0]!.name).toBe('ha-mcp'); - // 3. Start a virtual instance (external server — no Docker) - const startRes = await app.inject({ - method: 'POST', - url: '/api/v1/instances', - payload: { serverId: server.id }, + // 3. Verify instance was auto-created (no Docker for external servers) + const instancesRes = await app.inject({ + method: 'GET', + url: `/api/v1/instances?serverId=${server.id}`, }); - - expect(startRes.statusCode).toBe(201); - const instance = startRes.json<{ id: string; status: string; containerId: string | null }>(); - expect(instance.status).toBe('RUNNING'); - expect(instance.containerId).toBeNull(); + expect(instancesRes.statusCode).toBe(200); + const instances = instancesRes.json>(); + expect(instances).toHaveLength(1); + expect(instances[0]!.status).toBe('RUNNING'); + expect(instances[0]!.containerId).toBeNull(); // 4. Proxy tools/list to the fake MCP server const proxyRes = await app.inject({ @@ -401,7 +402,7 @@ describe('MCP server full flow', () => { }); it('proxies tools/call with parameters', async () => { - // Register + start + // Register (auto-creates instance via reconcile) const createRes = await app.inject({ method: 'POST', url: '/api/v1/servers', @@ -414,13 +415,7 @@ describe('MCP server full flow', () => { }); const server = createRes.json<{ id: string }>(); - await app.inject({ - method: 'POST', - url: '/api/v1/instances', - payload: { serverId: server.id }, - }); - - // Proxy tools/call + // Proxy tools/call (instance was auto-created) const proxyRes = await app.inject({ method: 'POST', url: '/api/v1/mcp/proxy', @@ -456,8 +451,8 @@ describe('MCP server full flow', () => { if (app) await app.close(); }); - it('registers server with dockerImage, starts container, and creates instance', async () => { - // 1. Register managed server + it('registers server with dockerImage, auto-creates container instance via reconcile', async () => { + // 1. Register managed server (replicas: 1 → auto-creates container) const createRes = await app.inject({ method: 'POST', url: '/api/v1/servers', @@ -481,20 +476,16 @@ describe('MCP server full flow', () => { expect(server.dockerImage).toBe('ghcr.io/homeassistant-ai/ha-mcp:2.4'); expect(server.command).toEqual(['python', '-c', 'print("hello")']); - // 2. Start container instance with env - const startRes = await app.inject({ - method: 'POST', - url: '/api/v1/instances', - payload: { - serverId: server.id, - env: { HOMEASSISTANT_URL: 'https://ha.example.com', HOMEASSISTANT_TOKEN: 'secret' }, - }, + // 2. Verify instance was auto-created with container + const instancesRes = await app.inject({ + method: 'GET', + url: `/api/v1/instances?serverId=${server.id}`, }); - - expect(startRes.statusCode).toBe(201); - const instance = startRes.json<{ id: string; status: string; containerId: string }>(); - expect(instance.status).toBe('RUNNING'); - expect(instance.containerId).toBeTruthy(); + expect(instancesRes.statusCode).toBe(200); + const instances = instancesRes.json>(); + expect(instances).toHaveLength(1); + expect(instances[0]!.status).toBe('RUNNING'); + expect(instances[0]!.containerId).toBeTruthy(); // 3. Verify orchestrator was called with correct spec expect(orchestrator.createContainer).toHaveBeenCalledTimes(1); @@ -502,15 +493,12 @@ describe('MCP server full flow', () => { expect(spec.image).toBe('ghcr.io/homeassistant-ai/ha-mcp:2.4'); expect(spec.containerPort).toBe(3000); expect(spec.command).toEqual(['python', '-c', 'print("hello")']); - expect(spec.env).toEqual({ - HOMEASSISTANT_URL: 'https://ha.example.com', - HOMEASSISTANT_TOKEN: 'secret', - }); }); it('marks instance as ERROR when Docker fails', async () => { vi.mocked(orchestrator.createContainer).mockRejectedValueOnce(new Error('Docker socket unavailable')); + // Creating server triggers reconcile which tries to create container → fails const createRes = await app.inject({ method: 'POST', url: '/api/v1/servers', @@ -521,17 +509,16 @@ describe('MCP server full flow', () => { transport: 'STDIO', }, }); + expect(createRes.statusCode).toBe(201); + const server = createRes.json<{ id: string }>(); - - const startRes = await app.inject({ - method: 'POST', - url: '/api/v1/instances', - payload: { serverId: server.id }, + const instancesRes = await app.inject({ + method: 'GET', + url: `/api/v1/instances?serverId=${server.id}`, }); - - expect(startRes.statusCode).toBe(201); - const instance = startRes.json<{ id: string; status: string }>(); - expect(instance.status).toBe('ERROR'); + const instances = instancesRes.json>(); + expect(instances).toHaveLength(1); + expect(instances[0]!.status).toBe('ERROR'); }); }); @@ -553,8 +540,8 @@ describe('MCP server full flow', () => { if (app) await app.close(); }); - it('register → start → list → stop → remove', async () => { - // Register + it('register → auto-create → list → delete instance (reconcile) → delete server (cascade)', async () => { + // Register (auto-creates instance via reconcile) const createRes = await app.inject({ method: 'POST', url: '/api/v1/servers', @@ -569,48 +556,34 @@ describe('MCP server full flow', () => { expect(createRes.statusCode).toBe(201); const server = createRes.json<{ id: string }>(); - // Start - const startRes = await app.inject({ - method: 'POST', - url: '/api/v1/instances', - payload: { serverId: server.id }, - }); - expect(startRes.statusCode).toBe(201); - const instance = startRes.json<{ id: string; status: string }>(); - expect(instance.status).toBe('RUNNING'); - - // List instances + // List instances (auto-created) const listRes = await app.inject({ method: 'GET', url: `/api/v1/instances?serverId=${server.id}`, }); expect(listRes.statusCode).toBe(200); - const instances = listRes.json>(); + const instances = listRes.json>(); expect(instances).toHaveLength(1); + expect(instances[0]!.status).toBe('RUNNING'); + const instanceId = instances[0]!.id; - // Stop - const stopRes = await app.inject({ - method: 'POST', - url: `/api/v1/instances/${instance.id}/stop`, - }); - expect(stopRes.statusCode).toBe(200); - expect(stopRes.json<{ status: string }>().status).toBe('STOPPED'); - - // Remove + // Delete instance → triggers reconcile → new instance auto-created const removeRes = await app.inject({ method: 'DELETE', - url: `/api/v1/instances/${instance.id}`, + url: `/api/v1/instances/${instanceId}`, }); expect(removeRes.statusCode).toBe(204); - // Verify instance is gone + // Verify a replacement instance was created (reconcile) const listAfter = await app.inject({ method: 'GET', url: `/api/v1/instances?serverId=${server.id}`, }); - expect(listAfter.json()).toHaveLength(0); + const afterInstances = listAfter.json>(); + expect(afterInstances).toHaveLength(1); + expect(afterInstances[0]!.id).not.toBe(instanceId); // New instance, not the old one - // Delete server + // Delete server (cascade removes all instances) const deleteRes = await app.inject({ method: 'DELETE', url: `/api/v1/servers/${server.id}`, @@ -622,8 +595,8 @@ describe('MCP server full flow', () => { expect(serversAfter.json()).toHaveLength(0); }); - it('external server lifecycle: register → start → proxy → stop → cleanup', async () => { - // Register external + it('external server lifecycle: register → auto-create → proxy → delete server (cascade)', async () => { + // Register external (auto-creates virtual instance) const createRes = await app.inject({ method: 'POST', url: '/api/v1/servers', @@ -635,15 +608,15 @@ describe('MCP server full flow', () => { }); const server = createRes.json<{ id: string }>(); - // Start (virtual instance) - const startRes = await app.inject({ - method: 'POST', - url: '/api/v1/instances', - payload: { serverId: server.id }, + // Verify auto-created instance + const instancesRes = await app.inject({ + method: 'GET', + url: `/api/v1/instances?serverId=${server.id}`, }); - const instance = startRes.json<{ id: string; status: string; containerId: string | null }>(); - expect(instance.status).toBe('RUNNING'); - expect(instance.containerId).toBeNull(); + const instances = instancesRes.json>(); + expect(instances).toHaveLength(1); + expect(instances[0]!.status).toBe('RUNNING'); + expect(instances[0]!.containerId).toBeNull(); // Proxy tools/list const proxyRes = await app.inject({ @@ -655,17 +628,16 @@ describe('MCP server full flow', () => { expect(proxyRes.statusCode).toBe(200); expect(proxyRes.json<{ result: { tools: unknown[] } }>().result.tools.length).toBeGreaterThan(0); - // Stop (no container to stop) - const stopRes = await app.inject({ - method: 'POST', - url: `/api/v1/instances/${instance.id}/stop`, - }); - expect(stopRes.statusCode).toBe(200); - expect(stopRes.json<{ status: string }>().status).toBe('STOPPED'); - - // Docker orchestrator should NOT have been called + // Docker orchestrator should NOT have been called (external server) expect(orchestrator.createContainer).not.toHaveBeenCalled(); expect(orchestrator.stopContainer).not.toHaveBeenCalled(); + + // Delete server (cascade) + const deleteRes = await app.inject({ + method: 'DELETE', + url: `/api/v1/servers/${server.id}`, + }); + expect(deleteRes.statusCode).toBe(204); }); }); @@ -713,7 +685,7 @@ describe('MCP server full flow', () => { }); it('creates and updates server fields', async () => { - // Create + // Create (with replicas: 0 to avoid creating instances in this test) const createRes = await app.inject({ method: 'POST', url: '/api/v1/servers', @@ -721,8 +693,10 @@ describe('MCP server full flow', () => { name: 'updatable', description: 'Original desc', transport: 'STDIO', + replicas: 0, }, }); + expect(createRes.statusCode).toBe(201); const server = createRes.json<{ id: string; description: string }>(); expect(server.description).toBe('Original desc'); diff --git a/src/mcpd/tests/mcp-server-routes.test.ts b/src/mcpd/tests/mcp-server-routes.test.ts index 95f7cb8..d191cb9 100644 --- a/src/mcpd/tests/mcp-server-routes.test.ts +++ b/src/mcpd/tests/mcp-server-routes.test.ts @@ -3,44 +3,66 @@ import Fastify from 'fastify'; import type { FastifyInstance } from 'fastify'; import { registerMcpServerRoutes } from '../src/routes/mcp-servers.js'; import { McpServerService, NotFoundError, ConflictError } from '../src/services/mcp-server.service.js'; +import { InstanceService } from '../src/services/instance.service.js'; import { errorHandler } from '../src/middleware/error-handler.js'; -import type { IMcpServerRepository } from '../src/repositories/interfaces.js'; +import type { IMcpServerRepository, IMcpInstanceRepository } from '../src/repositories/interfaces.js'; +import type { McpOrchestrator } from '../src/services/orchestrator.js'; let app: FastifyInstance; function mockRepo(): IMcpServerRepository { + let lastCreated: Record | null = null; return { findAll: vi.fn(async () => [ - { id: '1', name: 'slack', description: 'Slack server', transport: 'STDIO' }, + { id: '1', name: 'slack', description: 'Slack server', transport: 'STDIO', replicas: 1 }, ]), - findById: vi.fn(async () => null), + findById: vi.fn(async (id: string) => { + if (lastCreated && (lastCreated as { id: string }).id === id) return lastCreated as never; + return null; + }), findByName: vi.fn(async () => null), - create: vi.fn(async (data) => ({ - id: 'new-id', - name: data.name, - description: data.description ?? '', - packageName: data.packageName ?? null, - dockerImage: null, - transport: data.transport ?? 'STDIO', - repositoryUrl: null, - envTemplate: [], - version: 1, - createdAt: new Date(), - updatedAt: new Date(), - })), - update: vi.fn(async (id, data) => ({ - id, - name: 'slack', - description: (data.description as string) ?? 'Slack server', - packageName: null, - dockerImage: null, - transport: 'STDIO', - repositoryUrl: null, - envTemplate: [], - version: 2, - createdAt: new Date(), - updatedAt: new Date(), - })), + create: vi.fn(async (data) => { + const server = { + id: 'new-id', + name: data.name, + description: data.description ?? '', + packageName: data.packageName ?? null, + dockerImage: null, + transport: data.transport ?? 'STDIO', + repositoryUrl: null, + externalUrl: null, + command: null, + containerPort: null, + replicas: data.replicas ?? 1, + envTemplate: [], + version: 1, + createdAt: new Date(), + updatedAt: new Date(), + }; + lastCreated = server; + return server; + }), + update: vi.fn(async (id, data) => { + const server = { + id, + name: 'slack', + description: (data.description as string) ?? 'Slack server', + packageName: null, + dockerImage: null, + transport: 'STDIO', + repositoryUrl: null, + externalUrl: null, + command: null, + containerPort: null, + replicas: 1, + envTemplate: [], + version: 2, + createdAt: new Date(), + updatedAt: new Date(), + }; + lastCreated = server; + return server; + }), delete: vi.fn(async () => {}), }; } @@ -49,11 +71,56 @@ afterEach(async () => { if (app) await app.close(); }); +function stubInstanceRepo(): IMcpInstanceRepository { + return { + findAll: vi.fn(async () => []), + findById: vi.fn(async () => null), + findByContainerId: vi.fn(async () => null), + create: vi.fn(async (data) => ({ + id: 'inst-stub', + serverId: data.serverId, + containerId: null, + status: data.status ?? 'STOPPED', + port: null, + metadata: {}, + version: 1, + createdAt: new Date(), + updatedAt: new Date(), + })), + updateStatus: vi.fn(async (id, status) => ({ + id, + serverId: 'srv-1', + containerId: null, + status, + port: null, + metadata: {}, + version: 2, + createdAt: new Date(), + updatedAt: new Date(), + })), + delete: vi.fn(async () => {}), + }; +} + +function stubOrchestrator(): McpOrchestrator { + return { + ping: vi.fn(async () => true), + pullImage: vi.fn(async () => {}), + createContainer: vi.fn(async () => ({ containerId: 'ctr-stub', name: 'stub', state: 'running' as const, port: 3000, createdAt: new Date() })), + stopContainer: vi.fn(async () => {}), + removeContainer: vi.fn(async () => {}), + inspectContainer: vi.fn(async () => ({ containerId: 'ctr-stub', name: 'stub', state: 'running' as const, createdAt: new Date() })), + getContainerLogs: vi.fn(async () => ({ stdout: '', stderr: '' })), + }; +} + function createApp(repo: IMcpServerRepository) { app = Fastify({ logger: false }); app.setErrorHandler(errorHandler); const service = new McpServerService(repo); - registerMcpServerRoutes(app, service); + const instanceService = new InstanceService(stubInstanceRepo(), repo, stubOrchestrator()); + service.setInstanceService(instanceService); + registerMcpServerRoutes(app, service, instanceService); return app.ready(); } -- 2.49.1