feat: add Docker container management for MCP servers

McpOrchestrator interface with DockerContainerManager implementation,
instance service for lifecycle management, instance API routes,
and docker-compose with mcpd service. 127 tests passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michal
2026-02-21 04:52:12 +00:00
parent 0ff5c85cf6
commit d1390313a3
14 changed files with 1318 additions and 2 deletions

View File

@@ -20,10 +20,12 @@
"@mcpctl/db": "workspace:*",
"@mcpctl/shared": "workspace:*",
"@prisma/client": "^6.0.0",
"dockerode": "^4.0.9",
"fastify": "^5.0.0",
"zod": "^3.24.0"
},
"devDependencies": {
"@types/dockerode": "^4.0.1",
"@types/node": "^25.3.0"
}
}

View File

@@ -1,5 +1,6 @@
export type { IMcpServerRepository, IMcpProfileRepository } from './interfaces.js';
export type { IMcpServerRepository, IMcpProfileRepository, IMcpInstanceRepository } from './interfaces.js';
export { McpServerRepository } from './mcp-server.repository.js';
export { McpProfileRepository } from './mcp-profile.repository.js';
export type { IProjectRepository } from './project.repository.js';
export { ProjectRepository } from './project.repository.js';
export { McpInstanceRepository } from './mcp-instance.repository.js';

View File

