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:
@@ -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 });
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -3,44 +3,66 @@ import Fastify from 'fastify';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { registerMcpServerRoutes } from '../src/routes/mcp-servers.js';
|
||||
import { McpServerService, NotFoundError, ConflictError } from '../src/services/mcp-server.service.js';
|
||||
import { InstanceService } from '../src/services/instance.service.js';
|
||||
import { errorHandler } from '../src/middleware/error-handler.js';
|
||||
import type { IMcpServerRepository } from '../src/repositories/interfaces.js';
|
||||
import type { IMcpServerRepository, IMcpInstanceRepository } from '../src/repositories/interfaces.js';
|
||||
import type { McpOrchestrator } from '../src/services/orchestrator.js';
|
||||
|
||||
let app: FastifyInstance;
|
||||
|
||||
function mockRepo(): IMcpServerRepository {
|
||||
let lastCreated: Record<string, unknown> | null = null;
|
||||
return {
|
||||
findAll: vi.fn(async () => [
|
||||
{ id: '1', name: 'slack', description: 'Slack server', transport: 'STDIO' },
|
||||
{ id: '1', name: 'slack', description: 'Slack server', transport: 'STDIO', replicas: 1 },
|
||||
]),
|
||||
findById: vi.fn(async () => null),
|
||||
findById: vi.fn(async (id: string) => {
|
||||
if (lastCreated && (lastCreated as { id: string }).id === id) return lastCreated as never;
|
||||
return null;
|
||||
}),
|
||||
findByName: vi.fn(async () => null),
|
||||
create: vi.fn(async (data) => ({
|
||||
id: 'new-id',
|
||||
name: data.name,
|
||||
description: data.description ?? '',
|
||||
packageName: data.packageName ?? null,
|
||||
dockerImage: null,
|
||||
transport: data.transport ?? 'STDIO',
|
||||
repositoryUrl: null,
|
||||
envTemplate: [],
|
||||
version: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})),
|
||||
update: vi.fn(async (id, data) => ({
|
||||
id,
|
||||
name: 'slack',
|
||||
description: (data.description as string) ?? 'Slack server',
|
||||
packageName: null,
|
||||
dockerImage: null,
|
||||
transport: 'STDIO',
|
||||
repositoryUrl: null,
|
||||
envTemplate: [],
|
||||
version: 2,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})),
|
||||
create: vi.fn(async (data) => {
|
||||
const server = {
|
||||
id: 'new-id',
|
||||
name: data.name,
|
||||
description: data.description ?? '',
|
||||
packageName: data.packageName ?? null,
|
||||
dockerImage: null,
|
||||
transport: data.transport ?? 'STDIO',
|
||||
repositoryUrl: null,
|
||||
externalUrl: null,
|
||||
command: null,
|
||||
containerPort: null,
|
||||
replicas: data.replicas ?? 1,
|
||||
envTemplate: [],
|
||||
version: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
lastCreated = server;
|
||||
return server;
|
||||
}),
|
||||
update: vi.fn(async (id, data) => {
|
||||
const server = {
|
||||
id,
|
||||
name: 'slack',
|
||||
description: (data.description as string) ?? 'Slack server',
|
||||
packageName: null,
|
||||
dockerImage: null,
|
||||
transport: 'STDIO',
|
||||
repositoryUrl: null,
|
||||
externalUrl: null,
|
||||
command: null,
|
||||
containerPort: null,
|
||||
replicas: 1,
|
||||
envTemplate: [],
|
||||
version: 2,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
lastCreated = server;
|
||||
return server;
|
||||
}),
|
||||
delete: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
@@ -49,11 +71,56 @@ afterEach(async () => {
|
||||
if (app) await app.close();
|
||||
});
|
||||
|
||||
function stubInstanceRepo(): IMcpInstanceRepository {
|
||||
return {
|
||||
findAll: vi.fn(async () => []),
|
||||
findById: vi.fn(async () => null),
|
||||
findByContainerId: vi.fn(async () => null),
|
||||
create: vi.fn(async (data) => ({
|
||||
id: 'inst-stub',
|
||||
serverId: data.serverId,
|
||||
containerId: null,
|
||||
status: data.status ?? 'STOPPED',
|
||||
port: null,
|
||||
metadata: {},
|
||||
version: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})),
|
||||
updateStatus: vi.fn(async (id, status) => ({
|
||||
id,
|
||||
serverId: 'srv-1',
|
||||
containerId: null,
|
||||
status,
|
||||
port: null,
|
||||
metadata: {},
|
||||
version: 2,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})),
|
||||
delete: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
function stubOrchestrator(): McpOrchestrator {
|
||||
return {
|
||||
ping: vi.fn(async () => true),
|
||||
pullImage: vi.fn(async () => {}),
|
||||
createContainer: vi.fn(async () => ({ containerId: 'ctr-stub', name: 'stub', state: 'running' as const, port: 3000, createdAt: new Date() })),
|
||||
stopContainer: vi.fn(async () => {}),
|
||||
removeContainer: vi.fn(async () => {}),
|
||||
inspectContainer: vi.fn(async () => ({ containerId: 'ctr-stub', name: 'stub', state: 'running' as const, createdAt: new Date() })),
|
||||
getContainerLogs: vi.fn(async () => ({ stdout: '', stderr: '' })),
|
||||
};
|
||||
}
|
||||
|
||||
function createApp(repo: IMcpServerRepository) {
|
||||
app = Fastify({ logger: false });
|
||||
app.setErrorHandler(errorHandler);
|
||||
const service = new McpServerService(repo);
|
||||
registerMcpServerRoutes(app, service);
|
||||
const instanceService = new InstanceService(stubInstanceRepo(), repo, stubOrchestrator());
|
||||
service.setInstanceService(instanceService);
|
||||
registerMcpServerRoutes(app, service, instanceService);
|
||||
return app.ready();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user