Compare commits

...

8 Commits

Author SHA1 Message Date
Michal
97ade470df fix: resolve resource names in get/describe (not just IDs)
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
fetchResource and fetchSingleResource now use resolveNameOrId so
`mcpctl get server ha-mcp` works by name, not just by ID.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 16:39:21 +00:00
Michal
b25ff98374 feat: add create/edit commands, apply-compatible output, better describe
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
- `create server/profile/project` with all CLI flags (kubectl parity)
- `edit server/profile/project` opens $EDITOR for in-flight editing
- `get -o yaml/json` now outputs apply-compatible format (strips internal fields, wraps in resource key)
- `describe` shows visually clean sectioned output with aligned columns
- Extract shared utilities (resolveResource, resolveNameOrId, stripInternalFields)
- Instances are immutable (no create/edit, like pods)
- Full test coverage for create, edit, and updated describe/get

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 14:33:25 +00:00
Michal
22fe9c3435 fix: add replicas to restore-service server creation
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 13:47:03 +00:00
72643fceda Merge pull request 'feat: kubectl-style CLI + Deployment/Pod model' (#5) from feat/kubectl-deployment-model into main
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
Reviewed-on: #5
2026-02-22 13:39:02 +00:00
Michal
467357c2c6 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>
2026-02-22 13:30:46 +00:00
d6a80fc03d Merge pull request 'feat: external MCP server support + HA MCP PoC' (#4) from feat/external-mcp-servers into main
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
Reviewed-on: #4
2026-02-22 12:39:19 +00:00
Michal
c07da826a0 test: add integration test for full MCP server flow
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
Tests the complete lifecycle through Fastify routes with in-memory
repositories and a fake streamable-http MCP server:
- External server: register → start virtual instance → proxy tools/list
- Managed server: register with dockerImage → start container → verify spec
- Full lifecycle: register → start → list → stop → remove → delete
- Proxy auth enforcement
- Server update flow
- Error handling (Docker failure → ERROR status)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 12:34:55 +00:00
Michal
0482944056 feat: add external MCP server support with streamable-http proxy
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
Support non-containerized MCP servers via externalUrl field and add
streamable-http session management for HA MCP proof of concept.

- Add externalUrl, command, containerPort fields to McpServer schema
- Skip Docker orchestration for external servers (virtual instances)
- Implement streamable-http proxy with Mcp-Session-Id session management
- Parse SSE-framed responses from streamable-http endpoints
- Add command passthrough to Docker container creation
- Create HA MCP example manifest (examples/ha-mcp.yaml)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 12:21:25 +00:00
36 changed files with 2604 additions and 790 deletions

26
examples/ha-mcp.yaml Normal file
View File

@@ -0,0 +1,26 @@
servers:
- name: ha-mcp
description: "Home Assistant MCP - smart home control via MCP"
dockerImage: "ghcr.io/homeassistant-ai/ha-mcp:2.4"
transport: STREAMABLE_HTTP
containerPort: 3000
# For mcpd-managed containers:
command:
- python
- "-c"
- "from ha_mcp.server import HomeAssistantSmartMCPServer; s = HomeAssistantSmartMCPServer(); s.mcp.run(transport='sse', host='0.0.0.0', port=3000)"
# For connecting to an already-running instance (host.containers.internal for container-to-host):
externalUrl: "http://host.containers.internal:8086/mcp"
envTemplate:
- name: HOMEASSISTANT_URL
description: "Home Assistant instance URL (e.g. https://ha.example.com)"
- name: HOMEASSISTANT_TOKEN
description: "Home Assistant long-lived access token"
isSecret: true
profiles:
- name: production
server: ha-mcp
envOverrides:
HOMEASSISTANT_URL: "https://ha.itaz.eu"
HOMEASSISTANT_TOKEN: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiIyNjFlZTRhOWI2MGM0YTllOGJkNTIxN2Q3YmVmZDkzNSIsImlhdCI6MTc3MDA3NjYzOCwiZXhwIjoyMDg1NDM2NjM4fQ.17mAQxIrCBrQx3ogqAUetwEt-cngRmJiH-e7sLt-3FY"

View File

@@ -11,6 +11,10 @@ const ServerSpecSchema = z.object({
dockerImage: z.string().optional(), dockerImage: z.string().optional(),
transport: z.enum(['STDIO', 'SSE', 'STREAMABLE_HTTP']).default('STDIO'), transport: z.enum(['STDIO', 'SSE', 'STREAMABLE_HTTP']).default('STDIO'),
repositoryUrl: z.string().url().optional(), repositoryUrl: z.string().url().optional(),
externalUrl: z.string().url().optional(),
command: z.array(z.string()).optional(),
containerPort: z.number().int().min(1).max(65535).optional(),
replicas: z.number().int().min(0).max(10).default(1),
envTemplate: z.array(z.object({ envTemplate: z.array(z.object({
name: z.string(), name: z.string(),
description: z.string().default(''), description: z.string().default(''),

View File

@@ -0,0 +1,114 @@
import { Command } from 'commander';
import type { ApiClient } from '../api-client.js';
import { resolveNameOrId } from './shared.js';
export interface CreateCommandDeps {
client: ApiClient;
log: (...args: unknown[]) => void;
}
function collect(value: string, prev: string[]): string[] {
return [...prev, value];
}
function parseEnvTemplate(entries: string[]): Array<{ name: string; description: string; isSecret: boolean }> {
return entries.map((entry) => {
const parts = entry.split(':');
if (parts.length < 2) {
throw new Error(`Invalid env-template format '${entry}'. Expected NAME:description[:isSecret]`);
}
return {
name: parts[0]!,
description: parts[1]!,
isSecret: parts[2] === 'true',
};
});
}
function parseEnvEntries(entries: string[]): Record<string, string> {
const result: Record<string, string> = {};
for (const entry of entries) {
const eqIdx = entry.indexOf('=');
if (eqIdx === -1) {
throw new Error(`Invalid env format '${entry}'. Expected KEY=value`);
}
result[entry.slice(0, eqIdx)] = entry.slice(eqIdx + 1);
}
return result;
}
export function createCreateCommand(deps: CreateCommandDeps): Command {
const { client, log } = deps;
const cmd = new Command('create')
.description('Create a resource (server, profile, project)');
// --- create server ---
cmd.command('server')
.description('Create an MCP server definition')
.argument('<name>', 'Server name (lowercase, hyphens allowed)')
.option('-d, --description <text>', 'Server description', '')
.option('--package-name <name>', 'NPM package name')
.option('--docker-image <image>', 'Docker image')
.option('--transport <type>', 'Transport type (STDIO, SSE, STREAMABLE_HTTP)', 'STDIO')
.option('--repository-url <url>', 'Source repository URL')
.option('--external-url <url>', 'External endpoint URL')
.option('--command <arg>', 'Command argument (repeat for multiple)', collect, [])
.option('--container-port <port>', 'Container port number')
.option('--replicas <count>', 'Number of replicas', '1')
.option('--env-template <entry>', 'Env template (NAME:description[:isSecret], repeat for multiple)', collect, [])
.action(async (name: string, opts) => {
const body: Record<string, unknown> = {
name,
description: opts.description,
transport: opts.transport,
replicas: parseInt(opts.replicas, 10),
};
if (opts.packageName) body.packageName = opts.packageName;
if (opts.dockerImage) body.dockerImage = opts.dockerImage;
if (opts.repositoryUrl) body.repositoryUrl = opts.repositoryUrl;
if (opts.externalUrl) body.externalUrl = opts.externalUrl;
if (opts.command.length > 0) body.command = opts.command;
if (opts.containerPort) body.containerPort = parseInt(opts.containerPort, 10);
if (opts.envTemplate.length > 0) body.envTemplate = parseEnvTemplate(opts.envTemplate);
const server = await client.post<{ id: string; name: string }>('/api/v1/servers', body);
log(`server '${server.name}' created (id: ${server.id})`);
});
// --- create profile ---
cmd.command('profile')
.description('Create a profile for an MCP server')
.argument('<name>', 'Profile name')
.requiredOption('--server <name-or-id>', 'Server name or ID')
.option('--permissions <perm>', 'Permission (repeat for multiple)', collect, [])
.option('--env <entry>', 'Environment override KEY=value (repeat for multiple)', collect, [])
.action(async (name: string, opts) => {
const serverId = await resolveNameOrId(client, 'servers', opts.server);
const body: Record<string, unknown> = {
name,
serverId,
};
if (opts.permissions.length > 0) body.permissions = opts.permissions;
if (opts.env.length > 0) body.envOverrides = parseEnvEntries(opts.env);
const profile = await client.post<{ id: string; name: string }>('/api/v1/profiles', body);
log(`profile '${profile.name}' created (id: ${profile.id})`);
});
// --- create project ---
cmd.command('project')
.description('Create a project')
.argument('<name>', 'Project name')
.option('-d, --description <text>', 'Project description', '')
.action(async (name: string, opts) => {
const project = await client.post<{ id: string; name: string }>('/api/v1/projects', {
name,
description: opts.description,
});
log(`project '${project.name}' created (id: ${project.id})`);
});
return cmd;
}

View File

@@ -0,0 +1,33 @@
import { Command } from 'commander';
import type { ApiClient } from '../api-client.js';
import { resolveResource, resolveNameOrId } from './shared.js';
export interface DeleteCommandDeps {
client: ApiClient;
log: (...args: unknown[]) => void;
}
export function createDeleteCommand(deps: DeleteCommandDeps): Command {
const { client, log } = deps;
return new Command('delete')
.description('Delete a resource (server, instance, profile, project)')
.argument('<resource>', 'resource type')
.argument('<id>', 'resource ID or name')
.action(async (resourceArg: string, idOrName: string) => {
const resource = resolveResource(resourceArg);
// Resolve name → ID for any resource type
let id: string;
try {
id = await resolveNameOrId(client, resource, idOrName);
} catch {
id = idOrName; // Fall through with original
}
await client.delete(`/api/v1/${resource}/${id}`);
const singular = resource.replace(/s$/, '');
log(`${singular} '${idOrName}' deleted.`);
});
}

View File

@@ -1,53 +1,166 @@
import { Command } from 'commander'; import { Command } from 'commander';
import { formatJson, formatYaml } from '../formatters/output.js'; import { formatJson, formatYaml } from '../formatters/output.js';
import { resolveResource, resolveNameOrId } from './shared.js';
import type { ApiClient } from '../api-client.js';
export interface DescribeCommandDeps { export interface DescribeCommandDeps {
client: ApiClient;
fetchResource: (resource: string, id: string) => Promise<unknown>; fetchResource: (resource: string, id: string) => Promise<unknown>;
fetchInspect?: (id: string) => Promise<unknown>;
log: (...args: string[]) => void; log: (...args: string[]) => void;
} }
const RESOURCE_ALIASES: Record<string, string> = { function pad(label: string, width = 18): string {
server: 'servers', return label.padEnd(width);
srv: 'servers',
profile: 'profiles',
prof: 'profiles',
project: 'projects',
proj: 'projects',
instance: 'instances',
inst: 'instances',
};
function resolveResource(name: string): string {
const lower = name.toLowerCase();
return RESOURCE_ALIASES[lower] ?? lower;
} }
function formatDetail(obj: Record<string, unknown>, indent = 0): string { function formatServerDetail(server: Record<string, unknown>): string {
const pad = ' '.repeat(indent);
const lines: string[] = []; const lines: string[] = [];
lines.push(`=== Server: ${server.name} ===`);
lines.push(`${pad('Name:')}${server.name}`);
lines.push(`${pad('Transport:')}${server.transport ?? '-'}`);
lines.push(`${pad('Replicas:')}${server.replicas ?? 1}`);
if (server.dockerImage) lines.push(`${pad('Docker Image:')}${server.dockerImage}`);
if (server.packageName) lines.push(`${pad('Package:')}${server.packageName}`);
if (server.externalUrl) lines.push(`${pad('External URL:')}${server.externalUrl}`);
if (server.repositoryUrl) lines.push(`${pad('Repository:')}${server.repositoryUrl}`);
if (server.containerPort) lines.push(`${pad('Container Port:')}${server.containerPort}`);
if (server.description) lines.push(`${pad('Description:')}${server.description}`);
const command = server.command as string[] | null;
if (command && command.length > 0) {
lines.push('');
lines.push('Command:');
lines.push(` ${command.join(' ')}`);
}
const envTemplate = server.envTemplate as Array<{ name: string; description: string; isSecret?: boolean }> | undefined;
if (envTemplate && envTemplate.length > 0) {
lines.push('');
lines.push('Environment Template:');
const nameW = Math.max(6, ...envTemplate.map((e) => e.name.length)) + 2;
const descW = Math.max(12, ...envTemplate.map((e) => e.description.length)) + 2;
lines.push(` ${'NAME'.padEnd(nameW)}${'DESCRIPTION'.padEnd(descW)}SECRET`);
for (const env of envTemplate) {
lines.push(` ${env.name.padEnd(nameW)}${env.description.padEnd(descW)}${env.isSecret ? 'yes' : 'no'}`);
}
}
lines.push('');
lines.push('Metadata:');
lines.push(` ${pad('ID:', 12)}${server.id}`);
if (server.createdAt) lines.push(` ${pad('Created:', 12)}${server.createdAt}`);
if (server.updatedAt) lines.push(` ${pad('Updated:', 12)}${server.updatedAt}`);
return lines.join('\n');
}
function formatInstanceDetail(instance: Record<string, unknown>, inspect?: Record<string, unknown>): string {
const lines: string[] = [];
lines.push(`=== Instance: ${instance.id} ===`);
lines.push(`${pad('Status:')}${instance.status}`);
lines.push(`${pad('Server ID:')}${instance.serverId}`);
lines.push(`${pad('Container ID:')}${instance.containerId ?? '-'}`);
lines.push(`${pad('Port:')}${instance.port ?? '-'}`);
const metadata = instance.metadata as Record<string, unknown> | undefined;
if (metadata && Object.keys(metadata).length > 0) {
lines.push('');
lines.push('Metadata:');
for (const [key, value] of Object.entries(metadata)) {
lines.push(` ${pad(key + ':', 16)}${String(value)}`);
}
}
if (inspect) {
lines.push('');
lines.push('Container:');
for (const [key, value] of Object.entries(inspect)) {
if (typeof value === 'object' && value !== null) {
lines.push(` ${key}: ${JSON.stringify(value)}`);
} else {
lines.push(` ${pad(key + ':', 16)}${String(value)}`);
}
}
}
lines.push('');
lines.push(` ${pad('ID:', 12)}${instance.id}`);
if (instance.createdAt) lines.push(` ${pad('Created:', 12)}${instance.createdAt}`);
if (instance.updatedAt) lines.push(` ${pad('Updated:', 12)}${instance.updatedAt}`);
return lines.join('\n');
}
function formatProfileDetail(profile: Record<string, unknown>): string {
const lines: string[] = [];
lines.push(`=== Profile: ${profile.name} ===`);
lines.push(`${pad('Name:')}${profile.name}`);
lines.push(`${pad('Server ID:')}${profile.serverId}`);
const permissions = profile.permissions as string[] | undefined;
if (permissions && permissions.length > 0) {
lines.push(`${pad('Permissions:')}${permissions.join(', ')}`);
}
const envOverrides = profile.envOverrides as Record<string, string> | undefined;
if (envOverrides && Object.keys(envOverrides).length > 0) {
lines.push('');
lines.push('Environment Overrides:');
const keyW = Math.max(4, ...Object.keys(envOverrides).map((k) => k.length)) + 2;
for (const [key, value] of Object.entries(envOverrides)) {
lines.push(` ${key.padEnd(keyW)}${value}`);
}
}
lines.push('');
lines.push('Metadata:');
lines.push(` ${pad('ID:', 12)}${profile.id}`);
if (profile.createdAt) lines.push(` ${pad('Created:', 12)}${profile.createdAt}`);
if (profile.updatedAt) lines.push(` ${pad('Updated:', 12)}${profile.updatedAt}`);
return lines.join('\n');
}
function formatProjectDetail(project: Record<string, unknown>): string {
const lines: string[] = [];
lines.push(`=== Project: ${project.name} ===`);
lines.push(`${pad('Name:')}${project.name}`);
if (project.description) lines.push(`${pad('Description:')}${project.description}`);
if (project.ownerId) lines.push(`${pad('Owner:')}${project.ownerId}`);
lines.push('');
lines.push('Metadata:');
lines.push(` ${pad('ID:', 12)}${project.id}`);
if (project.createdAt) lines.push(` ${pad('Created:', 12)}${project.createdAt}`);
if (project.updatedAt) lines.push(` ${pad('Updated:', 12)}${project.updatedAt}`);
return lines.join('\n');
}
function formatGenericDetail(obj: Record<string, unknown>): string {
const lines: string[] = [];
for (const [key, value] of Object.entries(obj)) { for (const [key, value] of Object.entries(obj)) {
if (value === null || value === undefined) { if (value === null || value === undefined) {
lines.push(`${pad}${key}: -`); lines.push(`${pad(key + ':')} -`);
} else if (Array.isArray(value)) { } else if (Array.isArray(value)) {
if (value.length === 0) { if (value.length === 0) {
lines.push(`${pad}${key}: []`); lines.push(`${pad(key + ':')} []`);
} else if (typeof value[0] === 'object') {
lines.push(`${pad}${key}:`);
for (const item of value) {
lines.push(`${pad} - ${JSON.stringify(item)}`);
}
} else { } else {
lines.push(`${pad}${key}: ${value.join(', ')}`); lines.push(`${key}:`);
for (const item of value) {
lines.push(` - ${typeof item === 'object' ? JSON.stringify(item) : String(item)}`);
}
} }
} else if (typeof value === 'object') { } else if (typeof value === 'object') {
lines.push(`${pad}${key}:`); lines.push(`${key}:`);
lines.push(formatDetail(value as Record<string, unknown>, indent + 1)); for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
lines.push(` ${pad(k + ':')}${String(v)}`);
}
} else { } else {
lines.push(`${pad}${key}: ${String(value)}`); lines.push(`${pad(key + ':')}${String(value)}`);
} }
} }
return lines.join('\n'); return lines.join('\n');
} }
@@ -55,20 +168,54 @@ export function createDescribeCommand(deps: DescribeCommandDeps): Command {
return new Command('describe') return new Command('describe')
.description('Show detailed information about a resource') .description('Show detailed information about a resource')
.argument('<resource>', 'resource type (server, profile, project, instance)') .argument('<resource>', 'resource type (server, profile, project, instance)')
.argument('<id>', 'resource ID') .argument('<id>', 'resource ID or name')
.option('-o, --output <format>', 'output format (detail, json, yaml)', 'detail') .option('-o, --output <format>', 'output format (detail, json, yaml)', 'detail')
.action(async (resourceArg: string, id: string, opts: { output: string }) => { .action(async (resourceArg: string, idOrName: string, opts: { output: string }) => {
const resource = resolveResource(resourceArg); const resource = resolveResource(resourceArg);
const item = await deps.fetchResource(resource, id);
// Resolve name → ID
let id: string;
try {
id = await resolveNameOrId(deps.client, resource, idOrName);
} catch {
id = idOrName;
}
const item = await deps.fetchResource(resource, id) as Record<string, unknown>;
// Enrich instances with container inspect data
let inspect: Record<string, unknown> | undefined;
if (resource === 'instances' && deps.fetchInspect && item.containerId) {
try {
inspect = await deps.fetchInspect(id) as Record<string, unknown>;
item.containerInspect = inspect;
} catch {
// Container may not be available
}
}
if (opts.output === 'json') { if (opts.output === 'json') {
deps.log(formatJson(item)); deps.log(formatJson(item));
} else if (opts.output === 'yaml') { } else if (opts.output === 'yaml') {
deps.log(formatYaml(item)); deps.log(formatYaml(item));
} else { } else {
const typeName = resource.replace(/s$/, '').charAt(0).toUpperCase() + resource.replace(/s$/, '').slice(1); // Visually clean sectioned output
deps.log(`--- ${typeName} ---`); switch (resource) {
deps.log(formatDetail(item as Record<string, unknown>)); case 'servers':
deps.log(formatServerDetail(item));
break;
case 'instances':
deps.log(formatInstanceDetail(item, inspect));
break;
case 'profiles':
deps.log(formatProfileDetail(item));
break;
case 'projects':
deps.log(formatProjectDetail(item));
break;
default:
deps.log(formatGenericDetail(item));
}
} }
}); });
} }

View File

@@ -0,0 +1,114 @@
import { Command } from 'commander';
import { writeFileSync, readFileSync, unlinkSync, mkdtempSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { execSync } from 'node:child_process';
import yaml from 'js-yaml';
import type { ApiClient } from '../api-client.js';
import { resolveResource, resolveNameOrId, stripInternalFields } from './shared.js';
export interface EditCommandDeps {
client: ApiClient;
log: (...args: unknown[]) => void;
/** Override for testing — return editor binary name. */
getEditor?: () => string;
/** Override for testing — simulate opening the editor. */
openEditor?: (filePath: string, editor: string) => void;
}
function getEditor(deps: EditCommandDeps): string {
if (deps.getEditor) return deps.getEditor();
return process.env.VISUAL ?? process.env.EDITOR ?? 'vi';
}
function openEditor(filePath: string, editor: string, deps: EditCommandDeps): void {
if (deps.openEditor) {
deps.openEditor(filePath, editor);
return;
}
execSync(`${editor} "${filePath}"`, { stdio: 'inherit' });
}
export function createEditCommand(deps: EditCommandDeps): Command {
const { client, log } = deps;
return new Command('edit')
.description('Edit a resource in your default editor (server, profile, project)')
.argument('<resource>', 'Resource type (server, profile, project)')
.argument('<name-or-id>', 'Resource name or ID')
.action(async (resourceArg: string, nameOrId: string) => {
const resource = resolveResource(resourceArg);
// Instances are immutable
if (resource === 'instances') {
log('Error: instances are immutable and cannot be edited.');
log('To change an instance, update the server definition and let reconciliation handle it.');
process.exitCode = 1;
return;
}
const validResources = ['servers', 'profiles', 'projects'];
if (!validResources.includes(resource)) {
log(`Error: unknown resource type '${resourceArg}'`);
process.exitCode = 1;
return;
}
// Resolve name → ID
const id = await resolveNameOrId(client, resource, nameOrId);
// Fetch current state
const current = await client.get<Record<string, unknown>>(`/api/v1/${resource}/${id}`);
// Strip read-only fields for editor
const editable = stripInternalFields(current);
// Serialize to YAML
const singular = resource.replace(/s$/, '');
const header = `# Editing ${singular}: ${nameOrId}\n# Save and close to apply changes. Clear the file to cancel.\n`;
const originalYaml = yaml.dump(editable, { lineWidth: 120, noRefs: true });
const content = header + originalYaml;
// Write to temp file
const tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-edit-'));
const tmpFile = join(tmpDir, `${singular}-${nameOrId}.yaml`);
writeFileSync(tmpFile, content, 'utf-8');
try {
// Open editor
const editor = getEditor(deps);
openEditor(tmpFile, editor, deps);
// Read back
const modified = readFileSync(tmpFile, 'utf-8');
// Strip comments for comparison
const modifiedClean = modified
.split('\n')
.filter((line) => !line.startsWith('#'))
.join('\n')
.trim();
if (!modifiedClean) {
log('Edit cancelled (empty file).');
return;
}
if (modifiedClean === originalYaml.trim()) {
log(`${singular} '${nameOrId}' unchanged.`);
return;
}
// Parse and apply
const updates = yaml.load(modifiedClean) as Record<string, unknown>;
await client.put(`/api/v1/${resource}/${id}`, updates);
log(`${singular} '${nameOrId}' updated.`);
} finally {
try {
unlinkSync(tmpFile);
} catch {
// Ignore cleanup errors
}
}
});
}

View File

@@ -2,6 +2,7 @@ import { Command } from 'commander';
import { formatTable } from '../formatters/table.js'; import { formatTable } from '../formatters/table.js';
import { formatJson, formatYaml } from '../formatters/output.js'; import { formatJson, formatYaml } from '../formatters/output.js';
import type { Column } from '../formatters/table.js'; import type { Column } from '../formatters/table.js';
import { resolveResource, stripInternalFields } from './shared.js';
export interface GetCommandDeps { export interface GetCommandDeps {
fetchResource: (resource: string, id?: string) => Promise<unknown[]>; fetchResource: (resource: string, id?: string) => Promise<unknown[]>;
@@ -37,22 +38,6 @@ interface InstanceRow {
port: number | null; port: number | null;
} }
const RESOURCE_ALIASES: Record<string, string> = {
server: 'servers',
srv: 'servers',
profile: 'profiles',
prof: 'profiles',
project: 'projects',
proj: 'projects',
instance: 'instances',
inst: 'instances',
};
function resolveResource(name: string): string {
const lower = name.toLowerCase();
return RESOURCE_ALIASES[lower] ?? lower;
}
const serverColumns: Column<ServerRow>[] = [ const serverColumns: Column<ServerRow>[] = [
{ header: 'NAME', key: 'name' }, { header: 'NAME', key: 'name' },
{ header: 'TRANSPORT', key: 'transport', width: 16 }, { header: 'TRANSPORT', key: 'transport', width: 16 },
@@ -100,21 +85,44 @@ function getColumnsForResource(resource: string): Column<Record<string, unknown>
} }
} }
/**
* Transform API response items into apply-compatible format.
* Strips internal fields and wraps in the resource key.
*/
function toApplyFormat(resource: string, items: unknown[]): Record<string, unknown[]> {
const cleaned = items.map((item) => {
const obj = stripInternalFields(item as Record<string, unknown>);
// For profiles: convert serverId → server (name) for apply compat
// We can't resolve the name here without an API call, so keep serverId
// but also remove it's not in the apply schema. Actually profiles use
// "server" (name) in apply format but serverId from API. Keep serverId
// since it can still be used with apply (the apply command resolves names).
return obj;
});
return { [resource]: cleaned };
}
export function createGetCommand(deps: GetCommandDeps): Command { export function createGetCommand(deps: GetCommandDeps): Command {
return new Command('get') return new Command('get')
.description('List resources (servers, profiles, projects, instances)') .description('List resources (servers, profiles, projects, instances)')
.argument('<resource>', 'resource type (servers, profiles, projects, instances)') .argument('<resource>', 'resource type (servers, profiles, projects, instances)')
.argument('[id]', 'specific resource ID') .argument('[id]', 'specific resource ID or name')
.option('-o, --output <format>', 'output format (table, json, yaml)', 'table') .option('-o, --output <format>', 'output format (table, json, yaml)', 'table')
.action(async (resourceArg: string, id: string | undefined, opts: { output: string }) => { .action(async (resourceArg: string, id: string | undefined, opts: { output: string }) => {
const resource = resolveResource(resourceArg); const resource = resolveResource(resourceArg);
const items = await deps.fetchResource(resource, id); const items = await deps.fetchResource(resource, id);
if (opts.output === 'json') { if (opts.output === 'json') {
deps.log(formatJson(items.length === 1 ? items[0] : items)); // Apply-compatible JSON wrapped in resource key
deps.log(formatJson(toApplyFormat(resource, items)));
} else if (opts.output === 'yaml') { } else if (opts.output === 'yaml') {
deps.log(formatYaml(items.length === 1 ? items[0] : items)); // Apply-compatible YAML wrapped in resource key
deps.log(formatYaml(toApplyFormat(resource, items)));
} else { } else {
if (items.length === 0) {
deps.log(`No ${resource} found.`);
return;
}
const columns = getColumnsForResource(resource); const columns = getColumnsForResource(resource);
deps.log(formatTable(items as Record<string, unknown>[], columns)); deps.log(formatTable(items as Record<string, unknown>[], columns));
} }

View File

@@ -1,123 +0,0 @@
import { Command } from 'commander';
import type { ApiClient } from '../api-client.js';
interface Instance {
id: string;
serverId: string;
status: string;
containerId: string | null;
port: number | null;
createdAt: string;
}
export interface InstanceCommandDeps {
client: ApiClient;
log: (...args: unknown[]) => void;
}
export function createInstanceCommands(deps: InstanceCommandDeps): Command {
const { client, log } = deps;
const cmd = new Command('instance')
.alias('instances')
.alias('inst')
.description('Manage MCP server instances');
cmd
.command('list')
.alias('ls')
.description('List running instances')
.option('-s, --server <id>', 'Filter by server ID')
.option('-o, --output <format>', 'Output format (table, json)', 'table')
.action(async (opts: { server?: string; output: string }) => {
let url = '/api/v1/instances';
if (opts.server) {
url += `?serverId=${encodeURIComponent(opts.server)}`;
}
const instances = await client.get<Instance[]>(url);
if (opts.output === 'json') {
log(JSON.stringify(instances, null, 2));
return;
}
if (instances.length === 0) {
log('No instances found.');
return;
}
log('ID\tSERVER\tSTATUS\tPORT\tCONTAINER');
for (const inst of instances) {
const cid = inst.containerId ? inst.containerId.slice(0, 12) : '-';
const port = inst.port ?? '-';
log(`${inst.id}\t${inst.serverId}\t${inst.status}\t${port}\t${cid}`);
}
});
cmd
.command('start <serverId>')
.description('Start a new MCP server instance')
.option('-p, --port <port>', 'Host port to bind')
.option('-o, --output <format>', 'Output format (table, json)', 'table')
.action(async (serverId: string, opts: { port?: string; output: string }) => {
const body: Record<string, unknown> = { serverId };
if (opts.port !== undefined) {
body.hostPort = parseInt(opts.port, 10);
}
const instance = await client.post<Instance>('/api/v1/instances', body);
if (opts.output === 'json') {
log(JSON.stringify(instance, null, 2));
return;
}
log(`Instance ${instance.id} started (status: ${instance.status})`);
});
cmd
.command('stop <id>')
.description('Stop a running instance')
.action(async (id: string) => {
const instance = await client.post<Instance>(`/api/v1/instances/${id}/stop`);
log(`Instance ${id} stopped (status: ${instance.status})`);
});
cmd
.command('restart <id>')
.description('Restart an instance (stop, remove, start fresh)')
.action(async (id: string) => {
const instance = await client.post<Instance>(`/api/v1/instances/${id}/restart`);
log(`Instance restarted as ${instance.id} (status: ${instance.status})`);
});
cmd
.command('remove <id>')
.alias('rm')
.description('Remove an instance and its container')
.action(async (id: string) => {
await client.delete(`/api/v1/instances/${id}`);
log(`Instance ${id} removed.`);
});
cmd
.command('logs <id>')
.description('Get logs from an instance')
.option('-t, --tail <lines>', 'Number of lines to show')
.action(async (id: string, opts: { tail?: string }) => {
let url = `/api/v1/instances/${id}/logs`;
if (opts.tail) {
url += `?tail=${opts.tail}`;
}
const logs = await client.get<{ stdout: string; stderr: string }>(url);
if (logs.stdout) {
log(logs.stdout);
}
if (logs.stderr) {
process.stderr.write(logs.stderr);
}
});
cmd
.command('inspect <id>')
.description('Get detailed container info for an instance')
.action(async (id: string) => {
const info = await client.get(`/api/v1/instances/${id}/inspect`);
log(JSON.stringify(info, null, 2));
});
return cmd;
}

View File

@@ -0,0 +1,29 @@
import { Command } from 'commander';
import type { ApiClient } from '../api-client.js';
export interface LogsCommandDeps {
client: ApiClient;
log: (...args: unknown[]) => void;
}
export function createLogsCommand(deps: LogsCommandDeps): Command {
const { client, log } = deps;
return new Command('logs')
.description('Get logs from an MCP server instance')
.argument('<instance-id>', 'Instance ID')
.option('-t, --tail <lines>', 'Number of lines to show')
.action(async (id: string, opts: { tail?: string }) => {
let url = `/api/v1/instances/${id}/logs`;
if (opts.tail) {
url += `?tail=${opts.tail}`;
}
const logs = await client.get<{ stdout: string; stderr: string }>(url);
if (logs.stdout) {
log(logs.stdout);
}
if (logs.stderr) {
process.stderr.write(logs.stderr);
}
});
}

View File

@@ -24,77 +24,8 @@ export function createProjectCommand(deps: ProjectCommandDeps): Command {
const { client, log } = deps; const { client, log } = deps;
const cmd = new Command('project') const cmd = new Command('project')
.alias('projects')
.alias('proj') .alias('proj')
.description('Manage mcpctl projects'); .description('Project-specific actions (create with "create project", list with "get projects")');
cmd
.command('list')
.alias('ls')
.description('List all projects')
.option('-o, --output <format>', 'Output format (table, json)', 'table')
.action(async (opts: { output: string }) => {
const projects = await client.get<Project[]>('/api/v1/projects');
if (opts.output === 'json') {
log(JSON.stringify(projects, null, 2));
return;
}
if (projects.length === 0) {
log('No projects found.');
return;
}
log('ID\tNAME\tDESCRIPTION');
for (const p of projects) {
log(`${p.id}\t${p.name}\t${p.description || '-'}`);
}
});
cmd
.command('create <name>')
.description('Create a new project')
.option('-d, --description <text>', 'Project description', '')
.action(async (name: string, opts: { description: string }) => {
const project = await client.post<Project>('/api/v1/projects', {
name,
description: opts.description,
});
log(`Project '${project.name}' created (id: ${project.id})`);
});
cmd
.command('delete <id>')
.alias('rm')
.description('Delete a project')
.action(async (id: string) => {
await client.delete(`/api/v1/projects/${id}`);
log(`Project '${id}' deleted.`);
});
cmd
.command('show <id>')
.description('Show project details')
.action(async (id: string) => {
const project = await client.get<Project>(`/api/v1/projects/${id}`);
log(`Name: ${project.name}`);
log(`ID: ${project.id}`);
log(`Description: ${project.description || '-'}`);
log(`Owner: ${project.ownerId}`);
log(`Created: ${project.createdAt}`);
try {
const profiles = await client.get<Profile[]>(`/api/v1/projects/${id}/profiles`);
if (profiles.length > 0) {
log('\nProfiles:');
for (const p of profiles) {
log(` - ${p.name} (id: ${p.id})`);
}
} else {
log('\nNo profiles assigned.');
}
} catch {
// Profiles endpoint may not be available
}
});
cmd cmd
.command('profiles <id>') .command('profiles <id>')

View File

@@ -0,0 +1,42 @@
import type { ApiClient } from '../api-client.js';
export const RESOURCE_ALIASES: Record<string, string> = {
server: 'servers',
srv: 'servers',
profile: 'profiles',
prof: 'profiles',
project: 'projects',
proj: 'projects',
instance: 'instances',
inst: 'instances',
};
export function resolveResource(name: string): string {
const lower = name.toLowerCase();
return RESOURCE_ALIASES[lower] ?? lower;
}
/** Resolve a name-or-ID to an ID. CUIDs pass through; names are looked up. */
export async function resolveNameOrId(
client: ApiClient,
resource: string,
nameOrId: string,
): Promise<string> {
// CUIDs start with 'c' followed by 24+ alphanumeric chars
if (/^c[a-z0-9]{24}/.test(nameOrId)) {
return nameOrId;
}
const items = await client.get<Array<{ id: string; name: string }>>(`/api/v1/${resource}`);
const match = items.find((item) => item.name === nameOrId);
if (match) return match.id;
throw new Error(`${resource.replace(/s$/, '')} '${nameOrId}' not found`);
}
/** Strip internal/read-only fields from an API response to make it apply-compatible. */
export function stripInternalFields(obj: Record<string, unknown>): Record<string, unknown> {
const result = { ...obj };
for (const key of ['id', 'createdAt', 'updatedAt', 'version', 'ownerId']) {
delete result[key];
}
return result;
}

View File

@@ -5,8 +5,11 @@ import { createConfigCommand } from './commands/config.js';
import { createStatusCommand } from './commands/status.js'; import { createStatusCommand } from './commands/status.js';
import { createGetCommand } from './commands/get.js'; import { createGetCommand } from './commands/get.js';
import { createDescribeCommand } from './commands/describe.js'; import { createDescribeCommand } from './commands/describe.js';
import { createInstanceCommands } from './commands/instances.js'; import { createDeleteCommand } from './commands/delete.js';
import { createLogsCommand } from './commands/logs.js';
import { createApplyCommand } from './commands/apply.js'; import { createApplyCommand } from './commands/apply.js';
import { createCreateCommand } from './commands/create.js';
import { createEditCommand } from './commands/edit.js';
import { createSetupCommand } from './commands/setup.js'; import { createSetupCommand } from './commands/setup.js';
import { createClaudeCommand } from './commands/claude.js'; import { createClaudeCommand } from './commands/claude.js';
import { createProjectCommand } from './commands/project.js'; import { createProjectCommand } from './commands/project.js';
@@ -15,6 +18,7 @@ import { createLoginCommand, createLogoutCommand } from './commands/auth.js';
import { ApiClient } from './api-client.js'; import { ApiClient } from './api-client.js';
import { loadConfig } from './config/index.js'; import { loadConfig } from './config/index.js';
import { loadCredentials } from './auth/index.js'; import { loadCredentials } from './auth/index.js';
import { resolveNameOrId } from './commands/shared.js';
export function createProgram(): Command { export function createProgram(): Command {
const program = new Command() const program = new Command()
@@ -45,15 +49,27 @@ export function createProgram(): Command {
const client = new ApiClient({ baseUrl, token: creds?.token ?? undefined }); const client = new ApiClient({ baseUrl, token: creds?.token ?? undefined });
const fetchResource = async (resource: string, id?: string): Promise<unknown[]> => { const fetchResource = async (resource: string, nameOrId?: string): Promise<unknown[]> => {
if (id) { if (nameOrId) {
let id: string;
try {
id = await resolveNameOrId(client, resource, nameOrId);
} catch {
id = nameOrId;
}
const item = await client.get(`/api/v1/${resource}/${id}`); const item = await client.get(`/api/v1/${resource}/${id}`);
return [item]; return [item];
} }
return client.get<unknown[]>(`/api/v1/${resource}`); return client.get<unknown[]>(`/api/v1/${resource}`);
}; };
const fetchSingleResource = async (resource: string, id: string): Promise<unknown> => { const fetchSingleResource = async (resource: string, nameOrId: string): Promise<unknown> => {
let id: string;
try {
id = await resolveNameOrId(client, resource, nameOrId);
} catch {
id = nameOrId;
}
return client.get(`/api/v1/${resource}/${id}`); return client.get(`/api/v1/${resource}/${id}`);
}; };
@@ -63,11 +79,28 @@ export function createProgram(): Command {
})); }));
program.addCommand(createDescribeCommand({ program.addCommand(createDescribeCommand({
client,
fetchResource: fetchSingleResource, fetchResource: fetchSingleResource,
fetchInspect: async (id: string) => client.get(`/api/v1/instances/${id}/inspect`),
log: (...args) => console.log(...args), log: (...args) => console.log(...args),
})); }));
program.addCommand(createInstanceCommands({ program.addCommand(createDeleteCommand({
client,
log: (...args) => console.log(...args),
}));
program.addCommand(createLogsCommand({
client,
log: (...args) => console.log(...args),
}));
program.addCommand(createCreateCommand({
client,
log: (...args) => console.log(...args),
}));
program.addCommand(createEditCommand({
client, client,
log: (...args) => console.log(...args), log: (...args) => console.log(...args),
})); }));

View File

@@ -0,0 +1,144 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createCreateCommand } from '../../src/commands/create.js';
import type { ApiClient } from '../../src/api-client.js';
function mockClient(): ApiClient {
return {
get: vi.fn(async () => []),
post: vi.fn(async () => ({ id: 'new-id', name: 'test' })),
put: vi.fn(async () => ({})),
delete: vi.fn(async () => {}),
} as unknown as ApiClient;
}
describe('create command', () => {
let client: ReturnType<typeof mockClient>;
let output: string[];
const log = (...args: unknown[]) => output.push(args.map(String).join(' '));
beforeEach(() => {
client = mockClient();
output = [];
});
describe('create server', () => {
it('creates a server with minimal flags', async () => {
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync(['server', 'my-server'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/servers', expect.objectContaining({
name: 'my-server',
transport: 'STDIO',
replicas: 1,
}));
expect(output.join('\n')).toContain("server 'test' created");
});
it('creates a server with all flags', async () => {
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync([
'server', 'ha-mcp',
'-d', 'Home Assistant MCP',
'--docker-image', 'ghcr.io/ha-mcp:latest',
'--transport', 'STREAMABLE_HTTP',
'--external-url', 'http://localhost:8086/mcp',
'--container-port', '3000',
'--replicas', '2',
'--command', 'python',
'--command', '-c',
'--command', 'print("hello")',
'--env-template', 'API_KEY:API key:true',
'--env-template', 'BASE_URL:Base URL:false',
], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/servers', {
name: 'ha-mcp',
description: 'Home Assistant MCP',
dockerImage: 'ghcr.io/ha-mcp:latest',
transport: 'STREAMABLE_HTTP',
externalUrl: 'http://localhost:8086/mcp',
containerPort: 3000,
replicas: 2,
command: ['python', '-c', 'print("hello")'],
envTemplate: [
{ name: 'API_KEY', description: 'API key', isSecret: true },
{ name: 'BASE_URL', description: 'Base URL', isSecret: false },
],
});
});
it('defaults transport to STDIO', async () => {
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync(['server', 'test'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/servers', expect.objectContaining({
transport: 'STDIO',
}));
});
});
describe('create profile', () => {
it('creates a profile resolving server name', async () => {
vi.mocked(client.get).mockResolvedValue([
{ id: 'srv-abc', name: 'ha-mcp' },
]);
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync(['profile', 'production', '--server', 'ha-mcp'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/profiles', expect.objectContaining({
name: 'production',
serverId: 'srv-abc',
}));
});
it('parses --env KEY=value entries', async () => {
vi.mocked(client.get).mockResolvedValue([
{ id: 'srv-1', name: 'test' },
]);
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync([
'profile', 'dev',
'--server', 'test',
'--env', 'FOO=bar',
'--env', 'SECRET=s3cr3t',
], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/profiles', expect.objectContaining({
envOverrides: { FOO: 'bar', SECRET: 's3cr3t' },
}));
});
it('passes permissions', async () => {
vi.mocked(client.get).mockResolvedValue([
{ id: 'srv-1', name: 'test' },
]);
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync([
'profile', 'admin',
'--server', 'test',
'--permissions', 'read',
'--permissions', 'write',
], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/profiles', expect.objectContaining({
permissions: ['read', 'write'],
}));
});
});
describe('create project', () => {
it('creates a project', async () => {
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync(['project', 'my-project', '-d', 'A test project'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/projects', {
name: 'my-project',
description: 'A test project',
});
expect(output.join('\n')).toContain("project 'test' created");
});
it('creates a project with no description', async () => {
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync(['project', 'minimal'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/projects', {
name: 'minimal',
description: '',
});
});
});
});

View File

@@ -1,18 +1,29 @@
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { createDescribeCommand } from '../../src/commands/describe.js'; import { createDescribeCommand } from '../../src/commands/describe.js';
import type { DescribeCommandDeps } from '../../src/commands/describe.js'; import type { DescribeCommandDeps } from '../../src/commands/describe.js';
import type { ApiClient } from '../../src/api-client.js';
function mockClient(): ApiClient {
return {
get: vi.fn(async () => []),
post: vi.fn(async () => ({})),
put: vi.fn(async () => ({})),
delete: vi.fn(async () => {}),
} as unknown as ApiClient;
}
function makeDeps(item: unknown = {}): DescribeCommandDeps & { output: string[] } { function makeDeps(item: unknown = {}): DescribeCommandDeps & { output: string[] } {
const output: string[] = []; const output: string[] = [];
return { return {
output, output,
client: mockClient(),
fetchResource: vi.fn(async () => item), fetchResource: vi.fn(async () => item),
log: (...args: string[]) => output.push(args.join(' ')), log: (...args: string[]) => output.push(args.join(' ')),
}; };
} }
describe('describe command', () => { describe('describe command', () => {
it('shows detailed server info', async () => { it('shows detailed server info with sections', async () => {
const deps = makeDeps({ const deps = makeDeps({
id: 'srv-1', id: 'srv-1',
name: 'slack', name: 'slack',
@@ -20,16 +31,22 @@ describe('describe command', () => {
packageName: '@slack/mcp', packageName: '@slack/mcp',
dockerImage: null, dockerImage: null,
envTemplate: [], envTemplate: [],
createdAt: '2025-01-01',
}); });
const cmd = createDescribeCommand(deps); const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'server', 'srv-1']); await cmd.parseAsync(['node', 'test', 'server', 'srv-1']);
expect(deps.fetchResource).toHaveBeenCalledWith('servers', 'srv-1'); expect(deps.fetchResource).toHaveBeenCalledWith('servers', 'srv-1');
const text = deps.output.join('\n'); const text = deps.output.join('\n');
expect(text).toContain('--- Server ---'); expect(text).toContain('=== Server: slack ===');
expect(text).toContain('name: slack'); expect(text).toContain('Name:');
expect(text).toContain('transport: STDIO'); expect(text).toContain('slack');
expect(text).toContain('dockerImage: -'); expect(text).toContain('Transport:');
expect(text).toContain('STDIO');
expect(text).toContain('Package:');
expect(text).toContain('@slack/mcp');
expect(text).toContain('Metadata:');
expect(text).toContain('ID:');
}); });
it('resolves resource aliases', async () => { it('resolves resource aliases', async () => {
@@ -55,31 +72,58 @@ describe('describe command', () => {
expect(deps.output[0]).toContain('name: slack'); expect(deps.output[0]).toContain('name: slack');
}); });
it('formats nested objects', async () => { it('shows profile with permissions and env overrides', async () => {
const deps = makeDeps({ const deps = makeDeps({
id: 'srv-1', id: 'p1',
name: 'slack', name: 'production',
metadata: { version: '1.0', nested: { deep: true } }, serverId: 'srv-1',
});
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'server', 'srv-1']);
const text = deps.output.join('\n');
expect(text).toContain('metadata:');
expect(text).toContain('version: 1.0');
});
it('formats arrays correctly', async () => {
const deps = makeDeps({
id: 'srv-1',
permissions: ['read', 'write'], permissions: ['read', 'write'],
envTemplate: [], envOverrides: { FOO: 'bar', SECRET: 's3cr3t' },
createdAt: '2025-01-01',
}); });
const cmd = createDescribeCommand(deps); const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'server', 'srv-1']); await cmd.parseAsync(['node', 'test', 'profile', 'p1']);
const text = deps.output.join('\n'); const text = deps.output.join('\n');
expect(text).toContain('permissions: read, write'); expect(text).toContain('=== Profile: production ===');
expect(text).toContain('envTemplate: []'); expect(text).toContain('read, write');
expect(text).toContain('Environment Overrides:');
expect(text).toContain('FOO');
expect(text).toContain('bar');
});
it('shows project detail', async () => {
const deps = makeDeps({
id: 'proj-1',
name: 'my-project',
description: 'A test project',
ownerId: 'user-1',
createdAt: '2025-01-01',
});
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'project', 'proj-1']);
const text = deps.output.join('\n');
expect(text).toContain('=== Project: my-project ===');
expect(text).toContain('A test project');
expect(text).toContain('user-1');
});
it('shows instance detail with container info', async () => {
const deps = makeDeps({
id: 'inst-1',
serverId: 'srv-1',
status: 'RUNNING',
containerId: 'abc123',
port: 3000,
createdAt: '2025-01-01',
});
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'instance', 'inst-1']);
const text = deps.output.join('\n');
expect(text).toContain('=== Instance: inst-1 ===');
expect(text).toContain('RUNNING');
expect(text).toContain('abc123');
}); });
}); });

