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

@@ -3,6 +3,7 @@ import { InstanceService, InvalidStateError } from '../src/services/instance.ser
import { NotFoundError } from '../src/services/mcp-server.service.js';
import type { IMcpInstanceRepository, IMcpServerRepository } from '../src/repositories/interfaces.js';
import type { McpOrchestrator } from '../src/services/orchestrator.js';
import type { McpInstance } from '@prisma/client';
function mockInstanceRepo(): IMcpInstanceRepository {
return {
@@ -69,6 +70,41 @@ function mockOrchestrator(): McpOrchestrator {
};
}
function makeServer(overrides: Partial<{ id: string; name: string; replicas: number; dockerImage: string | null; externalUrl: string | null; transport: string; command: unknown; containerPort: number | null }> = {}) {
return {
id: overrides.id ?? 'srv-1',
name: overrides.name ?? 'slack',
dockerImage: overrides.dockerImage ?? 'ghcr.io/slack-mcp:latest',
packageName: null,
transport: overrides.transport ?? 'STDIO',
description: '',
repositoryUrl: null,
externalUrl: overrides.externalUrl ?? null,
command: overrides.command ?? null,
containerPort: overrides.containerPort ?? null,
replicas: overrides.replicas ?? 1,
envTemplate: [],
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
};
}
function makeInstance(overrides: Partial<McpInstance> = {}): McpInstance {
return {
id: 'inst-1',
serverId: 'srv-1',
containerId: overrides.containerId ?? 'ctr-abc',
status: overrides.status ?? 'RUNNING',
port: overrides.port ?? 3000,
metadata: overrides.metadata ?? {},
version: 1,
createdAt: overrides.createdAt ?? new Date(),
updatedAt: new Date(),
...overrides,
} as McpInstance;
}
describe('InstanceService', () => {
let instanceRepo: ReturnType<typeof mockInstanceRepo>;
let serverRepo: ReturnType<typeof mockServerRepo>;
@@ -101,199 +137,98 @@ describe('InstanceService', () => {
});
it('returns instance when found', async () => {
vi.mocked(instanceRepo.findById).mockResolvedValue({ id: 'inst-1' } as never);
vi.mocked(instanceRepo.findById).mockResolvedValue(makeInstance({ id: 'inst-1' }));
const result = await service.getById('inst-1');
expect(result.id).toBe('inst-1');
});
});
describe('start', () => {
describe('reconcile', () => {
it('starts instances when below desired replicas', async () => {
vi.mocked(serverRepo.findById).mockResolvedValue(makeServer({ replicas: 2 }));
vi.mocked(instanceRepo.findAll).mockResolvedValue([]);
await service.reconcile('srv-1');
// Should create 2 instances
expect(instanceRepo.create).toHaveBeenCalledTimes(2);
});
it('does nothing when at desired replicas', async () => {
vi.mocked(serverRepo.findById).mockResolvedValue(makeServer({ replicas: 1 }));
vi.mocked(instanceRepo.findAll).mockResolvedValue([makeInstance({ status: 'RUNNING' })]);
await service.reconcile('srv-1');
expect(instanceRepo.create).not.toHaveBeenCalled();
expect(instanceRepo.delete).not.toHaveBeenCalled();
});
it('removes excess instances when above desired replicas', async () => {
vi.mocked(serverRepo.findById).mockResolvedValue(makeServer({ replicas: 1 }));
vi.mocked(instanceRepo.findAll).mockResolvedValue([
makeInstance({ id: 'inst-old', createdAt: new Date('2025-01-01') }),
makeInstance({ id: 'inst-new', createdAt: new Date('2025-06-01') }),
]);
await service.reconcile('srv-1');
// Should remove the oldest one
expect(orchestrator.stopContainer).toHaveBeenCalledTimes(1);
expect(instanceRepo.delete).toHaveBeenCalledWith('inst-old');
});
it('creates external instances without Docker', async () => {
vi.mocked(serverRepo.findById).mockResolvedValue(
makeServer({ replicas: 1, externalUrl: 'http://localhost:8086/mcp', dockerImage: null }),
);
vi.mocked(instanceRepo.findAll).mockResolvedValue([]);
await service.reconcile('srv-1');
expect(instanceRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ status: 'RUNNING', metadata: expect.objectContaining({ external: true }) }),
);
expect(orchestrator.createContainer).not.toHaveBeenCalled();
});
it('handles replicas: 0 by removing all instances', async () => {
vi.mocked(serverRepo.findById).mockResolvedValue(makeServer({ replicas: 0 }));
vi.mocked(instanceRepo.findAll).mockResolvedValue([makeInstance()]);
await service.reconcile('srv-1');
expect(instanceRepo.delete).toHaveBeenCalledTimes(1);
});
it('throws NotFoundError for unknown server', async () => {
await expect(service.start('missing')).rejects.toThrow(NotFoundError);
});
it('creates instance and starts container', async () => {
vi.mocked(serverRepo.findById).mockResolvedValue({
id: 'srv-1', name: 'slack', dockerImage: 'ghcr.io/slack-mcp:latest',
packageName: null, transport: 'STDIO', description: '', repositoryUrl: null,
envTemplate: [], version: 1, createdAt: new Date(), updatedAt: new Date(),
});
const result = await service.start('srv-1');
expect(instanceRepo.create).toHaveBeenCalledWith({
serverId: 'srv-1',
status: 'STARTING',
});
expect(orchestrator.createContainer).toHaveBeenCalled();
expect(instanceRepo.updateStatus).toHaveBeenCalledWith(
'inst-1', 'RUNNING',
expect.objectContaining({ containerId: 'ctr-abc123' }),
);
expect(result.status).toBe('RUNNING');
});
it('marks instance as ERROR on container failure', async () => {
vi.mocked(serverRepo.findById).mockResolvedValue({
id: 'srv-1', name: 'slack', dockerImage: 'ghcr.io/slack-mcp:latest',
packageName: null, transport: 'STDIO', description: '', repositoryUrl: null,
envTemplate: [], version: 1, createdAt: new Date(), updatedAt: new Date(),
});
vi.mocked(orchestrator.createContainer).mockRejectedValue(new Error('Docker unavailable'));
const result = await service.start('srv-1');
expect(instanceRepo.updateStatus).toHaveBeenCalledWith(
'inst-1', 'ERROR',
expect.objectContaining({ metadata: { error: 'Docker unavailable' } }),
);
expect(result.status).toBe('ERROR');
});
it('uses dockerImage for container spec', async () => {
vi.mocked(serverRepo.findById).mockResolvedValue({
id: 'srv-1', name: 'slack', dockerImage: 'myregistry.com/slack:v1',
packageName: '@slack/mcp', transport: 'SSE', description: '', repositoryUrl: null,
envTemplate: [], version: 1, createdAt: new Date(), updatedAt: new Date(),
});
await service.start('srv-1');
const spec = vi.mocked(orchestrator.createContainer).mock.calls[0]?.[0];
expect(spec?.image).toBe('myregistry.com/slack:v1');
expect(spec?.containerPort).toBe(3000); // SSE transport
});
});
describe('stop', () => {
it('throws NotFoundError for missing instance', async () => {
await expect(service.stop('missing')).rejects.toThrow(NotFoundError);
});
it('stops a running container', async () => {
vi.mocked(instanceRepo.findById).mockResolvedValue({
id: 'inst-1', containerId: 'ctr-abc', status: 'RUNNING',
serverId: 'srv-1', port: 3000, metadata: {},
version: 1, createdAt: new Date(), updatedAt: new Date(),
});
await service.stop('inst-1');
expect(orchestrator.stopContainer).toHaveBeenCalledWith('ctr-abc');
expect(instanceRepo.updateStatus).toHaveBeenCalledWith('inst-1', 'STOPPED');
});
it('handles stop without containerId', async () => {
vi.mocked(instanceRepo.findById).mockResolvedValue({
id: 'inst-1', containerId: null, status: 'ERROR',
serverId: 'srv-1', port: null, metadata: {},
version: 1, createdAt: new Date(), updatedAt: new Date(),
});
await service.stop('inst-1');
expect(orchestrator.stopContainer).not.toHaveBeenCalled();
expect(instanceRepo.updateStatus).toHaveBeenCalledWith('inst-1', 'STOPPED');
});
it('throws InvalidStateError when already stopped', async () => {
vi.mocked(instanceRepo.findById).mockResolvedValue({
id: 'inst-1', containerId: 'ctr-abc', status: 'STOPPED',
serverId: 'srv-1', port: null, metadata: {},
version: 1, createdAt: new Date(), updatedAt: new Date(),
});
await expect(service.stop('inst-1')).rejects.toThrow(InvalidStateError);
});
});
describe('restart', () => {
it('stops, removes, and starts a new instance', async () => {
vi.mocked(instanceRepo.findById).mockResolvedValue({
id: 'inst-1', containerId: 'ctr-abc', status: 'RUNNING',
serverId: 'srv-1', port: 3000, metadata: {},
version: 1, createdAt: new Date(), updatedAt: new Date(),
});
vi.mocked(serverRepo.findById).mockResolvedValue({
id: 'srv-1', name: 'slack', dockerImage: 'slack:latest',
packageName: null, transport: 'STDIO', description: '', repositoryUrl: null,
envTemplate: [], version: 1, createdAt: new Date(), updatedAt: new Date(),
});
const result = await service.restart('inst-1');
expect(orchestrator.stopContainer).toHaveBeenCalledWith('ctr-abc');
expect(orchestrator.removeContainer).toHaveBeenCalledWith('ctr-abc', true);
expect(instanceRepo.delete).toHaveBeenCalledWith('inst-1');
expect(instanceRepo.create).toHaveBeenCalled();
expect(result.status).toBe('RUNNING');
});
it('handles restart when container already stopped', async () => {
vi.mocked(instanceRepo.findById).mockResolvedValue({
id: 'inst-1', containerId: 'ctr-abc', status: 'STOPPED',
serverId: 'srv-1', port: null, metadata: {},
version: 1, createdAt: new Date(), updatedAt: new Date(),
});
vi.mocked(serverRepo.findById).mockResolvedValue({
id: 'srv-1', name: 'slack', dockerImage: 'slack:latest',
packageName: null, transport: 'STDIO', description: '', repositoryUrl: null,
envTemplate: [], version: 1, createdAt: new Date(), updatedAt: new Date(),
});
const result = await service.restart('inst-1');
// Should not try to stop an already-stopped container
expect(orchestrator.stopContainer).not.toHaveBeenCalled();
expect(instanceRepo.delete).toHaveBeenCalledWith('inst-1');
expect(result.status).toBe('RUNNING');
});
});
describe('inspect', () => {
it('returns container info', async () => {
vi.mocked(instanceRepo.findById).mockResolvedValue({
id: 'inst-1', containerId: 'ctr-abc', status: 'RUNNING',
serverId: 'srv-1', port: 3000, metadata: {},
version: 1, createdAt: new Date(), updatedAt: new Date(),
});
const result = await service.inspect('inst-1');
expect(orchestrator.inspectContainer).toHaveBeenCalledWith('ctr-abc');
expect(result.containerId).toBe('ctr-abc123');
});
it('throws InvalidStateError when no container', async () => {
vi.mocked(instanceRepo.findById).mockResolvedValue({
id: 'inst-1', containerId: null, status: 'ERROR',
serverId: 'srv-1', port: null, metadata: {},
version: 1, createdAt: new Date(), updatedAt: new Date(),
});
await expect(service.inspect('inst-1')).rejects.toThrow(InvalidStateError);
await expect(service.reconcile('missing')).rejects.toThrow(NotFoundError);
});
});
describe('remove', () => {
it('removes container and DB record', async () => {
vi.mocked(instanceRepo.findById).mockResolvedValue({
id: 'inst-1', containerId: 'ctr-abc', status: 'STOPPED',
serverId: 'srv-1', port: null, metadata: {},
version: 1, createdAt: new Date(), updatedAt: new Date(),
});
it('stops container and deletes DB record', async () => {
vi.mocked(instanceRepo.findById).mockResolvedValue(makeInstance({ containerId: 'ctr-abc' }));
const result = await service.remove('inst-1');
expect(orchestrator.stopContainer).toHaveBeenCalledWith('ctr-abc');
expect(orchestrator.removeContainer).toHaveBeenCalledWith('ctr-abc', true);
expect(instanceRepo.delete).toHaveBeenCalledWith('inst-1');
expect(result.serverId).toBe('srv-1');
});
it('deletes DB record for external instance (no container)', async () => {
vi.mocked(instanceRepo.findById).mockResolvedValue(makeInstance({ containerId: null }));
await service.remove('inst-1');
expect(orchestrator.removeContainer).toHaveBeenCalledWith('ctr-abc', true);
expect(orchestrator.stopContainer).not.toHaveBeenCalled();
expect(instanceRepo.delete).toHaveBeenCalledWith('inst-1');
});
it('removes DB record even if container is already gone', async () => {
vi.mocked(instanceRepo.findById).mockResolvedValue({
id: 'inst-1', containerId: 'ctr-abc', status: 'STOPPED',
serverId: 'srv-1', port: null, metadata: {},
version: 1, createdAt: new Date(), updatedAt: new Date(),
});
it('deletes DB record even if container is already gone', async () => {
vi.mocked(instanceRepo.findById).mockResolvedValue(makeInstance({ containerId: 'ctr-abc' }));
vi.mocked(orchestrator.removeContainer).mockRejectedValue(new Error('No such container'));
await service.remove('inst-1');
@@ -302,24 +237,56 @@ describe('InstanceService', () => {
});
});
describe('removeAllForServer', () => {
it('stops all containers for a server', async () => {
vi.mocked(instanceRepo.findAll).mockResolvedValue([
makeInstance({ id: 'inst-1', containerId: 'ctr-1' }),
makeInstance({ id: 'inst-2', containerId: 'ctr-2' }),
]);
await service.removeAllForServer('srv-1');
expect(orchestrator.stopContainer).toHaveBeenCalledTimes(2);
expect(orchestrator.removeContainer).toHaveBeenCalledTimes(2);
});
it('skips external instances with no container', async () => {
vi.mocked(instanceRepo.findAll).mockResolvedValue([
makeInstance({ id: 'inst-1', containerId: null }),
]);
await service.removeAllForServer('srv-1');
expect(orchestrator.stopContainer).not.toHaveBeenCalled();
});
});
describe('inspect', () => {
it('returns container info', async () => {
vi.mocked(instanceRepo.findById).mockResolvedValue(makeInstance({ containerId: 'ctr-abc' }));
const result = await service.inspect('inst-1');
expect(orchestrator.inspectContainer).toHaveBeenCalledWith('ctr-abc');
expect(result.containerId).toBe('ctr-abc123');
});
it('throws InvalidStateError when no container', async () => {
vi.mocked(instanceRepo.findById).mockResolvedValue(makeInstance({ containerId: null }));
await expect(service.inspect('inst-1')).rejects.toThrow(InvalidStateError);
});
});
describe('getLogs', () => {
it('returns empty logs for instance without container', async () => {
vi.mocked(instanceRepo.findById).mockResolvedValue({
id: 'inst-1', containerId: null, status: 'ERROR',
serverId: 'srv-1', port: null, metadata: {},
version: 1, createdAt: new Date(), updatedAt: new Date(),
});
vi.mocked(instanceRepo.findById).mockResolvedValue(makeInstance({ containerId: null }));
const result = await service.getLogs('inst-1');
expect(result).toEqual({ stdout: '', stderr: '' });
});
it('returns container logs', async () => {
vi.mocked(instanceRepo.findById).mockResolvedValue({
id: 'inst-1', containerId: 'ctr-abc', status: 'RUNNING',
serverId: 'srv-1', port: 3000, metadata: {},
version: 1, createdAt: new Date(), updatedAt: new Date(),
});
vi.mocked(instanceRepo.findById).mockResolvedValue(makeInstance({ containerId: 'ctr-abc' }));
const result = await service.getLogs('inst-1', { tail: 50 });