@@ -1,4 +1,4 @@
import type { McpServer, McpProfile } from '@prisma/client';
import type { McpServer, McpProfile, McpInstance, InstanceStatus } from '@prisma/client';
import type { CreateMcpServerInput, UpdateMcpServerInput } from '../validation/mcp-server.schema.js';
import type { CreateMcpProfileInput, UpdateMcpProfileInput } from '../validation/mcp-profile.schema.js';
@@ -11,6 +11,15 @@ export interface IMcpServerRepository {
delete(id: string): Promise<void>;
}
export interface IMcpInstanceRepository {
findAll(serverId?: string): Promise<McpInstance[]>;
findById(id: string): Promise<McpInstance | null>;
findByContainerId(containerId: string): Promise<McpInstance | null>;
create(data: { serverId: string; containerId?: string; status?: InstanceStatus; port?: number; metadata?: Record<string, unknown> }): Promise<McpInstance>;
updateStatus(id: string, status: InstanceStatus, fields?: { containerId?: string; port?: number; metadata?: Record<string, unknown> }): Promise<McpInstance>;
delete(id: string): Promise<void>;
}
export interface IMcpProfileRepository {
findAll(serverId?: string): Promise<McpProfile[]>;
findById(id: string): Promise<McpProfile | null>;

View File

@@ -0,0 +1,71 @@
import type { PrismaClient, McpInstance, InstanceStatus, Prisma } from '@prisma/client';
import type { IMcpInstanceRepository } from './interfaces.js';
export class McpInstanceRepository implements IMcpInstanceRepository {
constructor(private prisma: PrismaClient) {}
async findAll(serverId?: string): Promise<McpInstance[]> {
const where: Prisma.McpInstanceWhereInput = {};
if (serverId) {
where.serverId = serverId;
}
return this.prisma.mcpInstance.findMany({
where,
orderBy: { createdAt: 'desc' },
});
}
async findById(id: string): Promise<McpInstance | null> {
return this.prisma.mcpInstance.findUnique({ where: { id } });
}
async findByContainerId(containerId: string): Promise<McpInstance | null> {
return this.prisma.mcpInstance.findFirst({ where: { containerId } });
}
async create(data: {
serverId: string;
containerId?: string;
status?: InstanceStatus;
port?: number;
metadata?: Record<string, unknown>;
}): Promise<McpInstance> {
return this.prisma.mcpInstance.create({
data: {
serverId: data.serverId,
containerId: data.containerId ?? null,
status: data.status ?? 'STOPPED',
port: data.port ?? null,
metadata: (data.metadata ?? {}) as Prisma.InputJsonValue,
},
});
}
async updateStatus(
id: string,
status: InstanceStatus,
fields?: { containerId?: string; port?: number; metadata?: Record<string, unknown> },
): Promise<McpInstance> {
const updateData: Prisma.McpInstanceUpdateInput = {
status,
version: { increment: 1 },
};
if (fields?.containerId !== undefined) {
updateData.containerId = fields.containerId;
}
if (fields?.port !== undefined) {
updateData.port = fields.port;
}
if (fields?.metadata !== undefined) {
updateData.metadata = fields.metadata as Prisma.InputJsonValue;
}
return this.prisma.mcpInstance.update({
where: { id },
data: updateData,
});
}
async delete(id: string): Promise<void> {
await this.prisma.mcpInstance.delete({ where: { id } });
}
}

View File

@@ -3,3 +3,4 @@ export type { HealthDeps } from './health.js';
export { registerMcpServerRoutes } from './mcp-servers.js';
export { registerMcpProfileRoutes } from './mcp-profiles.js';
export { registerProjectRoutes } from './projects.js';
export { registerInstanceRoutes } from './instances.js';

View File

@@ -0,0 +1,49 @@
import type { FastifyInstance } from 'fastify';
import type { InstanceService } from '../services/instance.service.js';
export function registerInstanceRoutes(app: FastifyInstance, service: InstanceService): void {
app.get<{ Querystring: { serverId?: string } }>('/api/v1/instances', async (request) => {
return service.list(request.query.serverId);
});
app.get<{ Params: { id: string } }>('/api/v1/instances/:id', async (request) => {
return service.getById(request.params.id);
});
app.post<{ Body: { serverId: string; env?: Record<string, string>; hostPort?: number } }>(
'/api/v1/instances',
async (request, reply) => {
const { serverId } = request.body;
const opts: { env?: Record<string, string>; hostPort?: number } = {};
if (request.body.env) {
opts.env = request.body.env;
}
if (request.body.hostPort !== undefined) {
opts.hostPort = request.body.hostPort;
}
const instance = await service.start(serverId, opts);
reply.code(201);
return instance;
},
);
app.post<{ Params: { id: string } }>('/api/v1/instances/:id/stop', async (request) => {
return service.stop(request.params.id);
});
app.delete<{ Params: { id: string } }>('/api/v1/instances/:id', async (request, reply) => {
await service.remove(request.params.id);
reply.code(204);
});
app.get<{ Params: { id: string }; Querystring: { tail?: string } }>(
'/api/v1/instances/:id/logs',
async (request) => {
const opts: { tail?: number } = {};
if (request.query.tail) {
opts.tail = parseInt(request.query.tail, 10);
}
return service.getLogs(request.params.id, opts);
},
);
}

View File

@@ -0,0 +1,156 @@
import Docker from 'dockerode';
import type {
McpOrchestrator,
ContainerSpec,
ContainerInfo,
ContainerLogs,
} from '../orchestrator.js';
import { DEFAULT_MEMORY_LIMIT, DEFAULT_NANO_CPUS } from '../orchestrator.js';
const MCPCTL_LABEL = 'mcpctl.managed';
function mapState(state: string | undefined): ContainerInfo['state'] {
switch (state?.toLowerCase()) {
case 'running':
return 'running';
case 'created':
case 'restarting':
return 'starting';
case 'exited':
case 'dead':
case 'paused':
return 'stopped';
default:
return 'unknown';
}
}
export class DockerContainerManager implements McpOrchestrator {
private docker: Docker;
constructor(opts?: Docker.DockerOptions) {
this.docker = new Docker(opts);
}
async ping(): Promise<boolean> {
try {
await this.docker.ping();
return true;
} catch {
return false;
}
}
async pullImage(image: string): Promise<void> {
const stream = await this.docker.pull(image);
// Wait for pull to complete
await new Promise<void>((resolve, reject) => {
this.docker.modem.followProgress(stream, (err: Error | null) => {
if (err) reject(err);
else resolve();
});
});
}
async createContainer(spec: ContainerSpec): Promise<ContainerInfo> {
const memoryLimit = spec.memoryLimit ?? DEFAULT_MEMORY_LIMIT;
const nanoCpus = spec.nanoCpus ?? DEFAULT_NANO_CPUS;
const portBindings: Record<string, Array<{ HostPort: string }>> = {};
const exposedPorts: Record<string, Record<string, never>> = {};
if (spec.containerPort) {
const key = `${spec.containerPort}/tcp`;
exposedPorts[key] = {};
portBindings[key] = [{ HostPort: spec.hostPort ? String(spec.hostPort) : '0' }];
}
const labels: Record<string, string> = {
[MCPCTL_LABEL]: 'true',
...spec.labels,
};
const envArr = spec.env
? Object.entries(spec.env).map(([k, v]) => `${k}=${v}`)
: undefined;
const container = await this.docker.createContainer({
Image: spec.image,
name: spec.name,
Env: envArr,
ExposedPorts: exposedPorts,
Labels: labels,
HostConfig: {
PortBindings: portBindings,
Memory: memoryLimit,
NanoCpus: nanoCpus,
NetworkMode: spec.network ?? 'bridge',
},
});
await container.start();
return this.inspectContainer(container.id);
}
async stopContainer(containerId: string, timeoutSeconds = 10): Promise<void> {
const container = this.docker.getContainer(containerId);
await container.stop({ t: timeoutSeconds });
}
async removeContainer(containerId: string, force = false): Promise<void> {
const container = this.docker.getContainer(containerId);
await container.remove({ force, v: true });
}
async inspectContainer(containerId: string): Promise<ContainerInfo> {
const container = this.docker.getContainer(containerId);
const info = await container.inspect();
let port: number | undefined;
const ports = info.NetworkSettings?.Ports;
if (ports) {
for (const bindings of Object.values(ports)) {
const arr = bindings as Array<{ HostIp: string; HostPort: string }> | undefined;
if (arr && arr.length > 0 && arr[0]) {
port = parseInt(arr[0].HostPort, 10);
break;
}
}
}
const result: ContainerInfo = {
containerId: info.Id,
name: info.Name.replace(/^\//, ''),
state: mapState(info.State?.Status),
createdAt: new Date(info.Created),
};
if (port !== undefined) {
result.port = port;
}
return result;
}
async getContainerLogs(
containerId: string,
opts?: { tail?: number; since?: number },
): Promise<ContainerLogs> {
const container = this.docker.getContainer(containerId);
const logOpts: Record<string, unknown> = {
stdout: true,
stderr: true,
follow: false,
tail: opts?.tail ?? 100,
};
if (opts?.since) {
logOpts['since'] = opts.since;
}
const buffer = await container.logs(logOpts) as unknown as Buffer;
const raw = buffer.toString('utf-8');
// Docker multiplexes stdout/stderr with 8-byte headers.
// For simplicity we return everything as stdout.
return { stdout: raw, stderr: '' };
}
}

View File

@@ -1,5 +1,9 @@
export { McpServerService, NotFoundError, ConflictError } from './mcp-server.service.js';
export { McpProfileService } from './mcp-profile.service.js';
export { ProjectService } from './project.service.js';
export { InstanceService } from './instance.service.js';
export { generateMcpConfig } from './mcp-config-generator.js';
export type { McpConfig, McpConfigServer, ProfileWithServer } from './mcp-config-generator.js';
export type { McpOrchestrator, ContainerSpec, ContainerInfo, ContainerLogs } from './orchestrator.js';
export { DEFAULT_MEMORY_LIMIT, DEFAULT_NANO_CPUS } from './orchestrator.js';
export { DockerContainerManager } from './docker/container-manager.js';

View File

@@ -0,0 +1,112 @@
import type { McpInstance } from '@prisma/client';
import type { IMcpInstanceRepository, IMcpServerRepository } from '../repositories/interfaces.js';
import type { McpOrchestrator, ContainerSpec } from './orchestrator.js';
import { NotFoundError } from './mcp-server.service.js';
export class InstanceService {
constructor(
private instanceRepo: IMcpInstanceRepository,
private serverRepo: IMcpServerRepository,
private orchestrator: McpOrchestrator,
) {}
async list(serverId?: string): Promise<McpInstance[]> {
return this.instanceRepo.findAll(serverId);
}
async getById(id: string): Promise<McpInstance> {
const instance = await this.instanceRepo.findById(id);
if (!instance) throw new NotFoundError(`Instance '${id}' not found`);
return instance;
}
async start(serverId: string, opts?: { env?: Record<string, string>; hostPort?: number }): Promise<McpInstance> {
const server = await this.serverRepo.findById(serverId);
if (!server) throw new NotFoundError(`McpServer '${serverId}' not found`);
const image = server.dockerImage ?? server.packageName ?? server.name;
// Create DB record first in STARTING state
let instance = await this.instanceRepo.create({
serverId,
status: 'STARTING',
});
try {
const spec: ContainerSpec = {
image,
name: `mcpctl-${server.name}-${instance.id}`,
hostPort: opts?.hostPort ?? null,
labels: {
'mcpctl.server-id': serverId,
'mcpctl.instance-id': instance.id,
},
};
if (server.transport === 'SSE' || server.transport === 'STREAMABLE_HTTP') {
spec.containerPort = 3000;
}
if (opts?.env) {
spec.env = opts.env;
}
const containerInfo = await this.orchestrator.createContainer(spec);
const updateFields: { containerId: string; port?: number } = {
containerId: containerInfo.containerId,
};
if (containerInfo.port !== undefined) {
updateFields.port = containerInfo.port;
}
instance = await this.instanceRepo.updateStatus(instance.id, 'RUNNING', updateFields);
} catch (err) {
// Mark as ERROR if container creation fails
instance = await this.instanceRepo.updateStatus(instance.id, 'ERROR', {
metadata: { error: err instanceof Error ? err.message : String(err) },
});
}
return instance;
}
async stop(id: string): Promise<McpInstance> {
const instance = await this.getById(id);
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 remove(id: string): Promise<void> {
const instance = await this.getById(id);
if (instance.containerId) {
try {
await this.orchestrator.removeContainer(instance.containerId, true);
} catch {
// Container may already be gone, proceed with DB cleanup
}
}
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);
}
}

View File

@@ -0,0 +1,64 @@
/**
* Container orchestrator abstraction. Implementations can back onto Docker, Podman, or Kubernetes.
*/
export interface ContainerSpec {
/** Docker/OCI image reference */
image: string;
/** Human-readable name (used as container name prefix) */
name: string;
/** Environment variables */
env?: Record<string, string>;
/** Host port to bind (null = auto-assign) */
hostPort?: number | null;
/** Container port to expose */
containerPort?: number;
/** Memory limit in bytes (default: 512 MB) */
memoryLimit?: number;
/** CPU period quota (nanoCPUs, default: 0.5 CPU) */
nanoCpus?: number;
/** Labels for identification / filtering */
labels?: Record<string, string>;
/** Network name to attach to */
network?: string;
}
export interface ContainerInfo {
containerId: string;
name: string;
state: 'running' | 'stopped' | 'starting' | 'error' | 'unknown';
port?: number;
createdAt: Date;
}
export interface ContainerLogs {
stdout: string;
stderr: string;
}
export interface McpOrchestrator {
/** Pull an image if not present locally */
pullImage(image: string): Promise<void>;
/** Create and start a container */
createContainer(spec: ContainerSpec): Promise<ContainerInfo>;
/** Stop a running container */
stopContainer(containerId: string, timeoutSeconds?: number): Promise<void>;
/** Remove a stopped container */
removeContainer(containerId: string, force?: boolean): Promise<void>;
/** Get container info */
inspectContainer(containerId: string): Promise<ContainerInfo>;
/** Get container logs */
getContainerLogs(containerId: string, opts?: { tail?: number; since?: number }): Promise<ContainerLogs>;
/** Check if the orchestrator runtime is available */
ping(): Promise<boolean>;
}
/** Default resource limits */
export const DEFAULT_MEMORY_LIMIT = 512 * 1024 * 1024; // 512 MB
export const DEFAULT_NANO_CPUS = 500_000_000; // 0.5 CPU

View File

@@ -0,0 +1,125 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { DockerContainerManager } from '../src/services/docker/container-manager.js';
import type { ContainerSpec } from '../src/services/orchestrator.js';
// Mock dockerode
vi.mock('dockerode', () => {
const mockContainer = {
id: 'ctr-abc123',
start: vi.fn(async () => {}),
stop: vi.fn(async () => {}),
remove: vi.fn(async () => {}),
inspect: vi.fn(async () => ({
Id: 'ctr-abc123',
Name: '/mcpctl-test',
State: { Status: 'running' },
Created: '2024-01-01T00:00:00Z',
NetworkSettings: {
Ports: {
'3000/tcp': [{ HostIp: '0.0.0.0', HostPort: '32768' }],
},
},
})),
logs: vi.fn(async () => Buffer.from('test log output')),
};
const mockModem = {
followProgress: vi.fn((_stream: unknown, onFinished: (err: Error | null) => void) => {
onFinished(null);
}),
};
class MockDocker {
modem = mockModem;
ping = vi.fn(async () => 'OK');
pull = vi.fn(async () => ({}));
createContainer = vi.fn(async () => mockContainer);
getContainer = vi.fn(() => mockContainer);
}
return { default: MockDocker };
});
describe('DockerContainerManager', () => {
let manager: DockerContainerManager;
beforeEach(() => {
vi.clearAllMocks();
manager = new DockerContainerManager();
});
describe('ping', () => {
it('returns true when Docker is available', async () => {
expect(await manager.ping()).toBe(true);
});
});
describe('pullImage', () => {
it('pulls an image', async () => {
await manager.pullImage('node:20-alpine');
// No error = success
});
});
describe('createContainer', () => {
it('creates and starts a container', async () => {
const spec: ContainerSpec = {
image: 'ghcr.io/slack-mcp:latest',
name: 'mcpctl-slack-inst1',
env: { SLACK_TOKEN: 'xoxb-test' },
containerPort: 3000,
hostPort: null,
labels: { 'mcpctl.server-id': 'srv-1' },
};
const result = await manager.createContainer(spec);
expect(result.containerId).toBe('ctr-abc123');
expect(result.name).toBe('mcpctl-test');
expect(result.state).toBe('running');
expect(result.port).toBe(32768);
});
it('applies resource limits', async () => {
const spec: ContainerSpec = {
image: 'test:latest',
name: 'test-container',
memoryLimit: 256 * 1024 * 1024,
nanoCpus: 250_000_000,
};
await manager.createContainer(spec);
// The mock captures the call - we verify it doesn't throw
});
});
describe('stopContainer', () => {
it('stops a container', async () => {
await manager.stopContainer('ctr-abc123');
// No error = success
});
});
describe('removeContainer', () => {
it('removes a container', async () => {
await manager.removeContainer('ctr-abc123', true);
// No error = success
});
});
describe('inspectContainer', () => {
it('returns container info with mapped state', async () => {
const info = await manager.inspectContainer('ctr-abc123');
expect(info.containerId).toBe('ctr-abc123');
expect(info.state).toBe('running');
expect(info.port).toBe(32768);
});
});
describe('getContainerLogs', () => {
it('returns container logs', async () => {
const logs = await manager.getContainerLogs('ctr-abc123', { tail: 50 });
expect(logs.stdout).toBe('test log output');
});
});
});

View File

@@ -0,0 +1,253 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { InstanceService } from '../src/services/instance.service.js';
import { NotFoundError } from '../src/services/mcp-server.service.js';
import type { IMcpInstanceRepository, IMcpServerRepository } from '../src/repositories/interfaces.js';
import type { McpOrchestrator } from '../src/services/orchestrator.js';
function mockInstanceRepo(): IMcpInstanceRepository {
return {
findAll: vi.fn(async () => []),
findById: vi.fn(async () => null),
findByContainerId: vi.fn(async () => null),
create: vi.fn(async (data) => ({
id: 'inst-1',
serverId: data.serverId,
containerId: data.containerId ?? null,
status: data.status ?? 'STOPPED',
port: data.port ?? null,
metadata: data.metadata ?? {},
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
})),
updateStatus: vi.fn(async (id, status, fields) => ({
id,
serverId: 'srv-1',
containerId: fields?.containerId ?? 'ctr-abc',
status,
port: fields?.port ?? null,
metadata: fields?.metadata ?? {},
version: 2,
createdAt: new Date(),
updatedAt: new Date(),
})),
delete: vi.fn(async () => {}),
};
}
function mockServerRepo(): IMcpServerRepository {
return {
findAll: vi.fn(async () => []),
findById: vi.fn(async () => null),
findByName: vi.fn(async () => null),
create: vi.fn(async () => ({} as never)),
update: vi.fn(async () => ({} as never)),
delete: vi.fn(async () => {}),
};
}
function mockOrchestrator(): McpOrchestrator {
return {
ping: vi.fn(async () => true),
pullImage: vi.fn(async () => {}),
createContainer: vi.fn(async (spec) => ({
containerId: 'ctr-abc123',
name: spec.name,
state: 'running' as const,
port: 3000,
createdAt: new Date(),
})),
stopContainer: vi.fn(async () => {}),
removeContainer: vi.fn(async () => {}),
inspectContainer: vi.fn(async () => ({
containerId: 'ctr-abc123',
name: 'test',
state: 'running' as const,
createdAt: new Date(),
})),
getContainerLogs: vi.fn(async () => ({ stdout: 'log output', stderr: '' })),
};
}
describe('InstanceService', () => {
let instanceRepo: ReturnType<typeof mockInstanceRepo>;
let serverRepo: ReturnType<typeof mockServerRepo>;
let orchestrator: ReturnType<typeof mockOrchestrator>;
let service: InstanceService;
beforeEach(() => {
instanceRepo = mockInstanceRepo();
serverRepo = mockServerRepo();
orchestrator = mockOrchestrator();
service = new InstanceService(instanceRepo, serverRepo, orchestrator);
});
describe('list', () => {
it('lists all instances', async () => {
const result = await service.list();
expect(instanceRepo.findAll).toHaveBeenCalledWith(undefined);
expect(result).toEqual([]);
});
it('filters by serverId', async () => {
await service.list('srv-1');
expect(instanceRepo.findAll).toHaveBeenCalledWith('srv-1');
});
});
describe('getById', () => {
it('throws NotFoundError when not found', async () => {
await expect(service.getById('missing')).rejects.toThrow(NotFoundError);
});
it('returns instance when found', async () => {
vi.mocked(instanceRepo.findById).mockResolvedValue({ id: 'inst-1' } as never);
const result = await service.getById('inst-1');
expect(result.id).toBe('inst-1');
});
});
describe('start', () => {
it('throws NotFoundError for unknown server', async () => {
await expect(service.start('missing')).rejects.toThrow(NotFoundError);
});
it('creates instance and starts container', async () => {
vi.mocked(serverRepo.findById).mockResolvedValue({
id: 'srv-1', name: 'slack', dockerImage: 'ghcr.io/slack-mcp:latest',
packageName: null, transport: 'STDIO', description: '', repositoryUrl: null,
envTemplate: [], version: 1, createdAt: new Date(), updatedAt: new Date(),
});
const result = await service.start('srv-1');
expect(instanceRepo.create).toHaveBeenCalledWith({
serverId: 'srv-1',
status: 'STARTING',
});
expect(orchestrator.createContainer).toHaveBeenCalled();
expect(instanceRepo.updateStatus).toHaveBeenCalledWith(
'inst-1', 'RUNNING',
expect.objectContaining({ containerId: 'ctr-abc123' }),
);
expect(result.status).toBe('RUNNING');
});
it('marks instance as ERROR on container failure', async () => {
vi.mocked(serverRepo.findById).mockResolvedValue({
id: 'srv-1', name: 'slack', dockerImage: 'ghcr.io/slack-mcp:latest',
packageName: null, transport: 'STDIO', description: '', repositoryUrl: null,
envTemplate: [], version: 1, createdAt: new Date(), updatedAt: new Date(),
});
vi.mocked(orchestrator.createContainer).mockRejectedValue(new Error('Docker unavailable'));
const result = await service.start('srv-1');
expect(instanceRepo.updateStatus).toHaveBeenCalledWith(
'inst-1', 'ERROR',
expect.objectContaining({ metadata: { error: 'Docker unavailable' } }),
);
expect(result.status).toBe('ERROR');
});
it('uses dockerImage for container spec', async () => {
vi.mocked(serverRepo.findById).mockResolvedValue({
id: 'srv-1', name: 'slack', dockerImage: 'myregistry.com/slack:v1',
packageName: '@slack/mcp', transport: 'SSE', description: '', repositoryUrl: null,
envTemplate: [], version: 1, createdAt: new Date(), updatedAt: new Date(),
});
await service.start('srv-1');
const spec = vi.mocked(orchestrator.createContainer).mock.calls[0]?.[0];
expect(spec?.image).toBe('myregistry.com/slack:v1');
expect(spec?.containerPort).toBe(3000); // SSE transport
});
});
describe('stop', () => {
it('throws NotFoundError for missing instance', async () => {
await expect(service.stop('missing')).rejects.toThrow(NotFoundError);
});
it('stops a running container', async () => {
vi.mocked(instanceRepo.findById).mockResolvedValue({
id: 'inst-1', containerId: 'ctr-abc', status: 'RUNNING',
serverId: 'srv-1', port: 3000, metadata: {},
version: 1, createdAt: new Date(), updatedAt: new Date(),
});
await service.stop('inst-1');
expect(orchestrator.stopContainer).toHaveBeenCalledWith('ctr-abc');
expect(instanceRepo.updateStatus).toHaveBeenCalledWith('inst-1', 'STOPPED');
});
it('handles stop without containerId', async () => {
vi.mocked(instanceRepo.findById).mockResolvedValue({
id: 'inst-1', containerId: null, status: 'ERROR',
serverId: 'srv-1', port: null, metadata: {},
version: 1, createdAt: new Date(), updatedAt: new Date(),
});
await service.stop('inst-1');
expect(orchestrator.stopContainer).not.toHaveBeenCalled();
expect(instanceRepo.updateStatus).toHaveBeenCalledWith('inst-1', 'STOPPED');
});
});
describe('remove', () => {
it('removes container and DB record', async () => {
vi.mocked(instanceRepo.findById).mockResolvedValue({
id: 'inst-1', containerId: 'ctr-abc', status: 'STOPPED',
serverId: 'srv-1', port: null, metadata: {},
version: 1, createdAt: new Date(), updatedAt: new Date(),
});
await service.remove('inst-1');
expect(orchestrator.removeContainer).toHaveBeenCalledWith('ctr-abc', true);
expect(instanceRepo.delete).toHaveBeenCalledWith('inst-1');
});
it('removes DB record even if container is already gone', async () => {
vi.mocked(instanceRepo.findById).mockResolvedValue({
id: 'inst-1', containerId: 'ctr-abc', status: 'STOPPED',
serverId: 'srv-1', port: null, metadata: {},
version: 1, createdAt: new Date(), updatedAt: new Date(),
});
vi.mocked(orchestrator.removeContainer).mockRejectedValue(new Error('No such container'));
await service.remove('inst-1');
expect(instanceRepo.delete).toHaveBeenCalledWith('inst-1');
});
});
describe('getLogs', () => {
it('returns empty logs for instance without container', async () => {
vi.mocked(instanceRepo.findById).mockResolvedValue({
id: 'inst-1', containerId: null, status: 'ERROR',
serverId: 'srv-1', port: null, metadata: {},
version: 1, createdAt: new Date(), updatedAt: new Date(),
});
const result = await service.getLogs('inst-1');
expect(result).toEqual({ stdout: '', stderr: '' });
});
it('returns container logs', async () => {
vi.mocked(instanceRepo.findById).mockResolvedValue({
id: 'inst-1', containerId: 'ctr-abc', status: 'RUNNING',
serverId: 'srv-1', port: 3000, metadata: {},
version: 1, createdAt: new Date(), updatedAt: new Date(),
});
const result = await service.getLogs('inst-1', { tail: 50 });
expect(orchestrator.getContainerLogs).toHaveBeenCalledWith('ctr-abc', { tail: 50 });
expect(result.stdout).toBe('log output');
});
});
});