View File

@@ -0,0 +1,180 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { readFileSync, writeFileSync } from 'node:fs';
import yaml from 'js-yaml';
import { createEditCommand } from '../../src/commands/edit.js';
import type { ApiClient } from '../../src/api-client.js';
function mockClient(): ApiClient {
return {
get: vi.fn(async () => ({})),
post: vi.fn(async () => ({})),
put: vi.fn(async () => ({})),
delete: vi.fn(async () => {}),
} as unknown as ApiClient;
}
describe('edit command', () => {
let client: ReturnType<typeof mockClient>;
let output: string[];
const log = (...args: unknown[]) => output.push(args.map(String).join(' '));
beforeEach(() => {
client = mockClient();
output = [];
});
it('fetches server, opens editor, applies changes on save', async () => {
// GET /api/v1/servers returns list for resolveNameOrId
vi.mocked(client.get).mockImplementation(async (path: string) => {
if (path === '/api/v1/servers') {
return [{ id: 'srv-1', name: 'ha-mcp' }];
}
// GET /api/v1/servers/srv-1 returns full server
return {
id: 'srv-1',
name: 'ha-mcp',
description: 'Old desc',
transport: 'STDIO',
replicas: 1,
createdAt: '2025-01-01',
updatedAt: '2025-01-01',
version: 1,
};
});
const cmd = createEditCommand({
client,
log,
getEditor: () => 'vi',
openEditor: (filePath) => {
// Simulate user editing the file
const content = readFileSync(filePath, 'utf-8');
const modified = content
.replace('Old desc', 'New desc')
.replace('replicas: 1', 'replicas: 3');
writeFileSync(filePath, modified, 'utf-8');
},
});
await cmd.parseAsync(['server', 'ha-mcp'], { from: 'user' });
expect(client.put).toHaveBeenCalledWith('/api/v1/servers/srv-1', expect.objectContaining({
description: 'New desc',
replicas: 3,
}));
expect(output.join('\n')).toContain("server 'ha-mcp' updated");
});
it('detects no changes and skips PUT', async () => {
vi.mocked(client.get).mockImplementation(async (path: string) => {
if (path === '/api/v1/servers') return [{ id: 'srv-1', name: 'test' }];
return {
id: 'srv-1', name: 'test', description: '', transport: 'STDIO',
createdAt: '2025-01-01', updatedAt: '2025-01-01', version: 1,
};
});
const cmd = createEditCommand({
client,
log,
getEditor: () => 'vi',
openEditor: () => {
// Don't modify the file
},
});
await cmd.parseAsync(['server', 'test'], { from: 'user' });
expect(client.put).not.toHaveBeenCalled();
expect(output.join('\n')).toContain("unchanged");
});
it('handles empty file as cancel', async () => {
vi.mocked(client.get).mockImplementation(async (path: string) => {
if (path === '/api/v1/servers') return [{ id: 'srv-1', name: 'test' }];
return { id: 'srv-1', name: 'test', createdAt: '2025-01-01', updatedAt: '2025-01-01', version: 1 };
});
const cmd = createEditCommand({
client,
log,
getEditor: () => 'vi',
openEditor: (filePath) => {
writeFileSync(filePath, '', 'utf-8');
},
});
await cmd.parseAsync(['server', 'test'], { from: 'user' });
expect(client.put).not.toHaveBeenCalled();
expect(output.join('\n')).toContain('cancelled');
});
it('strips read-only fields from editor content', async () => {
vi.mocked(client.get).mockImplementation(async (path: string) => {
if (path === '/api/v1/servers') return [{ id: 'srv-1', name: 'test' }];
return {
id: 'srv-1', name: 'test', description: '', transport: 'STDIO',
createdAt: '2025-01-01', updatedAt: '2025-01-01', version: 1,
};
});
let editorContent = '';
const cmd = createEditCommand({
client,
log,
getEditor: () => 'vi',
openEditor: (filePath) => {
editorContent = readFileSync(filePath, 'utf-8');
},
});
await cmd.parseAsync(['server', 'test'], { from: 'user' });
// The editor content should NOT contain read-only fields
expect(editorContent).not.toContain('id:');
expect(editorContent).not.toContain('createdAt');
expect(editorContent).not.toContain('updatedAt');
expect(editorContent).not.toContain('version');
// But should contain editable fields
expect(editorContent).toContain('name:');
});
it('rejects edit instance with error message', async () => {
const cmd = createEditCommand({ client, log });
await cmd.parseAsync(['instance', 'inst-1'], { from: 'user' });
expect(client.get).not.toHaveBeenCalled();
expect(client.put).not.toHaveBeenCalled();
expect(output.join('\n')).toContain('immutable');
});
it('edits a profile', async () => {
vi.mocked(client.get).mockImplementation(async (path: string) => {
if (path === '/api/v1/profiles') return [{ id: 'prof-1', name: 'production' }];
return {
id: 'prof-1', name: 'production', serverId: 'srv-1',
permissions: ['read'], envOverrides: { FOO: 'bar' },
createdAt: '2025-01-01', updatedAt: '2025-01-01', version: 1,
};
});
const cmd = createEditCommand({
client,
log,
getEditor: () => 'vi',
openEditor: (filePath) => {
const content = readFileSync(filePath, 'utf-8');
const modified = content.replace('FOO: bar', 'FOO: baz');
writeFileSync(filePath, modified, 'utf-8');
},
});
await cmd.parseAsync(['profile', 'production'], { from: 'user' });
expect(client.put).toHaveBeenCalledWith('/api/v1/profiles/prof-1', expect.objectContaining({
envOverrides: { FOO: 'baz' },
}));
});
});

