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:
Michal
2026-02-22 13:30:46 +00:00
parent 87dce55b94
commit bd09ae9687
21 changed files with 638 additions and 764 deletions

View File

@@ -28,7 +28,103 @@ export class InstanceService {
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);
if (!server) throw new NotFoundError(`McpServer '${serverId}' not found`);
@@ -43,7 +139,6 @@ export class InstanceService {
const image = server.dockerImage ?? server.packageName ?? server.name;
// Create DB record first in STARTING state
let instance = await this.instanceRepo.create({
serverId,
status: 'STARTING',
@@ -53,7 +148,7 @@ export class InstanceService {
const spec: ContainerSpec = {
image,
name: `mcpctl-${server.name}-${instance.id}`,
hostPort: opts?.hostPort ?? null,
hostPort: null,
labels: {
'mcpctl.server-id': serverId,
'mcpctl.instance-id': instance.id,
@@ -66,9 +161,6 @@ export class InstanceService {
if (command) {
spec.command = command;
}
if (opts?.env) {
spec.env = opts.env;
}
const containerInfo = await this.orchestrator.createContainer(spec);
@@ -81,7 +173,6 @@ export class InstanceService {
instance = await this.instanceRepo.updateStatus(instance.id, 'RUNNING', updateFields);
} catch (err) {
// Mark as ERROR if container creation fails
instance = await this.instanceRepo.updateStatus(instance.id, 'ERROR', {
metadata: { error: err instanceof Error ? err.message : String(err) },
});
@@ -90,78 +181,16 @@ export class InstanceService {
return instance;
}
async stop(id: string): Promise<McpInstance> {
const instance = await this.getById(id);
if (instance.status === 'STOPPED') {
throw new InvalidStateError(`Instance '${id}' is already stopped`);
}
if (!instance.containerId) {
return this.instanceRepo.updateStatus(id, 'STOPPED');
}
await this.instanceRepo.updateStatus(id, 'STOPPING');
try {
await this.orchestrator.stopContainer(instance.containerId);
return await this.instanceRepo.updateStatus(id, 'STOPPED');
} catch (err) {
return await this.instanceRepo.updateStatus(id, 'ERROR', {
metadata: { error: err instanceof Error ? err.message : String(err) },
});
}
}
async restart(id: string): Promise<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);
/** Stop and remove a single instance. */
private async removeOne(instance: McpInstance): Promise<void> {
if (instance.containerId) {
try {
await this.orchestrator.stopContainer(instance.containerId);
} catch { /* best-effort */ }
try {
await this.orchestrator.removeContainer(instance.containerId, true);
} catch {
// Container may already be gone, proceed with DB cleanup
}
} catch { /* best-effort */ }
}
await this.instanceRepo.delete(id);
}
async getLogs(id: string, opts?: { tail?: number }): Promise<{ stdout: string; stderr: string }> {
const instance = await this.getById(id);
if (!instance.containerId) {
return { stdout: '', stderr: '' };
}
return this.orchestrator.getContainerLogs(instance.containerId, opts);
await this.instanceRepo.delete(instance.id);
}
}