Compare commits
8 Commits
main
...
feat/creat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97ade470df | ||
|
|
b25ff98374 | ||
|
|
22fe9c3435 | ||
| 72643fceda | |||
|
|
467357c2c6 | ||
| d6a80fc03d | |||
|
|
c07da826a0 | ||
|
|
0482944056 |
26
examples/ha-mcp.yaml
Normal file
26
examples/ha-mcp.yaml
Normal 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"
|
||||
@@ -11,6 +11,10 @@ const ServerSpecSchema = z.object({
|
||||
dockerImage: z.string().optional(),
|
||||
transport: z.enum(['STDIO', 'SSE', 'STREAMABLE_HTTP']).default('STDIO'),
|
||||
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({
|
||||
name: z.string(),
|
||||
description: z.string().default(''),
|
||||
|
||||
114
src/cli/src/commands/create.ts
Normal file
114
src/cli/src/commands/create.ts
Normal 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;
|
||||
}
|
||||
33
src/cli/src/commands/delete.ts
Normal file
33
src/cli/src/commands/delete.ts
Normal 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.`);
|
||||
});
|
||||
}
|
||||
@@ -1,53 +1,166 @@
|
||||
import { Command } from 'commander';
|
||||
import { formatJson, formatYaml } from '../formatters/output.js';
|
||||
import { resolveResource, resolveNameOrId } from './shared.js';
|
||||
import type { ApiClient } from '../api-client.js';
|
||||
|
||||
export interface DescribeCommandDeps {
|
||||
client: ApiClient;
|
||||
fetchResource: (resource: string, id: string) => Promise<unknown>;
|
||||
fetchInspect?: (id: string) => Promise<unknown>;
|
||||
log: (...args: string[]) => void;
|
||||
}
|
||||
|
||||
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;
|
||||
function pad(label: string, width = 18): string {
|
||||
return label.padEnd(width);
|
||||
}
|
||||
|
||||
function formatDetail(obj: Record<string, unknown>, indent = 0): string {
|
||||
const pad = ' '.repeat(indent);
|
||||
function formatServerDetail(server: Record<string, unknown>): 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}`);
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (value === null || value === undefined) {
|
||||
lines.push(`${pad}${key}: -`);
|
||||
} else if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
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 {
|
||||
lines.push(`${pad}${key}: ${value.join(', ')}`);
|
||||
}
|
||||
} else if (typeof value === 'object') {
|
||||
lines.push(`${pad}${key}:`);
|
||||
lines.push(formatDetail(value as Record<string, unknown>, indent + 1));
|
||||
} else {
|
||||
lines.push(`${pad}${key}: ${String(value)}`);
|
||||
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)) {
|
||||
if (value === null || value === undefined) {
|
||||
lines.push(`${pad(key + ':')} -`);
|
||||
} else if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
lines.push(`${pad(key + ':')} []`);
|
||||
} else {
|
||||
lines.push(`${key}:`);
|
||||
for (const item of value) {
|
||||
lines.push(` - ${typeof item === 'object' ? JSON.stringify(item) : String(item)}`);
|
||||
}
|
||||
}
|
||||
} else if (typeof value === 'object') {
|
||||
lines.push(`${key}:`);
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
lines.push(` ${pad(k + ':')}${String(v)}`);
|
||||
}
|
||||
} else {
|
||||
lines.push(`${pad(key + ':')}${String(value)}`);
|
||||
}
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
@@ -55,20 +168,54 @@ export function createDescribeCommand(deps: DescribeCommandDeps): Command {
|
||||
return new Command('describe')
|
||||
.description('Show detailed information about a resource')
|
||||
.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')
|
||||
.action(async (resourceArg: string, id: string, opts: { output: string }) => {
|
||||
.action(async (resourceArg: string, idOrName: string, opts: { output: string }) => {
|
||||
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') {
|
||||
deps.log(formatJson(item));
|
||||
} else if (opts.output === 'yaml') {
|
||||
deps.log(formatYaml(item));
|
||||
} else {
|
||||
const typeName = resource.replace(/s$/, '').charAt(0).toUpperCase() + resource.replace(/s$/, '').slice(1);
|
||||
deps.log(`--- ${typeName} ---`);
|
||||
deps.log(formatDetail(item as Record<string, unknown>));
|
||||
// Visually clean sectioned output
|
||||
switch (resource) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
114
src/cli/src/commands/edit.ts
Normal file
114
src/cli/src/commands/edit.ts
Normal 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
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Command } from 'commander';
|
||||
import { formatTable } from '../formatters/table.js';
|
||||
import { formatJson, formatYaml } from '../formatters/output.js';
|
||||
import type { Column } from '../formatters/table.js';
|
||||
import { resolveResource, stripInternalFields } from './shared.js';
|
||||
|
||||
export interface GetCommandDeps {
|
||||
fetchResource: (resource: string, id?: string) => Promise<unknown[]>;
|
||||
@@ -37,22 +38,6 @@ interface InstanceRow {
|
||||
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>[] = [
|
||||
{ header: 'NAME', key: 'name' },
|
||||
{ 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 {
|
||||
return new Command('get')
|
||||
.description('List resources (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')
|
||||
.action(async (resourceArg: string, id: string | undefined, opts: { output: string }) => {
|
||||
const resource = resolveResource(resourceArg);
|
||||
const items = await deps.fetchResource(resource, id);
|
||||
|
||||
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') {
|
||||
deps.log(formatYaml(items.length === 1 ? items[0] : items));
|
||||
// Apply-compatible YAML wrapped in resource key
|
||||
deps.log(formatYaml(toApplyFormat(resource, items)));
|
||||
} else {
|
||||
if (items.length === 0) {
|
||||
deps.log(`No ${resource} found.`);
|
||||
return;
|
||||
}
|
||||
const columns = getColumnsForResource(resource);
|
||||
deps.log(formatTable(items as Record<string, unknown>[], columns));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
29
src/cli/src/commands/logs.ts
Normal file
29
src/cli/src/commands/logs.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -24,77 +24,8 @@ export function createProjectCommand(deps: ProjectCommandDeps): Command {
|
||||
const { client, log } = deps;
|
||||
|
||||
const cmd = new Command('project')
|
||||
.alias('projects')
|
||||
.alias('proj')
|
||||
.description('Manage mcpctl 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
|
||||
}
|
||||
});
|
||||
.description('Project-specific actions (create with "create project", list with "get projects")');
|
||||
|
||||
cmd
|
||||
.command('profiles <id>')
|
||||
|
||||
42
src/cli/src/commands/shared.ts
Normal file
42
src/cli/src/commands/shared.ts
Normal 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;
|
||||
}
|
||||
@@ -5,8 +5,11 @@ import { createConfigCommand } from './commands/config.js';
|
||||
import { createStatusCommand } from './commands/status.js';
|
||||
import { createGetCommand } from './commands/get.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 { createCreateCommand } from './commands/create.js';
|
||||
import { createEditCommand } from './commands/edit.js';
|
||||
import { createSetupCommand } from './commands/setup.js';
|
||||
import { createClaudeCommand } from './commands/claude.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 { loadConfig } from './config/index.js';
|
||||
import { loadCredentials } from './auth/index.js';
|
||||
import { resolveNameOrId } from './commands/shared.js';
|
||||
|
||||
export function createProgram(): Command {
|
||||
const program = new Command()
|
||||
@@ -45,15 +49,27 @@ export function createProgram(): Command {
|
||||
|
||||
const client = new ApiClient({ baseUrl, token: creds?.token ?? undefined });
|
||||
|
||||
const fetchResource = async (resource: string, id?: string): Promise<unknown[]> => {
|
||||
if (id) {
|
||||
const fetchResource = async (resource: string, nameOrId?: string): Promise<unknown[]> => {
|
||||
if (nameOrId) {
|
||||
let id: string;
|
||||
try {
|
||||
id = await resolveNameOrId(client, resource, nameOrId);
|
||||
} catch {
|
||||
id = nameOrId;
|
||||
}
|
||||
const item = await client.get(`/api/v1/${resource}/${id}`);
|
||||
return [item];
|
||||
}
|
||||
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}`);
|
||||
};
|
||||
|
||||
@@ -63,11 +79,28 @@ export function createProgram(): Command {
|
||||
}));
|
||||
|
||||
program.addCommand(createDescribeCommand({
|
||||
client,
|
||||
fetchResource: fetchSingleResource,
|
||||
fetchInspect: async (id: string) => client.get(`/api/v1/instances/${id}/inspect`),
|
||||
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,
|
||||
log: (...args) => console.log(...args),
|
||||
}));
|
||||
|
||||
144
src/cli/tests/commands/create.test.ts
Normal file
144
src/cli/tests/commands/create.test.ts
Normal 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: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,29 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { createDescribeCommand } 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[] } {
|
||||
const output: string[] = [];
|
||||
return {
|
||||
output,
|
||||
client: mockClient(),
|
||||
fetchResource: vi.fn(async () => item),
|
||||
log: (...args: string[]) => output.push(args.join(' ')),
|
||||
};
|
||||
}
|
||||
|
||||
describe('describe command', () => {
|
||||
it('shows detailed server info', async () => {
|
||||
it('shows detailed server info with sections', async () => {
|
||||
const deps = makeDeps({
|
||||
id: 'srv-1',
|
||||
name: 'slack',
|
||||
@@ -20,16 +31,22 @@ describe('describe command', () => {
|
||||
packageName: '@slack/mcp',
|
||||
dockerImage: null,
|
||||
envTemplate: [],
|
||||
createdAt: '2025-01-01',
|
||||
});
|
||||
const cmd = createDescribeCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'server', 'srv-1']);
|
||||
|
||||
expect(deps.fetchResource).toHaveBeenCalledWith('servers', 'srv-1');
|
||||
const text = deps.output.join('\n');
|
||||
expect(text).toContain('--- Server ---');
|
||||
expect(text).toContain('name: slack');
|
||||
expect(text).toContain('transport: STDIO');
|
||||
expect(text).toContain('dockerImage: -');
|
||||
expect(text).toContain('=== Server: slack ===');
|
||||
expect(text).toContain('Name:');
|
||||
expect(text).toContain('slack');
|
||||
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 () => {
|
||||
@@ -55,31 +72,58 @@ describe('describe command', () => {
|
||||
expect(deps.output[0]).toContain('name: slack');
|
||||
});
|
||||
|
||||
it('formats nested objects', async () => {
|
||||
it('shows profile with permissions and env overrides', async () => {
|
||||
const deps = makeDeps({
|
||||
id: 'srv-1',
|
||||
name: 'slack',
|
||||
metadata: { version: '1.0', nested: { deep: true } },
|
||||
id: 'p1',
|
||||
name: 'production',
|
||||
serverId: 'srv-1',
|
||||
permissions: ['read', 'write'],
|
||||
envOverrides: { FOO: 'bar', SECRET: 's3cr3t' },
|
||||
createdAt: '2025-01-01',
|
||||
});
|
||||
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');
|
||||
expect(text).toContain('metadata:');
|
||||
expect(text).toContain('version: 1.0');
|
||||
expect(text).toContain('=== Profile: production ===');
|
||||
expect(text).toContain('read, write');
|
||||
expect(text).toContain('Environment Overrides:');
|
||||
expect(text).toContain('FOO');
|
||||
expect(text).toContain('bar');
|
||||
});
|
||||
|
||||
it('formats arrays correctly', async () => {
|
||||
it('shows project detail', async () => {
|
||||
const deps = makeDeps({
|
||||
id: 'srv-1',
|
||||
permissions: ['read', 'write'],
|
||||
envTemplate: [],
|
||||
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', 'server', 'srv-1']);
|
||||
await cmd.parseAsync(['node', 'test', 'project', 'proj-1']);
|
||||
|
||||
const text = deps.output.join('\n');
|
||||
expect(text).toContain('permissions: read, write');
|
||||
expect(text).toContain('envTemplate: []');
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
180
src/cli/tests/commands/edit.test.ts
Normal file
180
src/cli/tests/commands/edit.test.ts
Normal 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' },
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -41,20 +41,30 @@ describe('get command', () => {
|
||||
expect(deps.fetchResource).toHaveBeenCalledWith('servers', 'srv-1');
|
||||
});
|
||||
|
||||
it('outputs JSON format', async () => {
|
||||
const deps = makeDeps([{ id: 'srv-1', name: 'slack' }]);
|
||||
it('outputs apply-compatible JSON format', async () => {
|
||||
const deps = makeDeps([{ id: 'srv-1', name: 'slack', createdAt: '2025-01-01', updatedAt: '2025-01-01', version: 1 }]);
|
||||
const cmd = createGetCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'servers', '-o', 'json']);
|
||||
|
||||
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 () => {
|
||||
const deps = makeDeps([{ id: 'srv-1', name: 'slack' }]);
|
||||
it('outputs apply-compatible YAML format', async () => {
|
||||
const deps = makeDeps([{ id: 'srv-1', name: 'slack', createdAt: '2025-01-01' }]);
|
||||
const cmd = createGetCommand(deps);
|
||||
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 () => {
|
||||
@@ -81,6 +91,6 @@ describe('get command', () => {
|
||||
const deps = makeDeps([]);
|
||||
const cmd = createGetCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'servers']);
|
||||
expect(deps.output[0]).toContain('No results');
|
||||
expect(deps.output[0]).toContain('No servers found');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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';
|
||||
|
||||
function mockClient(): ApiClient {
|
||||
@@ -11,7 +12,7 @@ function mockClient(): ApiClient {
|
||||
} as unknown as ApiClient;
|
||||
}
|
||||
|
||||
describe('instance commands', () => {
|
||||
describe('delete command', () => {
|
||||
let client: ReturnType<typeof mockClient>;
|
||||
let output: string[];
|
||||
const log = (...args: unknown[]) => output.push(args.map(String).join(' '));
|
||||
@@ -21,107 +22,70 @@ describe('instance commands', () => {
|
||||
output = [];
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
it('shows no instances message when empty', async () => {
|
||||
const cmd = createInstanceCommands({ client, log });
|
||||
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"');
|
||||
});
|
||||
it('deletes an instance by ID', async () => {
|
||||
const cmd = createDeleteCommand({ client, log });
|
||||
await cmd.parseAsync(['instance', 'inst-1'], { from: 'user' });
|
||||
expect(client.delete).toHaveBeenCalledWith('/api/v1/instances/inst-1');
|
||||
expect(output.join('\n')).toContain('deleted');
|
||||
});
|
||||
|
||||
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 });
|
||||
});
|
||||
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');
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
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');
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
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');
|
||||
});
|
||||
|
||||
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(output.join('\n')).toContain('removed');
|
||||
});
|
||||
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');
|
||||
});
|
||||
|
||||
describe('logs', () => {
|
||||
it('shows logs', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue({ stdout: 'hello world\n', stderr: '' });
|
||||
const cmd = createInstanceCommands({ client, log });
|
||||
await cmd.parseAsync(['logs', 'inst-1'], { from: 'user' });
|
||||
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/logs');
|
||||
expect(output.join('\n')).toContain('hello world');
|
||||
});
|
||||
|
||||
it('passes tail option', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue({ stdout: '', stderr: '' });
|
||||
const cmd = createInstanceCommands({ client, log });
|
||||
await cmd.parseAsync(['logs', 'inst-1', '-t', '50'], { from: 'user' });
|
||||
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');
|
||||
});
|
||||
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 = [];
|
||||
});
|
||||
|
||||
it('shows logs', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue({ stdout: 'hello world\n', stderr: '' });
|
||||
const cmd = createLogsCommand({ client, log });
|
||||
await cmd.parseAsync(['inst-1'], { from: 'user' });
|
||||
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/logs');
|
||||
expect(output.join('\n')).toContain('hello world');
|
||||
});
|
||||
|
||||
it('passes tail option', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue({ stdout: '', stderr: '' });
|
||||
const cmd = createLogsCommand({ client, log });
|
||||
await cmd.parseAsync(['inst-1', '-t', '50'], { from: 'user' });
|
||||
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/logs?tail=50');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,65 +21,6 @@ describe('project command', () => {
|
||||
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', () => {
|
||||
it('lists profiles for a project', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue([
|
||||
|
||||
@@ -16,26 +16,22 @@ describe('CLI command registration (e2e)', () => {
|
||||
expect(commandNames).toContain('logout');
|
||||
expect(commandNames).toContain('get');
|
||||
expect(commandNames).toContain('describe');
|
||||
expect(commandNames).toContain('instance');
|
||||
expect(commandNames).toContain('delete');
|
||||
expect(commandNames).toContain('logs');
|
||||
expect(commandNames).toContain('apply');
|
||||
expect(commandNames).toContain('create');
|
||||
expect(commandNames).toContain('edit');
|
||||
expect(commandNames).toContain('setup');
|
||||
expect(commandNames).toContain('claude');
|
||||
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 instance = program.commands.find((c) => c.name() === 'instance');
|
||||
expect(instance).toBeDefined();
|
||||
|
||||
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');
|
||||
const commandNames = program.commands.map((c) => c.name());
|
||||
expect(commandNames).not.toContain('instance');
|
||||
});
|
||||
|
||||
it('claude command has config management subcommands', () => {
|
||||
@@ -50,18 +46,19 @@ describe('CLI command registration (e2e)', () => {
|
||||
expect(subcommands).toContain('remove');
|
||||
});
|
||||
|
||||
it('project command has CRUD subcommands', () => {
|
||||
it('project command has action subcommands only', () => {
|
||||
const program = createProgram();
|
||||
const project = program.commands.find((c) => c.name() === 'project');
|
||||
expect(project).toBeDefined();
|
||||
|
||||
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('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', () => {
|
||||
|
||||
@@ -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;
|
||||
3
src/db/prisma/migrations/migration_lock.toml
Normal file
3
src/db/prisma/migrations/migration_lock.toml
Normal 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"
|
||||
@@ -57,6 +57,10 @@ model McpServer {
|
||||
dockerImage String?
|
||||
transport Transport @default(STDIO)
|
||||
repositoryUrl String?
|
||||
externalUrl String?
|
||||
command Json?
|
||||
containerPort Int?
|
||||
replicas Int @default(1)
|
||||
envTemplate Json @default("[]")
|
||||
version Int @default(1)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@ -60,8 +60,9 @@ async function main(): Promise<void> {
|
||||
|
||||
// Services
|
||||
const serverService = new McpServerService(serverRepo);
|
||||
const profileService = new McpProfileService(profileRepo, serverRepo);
|
||||
const instanceService = new InstanceService(instanceRepo, serverRepo, orchestrator);
|
||||
serverService.setInstanceService(instanceService);
|
||||
const profileService = new McpProfileService(profileRepo, serverRepo);
|
||||
const projectService = new ProjectService(projectRepo, profileRepo, serverRepo);
|
||||
const auditLogService = new AuditLogService(auditLogRepo);
|
||||
const metricsCollector = new MetricsCollector();
|
||||
@@ -69,7 +70,7 @@ async function main(): Promise<void> {
|
||||
const backupService = new BackupService(serverRepo, profileRepo, projectRepo);
|
||||
const restoreService = new RestoreService(serverRepo, profileRepo, projectRepo);
|
||||
const authService = new AuthService(prisma);
|
||||
const mcpProxyService = new McpProxyService(instanceRepo);
|
||||
const mcpProxyService = new McpProxyService(instanceRepo, serverRepo);
|
||||
|
||||
// Server
|
||||
const app = await createServer(config, {
|
||||
@@ -86,7 +87,7 @@ async function main(): Promise<void> {
|
||||
});
|
||||
|
||||
// Routes
|
||||
registerMcpServerRoutes(app, serverService);
|
||||
registerMcpServerRoutes(app, serverService, instanceService);
|
||||
registerMcpProfileRoutes(app, profileService);
|
||||
registerInstanceRoutes(app, instanceService);
|
||||
registerProjectRoutes(app, projectService);
|
||||
|
||||
@@ -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 { CreateMcpServerInput, UpdateMcpServerInput } from '../validation/mcp-server.schema.js';
|
||||
|
||||
@@ -26,6 +26,10 @@ export class McpServerRepository implements IMcpServerRepository {
|
||||
dockerImage: data.dockerImage ?? null,
|
||||
transport: data.transport,
|
||||
repositoryUrl: data.repositoryUrl ?? null,
|
||||
externalUrl: data.externalUrl ?? null,
|
||||
command: data.command ?? Prisma.DbNull,
|
||||
containerPort: data.containerPort ?? null,
|
||||
replicas: data.replicas,
|
||||
envTemplate: data.envTemplate,
|
||||
},
|
||||
});
|
||||
@@ -38,6 +42,10 @@ export class McpServerRepository implements IMcpServerRepository {
|
||||
if (data.dockerImage !== undefined) updateData['dockerImage'] = data.dockerImage;
|
||||
if (data.transport !== undefined) updateData['transport'] = data.transport;
|
||||
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;
|
||||
|
||||
return this.prisma.mcpServer.update({ where: { id }, data: updateData });
|
||||
|
||||
@@ -10,40 +10,17 @@ export function registerInstanceRoutes(app: FastifyInstance, service: InstanceSe
|
||||
return service.getById(request.params.id);
|
||||
});
|
||||
|
||||
app.post<{ Body: { serverId: string; env?: Record<string, string>; hostPort?: number } }>(
|
||||
'/api/v1/instances',
|
||||
async (request, reply) => {
|
||||
const { serverId } = request.body;
|
||||
const opts: { env?: Record<string, string>; hostPort?: number } = {};
|
||||
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.delete<{ Params: { id: string } }>('/api/v1/instances/:id', async (request, reply) => {
|
||||
const { serverId } = await service.remove(request.params.id);
|
||||
// Reconcile: server will auto-create a replacement if replicas > 0
|
||||
await service.reconcile(serverId);
|
||||
reply.code(204);
|
||||
});
|
||||
|
||||
app.get<{ Params: { id: string } }>('/api/v1/instances/:id/inspect', async (request) => {
|
||||
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 } }>(
|
||||
'/api/v1/instances/:id/logs',
|
||||
async (request) => {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
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 () => {
|
||||
return service.list();
|
||||
});
|
||||
@@ -12,12 +17,17 @@ export function registerMcpServerRoutes(app: FastifyInstance, service: McpServer
|
||||
|
||||
app.post('/api/v1/servers', async (request, reply) => {
|
||||
const server = await service.create(request.body);
|
||||
// Auto-reconcile: create instances to match replicas
|
||||
await instanceService.reconcile(server.id);
|
||||
reply.code(201);
|
||||
return server;
|
||||
});
|
||||
|
||||
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) => {
|
||||
|
||||
@@ -114,6 +114,7 @@ export class RestoreService {
|
||||
name: server.name,
|
||||
description: server.description,
|
||||
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 }>,
|
||||
};
|
||||
if (server.packageName) createData.packageName = server.packageName;
|
||||
|
||||
@@ -74,7 +74,7 @@ export class DockerContainerManager implements McpOrchestrator {
|
||||
? Object.entries(spec.env).map(([k, v]) => `${k}=${v}`)
|
||||
: undefined;
|
||||
|
||||
const container = await this.docker.createContainer({
|
||||
const createOpts: Docker.ContainerCreateOptions = {
|
||||
Image: spec.image,
|
||||
name: spec.name,
|
||||
Env: envArr,
|
||||
@@ -86,7 +86,12 @@ export class DockerContainerManager implements McpOrchestrator {
|
||||
NanoCpus: nanoCpus,
|
||||
NetworkMode: spec.network ?? 'bridge',
|
||||
},
|
||||
});
|
||||
};
|
||||
if (spec.command) {
|
||||
createOpts.Cmd = spec.command;
|
||||
}
|
||||
|
||||
const container = await this.docker.createContainer(createOpts);
|
||||
|
||||
await container.start();
|
||||
|
||||
|
||||
@@ -28,81 +28,46 @@ export class InstanceService {
|
||||
return instance;
|
||||
}
|
||||
|
||||
async start(serverId: string, opts?: { env?: Record<string, string>; hostPort?: number }): Promise<McpInstance> {
|
||||
/**
|
||||
* Reconcile instances for a server to match desired replica count.
|
||||
* - If fewer running instances than replicas: start new ones
|
||||
* - If more running instances than replicas: remove excess (oldest first)
|
||||
*/
|
||||
async reconcile(serverId: string): Promise<McpInstance[]> {
|
||||
const server = await this.serverRepo.findById(serverId);
|
||||
if (!server) throw new NotFoundError(`McpServer '${serverId}' not found`);
|
||||
|
||||
const 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
|
||||
let instance = await this.instanceRepo.create({
|
||||
serverId,
|
||||
status: 'STARTING',
|
||||
});
|
||||
|
||||
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 (active.length < desired) {
|
||||
// Scale up
|
||||
const toStart = desired - active.length;
|
||||
for (let i = 0; i < toStart; i++) {
|
||||
await this.startOne(serverId);
|
||||
}
|
||||
if (opts?.env) {
|
||||
spec.env = opts.env;
|
||||
} else if (active.length > desired) {
|
||||
// Scale down — remove oldest first
|
||||
const excess = active
|
||||
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
|
||||
.slice(0, active.length - desired);
|
||||
for (const inst of excess) {
|
||||
await this.removeOne(inst);
|
||||
}
|
||||
|
||||
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) {
|
||||
// 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;
|
||||
return this.instanceRepo.findAll(serverId);
|
||||
}
|
||||
|
||||
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> {
|
||||
/**
|
||||
* 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);
|
||||
|
||||
// Stop if running
|
||||
if (instance.containerId && (instance.status === 'RUNNING' || instance.status === 'STARTING')) {
|
||||
if (instance.containerId) {
|
||||
try {
|
||||
await this.orchestrator.stopContainer(instance.containerId);
|
||||
} catch {
|
||||
@@ -116,9 +81,29 @@ export class InstanceService {
|
||||
}
|
||||
|
||||
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> {
|
||||
@@ -129,20 +114,6 @@ export class InstanceService {
|
||||
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 }> {
|
||||
const instance = await this.getById(id);
|
||||
if (!instance.containerId) {
|
||||
@@ -151,4 +122,75 @@ export class InstanceService {
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { InvalidStateError } from './instance.service.js';
|
||||
|
||||
@@ -16,11 +16,39 @@ export interface McpProxyResponse {
|
||||
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 {
|
||||
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> {
|
||||
// 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 running = instances.find((i) => i.status === 'RUNNING');
|
||||
|
||||
@@ -37,6 +65,116 @@ export class McpProxyService {
|
||||
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(
|
||||
instance: McpInstance,
|
||||
method: string,
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import type { McpServer } from '@prisma/client';
|
||||
import type { IMcpServerRepository } from '../repositories/interfaces.js';
|
||||
import type { InstanceService } from './instance.service.js';
|
||||
import { CreateMcpServerSchema, UpdateMcpServerSchema } from '../validation/mcp-server.schema.js';
|
||||
|
||||
export class McpServerService {
|
||||
private instanceService: InstanceService | null = null;
|
||||
|
||||
constructor(private readonly repo: IMcpServerRepository) {}
|
||||
|
||||
/** Set after construction to avoid circular dependency. */
|
||||
setInstanceService(instanceService: InstanceService): void {
|
||||
this.instanceService = instanceService;
|
||||
}
|
||||
|
||||
async list(): Promise<McpServer[]> {
|
||||
return this.repo.findAll();
|
||||
}
|
||||
@@ -48,6 +56,10 @@ export class McpServerService {
|
||||
async delete(id: string): Promise<void> {
|
||||
// Verify exists
|
||||
await this.getById(id);
|
||||
// Stop all containers before DB cascade
|
||||
if (this.instanceService) {
|
||||
await this.instanceService.removeAllForServer(id);
|
||||
}
|
||||
await this.repo.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ export interface ContainerSpec {
|
||||
image: string;
|
||||
/** Human-readable name (used as container name prefix) */
|
||||
name: string;
|
||||
/** Custom command to run (overrides image CMD) */
|
||||
command?: string[];
|
||||
/** Environment variables */
|
||||
env?: Record<string, string>;
|
||||
/** Host port to bind (null = auto-assign) */
|
||||
|
||||
@@ -14,6 +14,10 @@ export const CreateMcpServerSchema = z.object({
|
||||
dockerImage: z.string().max(200).optional(),
|
||||
transport: z.enum(['STDIO', 'SSE', 'STREAMABLE_HTTP']).default('STDIO'),
|
||||
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([]),
|
||||
});
|
||||
|
||||
@@ -23,6 +27,10 @@ export const UpdateMcpServerSchema = z.object({
|
||||
dockerImage: z.string().max(200).nullable().optional(),
|
||||
transport: z.enum(['STDIO', 'SSE', 'STREAMABLE_HTTP']).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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { InstanceService, InvalidStateError } from '../src/services/instance.ser
|
||||
import { NotFoundError } from '../src/services/mcp-server.service.js';
|
||||
import type { IMcpInstanceRepository, IMcpServerRepository } from '../src/repositories/interfaces.js';
|
||||
import type { McpOrchestrator } from '../src/services/orchestrator.js';
|
||||
import type { McpInstance } from '@prisma/client';
|
||||
|
||||
function mockInstanceRepo(): IMcpInstanceRepository {
|
||||
return {
|
||||
@@ -69,6 +70,41 @@ function mockOrchestrator(): McpOrchestrator {
|
||||
};
|
||||
}
|
||||
|
||||
function makeServer(overrides: Partial<{ id: string; name: string; replicas: number; dockerImage: string | null; externalUrl: string | null; transport: string; command: unknown; containerPort: number | null }> = {}) {
|
||||
return {
|
||||
id: overrides.id ?? 'srv-1',
|
||||
name: overrides.name ?? 'slack',
|
||||
dockerImage: overrides.dockerImage ?? 'ghcr.io/slack-mcp:latest',
|
||||
packageName: null,
|
||||
transport: overrides.transport ?? 'STDIO',
|
||||
description: '',
|
||||
repositoryUrl: null,
|
||||
externalUrl: overrides.externalUrl ?? null,
|
||||
command: overrides.command ?? null,
|
||||
containerPort: overrides.containerPort ?? null,
|
||||
replicas: overrides.replicas ?? 1,
|
||||
envTemplate: [],
|
||||
version: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
function makeInstance(overrides: Partial<McpInstance> = {}): McpInstance {
|
||||
return {
|
||||
id: 'inst-1',
|
||||
serverId: 'srv-1',
|
||||
containerId: overrides.containerId ?? 'ctr-abc',
|
||||
status: overrides.status ?? 'RUNNING',
|
||||
port: overrides.port ?? 3000,
|
||||
metadata: overrides.metadata ?? {},
|
||||
version: 1,
|
||||
createdAt: overrides.createdAt ?? new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
} as McpInstance;
|
||||
}
|
||||
|
||||
describe('InstanceService', () => {
|
||||
let instanceRepo: ReturnType<typeof mockInstanceRepo>;
|
||||
let serverRepo: ReturnType<typeof mockServerRepo>;
|
||||
@@ -101,199 +137,98 @@ describe('InstanceService', () => {
|
||||
});
|
||||
|
||||
it('returns instance when found', async () => {
|
||||
vi.mocked(instanceRepo.findById).mockResolvedValue({ id: 'inst-1' } as never);
|
||||
vi.mocked(instanceRepo.findById).mockResolvedValue(makeInstance({ id: 'inst-1' }));
|
||||
const result = await service.getById('inst-1');
|
||||
expect(result.id).toBe('inst-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('start', () => {
|
||||
describe('reconcile', () => {
|
||||
it('starts instances when below desired replicas', async () => {
|
||||
vi.mocked(serverRepo.findById).mockResolvedValue(makeServer({ replicas: 2 }));
|
||||
vi.mocked(instanceRepo.findAll).mockResolvedValue([]);
|
||||
|
||||
await service.reconcile('srv-1');
|
||||
|
||||
// Should create 2 instances
|
||||
expect(instanceRepo.create).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does nothing when at desired replicas', async () => {
|
||||
vi.mocked(serverRepo.findById).mockResolvedValue(makeServer({ replicas: 1 }));
|
||||
vi.mocked(instanceRepo.findAll).mockResolvedValue([makeInstance({ status: 'RUNNING' })]);
|
||||
|
||||
await service.reconcile('srv-1');
|
||||
|
||||
expect(instanceRepo.create).not.toHaveBeenCalled();
|
||||
expect(instanceRepo.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removes excess instances when above desired replicas', async () => {
|
||||
vi.mocked(serverRepo.findById).mockResolvedValue(makeServer({ replicas: 1 }));
|
||||
vi.mocked(instanceRepo.findAll).mockResolvedValue([
|
||||
makeInstance({ id: 'inst-old', createdAt: new Date('2025-01-01') }),
|
||||
makeInstance({ id: 'inst-new', createdAt: new Date('2025-06-01') }),
|
||||
]);
|
||||
|
||||
await service.reconcile('srv-1');
|
||||
|
||||
// Should remove the oldest one
|
||||
expect(orchestrator.stopContainer).toHaveBeenCalledTimes(1);
|
||||
expect(instanceRepo.delete).toHaveBeenCalledWith('inst-old');
|
||||
});
|
||||
|
||||
it('creates external instances without Docker', async () => {
|
||||
vi.mocked(serverRepo.findById).mockResolvedValue(
|
||||
makeServer({ replicas: 1, externalUrl: 'http://localhost:8086/mcp', dockerImage: null }),
|
||||
);
|
||||
vi.mocked(instanceRepo.findAll).mockResolvedValue([]);
|
||||
|
||||
await service.reconcile('srv-1');
|
||||
|
||||
expect(instanceRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ status: 'RUNNING', metadata: expect.objectContaining({ external: true }) }),
|
||||
);
|
||||
expect(orchestrator.createContainer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles replicas: 0 by removing all instances', async () => {
|
||||
vi.mocked(serverRepo.findById).mockResolvedValue(makeServer({ replicas: 0 }));
|
||||
vi.mocked(instanceRepo.findAll).mockResolvedValue([makeInstance()]);
|
||||
|
||||
await service.reconcile('srv-1');
|
||||
|
||||
expect(instanceRepo.delete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('throws NotFoundError for unknown server', async () => {
|
||||
await expect(service.start('missing')).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
|
||||
it('creates instance and starts container', async () => {
|
||||
vi.mocked(serverRepo.findById).mockResolvedValue({
|
||||
id: 'srv-1', name: 'slack', dockerImage: 'ghcr.io/slack-mcp:latest',
|
||||
packageName: null, transport: 'STDIO', description: '', repositoryUrl: null,
|
||||
envTemplate: [], version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const result = await service.start('srv-1');
|
||||
|
||||
expect(instanceRepo.create).toHaveBeenCalledWith({
|
||||
serverId: 'srv-1',
|
||||
status: 'STARTING',
|
||||
});
|
||||
expect(orchestrator.createContainer).toHaveBeenCalled();
|
||||
expect(instanceRepo.updateStatus).toHaveBeenCalledWith(
|
||||
'inst-1', 'RUNNING',
|
||||
expect.objectContaining({ containerId: 'ctr-abc123' }),
|
||||
);
|
||||
expect(result.status).toBe('RUNNING');
|
||||
});
|
||||
|
||||
it('marks instance as ERROR on container failure', async () => {
|
||||
vi.mocked(serverRepo.findById).mockResolvedValue({
|
||||
id: 'srv-1', name: 'slack', dockerImage: 'ghcr.io/slack-mcp:latest',
|
||||
packageName: null, transport: 'STDIO', description: '', repositoryUrl: null,
|
||||
envTemplate: [], version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||
});
|
||||
vi.mocked(orchestrator.createContainer).mockRejectedValue(new Error('Docker unavailable'));
|
||||
|
||||
const result = await service.start('srv-1');
|
||||
|
||||
expect(instanceRepo.updateStatus).toHaveBeenCalledWith(
|
||||
'inst-1', 'ERROR',
|
||||
expect.objectContaining({ metadata: { error: 'Docker unavailable' } }),
|
||||
);
|
||||
expect(result.status).toBe('ERROR');
|
||||
});
|
||||
|
||||
it('uses dockerImage for container spec', async () => {
|
||||
vi.mocked(serverRepo.findById).mockResolvedValue({
|
||||
id: 'srv-1', name: 'slack', dockerImage: 'myregistry.com/slack:v1',
|
||||
packageName: '@slack/mcp', transport: 'SSE', description: '', repositoryUrl: null,
|
||||
envTemplate: [], version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||
});
|
||||
|
||||
await service.start('srv-1');
|
||||
|
||||
const spec = vi.mocked(orchestrator.createContainer).mock.calls[0]?.[0];
|
||||
expect(spec?.image).toBe('myregistry.com/slack:v1');
|
||||
expect(spec?.containerPort).toBe(3000); // SSE transport
|
||||
});
|
||||
});
|
||||
|
||||
describe('stop', () => {
|
||||
it('throws NotFoundError for missing instance', async () => {
|
||||
await expect(service.stop('missing')).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
|
||||
it('stops a running container', async () => {
|
||||
vi.mocked(instanceRepo.findById).mockResolvedValue({
|
||||
id: 'inst-1', containerId: 'ctr-abc', status: 'RUNNING',
|
||||
serverId: 'srv-1', port: 3000, metadata: {},
|
||||
version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||
});
|
||||
|
||||
await service.stop('inst-1');
|
||||
|
||||
expect(orchestrator.stopContainer).toHaveBeenCalledWith('ctr-abc');
|
||||
expect(instanceRepo.updateStatus).toHaveBeenCalledWith('inst-1', 'STOPPED');
|
||||
});
|
||||
|
||||
it('handles stop without containerId', async () => {
|
||||
vi.mocked(instanceRepo.findById).mockResolvedValue({
|
||||
id: 'inst-1', containerId: null, status: 'ERROR',
|
||||
serverId: 'srv-1', port: null, metadata: {},
|
||||
version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||
});
|
||||
|
||||
await service.stop('inst-1');
|
||||
|
||||
expect(orchestrator.stopContainer).not.toHaveBeenCalled();
|
||||
expect(instanceRepo.updateStatus).toHaveBeenCalledWith('inst-1', 'STOPPED');
|
||||
});
|
||||
|
||||
it('throws InvalidStateError when already stopped', async () => {
|
||||
vi.mocked(instanceRepo.findById).mockResolvedValue({
|
||||
id: 'inst-1', containerId: 'ctr-abc', status: 'STOPPED',
|
||||
serverId: 'srv-1', port: null, metadata: {},
|
||||
version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||
});
|
||||
|
||||
await expect(service.stop('inst-1')).rejects.toThrow(InvalidStateError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('restart', () => {
|
||||
it('stops, removes, and starts a new instance', async () => {
|
||||
vi.mocked(instanceRepo.findById).mockResolvedValue({
|
||||
id: 'inst-1', containerId: 'ctr-abc', status: 'RUNNING',
|
||||
serverId: 'srv-1', port: 3000, metadata: {},
|
||||
version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||
});
|
||||
vi.mocked(serverRepo.findById).mockResolvedValue({
|
||||
id: 'srv-1', name: 'slack', dockerImage: 'slack:latest',
|
||||
packageName: null, transport: 'STDIO', description: '', repositoryUrl: null,
|
||||
envTemplate: [], version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const result = await service.restart('inst-1');
|
||||
|
||||
expect(orchestrator.stopContainer).toHaveBeenCalledWith('ctr-abc');
|
||||
expect(orchestrator.removeContainer).toHaveBeenCalledWith('ctr-abc', true);
|
||||
expect(instanceRepo.delete).toHaveBeenCalledWith('inst-1');
|
||||
expect(instanceRepo.create).toHaveBeenCalled();
|
||||
expect(result.status).toBe('RUNNING');
|
||||
});
|
||||
|
||||
it('handles restart when container already stopped', async () => {
|
||||
vi.mocked(instanceRepo.findById).mockResolvedValue({
|
||||
id: 'inst-1', containerId: 'ctr-abc', status: 'STOPPED',
|
||||
serverId: 'srv-1', port: null, metadata: {},
|
||||
version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||
});
|
||||
vi.mocked(serverRepo.findById).mockResolvedValue({
|
||||
id: 'srv-1', name: 'slack', dockerImage: 'slack:latest',
|
||||
packageName: null, transport: 'STDIO', description: '', repositoryUrl: null,
|
||||
envTemplate: [], version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const result = await service.restart('inst-1');
|
||||
|
||||
// Should not try to stop an already-stopped container
|
||||
expect(orchestrator.stopContainer).not.toHaveBeenCalled();
|
||||
expect(instanceRepo.delete).toHaveBeenCalledWith('inst-1');
|
||||
expect(result.status).toBe('RUNNING');
|
||||
});
|
||||
});
|
||||
|
||||
describe('inspect', () => {
|
||||
it('returns container info', async () => {
|
||||
vi.mocked(instanceRepo.findById).mockResolvedValue({
|
||||
id: 'inst-1', containerId: 'ctr-abc', status: 'RUNNING',
|
||||
serverId: 'srv-1', port: 3000, metadata: {},
|
||||
version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const result = await service.inspect('inst-1');
|
||||
expect(orchestrator.inspectContainer).toHaveBeenCalledWith('ctr-abc');
|
||||
expect(result.containerId).toBe('ctr-abc123');
|
||||
});
|
||||
|
||||
it('throws InvalidStateError when no container', async () => {
|
||||
vi.mocked(instanceRepo.findById).mockResolvedValue({
|
||||
id: 'inst-1', containerId: null, status: 'ERROR',
|
||||
serverId: 'srv-1', port: null, metadata: {},
|
||||
version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||
});
|
||||
|
||||
await expect(service.inspect('inst-1')).rejects.toThrow(InvalidStateError);
|
||||
await expect(service.reconcile('missing')).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('removes container and DB record', async () => {
|
||||
vi.mocked(instanceRepo.findById).mockResolvedValue({
|
||||
id: 'inst-1', containerId: 'ctr-abc', status: 'STOPPED',
|
||||
serverId: 'srv-1', port: null, metadata: {},
|
||||
version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||
});
|
||||
it('stops container and deletes DB record', async () => {
|
||||
vi.mocked(instanceRepo.findById).mockResolvedValue(makeInstance({ containerId: 'ctr-abc' }));
|
||||
|
||||
const result = await service.remove('inst-1');
|
||||
|
||||
expect(orchestrator.stopContainer).toHaveBeenCalledWith('ctr-abc');
|
||||
expect(orchestrator.removeContainer).toHaveBeenCalledWith('ctr-abc', true);
|
||||
expect(instanceRepo.delete).toHaveBeenCalledWith('inst-1');
|
||||
expect(result.serverId).toBe('srv-1');
|
||||
});
|
||||
|
||||
it('deletes DB record for external instance (no container)', async () => {
|
||||
vi.mocked(instanceRepo.findById).mockResolvedValue(makeInstance({ containerId: null }));
|
||||
|
||||
await service.remove('inst-1');
|
||||
|
||||
expect(orchestrator.removeContainer).toHaveBeenCalledWith('ctr-abc', true);
|
||||
expect(orchestrator.stopContainer).not.toHaveBeenCalled();
|
||||
expect(instanceRepo.delete).toHaveBeenCalledWith('inst-1');
|
||||
});
|
||||
|
||||
it('removes DB record even if container is already gone', async () => {
|
||||
vi.mocked(instanceRepo.findById).mockResolvedValue({
|
||||
id: 'inst-1', containerId: 'ctr-abc', status: 'STOPPED',
|
||||
serverId: 'srv-1', port: null, metadata: {},
|
||||
version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||
});
|
||||
it('deletes DB record even if container is already gone', async () => {
|
||||
vi.mocked(instanceRepo.findById).mockResolvedValue(makeInstance({ containerId: 'ctr-abc' }));
|
||||
vi.mocked(orchestrator.removeContainer).mockRejectedValue(new Error('No such container'));
|
||||
|
||||
await service.remove('inst-1');
|
||||
@@ -302,24 +237,56 @@ describe('InstanceService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeAllForServer', () => {
|
||||
it('stops all containers for a server', async () => {
|
||||
vi.mocked(instanceRepo.findAll).mockResolvedValue([
|
||||
makeInstance({ id: 'inst-1', containerId: 'ctr-1' }),
|
||||
makeInstance({ id: 'inst-2', containerId: 'ctr-2' }),
|
||||
]);
|
||||
|
||||
await service.removeAllForServer('srv-1');
|
||||
|
||||
expect(orchestrator.stopContainer).toHaveBeenCalledTimes(2);
|
||||
expect(orchestrator.removeContainer).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('skips external instances with no container', async () => {
|
||||
vi.mocked(instanceRepo.findAll).mockResolvedValue([
|
||||
makeInstance({ id: 'inst-1', containerId: null }),
|
||||
]);
|
||||
|
||||
await service.removeAllForServer('srv-1');
|
||||
|
||||
expect(orchestrator.stopContainer).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('inspect', () => {
|
||||
it('returns container info', async () => {
|
||||
vi.mocked(instanceRepo.findById).mockResolvedValue(makeInstance({ containerId: 'ctr-abc' }));
|
||||
|
||||
const result = await service.inspect('inst-1');
|
||||
expect(orchestrator.inspectContainer).toHaveBeenCalledWith('ctr-abc');
|
||||
expect(result.containerId).toBe('ctr-abc123');
|
||||
});
|
||||
|
||||
it('throws InvalidStateError when no container', async () => {
|
||||
vi.mocked(instanceRepo.findById).mockResolvedValue(makeInstance({ containerId: null }));
|
||||
|
||||
await expect(service.inspect('inst-1')).rejects.toThrow(InvalidStateError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLogs', () => {
|
||||
it('returns empty logs for instance without container', async () => {
|
||||
vi.mocked(instanceRepo.findById).mockResolvedValue({
|
||||
id: 'inst-1', containerId: null, status: 'ERROR',
|
||||
serverId: 'srv-1', port: null, metadata: {},
|
||||
version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||
});
|
||||
vi.mocked(instanceRepo.findById).mockResolvedValue(makeInstance({ containerId: null }));
|
||||
|
||||
const result = await service.getLogs('inst-1');
|
||||
expect(result).toEqual({ stdout: '', stderr: '' });
|
||||
});
|
||||
|
||||
it('returns container logs', async () => {
|
||||
vi.mocked(instanceRepo.findById).mockResolvedValue({
|
||||
id: 'inst-1', containerId: 'ctr-abc', status: 'RUNNING',
|
||||
serverId: 'srv-1', port: 3000, metadata: {},
|
||||
version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||
});
|
||||
vi.mocked(instanceRepo.findById).mockResolvedValue(makeInstance({ containerId: 'ctr-abc' }));
|
||||
|
||||
const result = await service.getLogs('inst-1', { tail: 50 });
|
||||
|
||||
|
||||
727
src/mcpd/tests/mcp-server-flow.test.ts
Normal file
727
src/mcpd/tests/mcp-server-flow.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,44 +3,66 @@ import Fastify from 'fastify';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { registerMcpServerRoutes } from '../src/routes/mcp-servers.js';
|
||||
import { McpServerService, NotFoundError, ConflictError } from '../src/services/mcp-server.service.js';
|
||||
import { InstanceService } from '../src/services/instance.service.js';
|
||||
import { errorHandler } from '../src/middleware/error-handler.js';
|
||||
import type { IMcpServerRepository } from '../src/repositories/interfaces.js';
|
||||
import type { IMcpServerRepository, IMcpInstanceRepository } from '../src/repositories/interfaces.js';
|
||||
import type { McpOrchestrator } from '../src/services/orchestrator.js';
|
||||
|
||||
let app: FastifyInstance;
|
||||
|
||||
function mockRepo(): IMcpServerRepository {
|
||||
let lastCreated: Record<string, unknown> | null = null;
|
||||
return {
|
||||
findAll: vi.fn(async () => [
|
||||
{ id: '1', name: 'slack', description: 'Slack server', transport: 'STDIO' },
|
||||
{ id: '1', name: 'slack', description: 'Slack server', transport: 'STDIO', replicas: 1 },
|
||||
]),
|
||||
findById: vi.fn(async () => null),
|
||||
findById: vi.fn(async (id: string) => {
|
||||
if (lastCreated && (lastCreated as { id: string }).id === id) return lastCreated as never;
|
||||
return null;
|
||||
}),
|
||||
findByName: vi.fn(async () => null),
|
||||
create: vi.fn(async (data) => ({
|
||||
id: 'new-id',
|
||||
name: data.name,
|
||||
description: data.description ?? '',
|
||||
packageName: data.packageName ?? null,
|
||||
dockerImage: null,
|
||||
transport: data.transport ?? 'STDIO',
|
||||
repositoryUrl: null,
|
||||
envTemplate: [],
|
||||
version: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})),
|
||||
update: vi.fn(async (id, data) => ({
|
||||
id,
|
||||
name: 'slack',
|
||||
description: (data.description as string) ?? 'Slack server',
|
||||
packageName: null,
|
||||
dockerImage: null,
|
||||
transport: 'STDIO',
|
||||
repositoryUrl: null,
|
||||
envTemplate: [],
|
||||
version: 2,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})),
|
||||
create: vi.fn(async (data) => {
|
||||
const server = {
|
||||
id: 'new-id',
|
||||
name: data.name,
|
||||
description: data.description ?? '',
|
||||
packageName: data.packageName ?? null,
|
||||
dockerImage: null,
|
||||
transport: data.transport ?? 'STDIO',
|
||||
repositoryUrl: null,
|
||||
externalUrl: null,
|
||||
command: null,
|
||||
containerPort: null,
|
||||
replicas: data.replicas ?? 1,
|
||||
envTemplate: [],
|
||||
version: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
lastCreated = server;
|
||||
return server;
|
||||
}),
|
||||
update: vi.fn(async (id, data) => {
|
||||
const server = {
|
||||
id,
|
||||
name: 'slack',
|
||||
description: (data.description as string) ?? 'Slack server',
|
||||
packageName: null,
|
||||
dockerImage: null,
|
||||
transport: 'STDIO',
|
||||
repositoryUrl: null,
|
||||
externalUrl: null,
|
||||
command: null,
|
||||
containerPort: null,
|
||||
replicas: 1,
|
||||
envTemplate: [],
|
||||
version: 2,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
lastCreated = server;
|
||||
return server;
|
||||
}),
|
||||
delete: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
@@ -49,11 +71,56 @@ afterEach(async () => {
|
||||
if (app) await app.close();
|
||||
});
|
||||
|
||||
function stubInstanceRepo(): IMcpInstanceRepository {
|
||||
return {
|
||||
findAll: vi.fn(async () => []),
|
||||
findById: vi.fn(async () => null),
|
||||
findByContainerId: vi.fn(async () => null),
|
||||
create: vi.fn(async (data) => ({
|
||||
id: 'inst-stub',
|
||||
serverId: data.serverId,
|
||||
containerId: null,
|
||||
status: data.status ?? 'STOPPED',
|
||||
port: null,
|
||||
metadata: {},
|
||||
version: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})),
|
||||
updateStatus: vi.fn(async (id, status) => ({
|
||||
id,
|
||||
serverId: 'srv-1',
|
||||
containerId: null,
|
||||
status,
|
||||
port: null,
|
||||
metadata: {},
|
||||
version: 2,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})),
|
||||
delete: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
function stubOrchestrator(): McpOrchestrator {
|
||||
return {
|
||||
ping: vi.fn(async () => true),
|
||||
pullImage: vi.fn(async () => {}),
|
||||
createContainer: vi.fn(async () => ({ containerId: 'ctr-stub', name: 'stub', state: 'running' as const, port: 3000, createdAt: new Date() })),
|
||||
stopContainer: vi.fn(async () => {}),
|
||||
removeContainer: vi.fn(async () => {}),
|
||||
inspectContainer: vi.fn(async () => ({ containerId: 'ctr-stub', name: 'stub', state: 'running' as const, createdAt: new Date() })),
|
||||
getContainerLogs: vi.fn(async () => ({ stdout: '', stderr: '' })),
|
||||
};
|
||||
}
|
||||
|
||||
function createApp(repo: IMcpServerRepository) {
|
||||
app = Fastify({ logger: false });
|
||||
app.setErrorHandler(errorHandler);
|
||||
const service = new McpServerService(repo);
|
||||
registerMcpServerRoutes(app, service);
|
||||
const instanceService = new InstanceService(stubInstanceRepo(), repo, stubOrchestrator());
|
||||
service.setInstanceService(instanceService);
|
||||
registerMcpServerRoutes(app, service, instanceService);
|
||||
return app.ready();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user