View File

@@ -41,20 +41,30 @@ describe('get command', () => {
expect(deps.fetchResource).toHaveBeenCalledWith('servers', 'srv-1'); expect(deps.fetchResource).toHaveBeenCalledWith('servers', 'srv-1');
}); });
it('outputs JSON format', async () => { it('outputs apply-compatible JSON format', async () => {
const deps = makeDeps([{ id: 'srv-1', name: 'slack' }]); const deps = makeDeps([{ id: 'srv-1', name: 'slack', createdAt: '2025-01-01', updatedAt: '2025-01-01', version: 1 }]);
const cmd = createGetCommand(deps); const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'servers', '-o', 'json']); await cmd.parseAsync(['node', 'test', 'servers', '-o', 'json']);
const parsed = JSON.parse(deps.output[0] ?? ''); const parsed = JSON.parse(deps.output[0] ?? '');
expect(parsed).toEqual({ id: 'srv-1', name: 'slack' }); // Wrapped in resource key, internal fields stripped
expect(parsed).toHaveProperty('servers');
expect(parsed.servers[0].name).toBe('slack');
expect(parsed.servers[0]).not.toHaveProperty('id');
expect(parsed.servers[0]).not.toHaveProperty('createdAt');
expect(parsed.servers[0]).not.toHaveProperty('updatedAt');
expect(parsed.servers[0]).not.toHaveProperty('version');
}); });
it('outputs YAML format', async () => { it('outputs apply-compatible YAML format', async () => {
const deps = makeDeps([{ id: 'srv-1', name: 'slack' }]); const deps = makeDeps([{ id: 'srv-1', name: 'slack', createdAt: '2025-01-01' }]);
const cmd = createGetCommand(deps); const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'servers', '-o', 'yaml']); await cmd.parseAsync(['node', 'test', 'servers', '-o', 'yaml']);
expect(deps.output[0]).toContain('name: slack'); const text = deps.output[0];
expect(text).toContain('servers:');
expect(text).toContain('name: slack');
expect(text).not.toContain('id:');
expect(text).not.toContain('createdAt:');
}); });
it('lists profiles with correct columns', async () => { it('lists profiles with correct columns', async () => {
@@ -81,6 +91,6 @@ describe('get command', () => {
const deps = makeDeps([]); const deps = makeDeps([]);
const cmd = createGetCommand(deps); const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'servers']); await cmd.parseAsync(['node', 'test', 'servers']);
expect(deps.output[0]).toContain('No results'); expect(deps.output[0]).toContain('No servers found');
}); });
}); });

