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

@@ -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);
}
}