feat: kubectl-style CLI + Deployment/Pod model for servers/instances
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled

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 d6a80fc03d
commit 467357c2c6
21 changed files with 638 additions and 764 deletions

View File

@@ -43,6 +43,7 @@ function createInMemoryServerRepo(): IMcpServerRepository {
externalUrl: data.externalUrl ?? null,
command: data.command ?? null,
containerPort: data.containerPort ?? null,
replicas: data.replicas ?? 1,
envTemplate: data.envTemplate ?? [],
version: 1,
createdAt: new Date(),
@@ -279,10 +280,11 @@ async function buildTestApp(deps: {
const serverService = new McpServerService(deps.serverRepo);
const instanceService = new InstanceService(deps.instanceRepo, deps.serverRepo, deps.orchestrator);
serverService.setInstanceService(instanceService);
const proxyService = new McpProxyService(deps.instanceRepo, deps.serverRepo);
const auditLogService = new AuditLogService(deps.auditLogRepo);
registerMcpServerRoutes(app, serverService);
registerMcpServerRoutes(app, serverService, instanceService);
registerInstanceRoutes(app, instanceService);
registerMcpProxyRoutes(app, {
mcpProxyService: proxyService,
@@ -334,8 +336,8 @@ describe('MCP server full flow', () => {
if (app) await app.close();
});
it('registers server, starts virtual instance, and proxies tools/list', async () => {
// 1. Register external MCP server
it('registers server (auto-creates instance via reconcile), and proxies tools/list', async () => {
// 1. Register external MCP server (replicas defaults to 1 → auto-creates instance)
const createRes = await app.inject({
method: 'POST',
url: '/api/v1/servers',
@@ -363,17 +365,16 @@ describe('MCP server full flow', () => {
expect(servers).toHaveLength(1);
expect(servers[0]!.name).toBe('ha-mcp');
// 3. Start a virtual instance (external server — no Docker)
const startRes = await app.inject({
method: 'POST',
url: '/api/v1/instances',
payload: { serverId: server.id },
// 3. Verify instance was auto-created (no Docker for external servers)
const instancesRes = await app.inject({
method: 'GET',
url: `/api/v1/instances?serverId=${server.id}`,
});
expect(startRes.statusCode).toBe(201);
const instance = startRes.json<{ id: string; status: string; containerId: string | null }>();
expect(instance.status).toBe('RUNNING');
expect(instance.containerId).toBeNull();
expect(instancesRes.statusCode).toBe(200);
const instances = instancesRes.json<Array<{ id: string; status: string; containerId: string | null }>>();
expect(instances).toHaveLength(1);
expect(instances[0]!.status).toBe('RUNNING');
expect(instances[0]!.containerId).toBeNull();
// 4. Proxy tools/list to the fake MCP server
const proxyRes = await app.inject({
@@ -401,7 +402,7 @@ describe('MCP server full flow', () => {
});
it('proxies tools/call with parameters', async () => {
// Register + start
// Register (auto-creates instance via reconcile)
const createRes = await app.inject({
method: 'POST',
url: '/api/v1/servers',
@@ -414,13 +415,7 @@ describe('MCP server full flow', () => {
});
const server = createRes.json<{ id: string }>();
await app.inject({
method: 'POST',
url: '/api/v1/instances',
payload: { serverId: server.id },
});
// Proxy tools/call
// Proxy tools/call (instance was auto-created)
const proxyRes = await app.inject({
method: 'POST',
url: '/api/v1/mcp/proxy',
@@ -456,8 +451,8 @@ describe('MCP server full flow', () => {
if (app) await app.close();
});
it('registers server with dockerImage, starts container, and creates instance', async () => {
// 1. Register managed server
it('registers server with dockerImage, auto-creates container instance via reconcile', async () => {
// 1. Register managed server (replicas: 1 → auto-creates container)
const createRes = await app.inject({
method: 'POST',
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.command).toEqual(['python', '-c', 'print("hello")']);
// 2. Start container instance with env
const startRes = await app.inject({
method: 'POST',
url: '/api/v1/instances',
payload: {
serverId: server.id,
env: { HOMEASSISTANT_URL: 'https://ha.example.com', HOMEASSISTANT_TOKEN: 'secret' },
},
// 2. Verify instance was auto-created with container
const instancesRes = await app.inject({
method: 'GET',
url: `/api/v1/instances?serverId=${server.id}`,
});
expect(startRes.statusCode).toBe(201);
const instance = startRes.json<{ id: string; status: string; containerId: string }>();
expect(instance.status).toBe('RUNNING');
expect(instance.containerId).toBeTruthy();
expect(instancesRes.statusCode).toBe(200);
const instances = instancesRes.json<Array<{ id: string; status: string; containerId: string }>>();
expect(instances).toHaveLength(1);
expect(instances[0]!.status).toBe('RUNNING');
expect(instances[0]!.containerId).toBeTruthy();
// 3. Verify orchestrator was called with correct spec
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.containerPort).toBe(3000);
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 () => {
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({
method: 'POST',
url: '/api/v1/servers',
@@ -521,17 +509,16 @@ describe('MCP server full flow', () => {
transport: 'STDIO',
},
});
expect(createRes.statusCode).toBe(201);
const server = createRes.json<{ id: string }>();
const startRes = await app.inject({
method: 'POST',
url: '/api/v1/instances',
payload: { serverId: server.id },
const instancesRes = await app.inject({
method: 'GET',
url: `/api/v1/instances?serverId=${server.id}`,
});
expect(startRes.statusCode).toBe(201);
const instance = startRes.json<{ id: string; status: string }>();
expect(instance.status).toBe('ERROR');
const instances = instancesRes.json<Array<{ id: string; status: string }>>();
expect(instances).toHaveLength(1);
expect(instances[0]!.status).toBe('ERROR');
});
});
@@ -553,8 +540,8 @@ describe('MCP server full flow', () => {
if (app) await app.close();
});
it('register → start → list → stop → remove', async () => {
// Register
it('register → auto-create → list → delete instance (reconcile) → delete server (cascade)', async () => {
// Register (auto-creates instance via reconcile)
const createRes = await app.inject({
method: 'POST',
url: '/api/v1/servers',
@@ -569,48 +556,34 @@ describe('MCP server full flow', () => {
expect(createRes.statusCode).toBe(201);
const server = createRes.json<{ id: string }>();
// Start
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
// List instances (auto-created)
const listRes = await app.inject({
method: 'GET',
url: `/api/v1/instances?serverId=${server.id}`,
});
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[0]!.status).toBe('RUNNING');
const instanceId = instances[0]!.id;
// Stop
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
// Delete instance → triggers reconcile → new instance auto-created
const removeRes = await app.inject({
method: 'DELETE',
url: `/api/v1/instances/${instance.id}`,
url: `/api/v1/instances/${instanceId}`,
});
expect(removeRes.statusCode).toBe(204);
// Verify instance is gone
// Verify a replacement instance was created (reconcile)
const listAfter = await app.inject({
method: 'GET',
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({
method: 'DELETE',
url: `/api/v1/servers/${server.id}`,
@@ -622,8 +595,8 @@ describe('MCP server full flow', () => {
expect(serversAfter.json<unknown[]>()).toHaveLength(0);
});
it('external server lifecycle: register → start → proxy → stop → cleanup', async () => {
// Register external
it('external server lifecycle: register → auto-create → proxy → delete server (cascade)', async () => {
// Register external (auto-creates virtual instance)
const createRes = await app.inject({
method: 'POST',
url: '/api/v1/servers',
@@ -635,15 +608,15 @@ describe('MCP server full flow', () => {
});
const server = createRes.json<{ id: string }>();
// Start (virtual instance)
const startRes = await app.inject({
method: 'POST',
url: '/api/v1/instances',
payload: { serverId: server.id },
// Verify auto-created instance
const instancesRes = await app.inject({
method: 'GET',
url: `/api/v1/instances?serverId=${server.id}`,
});
const instance = startRes.json<{ id: string; status: string; containerId: string | null }>();
expect(instance.status).toBe('RUNNING');
expect(instance.containerId).toBeNull();
const instances = instancesRes.json<Array<{ id: string; status: string; containerId: string | null }>>();
expect(instances).toHaveLength(1);
expect(instances[0]!.status).toBe('RUNNING');
expect(instances[0]!.containerId).toBeNull();
// Proxy tools/list
const proxyRes = await app.inject({
@@ -655,17 +628,16 @@ describe('MCP server full flow', () => {
expect(proxyRes.statusCode).toBe(200);
expect(proxyRes.json<{ result: { tools: unknown[] } }>().result.tools.length).toBeGreaterThan(0);
// Stop (no container to stop)
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
// Docker orchestrator should NOT have been called (external server)
expect(orchestrator.createContainer).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 () => {
// Create
// Create (with replicas: 0 to avoid creating instances in this test)
const createRes = await app.inject({
method: 'POST',
url: '/api/v1/servers',
@@ -721,8 +693,10 @@ describe('MCP server full flow', () => {
name: 'updatable',
description: 'Original desc',
transport: 'STDIO',
replicas: 0,
},
});
expect(createRes.statusCode).toBe(201);
const server = createRes.json<{ id: string; description: string }>();
expect(server.description).toBe('Original desc');