View File

@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createInstanceCommands } from '../../src/commands/instances.js'; import { createDeleteCommand } from '../../src/commands/delete.js';
import { createLogsCommand } from '../../src/commands/logs.js';
import type { ApiClient } from '../../src/api-client.js'; import type { ApiClient } from '../../src/api-client.js';
function mockClient(): ApiClient { function mockClient(): ApiClient {
@@ -11,7 +12,7 @@ function mockClient(): ApiClient {
} as unknown as ApiClient; } as unknown as ApiClient;
} }
describe('instance commands', () => { describe('delete command', () => {
let client: ReturnType<typeof mockClient>; let client: ReturnType<typeof mockClient>;
let output: string[]; let output: string[];
const log = (...args: unknown[]) => output.push(args.map(String).join(' ')); const log = (...args: unknown[]) => output.push(args.map(String).join(' '));
@@ -21,107 +22,70 @@ describe('instance commands', () => {
output = []; output = [];
}); });
describe('list', () => { it('deletes an instance by ID', async () => {
it('shows no instances message when empty', async () => { const cmd = createDeleteCommand({ client, log });
const cmd = createInstanceCommands({ client, log }); await cmd.parseAsync(['instance', 'inst-1'], { from: 'user' });
await cmd.parseAsync(['list'], { from: 'user' });
expect(output.join('\n')).toContain('No instances found');
});
it('shows instance table', async () => {
vi.mocked(client.get).mockResolvedValue([
{ id: 'inst-1', serverId: 'srv-1', status: 'RUNNING', containerId: 'ctr-abc123def', port: 3000, createdAt: '2025-01-01' },
]);
const cmd = createInstanceCommands({ client, log });
await cmd.parseAsync(['list'], { from: 'user' });
expect(output.join('\n')).toContain('inst-1');
expect(output.join('\n')).toContain('RUNNING');
});
it('filters by server', async () => {
const cmd = createInstanceCommands({ client, log });
await cmd.parseAsync(['list', '-s', 'srv-1'], { from: 'user' });
expect(client.get).toHaveBeenCalledWith(expect.stringContaining('serverId=srv-1'));
});
it('outputs json', async () => {
vi.mocked(client.get).mockResolvedValue([{ id: 'inst-1' }]);
const cmd = createInstanceCommands({ client, log });
await cmd.parseAsync(['list', '-o', 'json'], { from: 'user' });
expect(output[0]).toContain('"id"');
});
});
describe('start', () => {
it('starts an instance', async () => {
vi.mocked(client.post).mockResolvedValue({ id: 'inst-new', status: 'RUNNING' });
const cmd = createInstanceCommands({ client, log });
await cmd.parseAsync(['start', 'srv-1'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/instances', { serverId: 'srv-1' });
expect(output.join('\n')).toContain('started');
});
it('passes host port', async () => {
vi.mocked(client.post).mockResolvedValue({ id: 'inst-new', status: 'RUNNING' });
const cmd = createInstanceCommands({ client, log });
await cmd.parseAsync(['start', 'srv-1', '-p', '8080'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/instances', { serverId: 'srv-1', hostPort: 8080 });
});
});
describe('stop', () => {
it('stops an instance', async () => {
vi.mocked(client.post).mockResolvedValue({ id: 'inst-1', status: 'STOPPED' });
const cmd = createInstanceCommands({ client, log });
await cmd.parseAsync(['stop', 'inst-1'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/instances/inst-1/stop');
expect(output.join('\n')).toContain('stopped');
});
});
describe('restart', () => {
it('restarts an instance', async () => {
vi.mocked(client.post).mockResolvedValue({ id: 'inst-2', status: 'RUNNING' });
const cmd = createInstanceCommands({ client, log });
await cmd.parseAsync(['restart', 'inst-1'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/instances/inst-1/restart');
expect(output.join('\n')).toContain('restarted');
});
});
describe('remove', () => {
it('removes an instance', async () => {
const cmd = createInstanceCommands({ client, log });
await cmd.parseAsync(['remove', 'inst-1'], { from: 'user' });
expect(client.delete).toHaveBeenCalledWith('/api/v1/instances/inst-1'); expect(client.delete).toHaveBeenCalledWith('/api/v1/instances/inst-1');
expect(output.join('\n')).toContain('removed'); expect(output.join('\n')).toContain('deleted');
}); });
it('deletes a server by ID', async () => {
const cmd = createDeleteCommand({ client, log });
await cmd.parseAsync(['server', 'srv-1'], { from: 'user' });
expect(client.delete).toHaveBeenCalledWith('/api/v1/servers/srv-1');
expect(output.join('\n')).toContain('deleted');
});
it('resolves server name to ID', async () => {
vi.mocked(client.get).mockResolvedValue([
{ id: 'srv-abc', name: 'ha-mcp' },
]);
const cmd = createDeleteCommand({ client, log });
await cmd.parseAsync(['server', 'ha-mcp'], { from: 'user' });
expect(client.delete).toHaveBeenCalledWith('/api/v1/servers/srv-abc');
});
it('deletes a profile', async () => {
const cmd = createDeleteCommand({ client, log });
await cmd.parseAsync(['profile', 'prof-1'], { from: 'user' });
expect(client.delete).toHaveBeenCalledWith('/api/v1/profiles/prof-1');
});
it('deletes a project', async () => {
const cmd = createDeleteCommand({ client, log });
await cmd.parseAsync(['project', 'proj-1'], { from: 'user' });
expect(client.delete).toHaveBeenCalledWith('/api/v1/projects/proj-1');
});
it('accepts resource aliases', async () => {
const cmd = createDeleteCommand({ client, log });
await cmd.parseAsync(['srv', 'srv-1'], { from: 'user' });
expect(client.delete).toHaveBeenCalledWith('/api/v1/servers/srv-1');
});
});
describe('logs command', () => {
let client: ReturnType<typeof mockClient>;
let output: string[];
const log = (...args: unknown[]) => output.push(args.map(String).join(' '));
beforeEach(() => {
client = mockClient();
output = [];
}); });
describe('logs', () => {
it('shows logs', async () => { it('shows logs', async () => {
vi.mocked(client.get).mockResolvedValue({ stdout: 'hello world\n', stderr: '' }); vi.mocked(client.get).mockResolvedValue({ stdout: 'hello world\n', stderr: '' });
const cmd = createInstanceCommands({ client, log }); const cmd = createLogsCommand({ client, log });
await cmd.parseAsync(['logs', 'inst-1'], { from: 'user' }); await cmd.parseAsync(['inst-1'], { from: 'user' });
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/logs'); expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/logs');
expect(output.join('\n')).toContain('hello world'); expect(output.join('\n')).toContain('hello world');
}); });
it('passes tail option', async () => { it('passes tail option', async () => {
vi.mocked(client.get).mockResolvedValue({ stdout: '', stderr: '' }); vi.mocked(client.get).mockResolvedValue({ stdout: '', stderr: '' });
const cmd = createInstanceCommands({ client, log }); const cmd = createLogsCommand({ client, log });
await cmd.parseAsync(['logs', 'inst-1', '-t', '50'], { from: 'user' }); await cmd.parseAsync(['inst-1', '-t', '50'], { from: 'user' });
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/logs?tail=50'); expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/logs?tail=50');
}); });
});
describe('inspect', () => {
it('shows container info as json', async () => {
vi.mocked(client.get).mockResolvedValue({ containerId: 'ctr-abc', state: 'running' });
const cmd = createInstanceCommands({ client, log });
await cmd.parseAsync(['inspect', 'inst-1'], { from: 'user' });
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/inspect');
expect(output[0]).toContain('ctr-abc');
});
});
}); });

View File

@@ -21,65 +21,6 @@ describe('project command', () => {
output = []; output = [];
}); });
describe('list', () => {
it('shows no projects message when empty', async () => {
const cmd = createProjectCommand({ client, log });
await cmd.parseAsync(['list'], { from: 'user' });
expect(output.join('\n')).toContain('No projects found');
});
it('shows project table', async () => {
vi.mocked(client.get).mockResolvedValue([
{ id: 'proj-1', name: 'dev', description: 'Dev project', ownerId: 'user-1', createdAt: '2025-01-01' },
]);
const cmd = createProjectCommand({ client, log });
await cmd.parseAsync(['list'], { from: 'user' });
expect(output.join('\n')).toContain('proj-1');
expect(output.join('\n')).toContain('dev');
});
it('outputs json', async () => {
vi.mocked(client.get).mockResolvedValue([{ id: 'proj-1', name: 'dev' }]);
const cmd = createProjectCommand({ client, log });
await cmd.parseAsync(['list', '-o', 'json'], { from: 'user' });
expect(output[0]).toContain('"id"');
});
});
describe('create', () => {
it('creates a project', async () => {
const cmd = createProjectCommand({ client, log });
await cmd.parseAsync(['create', 'my-project', '-d', 'A test project'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/projects', {
name: 'my-project',
description: 'A test project',
});
expect(output.join('\n')).toContain("Project 'my-project' created");
});
});
describe('delete', () => {
it('deletes a project', async () => {
const cmd = createProjectCommand({ client, log });
await cmd.parseAsync(['delete', 'proj-1'], { from: 'user' });
expect(client.delete).toHaveBeenCalledWith('/api/v1/projects/proj-1');
expect(output.join('\n')).toContain('deleted');
});
});
describe('show', () => {
it('shows project details', async () => {
vi.mocked(client.get).mockImplementation(async (url: string) => {
if (url.endsWith('/profiles')) return [];
return { id: 'proj-1', name: 'dev', description: 'Dev project', ownerId: 'user-1', createdAt: '2025-01-01' };
});
const cmd = createProjectCommand({ client, log });
await cmd.parseAsync(['show', 'proj-1'], { from: 'user' });
expect(output.join('\n')).toContain('Name: dev');
expect(output.join('\n')).toContain('ID: proj-1');
});
});
describe('profiles', () => { describe('profiles', () => {
it('lists profiles for a project', async () => { it('lists profiles for a project', async () => {
vi.mocked(client.get).mockResolvedValue([ vi.mocked(client.get).mockResolvedValue([

View File

@@ -16,26 +16,22 @@ describe('CLI command registration (e2e)', () => {
expect(commandNames).toContain('logout'); expect(commandNames).toContain('logout');
expect(commandNames).toContain('get'); expect(commandNames).toContain('get');
expect(commandNames).toContain('describe'); expect(commandNames).toContain('describe');
expect(commandNames).toContain('instance'); expect(commandNames).toContain('delete');
expect(commandNames).toContain('logs');
expect(commandNames).toContain('apply'); expect(commandNames).toContain('apply');
expect(commandNames).toContain('create');
expect(commandNames).toContain('edit');
expect(commandNames).toContain('setup'); expect(commandNames).toContain('setup');
expect(commandNames).toContain('claude'); expect(commandNames).toContain('claude');
expect(commandNames).toContain('project'); expect(commandNames).toContain('project');
expect(commandNames).toContain('backup');
expect(commandNames).toContain('restore');
}); });
it('instance command has lifecycle subcommands', () => { it('instance command is removed (use get/delete/logs instead)', () => {
const program = createProgram(); const program = createProgram();
const instance = program.commands.find((c) => c.name() === 'instance'); const commandNames = program.commands.map((c) => c.name());
expect(instance).toBeDefined(); expect(commandNames).not.toContain('instance');
const subcommands = instance!.commands.map((c) => c.name());
expect(subcommands).toContain('list');
expect(subcommands).toContain('start');
expect(subcommands).toContain('stop');
expect(subcommands).toContain('restart');
expect(subcommands).toContain('remove');
expect(subcommands).toContain('logs');
expect(subcommands).toContain('inspect');
}); });
it('claude command has config management subcommands', () => { it('claude command has config management subcommands', () => {
@@ -50,18 +46,19 @@ describe('CLI command registration (e2e)', () => {
expect(subcommands).toContain('remove'); expect(subcommands).toContain('remove');
}); });
it('project command has CRUD subcommands', () => { it('project command has action subcommands only', () => {
const program = createProgram(); const program = createProgram();
const project = program.commands.find((c) => c.name() === 'project'); const project = program.commands.find((c) => c.name() === 'project');
expect(project).toBeDefined(); expect(project).toBeDefined();
const subcommands = project!.commands.map((c) => c.name()); const subcommands = project!.commands.map((c) => c.name());
expect(subcommands).toContain('list');
expect(subcommands).toContain('create');
expect(subcommands).toContain('delete');
expect(subcommands).toContain('show');
expect(subcommands).toContain('profiles'); expect(subcommands).toContain('profiles');
expect(subcommands).toContain('set-profiles'); expect(subcommands).toContain('set-profiles');
// create is now top-level (mcpctl create project)
expect(subcommands).not.toContain('create');
expect(subcommands).not.toContain('list');
expect(subcommands).not.toContain('show');
expect(subcommands).not.toContain('delete');
}); });
it('displays version', () => { it('displays version', () => {

View File

@@ -0,0 +1,204 @@
-- CreateEnum
CREATE TYPE "Role" AS ENUM ('USER', 'ADMIN');
-- CreateEnum
CREATE TYPE "Transport" AS ENUM ('STDIO', 'SSE', 'STREAMABLE_HTTP');
-- CreateEnum
CREATE TYPE "InstanceStatus" AS ENUM ('STARTING', 'RUNNING', 'STOPPING', 'STOPPED', 'ERROR');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"name" TEXT,
"passwordHash" TEXT NOT NULL,
"role" "Role" NOT NULL DEFAULT 'USER',
"version" INTEGER NOT NULL DEFAULT 1,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"token" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "McpServer" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT NOT NULL DEFAULT '',
"packageName" TEXT,
"dockerImage" TEXT,
"transport" "Transport" NOT NULL DEFAULT 'STDIO',
"repositoryUrl" TEXT,
"externalUrl" TEXT,
"command" JSONB,
"containerPort" INTEGER,
"envTemplate" JSONB NOT NULL DEFAULT '[]',
"version" INTEGER NOT NULL DEFAULT 1,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "McpServer_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "McpProfile" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"serverId" TEXT NOT NULL,
"permissions" JSONB NOT NULL DEFAULT '[]',
"envOverrides" JSONB NOT NULL DEFAULT '{}',
"version" INTEGER NOT NULL DEFAULT 1,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "McpProfile_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Project" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT NOT NULL DEFAULT '',
"ownerId" TEXT NOT NULL,
"version" INTEGER NOT NULL DEFAULT 1,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Project_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ProjectMcpProfile" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"profileId" TEXT NOT NULL,
CONSTRAINT "ProjectMcpProfile_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "McpInstance" (
"id" TEXT NOT NULL,
"serverId" TEXT NOT NULL,
"containerId" TEXT,
"status" "InstanceStatus" NOT NULL DEFAULT 'STOPPED',
"port" INTEGER,
"metadata" JSONB NOT NULL DEFAULT '{}',
"version" INTEGER NOT NULL DEFAULT 1,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "McpInstance_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AuditLog" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"action" TEXT NOT NULL,
"resource" TEXT NOT NULL,
"resourceId" TEXT,
"details" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE INDEX "User_email_idx" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Session_token_key" ON "Session"("token");
-- CreateIndex
CREATE INDEX "Session_token_idx" ON "Session"("token");
-- CreateIndex
CREATE INDEX "Session_userId_idx" ON "Session"("userId");
-- CreateIndex
CREATE INDEX "Session_expiresAt_idx" ON "Session"("expiresAt");
-- CreateIndex
CREATE UNIQUE INDEX "McpServer_name_key" ON "McpServer"("name");
-- CreateIndex
CREATE INDEX "McpServer_name_idx" ON "McpServer"("name");
-- CreateIndex
CREATE INDEX "McpProfile_serverId_idx" ON "McpProfile"("serverId");
-- CreateIndex
CREATE UNIQUE INDEX "McpProfile_name_serverId_key" ON "McpProfile"("name", "serverId");
-- CreateIndex
CREATE UNIQUE INDEX "Project_name_key" ON "Project"("name");
-- CreateIndex
CREATE INDEX "Project_name_idx" ON "Project"("name");
-- CreateIndex
CREATE INDEX "Project_ownerId_idx" ON "Project"("ownerId");
-- CreateIndex
CREATE INDEX "ProjectMcpProfile_projectId_idx" ON "ProjectMcpProfile"("projectId");
-- CreateIndex
CREATE INDEX "ProjectMcpProfile_profileId_idx" ON "ProjectMcpProfile"("profileId");
-- CreateIndex
CREATE UNIQUE INDEX "ProjectMcpProfile_projectId_profileId_key" ON "ProjectMcpProfile"("projectId", "profileId");
-- CreateIndex
CREATE INDEX "McpInstance_serverId_idx" ON "McpInstance"("serverId");
-- CreateIndex
CREATE INDEX "McpInstance_status_idx" ON "McpInstance"("status");
-- CreateIndex
CREATE INDEX "AuditLog_userId_idx" ON "AuditLog"("userId");
-- CreateIndex
CREATE INDEX "AuditLog_action_idx" ON "AuditLog"("action");
-- CreateIndex
CREATE INDEX "AuditLog_resource_idx" ON "AuditLog"("resource");
-- CreateIndex
CREATE INDEX "AuditLog_createdAt_idx" ON "AuditLog"("createdAt");
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "McpProfile" ADD CONSTRAINT "McpProfile_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "McpServer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Project" ADD CONSTRAINT "Project_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProjectMcpProfile" ADD CONSTRAINT "ProjectMcpProfile_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProjectMcpProfile" ADD CONSTRAINT "ProjectMcpProfile_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "McpProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "McpInstance" ADD CONSTRAINT "McpInstance_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "McpServer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@@ -57,6 +57,10 @@ model McpServer {
dockerImage String? dockerImage String?
transport Transport @default(STDIO) transport Transport @default(STDIO)
repositoryUrl String? repositoryUrl String?
externalUrl String?
command Json?
containerPort Int?
replicas Int @default(1)
envTemplate Json @default("[]") envTemplate Json @default("[]")
version Int @default(1) version Int @default(1)
createdAt DateTime @default(now()) createdAt DateTime @default(now())

View File

@@ -60,8 +60,9 @@ async function main(): Promise<void> {
// Services // Services
const serverService = new McpServerService(serverRepo); const serverService = new McpServerService(serverRepo);
const profileService = new McpProfileService(profileRepo, serverRepo);
const instanceService = new InstanceService(instanceRepo, serverRepo, orchestrator); const instanceService = new InstanceService(instanceRepo, serverRepo, orchestrator);
serverService.setInstanceService(instanceService);
const profileService = new McpProfileService(profileRepo, serverRepo);
const projectService = new ProjectService(projectRepo, profileRepo, serverRepo); const projectService = new ProjectService(projectRepo, profileRepo, serverRepo);
const auditLogService = new AuditLogService(auditLogRepo); const auditLogService = new AuditLogService(auditLogRepo);
const metricsCollector = new MetricsCollector(); const metricsCollector = new MetricsCollector();
@@ -69,7 +70,7 @@ async function main(): Promise<void> {
const backupService = new BackupService(serverRepo, profileRepo, projectRepo); const backupService = new BackupService(serverRepo, profileRepo, projectRepo);
const restoreService = new RestoreService(serverRepo, profileRepo, projectRepo); const restoreService = new RestoreService(serverRepo, profileRepo, projectRepo);
const authService = new AuthService(prisma); const authService = new AuthService(prisma);
const mcpProxyService = new McpProxyService(instanceRepo); const mcpProxyService = new McpProxyService(instanceRepo, serverRepo);
// Server // Server
const app = await createServer(config, { const app = await createServer(config, {
@@ -86,7 +87,7 @@ async function main(): Promise<void> {
}); });
// Routes // Routes
registerMcpServerRoutes(app, serverService); registerMcpServerRoutes(app, serverService, instanceService);
registerMcpProfileRoutes(app, profileService); registerMcpProfileRoutes(app, profileService);
registerInstanceRoutes(app, instanceService); registerInstanceRoutes(app, instanceService);
registerProjectRoutes(app, projectService); registerProjectRoutes(app, projectService);

View File

@@ -1,4 +1,4 @@
import type { PrismaClient, McpServer } from '@prisma/client'; import { type PrismaClient, type McpServer, Prisma } from '@prisma/client';
import type { IMcpServerRepository } from './interfaces.js'; import type { IMcpServerRepository } from './interfaces.js';
import type { CreateMcpServerInput, UpdateMcpServerInput } from '../validation/mcp-server.schema.js'; import type { CreateMcpServerInput, UpdateMcpServerInput } from '../validation/mcp-server.schema.js';
@@ -26,6 +26,10 @@ export class McpServerRepository implements IMcpServerRepository {
dockerImage: data.dockerImage ?? null, dockerImage: data.dockerImage ?? null,
transport: data.transport, transport: data.transport,
repositoryUrl: data.repositoryUrl ?? null, repositoryUrl: data.repositoryUrl ?? null,
externalUrl: data.externalUrl ?? null,
command: data.command ?? Prisma.DbNull,
containerPort: data.containerPort ?? null,
replicas: data.replicas,
envTemplate: data.envTemplate, envTemplate: data.envTemplate,
}, },
}); });
@@ -38,6 +42,10 @@ export class McpServerRepository implements IMcpServerRepository {
if (data.dockerImage !== undefined) updateData['dockerImage'] = data.dockerImage; if (data.dockerImage !== undefined) updateData['dockerImage'] = data.dockerImage;
if (data.transport !== undefined) updateData['transport'] = data.transport; if (data.transport !== undefined) updateData['transport'] = data.transport;
if (data.repositoryUrl !== undefined) updateData['repositoryUrl'] = data.repositoryUrl; if (data.repositoryUrl !== undefined) updateData['repositoryUrl'] = data.repositoryUrl;
if (data.externalUrl !== undefined) updateData['externalUrl'] = data.externalUrl;
if (data.command !== undefined) updateData['command'] = data.command;
if (data.containerPort !== undefined) updateData['containerPort'] = data.containerPort;
if (data.replicas !== undefined) updateData['replicas'] = data.replicas;
if (data.envTemplate !== undefined) updateData['envTemplate'] = data.envTemplate; if (data.envTemplate !== undefined) updateData['envTemplate'] = data.envTemplate;
return this.prisma.mcpServer.update({ where: { id }, data: updateData }); return this.prisma.mcpServer.update({ where: { id }, data: updateData });

View File

@@ -10,40 +10,17 @@ export function registerInstanceRoutes(app: FastifyInstance, service: InstanceSe
return service.getById(request.params.id); return service.getById(request.params.id);
}); });
app.post<{ Body: { serverId: string; env?: Record<string, string>; hostPort?: number } }>( app.delete<{ Params: { id: string } }>('/api/v1/instances/:id', async (request, reply) => {
'/api/v1/instances', const { serverId } = await service.remove(request.params.id);
async (request, reply) => { // Reconcile: server will auto-create a replacement if replicas > 0
const { serverId } = request.body; await service.reconcile(serverId);
const opts: { env?: Record<string, string>; hostPort?: number } = {}; reply.code(204);
if (request.body.env) {
opts.env = request.body.env;
}
if (request.body.hostPort !== undefined) {
opts.hostPort = request.body.hostPort;
}
const instance = await service.start(serverId, opts);
reply.code(201);
return instance;
},
);
app.post<{ Params: { id: string } }>('/api/v1/instances/:id/stop', async (request) => {
return service.stop(request.params.id);
});
app.post<{ Params: { id: string } }>('/api/v1/instances/:id/restart', async (request) => {
return service.restart(request.params.id);
}); });
app.get<{ Params: { id: string } }>('/api/v1/instances/:id/inspect', async (request) => { app.get<{ Params: { id: string } }>('/api/v1/instances/:id/inspect', async (request) => {
return service.inspect(request.params.id); return service.inspect(request.params.id);
}); });
app.delete<{ Params: { id: string } }>('/api/v1/instances/:id', async (request, reply) => {
await service.remove(request.params.id);
reply.code(204);
});
app.get<{ Params: { id: string }; Querystring: { tail?: string } }>( app.get<{ Params: { id: string }; Querystring: { tail?: string } }>(
'/api/v1/instances/:id/logs', '/api/v1/instances/:id/logs',
async (request) => { async (request) => {

View File

@@ -1,7 +1,12 @@
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import type { McpServerService } from '../services/mcp-server.service.js'; import type { McpServerService } from '../services/mcp-server.service.js';
import type { InstanceService } from '../services/instance.service.js';
export function registerMcpServerRoutes(app: FastifyInstance, service: McpServerService): void { export function registerMcpServerRoutes(
app: FastifyInstance,
service: McpServerService,
instanceService: InstanceService,
): void {
app.get('/api/v1/servers', async () => { app.get('/api/v1/servers', async () => {
return service.list(); return service.list();
}); });
@@ -12,12 +17,17 @@ export function registerMcpServerRoutes(app: FastifyInstance, service: McpServer
app.post('/api/v1/servers', async (request, reply) => { app.post('/api/v1/servers', async (request, reply) => {
const server = await service.create(request.body); const server = await service.create(request.body);
// Auto-reconcile: create instances to match replicas
await instanceService.reconcile(server.id);
reply.code(201); reply.code(201);
return server; return server;
}); });
app.put<{ Params: { id: string } }>('/api/v1/servers/:id', async (request) => { app.put<{ Params: { id: string } }>('/api/v1/servers/:id', async (request) => {
return service.update(request.params.id, request.body); const server = await service.update(request.params.id, request.body);
// Re-reconcile after update (replicas may have changed)
await instanceService.reconcile(server.id);
return server;
}); });
app.delete<{ Params: { id: string } }>('/api/v1/servers/:id', async (request, reply) => { app.delete<{ Params: { id: string } }>('/api/v1/servers/:id', async (request, reply) => {

View File

@@ -114,6 +114,7 @@ export class RestoreService {
name: server.name, name: server.name,
description: server.description, description: server.description,
transport: server.transport as 'STDIO' | 'SSE' | 'STREAMABLE_HTTP', transport: server.transport as 'STDIO' | 'SSE' | 'STREAMABLE_HTTP',
replicas: (server as { replicas?: number }).replicas ?? 1,
envTemplate: (server.envTemplate ?? []) as Array<{ name: string; description: string; isSecret: boolean }>, envTemplate: (server.envTemplate ?? []) as Array<{ name: string; description: string; isSecret: boolean }>,
}; };
if (server.packageName) createData.packageName = server.packageName; if (server.packageName) createData.packageName = server.packageName;

View File

@@ -74,7 +74,7 @@ export class DockerContainerManager implements McpOrchestrator {
? Object.entries(spec.env).map(([k, v]) => `${k}=${v}`) ? Object.entries(spec.env).map(([k, v]) => `${k}=${v}`)
: undefined; : undefined;
const container = await this.docker.createContainer({ const createOpts: Docker.ContainerCreateOptions = {
Image: spec.image, Image: spec.image,
name: spec.name, name: spec.name,
Env: envArr, Env: envArr,
@@ -86,7 +86,12 @@ export class DockerContainerManager implements McpOrchestrator {
NanoCpus: nanoCpus, NanoCpus: nanoCpus,
NetworkMode: spec.network ?? 'bridge', NetworkMode: spec.network ?? 'bridge',
}, },
}); };
if (spec.command) {
createOpts.Cmd = spec.command;
}
const container = await this.docker.createContainer(createOpts);
await container.start(); await container.start();

View File

@@ -28,81 +28,46 @@ export class InstanceService {
return instance; 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); const server = await this.serverRepo.findById(serverId);
if (!server) throw new NotFoundError(`McpServer '${serverId}' not found`); if (!server) throw new NotFoundError(`McpServer '${serverId}' not found`);
const image = server.dockerImage ?? server.packageName ?? server.name; const instances = await this.instanceRepo.findAll(serverId);
const active = instances.filter((i) => i.status === 'RUNNING' || i.status === 'STARTING');
const desired = server.replicas;
// Create DB record first in STARTING state if (active.length < desired) {
let instance = await this.instanceRepo.create({ // Scale up
serverId, const toStart = desired - active.length;
status: 'STARTING', for (let i = 0; i < toStart; i++) {
}); await this.startOne(serverId);
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) { } else if (active.length > desired) {
spec.env = opts.env; // Scale down — remove oldest first
} const excess = active
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
const containerInfo = await this.orchestrator.createContainer(spec); .slice(0, active.length - desired);
for (const inst of excess) {
const updateFields: { containerId: string; port?: number } = { await this.removeOne(inst);
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.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> { 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); const instance = await this.getById(id);
// Stop if running if (instance.containerId) {
if (instance.containerId && (instance.status === 'RUNNING' || instance.status === 'STARTING')) {
try { try {
await this.orchestrator.stopContainer(instance.containerId); await this.orchestrator.stopContainer(instance.containerId);
} catch { } catch {
@@ -116,9 +81,29 @@ export class InstanceService {
} }
await this.instanceRepo.delete(id); await this.instanceRepo.delete(id);
return { serverId: instance.serverId };
}
// Start a fresh instance for the same server /**
return this.start(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> { async inspect(id: string): Promise<ContainerInfo> {
@@ -129,20 +114,6 @@ export class InstanceService {
return this.orchestrator.inspectContainer(instance.containerId); return this.orchestrator.inspectContainer(instance.containerId);
} }
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 }> { async getLogs(id: string, opts?: { tail?: number }): Promise<{ stdout: string; stderr: string }> {
const instance = await this.getById(id); const instance = await this.getById(id);
if (!instance.containerId) { if (!instance.containerId) {
@@ -151,4 +122,75 @@ export class InstanceService {
return this.orchestrator.getContainerLogs(instance.containerId, opts); 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`);
// External servers don't need container management
if (server.externalUrl) {
return this.instanceRepo.create({
serverId,
status: 'RUNNING',
metadata: { external: true, url: server.externalUrl },
});
}
const image = server.dockerImage ?? server.packageName ?? server.name;
let instance = await this.instanceRepo.create({
serverId,
status: 'STARTING',
});
try {
const spec: ContainerSpec = {
image,
name: `mcpctl-${server.name}-${instance.id}`,
hostPort: null,
labels: {
'mcpctl.server-id': serverId,
'mcpctl.instance-id': instance.id,
},
};
if (server.transport === 'SSE' || server.transport === 'STREAMABLE_HTTP') {
spec.containerPort = server.containerPort ?? 3000;
}
const command = server.command as string[] | null;
if (command) {
spec.command = command;
}
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) {
instance = await this.instanceRepo.updateStatus(instance.id, 'ERROR', {
metadata: { error: err instanceof Error ? err.message : String(err) },
});
}
return instance;
}
/** 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 { /* best-effort */ }
}
await this.instanceRepo.delete(instance.id);
}
} }

View File

@@ -1,5 +1,5 @@
import type { McpInstance } from '@prisma/client'; import type { McpInstance } from '@prisma/client';
import type { IMcpInstanceRepository } from '../repositories/interfaces.js'; import type { IMcpInstanceRepository, IMcpServerRepository } from '../repositories/interfaces.js';
import { NotFoundError } from './mcp-server.service.js'; import { NotFoundError } from './mcp-server.service.js';
import { InvalidStateError } from './instance.service.js'; import { InvalidStateError } from './instance.service.js';
@@ -16,11 +16,39 @@ export interface McpProxyResponse {
error?: { code: number; message: string; data?: unknown }; error?: { code: number; message: string; data?: unknown };
} }
/**
* Parses a streamable-http SSE response body to extract the JSON-RPC payload.
* Streamable-http returns `event: message\ndata: {...}\n\n` format.
*/
function parseStreamableResponse(body: string): McpProxyResponse {
for (const line of body.split('\n')) {
const trimmed = line.trim();
if (trimmed.startsWith('data: ')) {
return JSON.parse(trimmed.slice(6)) as McpProxyResponse;
}
}
// If body is plain JSON (no SSE framing), parse directly
return JSON.parse(body) as McpProxyResponse;
}
export class McpProxyService { export class McpProxyService {
constructor(private readonly instanceRepo: IMcpInstanceRepository) {} /** Session IDs per server for streamable-http protocol */
private sessions = new Map<string, string>();
constructor(
private readonly instanceRepo: IMcpInstanceRepository,
private readonly serverRepo: IMcpServerRepository,
) {}
async execute(request: McpProxyRequest): Promise<McpProxyResponse> { async execute(request: McpProxyRequest): Promise<McpProxyResponse> {
// Find a running instance for this server const server = await this.serverRepo.findById(request.serverId);
// External server: proxy directly to externalUrl
if (server?.externalUrl) {
return this.sendToExternal(server.id, server.externalUrl, request.method, request.params);
}
// Managed server: find running instance
const instances = await this.instanceRepo.findAll(request.serverId); const instances = await this.instanceRepo.findAll(request.serverId);
const running = instances.find((i) => i.status === 'RUNNING'); const running = instances.find((i) => i.status === 'RUNNING');
@@ -37,6 +65,116 @@ export class McpProxyService {
return this.sendJsonRpc(running, request.method, request.params); return this.sendJsonRpc(running, request.method, request.params);
} }
/**
* Send a JSON-RPC request to an external MCP server.
* Handles streamable-http protocol (session management + SSE response parsing).
*/
private async sendToExternal(
serverId: string,
url: string,
method: string,
params?: Record<string, unknown>,
): Promise<McpProxyResponse> {
// Ensure we have a session (initialize on first call)
if (!this.sessions.has(serverId)) {
await this.initSession(serverId, url);
}
const sessionId = this.sessions.get(serverId);
const body: Record<string, unknown> = {
jsonrpc: '2.0',
id: 1,
method,
};
if (params !== undefined) {
body.params = params;
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Accept': 'application/json, text/event-stream',
};
if (sessionId) {
headers['Mcp-Session-Id'] = sessionId;
}
const response = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(body),
});
if (!response.ok) {
// Session expired? Clear and retry once
if (response.status === 400 || response.status === 404) {
this.sessions.delete(serverId);
return this.sendToExternal(serverId, url, method, params);
}
return {
jsonrpc: '2.0',
id: 1,
error: {
code: -32000,
message: `External MCP server returned HTTP ${response.status}: ${response.statusText}`,
},
};
}
const text = await response.text();
return parseStreamableResponse(text);
}
/**
* Initialize a streamable-http session with an external server.
* Sends `initialize` and `notifications/initialized`, caches the session ID.
*/
private async initSession(serverId: string, url: string): Promise<void> {
const initBody = {
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2025-03-26',
capabilities: {},
clientInfo: { name: 'mcpctl', version: '0.1.0' },
},
};
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json, text/event-stream',
},
body: JSON.stringify(initBody),
});
if (!response.ok) {
throw new Error(`Failed to initialize session: HTTP ${response.status}`);
}
const sessionId = response.headers.get('mcp-session-id');
if (sessionId) {
this.sessions.set(serverId, sessionId);
}
// Send notifications/initialized
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Accept': 'application/json, text/event-stream',
};
if (sessionId) {
headers['Mcp-Session-Id'] = sessionId;
}
await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }),
});
}
private async sendJsonRpc( private async sendJsonRpc(
instance: McpInstance, instance: McpInstance,
method: string, method: string,

View File

@@ -1,10 +1,18 @@
import type { McpServer } from '@prisma/client'; import type { McpServer } from '@prisma/client';
import type { IMcpServerRepository } from '../repositories/interfaces.js'; import type { IMcpServerRepository } from '../repositories/interfaces.js';
import type { InstanceService } from './instance.service.js';
import { CreateMcpServerSchema, UpdateMcpServerSchema } from '../validation/mcp-server.schema.js'; import { CreateMcpServerSchema, UpdateMcpServerSchema } from '../validation/mcp-server.schema.js';
export class McpServerService { export class McpServerService {
private instanceService: InstanceService | null = null;
constructor(private readonly repo: IMcpServerRepository) {} constructor(private readonly repo: IMcpServerRepository) {}
/** Set after construction to avoid circular dependency. */
setInstanceService(instanceService: InstanceService): void {
this.instanceService = instanceService;
}
async list(): Promise<McpServer[]> { async list(): Promise<McpServer[]> {
return this.repo.findAll(); return this.repo.findAll();
} }
@@ -48,6 +56,10 @@ export class McpServerService {
async delete(id: string): Promise<void> { async delete(id: string): Promise<void> {
// Verify exists // Verify exists
await this.getById(id); await this.getById(id);
// Stop all containers before DB cascade
if (this.instanceService) {
await this.instanceService.removeAllForServer(id);
}
await this.repo.delete(id); await this.repo.delete(id);
} }
} }

View File

@@ -7,6 +7,8 @@ export interface ContainerSpec {
image: string; image: string;
/** Human-readable name (used as container name prefix) */ /** Human-readable name (used as container name prefix) */
name: string; name: string;
/** Custom command to run (overrides image CMD) */
command?: string[];
/** Environment variables */ /** Environment variables */
env?: Record<string, string>; env?: Record<string, string>;
/** Host port to bind (null = auto-assign) */ /** Host port to bind (null = auto-assign) */

View File

@@ -14,6 +14,10 @@ export const CreateMcpServerSchema = z.object({
dockerImage: z.string().max(200).optional(), dockerImage: z.string().max(200).optional(),
transport: z.enum(['STDIO', 'SSE', 'STREAMABLE_HTTP']).default('STDIO'), transport: z.enum(['STDIO', 'SSE', 'STREAMABLE_HTTP']).default('STDIO'),
repositoryUrl: z.string().url().optional(), repositoryUrl: z.string().url().optional(),
externalUrl: z.string().url().optional(),
command: z.array(z.string()).optional(),
containerPort: z.number().int().min(1).max(65535).optional(),
replicas: z.number().int().min(0).max(10).default(1),
envTemplate: z.array(EnvTemplateEntrySchema).default([]), envTemplate: z.array(EnvTemplateEntrySchema).default([]),
}); });
@@ -23,6 +27,10 @@ export const UpdateMcpServerSchema = z.object({
dockerImage: z.string().max(200).nullable().optional(), dockerImage: z.string().max(200).nullable().optional(),
transport: z.enum(['STDIO', 'SSE', 'STREAMABLE_HTTP']).optional(), transport: z.enum(['STDIO', 'SSE', 'STREAMABLE_HTTP']).optional(),
repositoryUrl: z.string().url().nullable().optional(), repositoryUrl: z.string().url().nullable().optional(),
externalUrl: z.string().url().nullable().optional(),
command: z.array(z.string()).nullable().optional(),
containerPort: z.number().int().min(1).max(65535).nullable().optional(),
replicas: z.number().int().min(0).max(10).optional(),
envTemplate: z.array(EnvTemplateEntrySchema).optional(), envTemplate: z.array(EnvTemplateEntrySchema).optional(),
}); });

View File

@@ -3,6 +3,7 @@ import { InstanceService, InvalidStateError } from '../src/services/instance.ser
import { NotFoundError } from '../src/services/mcp-server.service.js'; import { NotFoundError } from '../src/services/mcp-server.service.js';
import type { IMcpInstanceRepository, IMcpServerRepository } from '../src/repositories/interfaces.js'; import type { IMcpInstanceRepository, IMcpServerRepository } from '../src/repositories/interfaces.js';
import type { McpOrchestrator } from '../src/services/orchestrator.js'; import type { McpOrchestrator } from '../src/services/orchestrator.js';
import type { McpInstance } from '@prisma/client';
function mockInstanceRepo(): IMcpInstanceRepository { function mockInstanceRepo(): IMcpInstanceRepository {
return { 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', () => { describe('InstanceService', () => {
let instanceRepo: ReturnType<typeof mockInstanceRepo>; let instanceRepo: ReturnType<typeof mockInstanceRepo>;
let serverRepo: ReturnType<typeof mockServerRepo>; let serverRepo: ReturnType<typeof mockServerRepo>;
@@ -101,199 +137,98 @@ describe('InstanceService', () => {
}); });
it('returns instance when found', async () => { 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'); const result = await service.getById('inst-1');
expect(result.id).toBe('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 () => { it('throws NotFoundError for unknown server', async () => {
await expect(service.start('missing')).rejects.toThrow(NotFoundError); await expect(service.reconcile('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);
}); });
}); });
describe('remove', () => { describe('remove', () => {
it('removes container and DB record', async () => { it('stops container and deletes DB record', async () => {
vi.mocked(instanceRepo.findById).mockResolvedValue({ vi.mocked(instanceRepo.findById).mockResolvedValue(makeInstance({ containerId: 'ctr-abc' }));
id: 'inst-1', containerId: 'ctr-abc', status: 'STOPPED',
serverId: 'srv-1', port: null, metadata: {}, const result = await service.remove('inst-1');
version: 1, createdAt: new Date(), updatedAt: new Date(),
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'); await service.remove('inst-1');
expect(orchestrator.removeContainer).toHaveBeenCalledWith('ctr-abc', true); expect(orchestrator.stopContainer).not.toHaveBeenCalled();
expect(instanceRepo.delete).toHaveBeenCalledWith('inst-1'); expect(instanceRepo.delete).toHaveBeenCalledWith('inst-1');
}); });
it('removes DB record even if container is already gone', async () => { it('deletes DB record even if container is already gone', async () => {
vi.mocked(instanceRepo.findById).mockResolvedValue({ vi.mocked(instanceRepo.findById).mockResolvedValue(makeInstance({ containerId: 'ctr-abc' }));
id: 'inst-1', containerId: 'ctr-abc', status: 'STOPPED',
serverId: 'srv-1', port: null, metadata: {},
version: 1, createdAt: new Date(), updatedAt: new Date(),
});
vi.mocked(orchestrator.removeContainer).mockRejectedValue(new Error('No such container')); vi.mocked(orchestrator.removeContainer).mockRejectedValue(new Error('No such container'));
await service.remove('inst-1'); 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', () => { describe('getLogs', () => {
it('returns empty logs for instance without container', async () => { it('returns empty logs for instance without container', async () => {
vi.mocked(instanceRepo.findById).mockResolvedValue({ vi.mocked(instanceRepo.findById).mockResolvedValue(makeInstance({ containerId: null }));
id: 'inst-1', containerId: null, status: 'ERROR',
serverId: 'srv-1', port: null, metadata: {},
version: 1, createdAt: new Date(), updatedAt: new Date(),
});
const result = await service.getLogs('inst-1'); const result = await service.getLogs('inst-1');
expect(result).toEqual({ stdout: '', stderr: '' }); expect(result).toEqual({ stdout: '', stderr: '' });
}); });
it('returns container logs', async () => { it('returns container logs', async () => {
vi.mocked(instanceRepo.findById).mockResolvedValue({ vi.mocked(instanceRepo.findById).mockResolvedValue(makeInstance({ containerId: 'ctr-abc' }));
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.getLogs('inst-1', { tail: 50 }); const result = await service.getLogs('inst-1', { tail: 50 });

View File

@@ -0,0 +1,727 @@
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest';
import Fastify from 'fastify';
import type { FastifyInstance } from 'fastify';
import http from 'node:http';
import { McpServerService } from '../src/services/mcp-server.service.js';
import { InstanceService } from '../src/services/instance.service.js';
import { McpProxyService } from '../src/services/mcp-proxy-service.js';
import { AuditLogService } from '../src/services/audit-log.service.js';
import { errorHandler } from '../src/middleware/error-handler.js';
import { registerMcpServerRoutes } from '../src/routes/mcp-servers.js';
import { registerInstanceRoutes } from '../src/routes/instances.js';
import { registerMcpProxyRoutes } from '../src/routes/mcp-proxy.js';
import type {
IMcpServerRepository,
IMcpInstanceRepository,
IAuditLogRepository,
} from '../src/repositories/interfaces.js';
import type { McpOrchestrator } from '../src/services/orchestrator.js';
import type { McpServer, McpInstance, InstanceStatus } from '@prisma/client';
// ---------------------------------------------------------------------------
// In-memory repository implementations (stateful mocks)
// ---------------------------------------------------------------------------
function createInMemoryServerRepo(): IMcpServerRepository {
const servers = new Map<string, McpServer>();
let nextId = 1;
return {
findAll: vi.fn(async () => [...servers.values()]),
findById: vi.fn(async (id: string) => servers.get(id) ?? null),
findByName: vi.fn(async (name: string) => [...servers.values()].find((s) => s.name === name) ?? null),
create: vi.fn(async (data) => {
const id = `srv-${nextId++}`;
const server = {
id,
name: data.name,
description: data.description ?? '',
packageName: data.packageName ?? null,
dockerImage: data.dockerImage ?? null,
transport: data.transport ?? 'STDIO',
repositoryUrl: data.repositoryUrl ?? null,
externalUrl: data.externalUrl ?? null,
command: data.command ?? null,
containerPort: data.containerPort ?? null,
replicas: data.replicas ?? 1,
envTemplate: data.envTemplate ?? [],
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
} as McpServer;
servers.set(id, server);
return server;
}),
update: vi.fn(async (id: string, data) => {
const existing = servers.get(id);
if (!existing) throw new Error(`Server ${id} not found`);
const updated = { ...existing, ...data, updatedAt: new Date() } as McpServer;
servers.set(id, updated);
return updated;
}),
delete: vi.fn(async (id: string) => {
servers.delete(id);
}),
};
}
function createInMemoryInstanceRepo(): IMcpInstanceRepository {
const instances = new Map<string, McpInstance>();
let nextId = 1;
return {
findAll: vi.fn(async (serverId?: string) => {
const all = [...instances.values()];
return serverId ? all.filter((i) => i.serverId === serverId) : all;
}),
findById: vi.fn(async (id: string) => instances.get(id) ?? null),
findByContainerId: vi.fn(async (containerId: string) =>
[...instances.values()].find((i) => i.containerId === containerId) ?? null,
),
create: vi.fn(async (data) => {
const id = `inst-${nextId++}`;
const instance = {
id,
serverId: data.serverId,
containerId: data.containerId ?? null,
status: (data.status ?? 'STOPPED') as InstanceStatus,
port: data.port ?? null,
metadata: data.metadata ?? {},
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
} as McpInstance;
instances.set(id, instance);
return instance;
}),
updateStatus: vi.fn(async (id: string, status: InstanceStatus, fields?) => {
const existing = instances.get(id);
if (!existing) throw new Error(`Instance ${id} not found`);
const updated = {
...existing,
status,
...(fields?.containerId !== undefined ? { containerId: fields.containerId } : {}),
...(fields?.port !== undefined ? { port: fields.port } : {}),
...(fields?.metadata !== undefined ? { metadata: fields.metadata } : {}),
version: existing.version + 1,
updatedAt: new Date(),
} as McpInstance;
instances.set(id, updated);
return updated;
}),
delete: vi.fn(async (id: string) => {
instances.delete(id);
}),
};
}
function createInMemoryAuditLogRepo(): IAuditLogRepository {
const logs: Array<{ id: string; userId: string; action: string; resource: string; resourceId: string | null; details: Record<string, unknown>; createdAt: Date }> = [];
let nextId = 1;
return {
findAll: vi.fn(async () => logs as never[]),
findById: vi.fn(async (id: string) => (logs.find((l) => l.id === id) as never) ?? null),
create: vi.fn(async (data) => {
const log = {
id: `log-${nextId++}`,
userId: data.userId,
action: data.action,
resource: data.resource,
resourceId: data.resourceId ?? null,
details: data.details ?? {},
createdAt: new Date(),
};
logs.push(log);
return log as never;
}),
count: vi.fn(async () => logs.length),
deleteOlderThan: vi.fn(async () => 0),
};
}
function createMockOrchestrator(): McpOrchestrator {
let containerPort = 40000;
return {
ping: vi.fn(async () => true),
pullImage: vi.fn(async () => {}),
createContainer: vi.fn(async (spec) => ({
containerId: `ctr-${spec.name}`,
name: spec.name,
state: 'running' as const,
port: spec.containerPort ?? ++containerPort,
createdAt: new Date(),
})),
stopContainer: vi.fn(async () => {}),
removeContainer: vi.fn(async () => {}),
inspectContainer: vi.fn(async (id) => ({
containerId: id,
name: 'test',
state: 'running' as const,
createdAt: new Date(),
})),
getContainerLogs: vi.fn(async () => ({ stdout: '', stderr: '' })),
};
}
// ---------------------------------------------------------------------------
// Fake MCP server (streamable-http)
// ---------------------------------------------------------------------------
function createFakeMcpServer(): { server: http.Server; getPort: () => number; requests: Array<{ method: string; body: unknown }> } {
const requests: Array<{ method: string; body: unknown }> = [];
let sessionCounter = 0;
const server = http.createServer((req, res) => {
let body = '';
req.on('data', (chunk) => (body += chunk));
req.on('end', () => {
let parsed: { method?: string; id?: number; params?: unknown } = {};
try {
parsed = JSON.parse(body);
} catch {
// notifications may not have id
}
requests.push({ method: parsed.method ?? 'unknown', body: parsed });
if (parsed.method === 'initialize') {
const sessionId = `session-${++sessionCounter}`;
const response = {
jsonrpc: '2.0',
id: parsed.id,
result: {
protocolVersion: '2025-03-26',
capabilities: { tools: {} },
serverInfo: { name: 'fake-mcp', version: '1.0.0' },
},
};
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Mcp-Session-Id': sessionId,
});
res.end(`event: message\ndata: ${JSON.stringify(response)}\n\n`);
return;
}
if (parsed.method === 'notifications/initialized') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end('');
return;
}
if (parsed.method === 'tools/list') {
const response = {
jsonrpc: '2.0',
id: parsed.id,
result: {
tools: [
{ name: 'ha_get_overview', description: 'Get Home Assistant overview', inputSchema: { type: 'object', properties: {} } },
{ name: 'ha_search_entities', description: 'Search HA entities', inputSchema: { type: 'object', properties: { query: { type: 'string' } } } },
],
},
};
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
res.end(`event: message\ndata: ${JSON.stringify(response)}\n\n`);
return;
}
if (parsed.method === 'tools/call') {
const toolName = (parsed.params as { name?: string })?.name;
const response = {
jsonrpc: '2.0',
id: parsed.id,
result: {
content: [{ type: 'text', text: `Result from ${toolName}` }],
},
};
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
res.end(`event: message\ndata: ${JSON.stringify(response)}\n\n`);
return;
}
// Default: echo back
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ jsonrpc: '2.0', id: parsed.id, result: {} }));
});
});
let port = 0;
return {
server,
getPort: () => port,
requests,
...{
listen: () =>
new Promise<void>((resolve) => {
server.listen(0, () => {
const addr = server.address();
if (addr && typeof addr === 'object') port = addr.port;
resolve();
});
}),
close: () => new Promise<void>((resolve) => server.close(() => resolve())),
},
} as ReturnType<typeof createFakeMcpServer> & { listen: () => Promise<void>; close: () => Promise<void> };
}
// ---------------------------------------------------------------------------
// Test app builder
// ---------------------------------------------------------------------------
async function buildTestApp(deps: {
serverRepo: IMcpServerRepository;
instanceRepo: IMcpInstanceRepository;
auditLogRepo: IAuditLogRepository;
orchestrator: McpOrchestrator;
}): Promise<FastifyInstance> {
const app = Fastify({ logger: false });
app.setErrorHandler(errorHandler);
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, instanceService);
registerInstanceRoutes(app, instanceService);
registerMcpProxyRoutes(app, {
mcpProxyService: proxyService,
auditLogService,
authDeps: {
findSession: async () => ({ userId: 'test-user', expiresAt: new Date(Date.now() + 3600_000) }),
},
});
await app.ready();
return app;
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('MCP server full flow', () => {
let fakeMcp: ReturnType<typeof createFakeMcpServer> & { listen: () => Promise<void>; close: () => Promise<void> };
let fakeMcpPort: number;
beforeAll(async () => {
fakeMcp = createFakeMcpServer() as typeof fakeMcp;
await fakeMcp.listen();
fakeMcpPort = fakeMcp.getPort();
});
afterAll(async () => {
await fakeMcp.close();
});
describe('external server flow (externalUrl)', () => {
let app: FastifyInstance;
let serverRepo: IMcpServerRepository;
let instanceRepo: IMcpInstanceRepository;
beforeEach(async () => {
serverRepo = createInMemoryServerRepo();
instanceRepo = createInMemoryInstanceRepo();
app = await buildTestApp({
serverRepo,
instanceRepo,
auditLogRepo: createInMemoryAuditLogRepo(),
orchestrator: createMockOrchestrator(),
});
});
afterAll(async () => {
if (app) await app.close();
});
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',
payload: {
name: 'ha-mcp',
description: 'Home Assistant MCP',
transport: 'STREAMABLE_HTTP',
externalUrl: `http://localhost:${fakeMcpPort}`,
containerPort: 3000,
envTemplate: [
{ name: 'HOMEASSISTANT_TOKEN', description: 'HA token', isSecret: true },
],
},
});
expect(createRes.statusCode).toBe(201);
const server = createRes.json<{ id: string; name: string; externalUrl: string }>();
expect(server.name).toBe('ha-mcp');
expect(server.externalUrl).toBe(`http://localhost:${fakeMcpPort}`);
// 2. Verify server is listed
const listRes = await app.inject({ method: 'GET', url: '/api/v1/servers' });
expect(listRes.statusCode).toBe(200);
const servers = listRes.json<Array<{ name: string }>>();
expect(servers).toHaveLength(1);
expect(servers[0]!.name).toBe('ha-mcp');
// 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(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({
method: 'POST',
url: '/api/v1/mcp/proxy',
headers: { authorization: 'Bearer test-token' },
payload: {
serverId: server.id,
method: 'tools/list',
},
});
expect(proxyRes.statusCode).toBe(200);
const proxyBody = proxyRes.json<{ jsonrpc: string; result: { tools: Array<{ name: string }> } }>();
expect(proxyBody.jsonrpc).toBe('2.0');
expect(proxyBody.result.tools).toHaveLength(2);
expect(proxyBody.result.tools.map((t) => t.name)).toContain('ha_get_overview');
expect(proxyBody.result.tools.map((t) => t.name)).toContain('ha_search_entities');
// 5. Verify the fake server received the protocol handshake + tools/list
const methods = fakeMcp.requests.map((r) => r.method);
expect(methods).toContain('initialize');
expect(methods).toContain('notifications/initialized');
expect(methods).toContain('tools/list');
});
it('proxies tools/call with parameters', async () => {
// Register (auto-creates instance via reconcile)
const createRes = await app.inject({
method: 'POST',
url: '/api/v1/servers',
payload: {
name: 'ha-mcp-call',
description: 'HA MCP for call test',
transport: 'STREAMABLE_HTTP',
externalUrl: `http://localhost:${fakeMcpPort}`,
},
});
const server = createRes.json<{ id: string }>();
// Proxy tools/call (instance was auto-created)
const proxyRes = await app.inject({
method: 'POST',
url: '/api/v1/mcp/proxy',
headers: { authorization: 'Bearer test-token' },
payload: {
serverId: server.id,
method: 'tools/call',
params: { name: 'ha_get_overview' },
},
});
expect(proxyRes.statusCode).toBe(200);
const body = proxyRes.json<{ result: { content: Array<{ text: string }> } }>();
expect(body.result.content[0]!.text).toBe('Result from ha_get_overview');
});
});
describe('managed server flow (Docker)', () => {
let app: FastifyInstance;
let orchestrator: ReturnType<typeof createMockOrchestrator>;
beforeEach(async () => {
orchestrator = createMockOrchestrator();
app = await buildTestApp({
serverRepo: createInMemoryServerRepo(),
instanceRepo: createInMemoryInstanceRepo(),
auditLogRepo: createInMemoryAuditLogRepo(),
orchestrator,
});
});
afterAll(async () => {
if (app) await app.close();
});
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',
payload: {
name: 'ha-mcp-docker',
description: 'HA MCP managed by Docker',
dockerImage: 'ghcr.io/homeassistant-ai/ha-mcp:2.4',
transport: 'STREAMABLE_HTTP',
containerPort: 3000,
command: ['python', '-c', 'print("hello")'],
envTemplate: [
{ name: 'HOMEASSISTANT_URL', description: 'HA URL' },
{ name: 'HOMEASSISTANT_TOKEN', description: 'HA token', isSecret: true },
],
},
});
expect(createRes.statusCode).toBe(201);
const server = createRes.json<{ id: string; name: string; dockerImage: string; command: string[] }>();
expect(server.name).toBe('ha-mcp-docker');
expect(server.dockerImage).toBe('ghcr.io/homeassistant-ai/ha-mcp:2.4');
expect(server.command).toEqual(['python', '-c', 'print("hello")']);
// 2. Verify instance was auto-created with container
const instancesRes = await app.inject({
method: 'GET',
url: `/api/v1/instances?serverId=${server.id}`,
});
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);
const spec = vi.mocked(orchestrator.createContainer).mock.calls[0]![0];
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")']);
});
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',
payload: {
name: 'failing-server',
description: 'Will fail to start',
dockerImage: 'some-image:latest',
transport: 'STDIO',
},
});
expect(createRes.statusCode).toBe(201);
const server = createRes.json<{ id: string }>();
const instancesRes = await app.inject({
method: 'GET',
url: `/api/v1/instances?serverId=${server.id}`,
});
const instances = instancesRes.json<Array<{ id: string; status: string }>>();
expect(instances).toHaveLength(1);
expect(instances[0]!.status).toBe('ERROR');
});
});
describe('full lifecycle', () => {
let app: FastifyInstance;
let orchestrator: ReturnType<typeof createMockOrchestrator>;
beforeEach(async () => {
orchestrator = createMockOrchestrator();
app = await buildTestApp({
serverRepo: createInMemoryServerRepo(),
instanceRepo: createInMemoryInstanceRepo(),
auditLogRepo: createInMemoryAuditLogRepo(),
orchestrator,
});
});
afterAll(async () => {
if (app) await app.close();
});
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',
payload: {
name: 'lifecycle-test',
description: 'Full lifecycle',
dockerImage: 'test:latest',
transport: 'SSE',
containerPort: 8080,
},
});
expect(createRes.statusCode).toBe(201);
const server = createRes.json<{ id: string }>();
// 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; status: string }>>();
expect(instances).toHaveLength(1);
expect(instances[0]!.status).toBe('RUNNING');
const instanceId = instances[0]!.id;
// Delete instance → triggers reconcile → new instance auto-created
const removeRes = await app.inject({
method: 'DELETE',
url: `/api/v1/instances/${instanceId}`,
});
expect(removeRes.statusCode).toBe(204);
// Verify a replacement instance was created (reconcile)
const listAfter = await app.inject({
method: 'GET',
url: `/api/v1/instances?serverId=${server.id}`,
});
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 (cascade removes all instances)
const deleteRes = await app.inject({
method: 'DELETE',
url: `/api/v1/servers/${server.id}`,
});
expect(deleteRes.statusCode).toBe(204);
// Verify server is gone
const serversAfter = await app.inject({ method: 'GET', url: '/api/v1/servers' });
expect(serversAfter.json<unknown[]>()).toHaveLength(0);
});
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',
payload: {
name: 'external-lifecycle',
transport: 'STREAMABLE_HTTP',
externalUrl: `http://localhost:${fakeMcpPort}`,
},
});
const server = createRes.json<{ id: string }>();
// Verify auto-created instance
const instancesRes = await app.inject({
method: 'GET',
url: `/api/v1/instances?serverId=${server.id}`,
});
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({
method: 'POST',
url: '/api/v1/mcp/proxy',
headers: { authorization: 'Bearer test-token' },
payload: { serverId: server.id, method: 'tools/list' },
});
expect(proxyRes.statusCode).toBe(200);
expect(proxyRes.json<{ result: { tools: unknown[] } }>().result.tools.length).toBeGreaterThan(0);
// 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);
});
});
describe('proxy authentication', () => {
let app: FastifyInstance;
beforeEach(async () => {
app = await buildTestApp({
serverRepo: createInMemoryServerRepo(),
instanceRepo: createInMemoryInstanceRepo(),
auditLogRepo: createInMemoryAuditLogRepo(),
orchestrator: createMockOrchestrator(),
});
});
afterAll(async () => {
if (app) await app.close();
});
it('rejects proxy calls without auth header', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/v1/mcp/proxy',
payload: { serverId: 'srv-1', method: 'tools/list' },
});
// Auth middleware rejects with 401 (no Bearer token)
expect(res.statusCode).toBe(401);
});
});
describe('server update flow', () => {
let app: FastifyInstance;
beforeEach(async () => {
app = await buildTestApp({
serverRepo: createInMemoryServerRepo(),
instanceRepo: createInMemoryInstanceRepo(),
auditLogRepo: createInMemoryAuditLogRepo(),
orchestrator: createMockOrchestrator(),
});
});
afterAll(async () => {
if (app) await app.close();
});
it('creates and updates server fields', async () => {
// Create (with replicas: 0 to avoid creating instances in this test)
const createRes = await app.inject({
method: 'POST',
url: '/api/v1/servers',
payload: {
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');
// Update
const updateRes = await app.inject({
method: 'PUT',
url: `/api/v1/servers/${server.id}`,
payload: {
description: 'Updated desc',
externalUrl: `http://localhost:${fakeMcpPort}`,
transport: 'STREAMABLE_HTTP',
},
});
expect(updateRes.statusCode).toBe(200);
const updated = updateRes.json<{ description: string; externalUrl: string; transport: string }>();
expect(updated.description).toBe('Updated desc');
expect(updated.externalUrl).toBe(`http://localhost:${fakeMcpPort}`);
expect(updated.transport).toBe('STREAMABLE_HTTP');
// Fetch to verify persistence
const getRes = await app.inject({
method: 'GET',
url: `/api/v1/servers/${server.id}`,
});
expect(getRes.json<{ description: string }>().description).toBe('Updated desc');
});
});
});

View File

@@ -3,19 +3,26 @@ import Fastify from 'fastify';
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import { registerMcpServerRoutes } from '../src/routes/mcp-servers.js'; import { registerMcpServerRoutes } from '../src/routes/mcp-servers.js';
import { McpServerService, NotFoundError, ConflictError } from '../src/services/mcp-server.service.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 { 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; let app: FastifyInstance;
function mockRepo(): IMcpServerRepository { function mockRepo(): IMcpServerRepository {
let lastCreated: Record<string, unknown> | null = null;
return { return {
findAll: vi.fn(async () => [ 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), findByName: vi.fn(async () => null),
create: vi.fn(async (data) => ({ create: vi.fn(async (data) => {
const server = {
id: 'new-id', id: 'new-id',
name: data.name, name: data.name,
description: data.description ?? '', description: data.description ?? '',
@@ -23,12 +30,20 @@ function mockRepo(): IMcpServerRepository {
dockerImage: null, dockerImage: null,
transport: data.transport ?? 'STDIO', transport: data.transport ?? 'STDIO',
repositoryUrl: null, repositoryUrl: null,
externalUrl: null,
command: null,
containerPort: null,
replicas: data.replicas ?? 1,
envTemplate: [], envTemplate: [],
version: 1, version: 1,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
})), };
update: vi.fn(async (id, data) => ({ lastCreated = server;
return server;
}),
update: vi.fn(async (id, data) => {
const server = {
id, id,
name: 'slack', name: 'slack',
description: (data.description as string) ?? 'Slack server', description: (data.description as string) ?? 'Slack server',
@@ -36,11 +51,18 @@ function mockRepo(): IMcpServerRepository {
dockerImage: null, dockerImage: null,
transport: 'STDIO', transport: 'STDIO',
repositoryUrl: null, repositoryUrl: null,
externalUrl: null,
command: null,
containerPort: null,
replicas: 1,
envTemplate: [], envTemplate: [],
version: 2, version: 2,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
})), };
lastCreated = server;
return server;
}),
delete: vi.fn(async () => {}), delete: vi.fn(async () => {}),
}; };
} }
@@ -49,11 +71,56 @@ afterEach(async () => {
if (app) await app.close(); 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) { function createApp(repo: IMcpServerRepository) {
app = Fastify({ logger: false }); app = Fastify({ logger: false });
app.setErrorHandler(errorHandler); app.setErrorHandler(errorHandler);
const service = new McpServerService(repo); 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(); return app.ready();
} }