feat: kubectl-style CLI + Deployment/Pod model for servers/instances
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 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ const ServerSpecSchema = z.object({
|
|||||||
externalUrl: z.string().url().optional(),
|
externalUrl: z.string().url().optional(),
|
||||||
command: z.array(z.string()).optional(),
|
command: z.array(z.string()).optional(),
|
||||||
containerPort: z.number().int().min(1).max(65535).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({
|
envTemplate: z.array(z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
description: z.string().default(''),
|
description: z.string().default(''),
|
||||||
|
|||||||
54
src/cli/src/commands/delete.ts
Normal file
54
src/cli/src/commands/delete.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { Command } from 'commander';
|
||||||
|
import type { ApiClient } from '../api-client.js';
|
||||||
|
|
||||||
|
const RESOURCE_ALIASES: Record<string, string> = {
|
||||||
|
server: 'servers',
|
||||||
|
srv: 'servers',
|
||||||
|
profile: 'profiles',
|
||||||
|
prof: 'profiles',
|
||||||
|
project: 'projects',
|
||||||
|
proj: 'projects',
|
||||||
|
instance: 'instances',
|
||||||
|
inst: 'instances',
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveResource(name: string): string {
|
||||||
|
const lower = name.toLowerCase();
|
||||||
|
return RESOURCE_ALIASES[lower] ?? lower;
|
||||||
|
}
|
||||||
|
|
||||||
|
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>', 'resource type')
|
||||||
|
.argument('<id>', '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<Array<{ id: string; name: string }>>(`/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.`);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { formatJson, formatYaml } from '../formatters/output.js';
|
|||||||
|
|
||||||
export interface DescribeCommandDeps {
|
export interface DescribeCommandDeps {
|
||||||
fetchResource: (resource: string, id: string) => Promise<unknown>;
|
fetchResource: (resource: string, id: string) => Promise<unknown>;
|
||||||
|
fetchInspect?: (id: string) => Promise<unknown>;
|
||||||
log: (...args: string[]) => void;
|
log: (...args: string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +60,17 @@ export function createDescribeCommand(deps: DescribeCommandDeps): Command {
|
|||||||
.option('-o, --output <format>', 'output format (detail, json, yaml)', 'detail')
|
.option('-o, --output <format>', 'output format (detail, json, yaml)', 'detail')
|
||||||
.action(async (resourceArg: string, id: string, opts: { output: string }) => {
|
.action(async (resourceArg: string, id: string, opts: { output: string }) => {
|
||||||
const resource = resolveResource(resourceArg);
|
const resource = resolveResource(resourceArg);
|
||||||
const item = await deps.fetchResource(resource, id);
|
const item = await deps.fetchResource(resource, id) as Record<string, unknown>;
|
||||||
|
|
||||||
|
// 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') {
|
if (opts.output === 'json') {
|
||||||
deps.log(formatJson(item));
|
deps.log(formatJson(item));
|
||||||
@@ -68,7 +79,7 @@ export function createDescribeCommand(deps: DescribeCommandDeps): Command {
|
|||||||
} else {
|
} else {
|
||||||
const typeName = resource.replace(/s$/, '').charAt(0).toUpperCase() + resource.replace(/s$/, '').slice(1);
|
const typeName = resource.replace(/s$/, '').charAt(0).toUpperCase() + resource.replace(/s$/, '').slice(1);
|
||||||
deps.log(`--- ${typeName} ---`);
|
deps.log(`--- ${typeName} ---`);
|
||||||
deps.log(formatDetail(item as Record<string, unknown>));
|
deps.log(formatDetail(item));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 <id>', 'Filter by server ID')
|
|
||||||
.option('-o, --output <format>', '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<Instance[]>(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 <serverId>')
|
|
||||||
.description('Start a new MCP server instance')
|
|
||||||
.option('-p, --port <port>', 'Host port to bind')
|
|
||||||
.option('-o, --output <format>', 'Output format (table, json)', 'table')
|
|
||||||
.action(async (serverId: string, opts: { port?: string; output: string }) => {
|
|
||||||
const body: Record<string, unknown> = { serverId };
|
|
||||||
if (opts.port !== undefined) {
|
|
||||||
body.hostPort = parseInt(opts.port, 10);
|
|
||||||
}
|
|
||||||
const instance = await client.post<Instance>('/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 <id>')
|
|
||||||
.description('Stop a running instance')
|
|
||||||
.action(async (id: string) => {
|
|
||||||
const instance = await client.post<Instance>(`/api/v1/instances/${id}/stop`);
|
|
||||||
log(`Instance ${id} stopped (status: ${instance.status})`);
|
|
||||||
});
|
|
||||||
|
|
||||||
cmd
|
|
||||||
.command('restart <id>')
|
|
||||||
.description('Restart an instance (stop, remove, start fresh)')
|
|
||||||
.action(async (id: string) => {
|
|
||||||
const instance = await client.post<Instance>(`/api/v1/instances/${id}/restart`);
|
|
||||||
log(`Instance restarted as ${instance.id} (status: ${instance.status})`);
|
|
||||||
});
|
|
||||||
|
|
||||||
cmd
|
|
||||||
.command('remove <id>')
|
|
||||||
.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 <id>')
|
|
||||||
.description('Get logs from an instance')
|
|
||||||
.option('-t, --tail <lines>', '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 <id>')
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
29
src/cli/src/commands/logs.ts
Normal file
29
src/cli/src/commands/logs.ts
Normal file
@@ -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>', 'Instance ID')
|
||||||
|
.option('-t, --tail <lines>', '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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -24,30 +24,8 @@ export function createProjectCommand(deps: ProjectCommandDeps): Command {
|
|||||||
const { client, log } = deps;
|
const { client, log } = deps;
|
||||||
|
|
||||||
const cmd = new Command('project')
|
const cmd = new Command('project')
|
||||||
.alias('projects')
|
|
||||||
.alias('proj')
|
.alias('proj')
|
||||||
.description('Manage mcpctl projects');
|
.description('Project-specific actions (use "get projects" to list, "delete project" to remove)');
|
||||||
|
|
||||||
cmd
|
|
||||||
.command('list')
|
|
||||||
.alias('ls')
|
|
||||||
.description('List all projects')
|
|
||||||
.option('-o, --output <format>', 'Output format (table, json)', 'table')
|
|
||||||
.action(async (opts: { output: string }) => {
|
|
||||||
const projects = await client.get<Project[]>('/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 || '-'}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
cmd
|
cmd
|
||||||
.command('create <name>')
|
.command('create <name>')
|
||||||
@@ -61,41 +39,6 @@ export function createProjectCommand(deps: ProjectCommandDeps): Command {
|
|||||||
log(`Project '${project.name}' created (id: ${project.id})`);
|
log(`Project '${project.name}' created (id: ${project.id})`);
|
||||||
});
|
});
|
||||||
|
|
||||||
cmd
|
|
||||||
.command('delete <id>')
|
|
||||||
.alias('rm')
|
|
||||||
.description('Delete a project')
|
|
||||||
.action(async (id: string) => {
|
|
||||||
await client.delete(`/api/v1/projects/${id}`);
|
|
||||||
log(`Project '${id}' deleted.`);
|
|
||||||
});
|
|
||||||
|
|
||||||
cmd
|
|
||||||
.command('show <id>')
|
|
||||||
.description('Show project details')
|
|
||||||
.action(async (id: string) => {
|
|
||||||
const project = await client.get<Project>(`/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<Profile[]>(`/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
|
cmd
|
||||||
.command('profiles <id>')
|
.command('profiles <id>')
|
||||||
.description('List profiles assigned to a project')
|
.description('List profiles assigned to a project')
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { createConfigCommand } from './commands/config.js';
|
|||||||
import { createStatusCommand } from './commands/status.js';
|
import { createStatusCommand } from './commands/status.js';
|
||||||
import { createGetCommand } from './commands/get.js';
|
import { createGetCommand } from './commands/get.js';
|
||||||
import { createDescribeCommand } from './commands/describe.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 { createApplyCommand } from './commands/apply.js';
|
||||||
import { createSetupCommand } from './commands/setup.js';
|
import { createSetupCommand } from './commands/setup.js';
|
||||||
import { createClaudeCommand } from './commands/claude.js';
|
import { createClaudeCommand } from './commands/claude.js';
|
||||||
@@ -64,10 +65,16 @@ export function createProgram(): Command {
|
|||||||
|
|
||||||
program.addCommand(createDescribeCommand({
|
program.addCommand(createDescribeCommand({
|
||||||
fetchResource: fetchSingleResource,
|
fetchResource: fetchSingleResource,
|
||||||
|
fetchInspect: async (id: string) => client.get(`/api/v1/instances/${id}/inspect`),
|
||||||
log: (...args) => console.log(...args),
|
log: (...args) => console.log(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
program.addCommand(createInstanceCommands({
|
program.addCommand(createDeleteCommand({
|
||||||
|
client,
|
||||||
|
log: (...args) => console.log(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
program.addCommand(createLogsCommand({
|
||||||
client,
|
client,
|
||||||
log: (...args) => console.log(...args),
|
log: (...args) => console.log(...args),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
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';
|
import type { ApiClient } from '../../src/api-client.js';
|
||||||
|
|
||||||
function mockClient(): ApiClient {
|
function mockClient(): ApiClient {
|
||||||
@@ -11,7 +12,7 @@ function mockClient(): ApiClient {
|
|||||||
} as unknown as ApiClient;
|
} as unknown as ApiClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('instance commands', () => {
|
describe('delete command', () => {
|
||||||
let client: ReturnType<typeof mockClient>;
|
let client: ReturnType<typeof mockClient>;
|
||||||
let output: string[];
|
let output: string[];
|
||||||
const log = (...args: unknown[]) => output.push(args.map(String).join(' '));
|
const log = (...args: unknown[]) => output.push(args.map(String).join(' '));
|
||||||
@@ -21,107 +22,70 @@ describe('instance commands', () => {
|
|||||||
output = [];
|
output = [];
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('list', () => {
|
it('deletes an instance by ID', async () => {
|
||||||
it('shows no instances message when empty', async () => {
|
const cmd = createDeleteCommand({ client, log });
|
||||||
const cmd = createInstanceCommands({ client, log });
|
await cmd.parseAsync(['instance', 'inst-1'], { from: 'user' });
|
||||||
await cmd.parseAsync(['list'], { from: 'user' });
|
expect(client.delete).toHaveBeenCalledWith('/api/v1/instances/inst-1');
|
||||||
expect(output.join('\n')).toContain('No instances found');
|
expect(output.join('\n')).toContain('deleted');
|
||||||
});
|
|
||||||
|
|
||||||
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('deletes a server by ID', async () => {
|
||||||
it('starts an instance', async () => {
|
const cmd = createDeleteCommand({ client, log });
|
||||||
vi.mocked(client.post).mockResolvedValue({ id: 'inst-new', status: 'RUNNING' });
|
await cmd.parseAsync(['server', 'srv-1'], { from: 'user' });
|
||||||
const cmd = createInstanceCommands({ client, log });
|
expect(client.delete).toHaveBeenCalledWith('/api/v1/servers/srv-1');
|
||||||
await cmd.parseAsync(['start', 'srv-1'], { from: 'user' });
|
expect(output.join('\n')).toContain('deleted');
|
||||||
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('resolves server name to ID', async () => {
|
||||||
it('stops an instance', async () => {
|
vi.mocked(client.get).mockResolvedValue([
|
||||||
vi.mocked(client.post).mockResolvedValue({ id: 'inst-1', status: 'STOPPED' });
|
{ id: 'srv-abc', name: 'ha-mcp' },
|
||||||
const cmd = createInstanceCommands({ client, log });
|
]);
|
||||||
await cmd.parseAsync(['stop', 'inst-1'], { from: 'user' });
|
const cmd = createDeleteCommand({ client, log });
|
||||||
expect(client.post).toHaveBeenCalledWith('/api/v1/instances/inst-1/stop');
|
await cmd.parseAsync(['server', 'ha-mcp'], { from: 'user' });
|
||||||
expect(output.join('\n')).toContain('stopped');
|
expect(client.delete).toHaveBeenCalledWith('/api/v1/servers/srv-abc');
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('restart', () => {
|
it('deletes a profile', async () => {
|
||||||
it('restarts an instance', async () => {
|
const cmd = createDeleteCommand({ client, log });
|
||||||
vi.mocked(client.post).mockResolvedValue({ id: 'inst-2', status: 'RUNNING' });
|
await cmd.parseAsync(['profile', 'prof-1'], { from: 'user' });
|
||||||
const cmd = createInstanceCommands({ client, log });
|
expect(client.delete).toHaveBeenCalledWith('/api/v1/profiles/prof-1');
|
||||||
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('deletes a project', async () => {
|
||||||
it('removes an instance', async () => {
|
const cmd = createDeleteCommand({ client, log });
|
||||||
const cmd = createInstanceCommands({ client, log });
|
await cmd.parseAsync(['project', 'proj-1'], { from: 'user' });
|
||||||
await cmd.parseAsync(['remove', 'inst-1'], { from: 'user' });
|
expect(client.delete).toHaveBeenCalledWith('/api/v1/projects/proj-1');
|
||||||
expect(client.delete).toHaveBeenCalledWith('/api/v1/instances/inst-1');
|
|
||||||
expect(output.join('\n')).toContain('removed');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('logs', () => {
|
it('accepts resource aliases', async () => {
|
||||||
it('shows logs', async () => {
|
const cmd = createDeleteCommand({ client, log });
|
||||||
vi.mocked(client.get).mockResolvedValue({ stdout: 'hello world\n', stderr: '' });
|
await cmd.parseAsync(['srv', 'srv-1'], { from: 'user' });
|
||||||
const cmd = createInstanceCommands({ client, log });
|
expect(client.delete).toHaveBeenCalledWith('/api/v1/servers/srv-1');
|
||||||
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');
|
|
||||||
});
|
describe('logs command', () => {
|
||||||
|
let client: ReturnType<typeof mockClient>;
|
||||||
it('passes tail option', async () => {
|
let output: string[];
|
||||||
vi.mocked(client.get).mockResolvedValue({ stdout: '', stderr: '' });
|
const log = (...args: unknown[]) => output.push(args.map(String).join(' '));
|
||||||
const cmd = createInstanceCommands({ client, log });
|
|
||||||
await cmd.parseAsync(['logs', 'inst-1', '-t', '50'], { from: 'user' });
|
beforeEach(() => {
|
||||||
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/logs?tail=50');
|
client = mockClient();
|
||||||
});
|
output = [];
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('inspect', () => {
|
it('shows logs', async () => {
|
||||||
it('shows container info as json', async () => {
|
vi.mocked(client.get).mockResolvedValue({ stdout: 'hello world\n', stderr: '' });
|
||||||
vi.mocked(client.get).mockResolvedValue({ containerId: 'ctr-abc', state: 'running' });
|
const cmd = createLogsCommand({ client, log });
|
||||||
const cmd = createInstanceCommands({ client, log });
|
await cmd.parseAsync(['inst-1'], { from: 'user' });
|
||||||
await cmd.parseAsync(['inspect', 'inst-1'], { from: 'user' });
|
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/logs');
|
||||||
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/inspect');
|
expect(output.join('\n')).toContain('hello world');
|
||||||
expect(output[0]).toContain('ctr-abc');
|
});
|
||||||
});
|
|
||||||
|
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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,31 +21,6 @@ describe('project command', () => {
|
|||||||
output = [];
|
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', () => {
|
describe('create', () => {
|
||||||
it('creates a project', async () => {
|
it('creates a project', async () => {
|
||||||
const cmd = createProjectCommand({ client, log });
|
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', () => {
|
describe('profiles', () => {
|
||||||
it('lists profiles for a project', async () => {
|
it('lists profiles for a project', async () => {
|
||||||
vi.mocked(client.get).mockResolvedValue([
|
vi.mocked(client.get).mockResolvedValue([
|
||||||
|
|||||||
@@ -16,26 +16,18 @@ describe('CLI command registration (e2e)', () => {
|
|||||||
expect(commandNames).toContain('logout');
|
expect(commandNames).toContain('logout');
|
||||||
expect(commandNames).toContain('get');
|
expect(commandNames).toContain('get');
|
||||||
expect(commandNames).toContain('describe');
|
expect(commandNames).toContain('describe');
|
||||||
expect(commandNames).toContain('instance');
|
expect(commandNames).toContain('delete');
|
||||||
|
expect(commandNames).toContain('logs');
|
||||||
expect(commandNames).toContain('apply');
|
expect(commandNames).toContain('apply');
|
||||||
expect(commandNames).toContain('setup');
|
expect(commandNames).toContain('setup');
|
||||||
expect(commandNames).toContain('claude');
|
expect(commandNames).toContain('claude');
|
||||||
expect(commandNames).toContain('project');
|
expect(commandNames).toContain('project');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('instance command has lifecycle subcommands', () => {
|
it('instance command is removed (use get/delete/logs instead)', () => {
|
||||||
const program = createProgram();
|
const program = createProgram();
|
||||||
const instance = program.commands.find((c) => c.name() === 'instance');
|
const commandNames = program.commands.map((c) => c.name());
|
||||||
expect(instance).toBeDefined();
|
expect(commandNames).not.toContain('instance');
|
||||||
|
|
||||||
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');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('claude command has config management subcommands', () => {
|
it('claude command has config management subcommands', () => {
|
||||||
@@ -50,18 +42,19 @@ describe('CLI command registration (e2e)', () => {
|
|||||||
expect(subcommands).toContain('remove');
|
expect(subcommands).toContain('remove');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('project command has CRUD subcommands', () => {
|
it('project command has action subcommands only', () => {
|
||||||
const program = createProgram();
|
const program = createProgram();
|
||||||
const project = program.commands.find((c) => c.name() === 'project');
|
const project = program.commands.find((c) => c.name() === 'project');
|
||||||
expect(project).toBeDefined();
|
expect(project).toBeDefined();
|
||||||
|
|
||||||
const subcommands = project!.commands.map((c) => c.name());
|
const subcommands = project!.commands.map((c) => c.name());
|
||||||
expect(subcommands).toContain('list');
|
|
||||||
expect(subcommands).toContain('create');
|
expect(subcommands).toContain('create');
|
||||||
expect(subcommands).toContain('delete');
|
|
||||||
expect(subcommands).toContain('show');
|
|
||||||
expect(subcommands).toContain('profiles');
|
expect(subcommands).toContain('profiles');
|
||||||
expect(subcommands).toContain('set-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', () => {
|
it('displays version', () => {
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ model McpServer {
|
|||||||
externalUrl String?
|
externalUrl String?
|
||||||
command Json?
|
command Json?
|
||||||
containerPort Int?
|
containerPort Int?
|
||||||
|
replicas Int @default(1)
|
||||||
envTemplate Json @default("[]")
|
envTemplate Json @default("[]")
|
||||||
version Int @default(1)
|
version Int @default(1)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|||||||
@@ -60,8 +60,9 @@ async function main(): Promise<void> {
|
|||||||
|
|
||||||
// Services
|
// Services
|
||||||
const serverService = new McpServerService(serverRepo);
|
const serverService = new McpServerService(serverRepo);
|
||||||
const profileService = new McpProfileService(profileRepo, serverRepo);
|
|
||||||
const instanceService = new InstanceService(instanceRepo, serverRepo, orchestrator);
|
const instanceService = new InstanceService(instanceRepo, serverRepo, orchestrator);
|
||||||
|
serverService.setInstanceService(instanceService);
|
||||||
|
const profileService = new McpProfileService(profileRepo, serverRepo);
|
||||||
const projectService = new ProjectService(projectRepo, profileRepo, serverRepo);
|
const projectService = new ProjectService(projectRepo, profileRepo, serverRepo);
|
||||||
const auditLogService = new AuditLogService(auditLogRepo);
|
const auditLogService = new AuditLogService(auditLogRepo);
|
||||||
const metricsCollector = new MetricsCollector();
|
const metricsCollector = new MetricsCollector();
|
||||||
@@ -86,7 +87,7 @@ async function main(): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Routes
|
// Routes
|
||||||
registerMcpServerRoutes(app, serverService);
|
registerMcpServerRoutes(app, serverService, instanceService);
|
||||||
registerMcpProfileRoutes(app, profileService);
|
registerMcpProfileRoutes(app, profileService);
|
||||||
registerInstanceRoutes(app, instanceService);
|
registerInstanceRoutes(app, instanceService);
|
||||||
registerProjectRoutes(app, projectService);
|
registerProjectRoutes(app, projectService);
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export class McpServerRepository implements IMcpServerRepository {
|
|||||||
externalUrl: data.externalUrl ?? null,
|
externalUrl: data.externalUrl ?? null,
|
||||||
command: data.command ?? Prisma.DbNull,
|
command: data.command ?? Prisma.DbNull,
|
||||||
containerPort: data.containerPort ?? null,
|
containerPort: data.containerPort ?? null,
|
||||||
|
replicas: data.replicas,
|
||||||
envTemplate: data.envTemplate,
|
envTemplate: data.envTemplate,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -44,6 +45,7 @@ export class McpServerRepository implements IMcpServerRepository {
|
|||||||
if (data.externalUrl !== undefined) updateData['externalUrl'] = data.externalUrl;
|
if (data.externalUrl !== undefined) updateData['externalUrl'] = data.externalUrl;
|
||||||
if (data.command !== undefined) updateData['command'] = data.command;
|
if (data.command !== undefined) updateData['command'] = data.command;
|
||||||
if (data.containerPort !== undefined) updateData['containerPort'] = data.containerPort;
|
if (data.containerPort !== undefined) updateData['containerPort'] = data.containerPort;
|
||||||
|
if (data.replicas !== undefined) updateData['replicas'] = data.replicas;
|
||||||
if (data.envTemplate !== undefined) updateData['envTemplate'] = data.envTemplate;
|
if (data.envTemplate !== undefined) updateData['envTemplate'] = data.envTemplate;
|
||||||
|
|
||||||
return this.prisma.mcpServer.update({ where: { id }, data: updateData });
|
return this.prisma.mcpServer.update({ where: { id }, data: updateData });
|
||||||
|
|||||||
@@ -10,40 +10,17 @@ export function registerInstanceRoutes(app: FastifyInstance, service: InstanceSe
|
|||||||
return service.getById(request.params.id);
|
return service.getById(request.params.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post<{ Body: { serverId: string; env?: Record<string, string>; hostPort?: number } }>(
|
app.delete<{ Params: { id: string } }>('/api/v1/instances/:id', async (request, reply) => {
|
||||||
'/api/v1/instances',
|
const { serverId } = await service.remove(request.params.id);
|
||||||
async (request, reply) => {
|
// Reconcile: server will auto-create a replacement if replicas > 0
|
||||||
const { serverId } = request.body;
|
await service.reconcile(serverId);
|
||||||
const opts: { env?: Record<string, string>; hostPort?: number } = {};
|
reply.code(204);
|
||||||
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.get<{ Params: { id: string } }>('/api/v1/instances/:id/inspect', async (request) => {
|
app.get<{ Params: { id: string } }>('/api/v1/instances/:id/inspect', async (request) => {
|
||||||
return service.inspect(request.params.id);
|
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 } }>(
|
app.get<{ Params: { id: string }; Querystring: { tail?: string } }>(
|
||||||
'/api/v1/instances/:id/logs',
|
'/api/v1/instances/:id/logs',
|
||||||
async (request) => {
|
async (request) => {
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import type { McpServerService } from '../services/mcp-server.service.js';
|
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 () => {
|
app.get('/api/v1/servers', async () => {
|
||||||
return service.list();
|
return service.list();
|
||||||
});
|
});
|
||||||
@@ -12,12 +17,17 @@ export function registerMcpServerRoutes(app: FastifyInstance, service: McpServer
|
|||||||
|
|
||||||
app.post('/api/v1/servers', async (request, reply) => {
|
app.post('/api/v1/servers', async (request, reply) => {
|
||||||
const server = await service.create(request.body);
|
const server = await service.create(request.body);
|
||||||
|
// Auto-reconcile: create instances to match replicas
|
||||||
|
await instanceService.reconcile(server.id);
|
||||||
reply.code(201);
|
reply.code(201);
|
||||||
return server;
|
return server;
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put<{ Params: { id: string } }>('/api/v1/servers/:id', async (request) => {
|
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) => {
|
app.delete<{ Params: { id: string } }>('/api/v1/servers/:id', async (request, reply) => {
|
||||||
|
|||||||
@@ -28,7 +28,103 @@ export class InstanceService {
|
|||||||
return instance;
|
return instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
async start(serverId: string, opts?: { env?: Record<string, string>; hostPort?: number }): Promise<McpInstance> {
|
/**
|
||||||
|
* 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<McpInstance[]> {
|
||||||
|
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<void> {
|
||||||
|
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<ContainerInfo> {
|
||||||
|
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<McpInstance> {
|
||||||
const server = await this.serverRepo.findById(serverId);
|
const server = await this.serverRepo.findById(serverId);
|
||||||
if (!server) throw new NotFoundError(`McpServer '${serverId}' not found`);
|
if (!server) throw new NotFoundError(`McpServer '${serverId}' not found`);
|
||||||
|
|
||||||
@@ -43,7 +139,6 @@ export class InstanceService {
|
|||||||
|
|
||||||
const image = server.dockerImage ?? server.packageName ?? server.name;
|
const image = server.dockerImage ?? server.packageName ?? server.name;
|
||||||
|
|
||||||
// Create DB record first in STARTING state
|
|
||||||
let instance = await this.instanceRepo.create({
|
let instance = await this.instanceRepo.create({
|
||||||
serverId,
|
serverId,
|
||||||
status: 'STARTING',
|
status: 'STARTING',
|
||||||
@@ -53,7 +148,7 @@ export class InstanceService {
|
|||||||
const spec: ContainerSpec = {
|
const spec: ContainerSpec = {
|
||||||
image,
|
image,
|
||||||
name: `mcpctl-${server.name}-${instance.id}`,
|
name: `mcpctl-${server.name}-${instance.id}`,
|
||||||
hostPort: opts?.hostPort ?? null,
|
hostPort: null,
|
||||||
labels: {
|
labels: {
|
||||||
'mcpctl.server-id': serverId,
|
'mcpctl.server-id': serverId,
|
||||||
'mcpctl.instance-id': instance.id,
|
'mcpctl.instance-id': instance.id,
|
||||||
@@ -66,9 +161,6 @@ export class InstanceService {
|
|||||||
if (command) {
|
if (command) {
|
||||||
spec.command = command;
|
spec.command = command;
|
||||||
}
|
}
|
||||||
if (opts?.env) {
|
|
||||||
spec.env = opts.env;
|
|
||||||
}
|
|
||||||
|
|
||||||
const containerInfo = await this.orchestrator.createContainer(spec);
|
const containerInfo = await this.orchestrator.createContainer(spec);
|
||||||
|
|
||||||
@@ -81,7 +173,6 @@ export class InstanceService {
|
|||||||
|
|
||||||
instance = await this.instanceRepo.updateStatus(instance.id, 'RUNNING', updateFields);
|
instance = await this.instanceRepo.updateStatus(instance.id, 'RUNNING', updateFields);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Mark as ERROR if container creation fails
|
|
||||||
instance = await this.instanceRepo.updateStatus(instance.id, 'ERROR', {
|
instance = await this.instanceRepo.updateStatus(instance.id, 'ERROR', {
|
||||||
metadata: { error: err instanceof Error ? err.message : String(err) },
|
metadata: { error: err instanceof Error ? err.message : String(err) },
|
||||||
});
|
});
|
||||||
@@ -90,78 +181,16 @@ export class InstanceService {
|
|||||||
return instance;
|
return instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
async stop(id: string): Promise<McpInstance> {
|
/** Stop and remove a single instance. */
|
||||||
const instance = await this.getById(id);
|
private async removeOne(instance: McpInstance): Promise<void> {
|
||||||
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<McpInstance> {
|
|
||||||
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<ContainerInfo> {
|
|
||||||
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<void> {
|
|
||||||
const instance = await this.getById(id);
|
|
||||||
|
|
||||||
if (instance.containerId) {
|
if (instance.containerId) {
|
||||||
|
try {
|
||||||
|
await this.orchestrator.stopContainer(instance.containerId);
|
||||||
|
} catch { /* best-effort */ }
|
||||||
try {
|
try {
|
||||||
await this.orchestrator.removeContainer(instance.containerId, true);
|
await this.orchestrator.removeContainer(instance.containerId, true);
|
||||||
} catch {
|
} catch { /* best-effort */ }
|
||||||
// Container may already be gone, proceed with DB cleanup
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
await this.instanceRepo.delete(instance.id);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
import type { McpServer } from '@prisma/client';
|
import type { McpServer } from '@prisma/client';
|
||||||
import type { IMcpServerRepository } from '../repositories/interfaces.js';
|
import type { IMcpServerRepository } from '../repositories/interfaces.js';
|
||||||
|
import type { InstanceService } from './instance.service.js';
|
||||||
import { CreateMcpServerSchema, UpdateMcpServerSchema } from '../validation/mcp-server.schema.js';
|
import { CreateMcpServerSchema, UpdateMcpServerSchema } from '../validation/mcp-server.schema.js';
|
||||||
|
|
||||||
export class McpServerService {
|
export class McpServerService {
|
||||||
|
private instanceService: InstanceService | null = null;
|
||||||
|
|
||||||
constructor(private readonly repo: IMcpServerRepository) {}
|
constructor(private readonly repo: IMcpServerRepository) {}
|
||||||
|
|
||||||
|
/** Set after construction to avoid circular dependency. */
|
||||||
|
setInstanceService(instanceService: InstanceService): void {
|
||||||
|
this.instanceService = instanceService;
|
||||||
|
}
|
||||||
|
|
||||||
async list(): Promise<McpServer[]> {
|
async list(): Promise<McpServer[]> {
|
||||||
return this.repo.findAll();
|
return this.repo.findAll();
|
||||||
}
|
}
|
||||||
@@ -48,6 +56,10 @@ export class McpServerService {
|
|||||||
async delete(id: string): Promise<void> {
|
async delete(id: string): Promise<void> {
|
||||||
// Verify exists
|
// Verify exists
|
||||||
await this.getById(id);
|
await this.getById(id);
|
||||||
|
// Stop all containers before DB cascade
|
||||||
|
if (this.instanceService) {
|
||||||
|
await this.instanceService.removeAllForServer(id);
|
||||||
|
}
|
||||||
await this.repo.delete(id);
|
await this.repo.delete(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export const CreateMcpServerSchema = z.object({
|
|||||||
externalUrl: z.string().url().optional(),
|
externalUrl: z.string().url().optional(),
|
||||||
command: z.array(z.string()).optional(),
|
command: z.array(z.string()).optional(),
|
||||||
containerPort: z.number().int().min(1).max(65535).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([]),
|
envTemplate: z.array(EnvTemplateEntrySchema).default([]),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ export const UpdateMcpServerSchema = z.object({
|
|||||||
externalUrl: z.string().url().nullable().optional(),
|
externalUrl: z.string().url().nullable().optional(),
|
||||||
command: z.array(z.string()).nullable().optional(),
|
command: z.array(z.string()).nullable().optional(),
|
||||||
containerPort: z.number().int().min(1).max(65535).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(),
|
envTemplate: z.array(EnvTemplateEntrySchema).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { InstanceService, InvalidStateError } from '../src/services/instance.ser
|
|||||||
import { NotFoundError } from '../src/services/mcp-server.service.js';
|
import { NotFoundError } from '../src/services/mcp-server.service.js';
|
||||||
import type { IMcpInstanceRepository, IMcpServerRepository } from '../src/repositories/interfaces.js';
|
import type { IMcpInstanceRepository, IMcpServerRepository } from '../src/repositories/interfaces.js';
|
||||||
import type { McpOrchestrator } from '../src/services/orchestrator.js';
|
import type { McpOrchestrator } from '../src/services/orchestrator.js';
|
||||||
|
import type { McpInstance } from '@prisma/client';
|
||||||
|
|
||||||
function mockInstanceRepo(): IMcpInstanceRepository {
|
function mockInstanceRepo(): IMcpInstanceRepository {
|
||||||
return {
|
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> = {}): 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', () => {
|
describe('InstanceService', () => {
|
||||||
let instanceRepo: ReturnType<typeof mockInstanceRepo>;
|
let instanceRepo: ReturnType<typeof mockInstanceRepo>;
|
||||||
let serverRepo: ReturnType<typeof mockServerRepo>;
|
let serverRepo: ReturnType<typeof mockServerRepo>;
|
||||||
@@ -101,199 +137,98 @@ describe('InstanceService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns instance when found', async () => {
|
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');
|
const result = await service.getById('inst-1');
|
||||||
expect(result.id).toBe('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 () => {
|
it('throws NotFoundError for unknown server', async () => {
|
||||||
await expect(service.start('missing')).rejects.toThrow(NotFoundError);
|
await expect(service.reconcile('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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('remove', () => {
|
describe('remove', () => {
|
||||||
it('removes container and DB record', async () => {
|
it('stops container and deletes DB record', async () => {
|
||||||
vi.mocked(instanceRepo.findById).mockResolvedValue({
|
vi.mocked(instanceRepo.findById).mockResolvedValue(makeInstance({ containerId: 'ctr-abc' }));
|
||||||
id: 'inst-1', containerId: 'ctr-abc', status: 'STOPPED',
|
|
||||||
serverId: 'srv-1', port: null, metadata: {},
|
const result = await service.remove('inst-1');
|
||||||
version: 1, createdAt: new Date(), updatedAt: new Date(),
|
|
||||||
});
|
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');
|
await service.remove('inst-1');
|
||||||
|
|
||||||
expect(orchestrator.removeContainer).toHaveBeenCalledWith('ctr-abc', true);
|
expect(orchestrator.stopContainer).not.toHaveBeenCalled();
|
||||||
expect(instanceRepo.delete).toHaveBeenCalledWith('inst-1');
|
expect(instanceRepo.delete).toHaveBeenCalledWith('inst-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('removes DB record even if container is already gone', async () => {
|
it('deletes DB record even if container is already gone', async () => {
|
||||||
vi.mocked(instanceRepo.findById).mockResolvedValue({
|
vi.mocked(instanceRepo.findById).mockResolvedValue(makeInstance({ containerId: 'ctr-abc' }));
|
||||||
id: 'inst-1', containerId: 'ctr-abc', status: 'STOPPED',
|
|
||||||
serverId: 'srv-1', port: null, metadata: {},
|
|
||||||
version: 1, createdAt: new Date(), updatedAt: new Date(),
|
|
||||||
});
|
|
||||||
vi.mocked(orchestrator.removeContainer).mockRejectedValue(new Error('No such container'));
|
vi.mocked(orchestrator.removeContainer).mockRejectedValue(new Error('No such container'));
|
||||||
|
|
||||||
await service.remove('inst-1');
|
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', () => {
|
describe('getLogs', () => {
|
||||||
it('returns empty logs for instance without container', async () => {
|
it('returns empty logs for instance without container', async () => {
|
||||||
vi.mocked(instanceRepo.findById).mockResolvedValue({
|
vi.mocked(instanceRepo.findById).mockResolvedValue(makeInstance({ containerId: null }));
|
||||||
id: 'inst-1', containerId: null, status: 'ERROR',
|
|
||||||
serverId: 'srv-1', port: null, metadata: {},
|
|
||||||
version: 1, createdAt: new Date(), updatedAt: new Date(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await service.getLogs('inst-1');
|
const result = await service.getLogs('inst-1');
|
||||||
expect(result).toEqual({ stdout: '', stderr: '' });
|
expect(result).toEqual({ stdout: '', stderr: '' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns container logs', async () => {
|
it('returns container logs', async () => {
|
||||||
vi.mocked(instanceRepo.findById).mockResolvedValue({
|
vi.mocked(instanceRepo.findById).mockResolvedValue(makeInstance({ containerId: 'ctr-abc' }));
|
||||||
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.getLogs('inst-1', { tail: 50 });
|
const result = await service.getLogs('inst-1', { tail: 50 });
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ function createInMemoryServerRepo(): IMcpServerRepository {
|
|||||||
externalUrl: data.externalUrl ?? null,
|
externalUrl: data.externalUrl ?? null,
|
||||||
command: data.command ?? null,
|
command: data.command ?? null,
|
||||||
containerPort: data.containerPort ?? null,
|
containerPort: data.containerPort ?? null,
|
||||||
|
replicas: data.replicas ?? 1,
|
||||||
envTemplate: data.envTemplate ?? [],
|
envTemplate: data.envTemplate ?? [],
|
||||||
version: 1,
|
version: 1,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
@@ -279,10 +280,11 @@ async function buildTestApp(deps: {
|
|||||||
|
|
||||||
const serverService = new McpServerService(deps.serverRepo);
|
const serverService = new McpServerService(deps.serverRepo);
|
||||||
const instanceService = new InstanceService(deps.instanceRepo, deps.serverRepo, deps.orchestrator);
|
const instanceService = new InstanceService(deps.instanceRepo, deps.serverRepo, deps.orchestrator);
|
||||||
|
serverService.setInstanceService(instanceService);
|
||||||
const proxyService = new McpProxyService(deps.instanceRepo, deps.serverRepo);
|
const proxyService = new McpProxyService(deps.instanceRepo, deps.serverRepo);
|
||||||
const auditLogService = new AuditLogService(deps.auditLogRepo);
|
const auditLogService = new AuditLogService(deps.auditLogRepo);
|
||||||
|
|
||||||
registerMcpServerRoutes(app, serverService);
|
registerMcpServerRoutes(app, serverService, instanceService);
|
||||||
registerInstanceRoutes(app, instanceService);
|
registerInstanceRoutes(app, instanceService);
|
||||||
registerMcpProxyRoutes(app, {
|
registerMcpProxyRoutes(app, {
|
||||||
mcpProxyService: proxyService,
|
mcpProxyService: proxyService,
|
||||||
@@ -334,8 +336,8 @@ describe('MCP server full flow', () => {
|
|||||||
if (app) await app.close();
|
if (app) await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('registers server, starts virtual instance, and proxies tools/list', async () => {
|
it('registers server (auto-creates instance via reconcile), and proxies tools/list', async () => {
|
||||||
// 1. Register external MCP server
|
// 1. Register external MCP server (replicas defaults to 1 → auto-creates instance)
|
||||||
const createRes = await app.inject({
|
const createRes = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/api/v1/servers',
|
url: '/api/v1/servers',
|
||||||
@@ -363,17 +365,16 @@ describe('MCP server full flow', () => {
|
|||||||
expect(servers).toHaveLength(1);
|
expect(servers).toHaveLength(1);
|
||||||
expect(servers[0]!.name).toBe('ha-mcp');
|
expect(servers[0]!.name).toBe('ha-mcp');
|
||||||
|
|
||||||
// 3. Start a virtual instance (external server — no Docker)
|
// 3. Verify instance was auto-created (no Docker for external servers)
|
||||||
const startRes = await app.inject({
|
const instancesRes = await app.inject({
|
||||||
method: 'POST',
|
method: 'GET',
|
||||||
url: '/api/v1/instances',
|
url: `/api/v1/instances?serverId=${server.id}`,
|
||||||
payload: { serverId: server.id },
|
|
||||||
});
|
});
|
||||||
|
expect(instancesRes.statusCode).toBe(200);
|
||||||
expect(startRes.statusCode).toBe(201);
|
const instances = instancesRes.json<Array<{ id: string; status: string; containerId: string | null }>>();
|
||||||
const instance = startRes.json<{ id: string; status: string; containerId: string | null }>();
|
expect(instances).toHaveLength(1);
|
||||||
expect(instance.status).toBe('RUNNING');
|
expect(instances[0]!.status).toBe('RUNNING');
|
||||||
expect(instance.containerId).toBeNull();
|
expect(instances[0]!.containerId).toBeNull();
|
||||||
|
|
||||||
// 4. Proxy tools/list to the fake MCP server
|
// 4. Proxy tools/list to the fake MCP server
|
||||||
const proxyRes = await app.inject({
|
const proxyRes = await app.inject({
|
||||||
@@ -401,7 +402,7 @@ describe('MCP server full flow', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('proxies tools/call with parameters', async () => {
|
it('proxies tools/call with parameters', async () => {
|
||||||
// Register + start
|
// Register (auto-creates instance via reconcile)
|
||||||
const createRes = await app.inject({
|
const createRes = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/api/v1/servers',
|
url: '/api/v1/servers',
|
||||||
@@ -414,13 +415,7 @@ describe('MCP server full flow', () => {
|
|||||||
});
|
});
|
||||||
const server = createRes.json<{ id: string }>();
|
const server = createRes.json<{ id: string }>();
|
||||||
|
|
||||||
await app.inject({
|
// Proxy tools/call (instance was auto-created)
|
||||||
method: 'POST',
|
|
||||||
url: '/api/v1/instances',
|
|
||||||
payload: { serverId: server.id },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Proxy tools/call
|
|
||||||
const proxyRes = await app.inject({
|
const proxyRes = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/api/v1/mcp/proxy',
|
url: '/api/v1/mcp/proxy',
|
||||||
@@ -456,8 +451,8 @@ describe('MCP server full flow', () => {
|
|||||||
if (app) await app.close();
|
if (app) await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('registers server with dockerImage, starts container, and creates instance', async () => {
|
it('registers server with dockerImage, auto-creates container instance via reconcile', async () => {
|
||||||
// 1. Register managed server
|
// 1. Register managed server (replicas: 1 → auto-creates container)
|
||||||
const createRes = await app.inject({
|
const createRes = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/api/v1/servers',
|
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.dockerImage).toBe('ghcr.io/homeassistant-ai/ha-mcp:2.4');
|
||||||
expect(server.command).toEqual(['python', '-c', 'print("hello")']);
|
expect(server.command).toEqual(['python', '-c', 'print("hello")']);
|
||||||
|
|
||||||
// 2. Start container instance with env
|
// 2. Verify instance was auto-created with container
|
||||||
const startRes = await app.inject({
|
const instancesRes = await app.inject({
|
||||||
method: 'POST',
|
method: 'GET',
|
||||||
url: '/api/v1/instances',
|
url: `/api/v1/instances?serverId=${server.id}`,
|
||||||
payload: {
|
|
||||||
serverId: server.id,
|
|
||||||
env: { HOMEASSISTANT_URL: 'https://ha.example.com', HOMEASSISTANT_TOKEN: 'secret' },
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
expect(instancesRes.statusCode).toBe(200);
|
||||||
expect(startRes.statusCode).toBe(201);
|
const instances = instancesRes.json<Array<{ id: string; status: string; containerId: string }>>();
|
||||||
const instance = startRes.json<{ id: string; status: string; containerId: string }>();
|
expect(instances).toHaveLength(1);
|
||||||
expect(instance.status).toBe('RUNNING');
|
expect(instances[0]!.status).toBe('RUNNING');
|
||||||
expect(instance.containerId).toBeTruthy();
|
expect(instances[0]!.containerId).toBeTruthy();
|
||||||
|
|
||||||
// 3. Verify orchestrator was called with correct spec
|
// 3. Verify orchestrator was called with correct spec
|
||||||
expect(orchestrator.createContainer).toHaveBeenCalledTimes(1);
|
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.image).toBe('ghcr.io/homeassistant-ai/ha-mcp:2.4');
|
||||||
expect(spec.containerPort).toBe(3000);
|
expect(spec.containerPort).toBe(3000);
|
||||||
expect(spec.command).toEqual(['python', '-c', 'print("hello")']);
|
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 () => {
|
it('marks instance as ERROR when Docker fails', async () => {
|
||||||
vi.mocked(orchestrator.createContainer).mockRejectedValueOnce(new Error('Docker socket unavailable'));
|
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({
|
const createRes = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/api/v1/servers',
|
url: '/api/v1/servers',
|
||||||
@@ -521,17 +509,16 @@ describe('MCP server full flow', () => {
|
|||||||
transport: 'STDIO',
|
transport: 'STDIO',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
expect(createRes.statusCode).toBe(201);
|
||||||
|
|
||||||
const server = createRes.json<{ id: string }>();
|
const server = createRes.json<{ id: string }>();
|
||||||
|
const instancesRes = await app.inject({
|
||||||
const startRes = await app.inject({
|
method: 'GET',
|
||||||
method: 'POST',
|
url: `/api/v1/instances?serverId=${server.id}`,
|
||||||
url: '/api/v1/instances',
|
|
||||||
payload: { serverId: server.id },
|
|
||||||
});
|
});
|
||||||
|
const instances = instancesRes.json<Array<{ id: string; status: string }>>();
|
||||||
expect(startRes.statusCode).toBe(201);
|
expect(instances).toHaveLength(1);
|
||||||
const instance = startRes.json<{ id: string; status: string }>();
|
expect(instances[0]!.status).toBe('ERROR');
|
||||||
expect(instance.status).toBe('ERROR');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -553,8 +540,8 @@ describe('MCP server full flow', () => {
|
|||||||
if (app) await app.close();
|
if (app) await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('register → start → list → stop → remove', async () => {
|
it('register → auto-create → list → delete instance (reconcile) → delete server (cascade)', async () => {
|
||||||
// Register
|
// Register (auto-creates instance via reconcile)
|
||||||
const createRes = await app.inject({
|
const createRes = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/api/v1/servers',
|
url: '/api/v1/servers',
|
||||||
@@ -569,48 +556,34 @@ describe('MCP server full flow', () => {
|
|||||||
expect(createRes.statusCode).toBe(201);
|
expect(createRes.statusCode).toBe(201);
|
||||||
const server = createRes.json<{ id: string }>();
|
const server = createRes.json<{ id: string }>();
|
||||||
|
|
||||||
// Start
|
// List instances (auto-created)
|
||||||
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
|
|
||||||
const listRes = await app.inject({
|
const listRes = await app.inject({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url: `/api/v1/instances?serverId=${server.id}`,
|
url: `/api/v1/instances?serverId=${server.id}`,
|
||||||
});
|
});
|
||||||
expect(listRes.statusCode).toBe(200);
|
expect(listRes.statusCode).toBe(200);
|
||||||
const instances = listRes.json<Array<{ id: string }>>();
|
const instances = listRes.json<Array<{ id: string; status: string }>>();
|
||||||
expect(instances).toHaveLength(1);
|
expect(instances).toHaveLength(1);
|
||||||
|
expect(instances[0]!.status).toBe('RUNNING');
|
||||||
|
const instanceId = instances[0]!.id;
|
||||||
|
|
||||||
// Stop
|
// Delete instance → triggers reconcile → new instance auto-created
|
||||||
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
|
|
||||||
const removeRes = await app.inject({
|
const removeRes = await app.inject({
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
url: `/api/v1/instances/${instance.id}`,
|
url: `/api/v1/instances/${instanceId}`,
|
||||||
});
|
});
|
||||||
expect(removeRes.statusCode).toBe(204);
|
expect(removeRes.statusCode).toBe(204);
|
||||||
|
|
||||||
// Verify instance is gone
|
// Verify a replacement instance was created (reconcile)
|
||||||
const listAfter = await app.inject({
|
const listAfter = await app.inject({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url: `/api/v1/instances?serverId=${server.id}`,
|
url: `/api/v1/instances?serverId=${server.id}`,
|
||||||
});
|
});
|
||||||
expect(listAfter.json<unknown[]>()).toHaveLength(0);
|
const afterInstances = listAfter.json<Array<{ id: string }>>();
|
||||||
|
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({
|
const deleteRes = await app.inject({
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
url: `/api/v1/servers/${server.id}`,
|
url: `/api/v1/servers/${server.id}`,
|
||||||
@@ -622,8 +595,8 @@ describe('MCP server full flow', () => {
|
|||||||
expect(serversAfter.json<unknown[]>()).toHaveLength(0);
|
expect(serversAfter.json<unknown[]>()).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('external server lifecycle: register → start → proxy → stop → cleanup', async () => {
|
it('external server lifecycle: register → auto-create → proxy → delete server (cascade)', async () => {
|
||||||
// Register external
|
// Register external (auto-creates virtual instance)
|
||||||
const createRes = await app.inject({
|
const createRes = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/api/v1/servers',
|
url: '/api/v1/servers',
|
||||||
@@ -635,15 +608,15 @@ describe('MCP server full flow', () => {
|
|||||||
});
|
});
|
||||||
const server = createRes.json<{ id: string }>();
|
const server = createRes.json<{ id: string }>();
|
||||||
|
|
||||||
// Start (virtual instance)
|
// Verify auto-created instance
|
||||||
const startRes = await app.inject({
|
const instancesRes = await app.inject({
|
||||||
method: 'POST',
|
method: 'GET',
|
||||||
url: '/api/v1/instances',
|
url: `/api/v1/instances?serverId=${server.id}`,
|
||||||
payload: { serverId: server.id },
|
|
||||||
});
|
});
|
||||||
const instance = startRes.json<{ id: string; status: string; containerId: string | null }>();
|
const instances = instancesRes.json<Array<{ id: string; status: string; containerId: string | null }>>();
|
||||||
expect(instance.status).toBe('RUNNING');
|
expect(instances).toHaveLength(1);
|
||||||
expect(instance.containerId).toBeNull();
|
expect(instances[0]!.status).toBe('RUNNING');
|
||||||
|
expect(instances[0]!.containerId).toBeNull();
|
||||||
|
|
||||||
// Proxy tools/list
|
// Proxy tools/list
|
||||||
const proxyRes = await app.inject({
|
const proxyRes = await app.inject({
|
||||||
@@ -655,17 +628,16 @@ describe('MCP server full flow', () => {
|
|||||||
expect(proxyRes.statusCode).toBe(200);
|
expect(proxyRes.statusCode).toBe(200);
|
||||||
expect(proxyRes.json<{ result: { tools: unknown[] } }>().result.tools.length).toBeGreaterThan(0);
|
expect(proxyRes.json<{ result: { tools: unknown[] } }>().result.tools.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
// Stop (no container to stop)
|
// Docker orchestrator should NOT have been called (external server)
|
||||||
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
|
|
||||||
expect(orchestrator.createContainer).not.toHaveBeenCalled();
|
expect(orchestrator.createContainer).not.toHaveBeenCalled();
|
||||||
expect(orchestrator.stopContainer).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 () => {
|
it('creates and updates server fields', async () => {
|
||||||
// Create
|
// Create (with replicas: 0 to avoid creating instances in this test)
|
||||||
const createRes = await app.inject({
|
const createRes = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/api/v1/servers',
|
url: '/api/v1/servers',
|
||||||
@@ -721,8 +693,10 @@ describe('MCP server full flow', () => {
|
|||||||
name: 'updatable',
|
name: 'updatable',
|
||||||
description: 'Original desc',
|
description: 'Original desc',
|
||||||
transport: 'STDIO',
|
transport: 'STDIO',
|
||||||
|
replicas: 0,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
expect(createRes.statusCode).toBe(201);
|
||||||
const server = createRes.json<{ id: string; description: string }>();
|
const server = createRes.json<{ id: string; description: string }>();
|
||||||
expect(server.description).toBe('Original desc');
|
expect(server.description).toBe('Original desc');
|
||||||
|
|
||||||
|
|||||||
@@ -3,44 +3,66 @@ import Fastify from 'fastify';
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { registerMcpServerRoutes } from '../src/routes/mcp-servers.js';
|
import { registerMcpServerRoutes } from '../src/routes/mcp-servers.js';
|
||||||
import { McpServerService, NotFoundError, ConflictError } from '../src/services/mcp-server.service.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 { 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;
|
let app: FastifyInstance;
|
||||||
|
|
||||||
function mockRepo(): IMcpServerRepository {
|
function mockRepo(): IMcpServerRepository {
|
||||||
|
let lastCreated: Record<string, unknown> | null = null;
|
||||||
return {
|
return {
|
||||||
findAll: vi.fn(async () => [
|
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),
|
findByName: vi.fn(async () => null),
|
||||||
create: vi.fn(async (data) => ({
|
create: vi.fn(async (data) => {
|
||||||
id: 'new-id',
|
const server = {
|
||||||
name: data.name,
|
id: 'new-id',
|
||||||
description: data.description ?? '',
|
name: data.name,
|
||||||
packageName: data.packageName ?? null,
|
description: data.description ?? '',
|
||||||
dockerImage: null,
|
packageName: data.packageName ?? null,
|
||||||
transport: data.transport ?? 'STDIO',
|
dockerImage: null,
|
||||||
repositoryUrl: null,
|
transport: data.transport ?? 'STDIO',
|
||||||
envTemplate: [],
|
repositoryUrl: null,
|
||||||
version: 1,
|
externalUrl: null,
|
||||||
createdAt: new Date(),
|
command: null,
|
||||||
updatedAt: new Date(),
|
containerPort: null,
|
||||||
})),
|
replicas: data.replicas ?? 1,
|
||||||
update: vi.fn(async (id, data) => ({
|
envTemplate: [],
|
||||||
id,
|
version: 1,
|
||||||
name: 'slack',
|
createdAt: new Date(),
|
||||||
description: (data.description as string) ?? 'Slack server',
|
updatedAt: new Date(),
|
||||||
packageName: null,
|
};
|
||||||
dockerImage: null,
|
lastCreated = server;
|
||||||
transport: 'STDIO',
|
return server;
|
||||||
repositoryUrl: null,
|
}),
|
||||||
envTemplate: [],
|
update: vi.fn(async (id, data) => {
|
||||||
version: 2,
|
const server = {
|
||||||
createdAt: new Date(),
|
id,
|
||||||
updatedAt: new Date(),
|
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 () => {}),
|
delete: vi.fn(async () => {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -49,11 +71,56 @@ afterEach(async () => {
|
|||||||
if (app) await app.close();
|
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) {
|
function createApp(repo: IMcpServerRepository) {
|
||||||
app = Fastify({ logger: false });
|
app = Fastify({ logger: false });
|
||||||
app.setErrorHandler(errorHandler);
|
app.setErrorHandler(errorHandler);
|
||||||
const service = new McpServerService(repo);
|
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();
|
return app.ready();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user