Compare commits

...

12 Commits

Author SHA1 Message Date
Michal
ca02340a4c feat: replace profiles with kubernetes-style secrets
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
Replace the confused Profile abstraction with a dedicated Secret resource
following Kubernetes conventions. Servers now have env entries with inline
values or secretRef references. Env vars are resolved and passed to
containers at startup (fixes existing gap).

- Add Secret CRUD (model, repo, service, routes, CLI commands)
- Server env: {name, value} or {name, valueFrom: {secretRef: {name, key}}}
- Add env-resolver utility shared by instance startup and config generation
- Remove all profile-related code (models, services, routes, CLI, tests)
- Update backup/restore for secrets instead of profiles
- describe secret masks values by default, --show-values to reveal

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:40:58 +00:00
Michal
02254f2aac fix: enable positional options so -o works on subcommands
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
Remove global -o/--output from parent program and enable
enablePositionalOptions() so -o yaml/json is parsed by subcommands.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 16:43:35 +00:00
Michal
540dd6fd63 fix: remove unused Project interface from project.ts
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 16:41:14 +00:00
a05a4c4816 Merge pull request 'feat: create/edit commands, apply-compatible output, better describe' (#6) from feat/create-edit-commands into main
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
2026-02-22 16:40:36 +00:00
Michal
97ade470df fix: resolve resource names in get/describe (not just IDs)
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
fetchResource and fetchSingleResource now use resolveNameOrId so
`mcpctl get server ha-mcp` works by name, not just by ID.

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 14:33:25 +00:00
Michal
22fe9c3435 fix: add replicas to restore-service server creation
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 13:47:03 +00:00
72643fceda Merge pull request 'feat: kubectl-style CLI + Deployment/Pod model' (#5) from feat/kubectl-deployment-model into main
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
Reviewed-on: #5
2026-02-22 13:39:02 +00:00
Michal
467357c2c6 feat: kubectl-style CLI + Deployment/Pod model for servers/instances
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
Server = Deployment (defines what to run + desired replicas)
Instance = Pod (ephemeral, auto-created by reconciliation)

Backend:
- Add replicas field to McpServer schema
- Add reconcile() to InstanceService (scales instances to match replicas)
- Remove manual start/stop/restart - instances are auto-managed
- Cascade: deleting server stops all containers then cascades DB
- Server create/update auto-triggers reconciliation

CLI:
- Add top-level delete command (servers, instances, profiles, projects)
- Add top-level logs command
- Remove instance compound command (use get/delete/logs instead)
- Clean up project command (list/show/delete → top-level get/describe/delete)
- Enhance describe for instances with container inspect info
- Add replicas to apply command's ServerSpec

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 13:30:46 +00:00
d6a80fc03d Merge pull request 'feat: external MCP server support + HA MCP PoC' (#4) from feat/external-mcp-servers into main
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
Reviewed-on: #4
2026-02-22 12:39:19 +00:00
Michal
c07da826a0 test: add integration test for full MCP server flow
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
Tests the complete lifecycle through Fastify routes with in-memory
repositories and a fake streamable-http MCP server:
- External server: register → start virtual instance → proxy tools/list
- Managed server: register with dockerImage → start container → verify spec
- Full lifecycle: register → start → list → stop → remove → delete
- Proxy auth enforcement
- Server update flow
- Error handling (Docker failure → ERROR status)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 12:34:55 +00:00
Michal
0482944056 feat: add external MCP server support with streamable-http proxy
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
Support non-containerized MCP servers via externalUrl field and add
streamable-http session management for HA MCP proof of concept.

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

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

View File

@@ -96,10 +96,12 @@ servers:
description: Slack MCP server description: Slack MCP server
transport: STDIO transport: STDIO
packageName: "@anthropic/slack-mcp" packageName: "@anthropic/slack-mcp"
envTemplate: env:
- name: SLACK_TOKEN - name: SLACK_TOKEN
description: Slack bot token valueFrom:
isSecret: true secretRef:
name: slack-secrets
key: token
- name: github - name: github
description: GitHub MCP server description: GitHub MCP server

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

@@ -0,0 +1,28 @@
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"
env:
- name: HOMEASSISTANT_URL
value: ""
- name: HOMEASSISTANT_TOKEN
valueFrom:
secretRef:
name: ha-secrets
key: token
profiles:
- name: production
server: ha-mcp
envOverrides:
HOMEASSISTANT_URL: "https://ha.itaz.eu"
HOMEASSISTANT_TOKEN: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiIyNjFlZTRhOWI2MGM0YTllOGJkNTIxN2Q3YmVmZDkzNSIsImlhdCI6MTc3MDA3NjYzOCwiZXhwIjoyMDg1NDM2NjM4fQ.17mAQxIrCBrQx3ogqAUetwEt-cngRmJiH-e7sLt-3FY"

View File

@@ -4,6 +4,14 @@ import yaml from 'js-yaml';
import { z } from 'zod'; import { z } from 'zod';
import type { ApiClient } from '../api-client.js'; import type { ApiClient } from '../api-client.js';
const ServerEnvEntrySchema = z.object({
name: z.string().min(1),
value: z.string().optional(),
valueFrom: z.object({
secretRef: z.object({ name: z.string(), key: z.string() }),
}).optional(),
});
const ServerSpecSchema = z.object({ const ServerSpecSchema = z.object({
name: z.string().min(1), name: z.string().min(1),
description: z.string().default(''), description: z.string().default(''),
@@ -11,29 +19,26 @@ const ServerSpecSchema = z.object({
dockerImage: z.string().optional(), dockerImage: z.string().optional(),
transport: z.enum(['STDIO', 'SSE', 'STREAMABLE_HTTP']).default('STDIO'), transport: z.enum(['STDIO', 'SSE', 'STREAMABLE_HTTP']).default('STDIO'),
repositoryUrl: z.string().url().optional(), repositoryUrl: z.string().url().optional(),
envTemplate: z.array(z.object({ externalUrl: z.string().url().optional(),
name: z.string(), command: z.array(z.string()).optional(),
description: z.string().default(''), containerPort: z.number().int().min(1).max(65535).optional(),
isSecret: z.boolean().default(false), replicas: z.number().int().min(0).max(10).default(1),
})).default([]), env: z.array(ServerEnvEntrySchema).default([]),
}); });
const ProfileSpecSchema = z.object({ const SecretSpecSchema = z.object({
name: z.string().min(1), name: z.string().min(1),
server: z.string().min(1), data: z.record(z.string()).default({}),
permissions: z.array(z.string()).default([]),
envOverrides: z.record(z.string()).default({}),
}); });
const ProjectSpecSchema = z.object({ const ProjectSpecSchema = z.object({
name: z.string().min(1), name: z.string().min(1),
description: z.string().default(''), description: z.string().default(''),
profiles: z.array(z.string()).default([]),
}); });
const ApplyConfigSchema = z.object({ const ApplyConfigSchema = z.object({
servers: z.array(ServerSpecSchema).default([]), servers: z.array(ServerSpecSchema).default([]),
profiles: z.array(ProfileSpecSchema).default([]), secrets: z.array(SecretSpecSchema).default([]),
projects: z.array(ProjectSpecSchema).default([]), projects: z.array(ProjectSpecSchema).default([]),
}); });
@@ -57,7 +62,7 @@ export function createApplyCommand(deps: ApplyCommandDeps): Command {
if (opts.dryRun) { if (opts.dryRun) {
log('Dry run - would apply:'); log('Dry run - would apply:');
if (config.servers.length > 0) log(` ${config.servers.length} server(s)`); if (config.servers.length > 0) log(` ${config.servers.length} server(s)`);
if (config.profiles.length > 0) log(` ${config.profiles.length} profile(s)`); if (config.secrets.length > 0) log(` ${config.secrets.length} secret(s)`);
if (config.projects.length > 0) log(` ${config.projects.length} project(s)`); if (config.projects.length > 0) log(` ${config.projects.length} project(s)`);
return; return;
} }
@@ -80,7 +85,7 @@ function loadConfigFile(path: string): ApplyConfig {
} }
async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args: unknown[]) => void): Promise<void> { async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args: unknown[]) => void): Promise<void> {
// Apply servers first (profiles depend on servers) // Apply servers first
for (const server of config.servers) { for (const server of config.servers) {
try { try {
const existing = await findByName(client, 'servers', server.name); const existing = await findByName(client, 'servers', server.name);
@@ -96,34 +101,19 @@ async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args
} }
} }
// Apply profiles (need server IDs) // Apply secrets
for (const profile of config.profiles) { for (const secret of config.secrets) {
try { try {
const server = await findByName(client, 'servers', profile.server); const existing = await findByName(client, 'secrets', secret.name);
if (!server) {
log(`Skipping profile '${profile.name}': server '${profile.server}' not found`);
continue;
}
const serverId = (server as { id: string }).id;
const existing = await findProfile(client, serverId, profile.name);
if (existing) { if (existing) {
await client.put(`/api/v1/profiles/${(existing as { id: string }).id}`, { await client.put(`/api/v1/secrets/${(existing as { id: string }).id}`, { data: secret.data });
permissions: profile.permissions, log(`Updated secret: ${secret.name}`);
envOverrides: profile.envOverrides,
});
log(`Updated profile: ${profile.name} (server: ${profile.server})`);
} else { } else {
await client.post('/api/v1/profiles', { await client.post('/api/v1/secrets', secret);
name: profile.name, log(`Created secret: ${secret.name}`);
serverId,
permissions: profile.permissions,
envOverrides: profile.envOverrides,
});
log(`Created profile: ${profile.name} (server: ${profile.server})`);
} }
} catch (err) { } catch (err) {
log(`Error applying profile '${profile.name}': ${err instanceof Error ? err.message : err}`); log(`Error applying secret '${secret.name}': ${err instanceof Error ? err.message : err}`);
} }
} }
@@ -158,16 +148,5 @@ async function findByName(client: ApiClient, resource: string, name: string): Pr
} }
} }
async function findProfile(client: ApiClient, serverId: string, name: string): Promise<unknown | null> {
try {
const profiles = await client.get<Array<{ name: string; serverId: string }>>(
`/api/v1/profiles?serverId=${serverId}`,
);
return profiles.find((p) => p.name === name) ?? null;
} catch {
return null;
}
}
// Export for testing // Export for testing
export { loadConfigFile, applyConfig }; export { loadConfigFile, applyConfig };

View File

@@ -0,0 +1,121 @@
import { Command } from 'commander';
import type { ApiClient } from '../api-client.js';
export interface CreateCommandDeps {
client: ApiClient;
log: (...args: unknown[]) => void;
}
function collect(value: string, prev: string[]): string[] {
return [...prev, value];
}
interface ServerEnvEntry {
name: string;
value?: string;
valueFrom?: { secretRef: { name: string; key: string } };
}
function parseServerEnv(entries: string[]): ServerEnvEntry[] {
return entries.map((entry) => {
const eqIdx = entry.indexOf('=');
if (eqIdx === -1) {
throw new Error(`Invalid env format '${entry}'. Expected KEY=value or KEY=secretRef:SECRET:KEY`);
}
const envName = entry.slice(0, eqIdx);
const rhs = entry.slice(eqIdx + 1);
if (rhs.startsWith('secretRef:')) {
const parts = rhs.split(':');
if (parts.length !== 3) {
throw new Error(`Invalid secretRef format '${entry}'. Expected KEY=secretRef:SECRET_NAME:SECRET_KEY`);
}
return {
name: envName,
valueFrom: { secretRef: { name: parts[1]!, key: parts[2]! } },
};
}
return { name: envName, value: rhs };
});
}
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, 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 <entry>', 'Env var: KEY=value (inline) or KEY=secretRef:SECRET:KEY (secret ref, 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.env.length > 0) body.env = parseServerEnv(opts.env);
const server = await client.post<{ id: string; name: string }>('/api/v1/servers', body);
log(`server '${server.name}' created (id: ${server.id})`);
});
// --- create secret ---
cmd.command('secret')
.description('Create a secret')
.argument('<name>', 'Secret name (lowercase, hyphens allowed)')
.option('--data <entry>', 'Secret data KEY=value (repeat for multiple)', collect, [])
.action(async (name: string, opts) => {
const data = parseEnvEntries(opts.data);
const secret = await client.post<{ id: string; name: string }>('/api/v1/secrets', {
name,
data,
});
log(`secret '${secret.name}' created (id: ${secret.id})`);
});
// --- create project ---
cmd.command('project')
.description('Create a project')
.argument('<name>', 'Project name')
.option('-d, --description <text>', 'Project description', '')
.action(async (name: string, opts) => {
const project = await client.post<{ id: string; name: string }>('/api/v1/projects', {
name,
description: opts.description,
});
log(`project '${project.name}' created (id: ${project.id})`);
});
return cmd;
}

View File

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

View File

@@ -1,74 +1,227 @@
import { Command } from 'commander'; import { Command } from 'commander';
import { formatJson, formatYaml } from '../formatters/output.js'; import { formatJson, formatYaml } from '../formatters/output.js';
import { resolveResource, resolveNameOrId } from './shared.js';
import type { ApiClient } from '../api-client.js';
export interface DescribeCommandDeps { export interface DescribeCommandDeps {
client: ApiClient;
fetchResource: (resource: string, id: string) => Promise<unknown>; fetchResource: (resource: string, id: string) => Promise<unknown>;
fetchInspect?: (id: string) => Promise<unknown>;
log: (...args: string[]) => void; log: (...args: string[]) => void;
} }
const RESOURCE_ALIASES: Record<string, string> = { function pad(label: string, width = 18): string {
server: 'servers', return label.padEnd(width);
srv: 'servers',
profile: 'profiles',
prof: 'profiles',
project: 'projects',
proj: 'projects',
instance: 'instances',
inst: 'instances',
};
function resolveResource(name: string): string {
const lower = name.toLowerCase();
return RESOURCE_ALIASES[lower] ?? lower;
} }
function formatDetail(obj: Record<string, unknown>, indent = 0): string { function formatServerDetail(server: Record<string, unknown>): string {
const pad = ' '.repeat(indent);
const lines: string[] = []; const lines: string[] = [];
lines.push(`=== Server: ${server.name} ===`);
lines.push(`${pad('Name:')}${server.name}`);
lines.push(`${pad('Transport:')}${server.transport ?? '-'}`);
lines.push(`${pad('Replicas:')}${server.replicas ?? 1}`);
if (server.dockerImage) lines.push(`${pad('Docker Image:')}${server.dockerImage}`);
if (server.packageName) lines.push(`${pad('Package:')}${server.packageName}`);
if (server.externalUrl) lines.push(`${pad('External URL:')}${server.externalUrl}`);
if (server.repositoryUrl) lines.push(`${pad('Repository:')}${server.repositoryUrl}`);
if (server.containerPort) lines.push(`${pad('Container Port:')}${server.containerPort}`);
if (server.description) lines.push(`${pad('Description:')}${server.description}`);
const command = server.command as string[] | null;
if (command && command.length > 0) {
lines.push('');
lines.push('Command:');
lines.push(` ${command.join(' ')}`);
}
const env = server.env as Array<{ name: string; value?: string; valueFrom?: { secretRef: { name: string; key: string } } }> | undefined;
if (env && env.length > 0) {
lines.push('');
lines.push('Environment:');
const nameW = Math.max(6, ...env.map((e) => e.name.length)) + 2;
lines.push(` ${'NAME'.padEnd(nameW)}SOURCE`);
for (const e of env) {
if (e.value !== undefined) {
lines.push(` ${e.name.padEnd(nameW)}${e.value}`);
} else if (e.valueFrom?.secretRef) {
const ref = e.valueFrom.secretRef;
lines.push(` ${e.name.padEnd(nameW)}secret:${ref.name}/${ref.key}`);
}
}
}
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 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 formatSecretDetail(secret: Record<string, unknown>, showValues: boolean): string {
const lines: string[] = [];
lines.push(`=== Secret: ${secret.name} ===`);
lines.push(`${pad('Name:')}${secret.name}`);
const data = secret.data as Record<string, string> | undefined;
if (data && Object.keys(data).length > 0) {
lines.push('');
lines.push('Data:');
const keyW = Math.max(4, ...Object.keys(data).map((k) => k.length)) + 2;
for (const [key, value] of Object.entries(data)) {
const display = showValues ? value : '***';
lines.push(` ${key.padEnd(keyW)}${display}`);
}
if (!showValues) {
lines.push('');
lines.push(' (use --show-values to reveal)');
}
} else {
lines.push(`${pad('Data:')}(empty)`);
}
lines.push('');
lines.push('Metadata:');
lines.push(` ${pad('ID:', 12)}${secret.id}`);
if (secret.createdAt) lines.push(` ${pad('Created:', 12)}${secret.createdAt}`);
if (secret.updatedAt) lines.push(` ${pad('Updated:', 12)}${secret.updatedAt}`);
return lines.join('\n');
}
function formatGenericDetail(obj: Record<string, unknown>): string {
const lines: string[] = [];
for (const [key, value] of Object.entries(obj)) { for (const [key, value] of Object.entries(obj)) {
if (value === null || value === undefined) { if (value === null || value === undefined) {
lines.push(`${pad}${key}: -`); lines.push(`${pad(key + ':')} -`);
} else if (Array.isArray(value)) { } else if (Array.isArray(value)) {
if (value.length === 0) { if (value.length === 0) {
lines.push(`${pad}${key}: []`); lines.push(`${pad(key + ':')} []`);
} else if (typeof value[0] === 'object') {
lines.push(`${pad}${key}:`);
for (const item of value) {
lines.push(`${pad} - ${JSON.stringify(item)}`);
}
} else { } else {
lines.push(`${pad}${key}: ${value.join(', ')}`); lines.push(`${key}:`);
for (const item of value) {
lines.push(` - ${typeof item === 'object' ? JSON.stringify(item) : String(item)}`);
}
} }
} else if (typeof value === 'object') { } else if (typeof value === 'object') {
lines.push(`${pad}${key}:`); lines.push(`${key}:`);
lines.push(formatDetail(value as Record<string, unknown>, indent + 1)); for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
lines.push(` ${pad(k + ':')}${String(v)}`);
}
} else { } else {
lines.push(`${pad}${key}: ${String(value)}`); lines.push(`${pad(key + ':')}${String(value)}`);
} }
} }
return lines.join('\n'); return lines.join('\n');
} }
export function createDescribeCommand(deps: DescribeCommandDeps): Command { export function createDescribeCommand(deps: DescribeCommandDeps): Command {
return new Command('describe') return new Command('describe')
.description('Show detailed information about a resource') .description('Show detailed information about a resource')
.argument('<resource>', 'resource type (server, profile, project, instance)') .argument('<resource>', 'resource type (server, project, instance)')
.argument('<id>', 'resource ID') .argument('<id>', 'resource ID or name')
.option('-o, --output <format>', 'output format (detail, json, yaml)', 'detail') .option('-o, --output <format>', 'output format (detail, json, yaml)', 'detail')
.action(async (resourceArg: string, id: string, opts: { output: string }) => { .option('--show-values', 'Show secret values (default: masked)')
.action(async (resourceArg: string, idOrName: string, opts: { output: string; showValues?: boolean }) => {
const resource = resolveResource(resourceArg); const resource = resolveResource(resourceArg);
const item = await deps.fetchResource(resource, id);
// Resolve name → ID
let id: string;
try {
id = await resolveNameOrId(deps.client, resource, idOrName);
} catch {
id = idOrName;
}
const item = await deps.fetchResource(resource, id) as Record<string, unknown>;
// Enrich instances with container inspect data
let inspect: Record<string, unknown> | undefined;
if (resource === 'instances' && deps.fetchInspect && item.containerId) {
try {
inspect = await deps.fetchInspect(id) as Record<string, unknown>;
item.containerInspect = inspect;
} catch {
// Container may not be available
}
}
if (opts.output === 'json') { if (opts.output === 'json') {
deps.log(formatJson(item)); deps.log(formatJson(item));
} else if (opts.output === 'yaml') { } else if (opts.output === 'yaml') {
deps.log(formatYaml(item)); deps.log(formatYaml(item));
} else { } else {
const typeName = resource.replace(/s$/, '').charAt(0).toUpperCase() + resource.replace(/s$/, '').slice(1); // Visually clean sectioned output
deps.log(`--- ${typeName} ---`); switch (resource) {
deps.log(formatDetail(item as Record<string, unknown>)); case 'servers':
deps.log(formatServerDetail(item));
break;
case 'instances':
deps.log(formatInstanceDetail(item, inspect));
break;
case 'secrets':
deps.log(formatSecretDetail(item, opts.showValues === true));
break;
case 'projects':
deps.log(formatProjectDetail(item));
break;
default:
deps.log(formatGenericDetail(item));
}
} }
}); });
} }

View File

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

View File

@@ -2,6 +2,7 @@ import { Command } from 'commander';
import { formatTable } from '../formatters/table.js'; import { formatTable } from '../formatters/table.js';
import { formatJson, formatYaml } from '../formatters/output.js'; import { formatJson, formatYaml } from '../formatters/output.js';
import type { Column } from '../formatters/table.js'; import type { Column } from '../formatters/table.js';
import { resolveResource, stripInternalFields } from './shared.js';
export interface GetCommandDeps { export interface GetCommandDeps {
fetchResource: (resource: string, id?: string) => Promise<unknown[]>; fetchResource: (resource: string, id?: string) => Promise<unknown[]>;
@@ -16,12 +17,6 @@ interface ServerRow {
dockerImage: string | null; dockerImage: string | null;
} }
interface ProfileRow {
id: string;
name: string;
serverId: string;
}
interface ProjectRow { interface ProjectRow {
id: string; id: string;
name: string; name: string;
@@ -29,6 +24,12 @@ interface ProjectRow {
ownerId: string; ownerId: string;
} }
interface SecretRow {
id: string;
name: string;
data: Record<string, string>;
}
interface InstanceRow { interface InstanceRow {
id: string; id: string;
serverId: string; serverId: string;
@@ -37,22 +38,6 @@ interface InstanceRow {
port: number | null; port: number | null;
} }
const RESOURCE_ALIASES: Record<string, string> = {
server: 'servers',
srv: 'servers',
profile: 'profiles',
prof: 'profiles',
project: 'projects',
proj: 'projects',
instance: 'instances',
inst: 'instances',
};
function resolveResource(name: string): string {
const lower = name.toLowerCase();
return RESOURCE_ALIASES[lower] ?? lower;
}
const serverColumns: Column<ServerRow>[] = [ const serverColumns: Column<ServerRow>[] = [
{ header: 'NAME', key: 'name' }, { header: 'NAME', key: 'name' },
{ header: 'TRANSPORT', key: 'transport', width: 16 }, { header: 'TRANSPORT', key: 'transport', width: 16 },
@@ -61,12 +46,6 @@ const serverColumns: Column<ServerRow>[] = [
{ header: 'ID', key: 'id' }, { header: 'ID', key: 'id' },
]; ];
const profileColumns: Column<ProfileRow>[] = [
{ header: 'NAME', key: 'name' },
{ header: 'SERVER ID', key: 'serverId' },
{ header: 'ID', key: 'id' },
];
const projectColumns: Column<ProjectRow>[] = [ const projectColumns: Column<ProjectRow>[] = [
{ header: 'NAME', key: 'name' }, { header: 'NAME', key: 'name' },
{ header: 'DESCRIPTION', key: 'description', width: 40 }, { header: 'DESCRIPTION', key: 'description', width: 40 },
@@ -74,6 +53,12 @@ const projectColumns: Column<ProjectRow>[] = [
{ header: 'ID', key: 'id' }, { header: 'ID', key: 'id' },
]; ];
const secretColumns: Column<SecretRow>[] = [
{ header: 'NAME', key: 'name' },
{ header: 'KEYS', key: (r) => Object.keys(r.data).join(', ') || '-', width: 40 },
{ header: 'ID', key: 'id' },
];
const instanceColumns: Column<InstanceRow>[] = [ const instanceColumns: Column<InstanceRow>[] = [
{ header: 'STATUS', key: 'status', width: 10 }, { header: 'STATUS', key: 'status', width: 10 },
{ header: 'SERVER ID', key: 'serverId' }, { header: 'SERVER ID', key: 'serverId' },
@@ -86,10 +71,10 @@ function getColumnsForResource(resource: string): Column<Record<string, unknown>
switch (resource) { switch (resource) {
case 'servers': case 'servers':
return serverColumns as unknown as Column<Record<string, unknown>>[]; return serverColumns as unknown as Column<Record<string, unknown>>[];
case 'profiles':
return profileColumns as unknown as Column<Record<string, unknown>>[];
case 'projects': case 'projects':
return projectColumns as unknown as Column<Record<string, unknown>>[]; return projectColumns as unknown as Column<Record<string, unknown>>[];
case 'secrets':
return secretColumns as unknown as Column<Record<string, unknown>>[];
case 'instances': case 'instances':
return instanceColumns as unknown as Column<Record<string, unknown>>[]; return instanceColumns as unknown as Column<Record<string, unknown>>[];
default: default:
@@ -100,21 +85,38 @@ 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) => {
return stripInternalFields(item as Record<string, unknown>);
});
return { [resource]: cleaned };
}
export function createGetCommand(deps: GetCommandDeps): Command { export function createGetCommand(deps: GetCommandDeps): Command {
return new Command('get') return new Command('get')
.description('List resources (servers, profiles, projects, instances)') .description('List resources (servers, projects, instances)')
.argument('<resource>', 'resource type (servers, profiles, projects, instances)') .argument('<resource>', 'resource type (servers, projects, instances)')
.argument('[id]', 'specific resource ID') .argument('[id]', 'specific resource ID or name')
.option('-o, --output <format>', 'output format (table, json, yaml)', 'table') .option('-o, --output <format>', 'output format (table, json, yaml)', 'table')
.action(async (resourceArg: string, id: string | undefined, opts: { output: string }) => { .action(async (resourceArg: string, id: string | undefined, opts: { output: string }) => {
const resource = resolveResource(resourceArg); const resource = resolveResource(resourceArg);
const items = await deps.fetchResource(resource, id); const items = await deps.fetchResource(resource, id);
if (opts.output === 'json') { if (opts.output === 'json') {
deps.log(formatJson(items.length === 1 ? items[0] : items)); // Apply-compatible JSON wrapped in resource key
deps.log(formatJson(toApplyFormat(resource, items)));
} else if (opts.output === 'yaml') { } else if (opts.output === 'yaml') {
deps.log(formatYaml(items.length === 1 ? items[0] : items)); // Apply-compatible YAML wrapped in resource key
deps.log(formatYaml(toApplyFormat(resource, items)));
} else { } else {
if (items.length === 0) {
deps.log(`No ${resource} found.`);
return;
}
const columns = getColumnsForResource(resource); const columns = getColumnsForResource(resource);
deps.log(formatTable(items as Record<string, unknown>[], columns)); deps.log(formatTable(items as Record<string, unknown>[], columns));
} }

View File

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

View File

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

View File

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

View File

@@ -1,103 +0,0 @@
import { Command } from 'commander';
import type { ApiClient } from '../api-client.js';
export interface SetupPromptDeps {
input: (message: string) => Promise<string>;
password: (message: string) => Promise<string>;
select: <T extends string>(message: string, choices: Array<{ name: string; value: T }>) => Promise<T>;
confirm: (message: string) => Promise<boolean>;
}
export interface SetupCommandDeps {
client: ApiClient;
prompt: SetupPromptDeps;
log: (...args: unknown[]) => void;
}
export function createSetupCommand(deps: SetupCommandDeps): Command {
const { client, prompt, log } = deps;
return new Command('setup')
.description('Interactive wizard for configuring an MCP server')
.argument('[server-name]', 'Server name to set up (will prompt if not given)')
.action(async (serverName?: string) => {
log('MCP Server Setup Wizard\n');
// Step 1: Server name
const name = serverName ?? await prompt.input('Server name (lowercase, hyphens allowed):');
if (!name) {
log('Setup cancelled.');
return;
}
// Step 2: Transport
const transport = await prompt.select('Transport type:', [
{ name: 'STDIO (command-line process)', value: 'STDIO' as const },
{ name: 'SSE (Server-Sent Events over HTTP)', value: 'SSE' as const },
{ name: 'Streamable HTTP', value: 'STREAMABLE_HTTP' as const },
]);
// Step 3: Package or image
const packageName = await prompt.input('NPM package name (or leave empty):');
const dockerImage = await prompt.input('Docker image (or leave empty):');
// Step 4: Description
const description = await prompt.input('Description:');
// Step 5: Create the server
const serverData: Record<string, unknown> = {
name,
transport,
description,
};
if (packageName) serverData.packageName = packageName;
if (dockerImage) serverData.dockerImage = dockerImage;
let server: { id: string; name: string };
try {
server = await client.post<{ id: string; name: string }>('/api/v1/servers', serverData);
log(`\nServer '${server.name}' created.`);
} catch (err) {
log(`\nFailed to create server: ${err instanceof Error ? err.message : err}`);
return;
}
// Step 6: Create a profile with env vars
const createProfile = await prompt.confirm('Create a profile with environment variables?');
if (!createProfile) {
log('\nSetup complete!');
return;
}
const profileName = await prompt.input('Profile name:') || 'default';
// Collect env vars
const envOverrides: Record<string, string> = {};
let addMore = true;
while (addMore) {
const envName = await prompt.input('Environment variable name (empty to finish):');
if (!envName) break;
const isSecret = await prompt.confirm(`Is '${envName}' a secret (e.g., API key)?`);
const envValue = isSecret
? await prompt.password(`Value for ${envName}:`)
: await prompt.input(`Value for ${envName}:`);
envOverrides[envName] = envValue;
addMore = await prompt.confirm('Add another environment variable?');
}
try {
await client.post('/api/v1/profiles', {
name: profileName,
serverId: server.id,
envOverrides,
});
log(`Profile '${profileName}' created for server '${name}'.`);
} catch (err) {
log(`Failed to create profile: ${err instanceof Error ? err.message : err}`);
}
log('\nSetup complete!');
});
}

View File

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

View File

@@ -5,9 +5,11 @@ import { createConfigCommand } from './commands/config.js';
import { createStatusCommand } from './commands/status.js'; import { createStatusCommand } from './commands/status.js';
import { createGetCommand } from './commands/get.js'; import { createGetCommand } from './commands/get.js';
import { createDescribeCommand } from './commands/describe.js'; import { createDescribeCommand } from './commands/describe.js';
import { createInstanceCommands } from './commands/instances.js'; import { createDeleteCommand } from './commands/delete.js';
import { createLogsCommand } from './commands/logs.js';
import { createApplyCommand } from './commands/apply.js'; import { createApplyCommand } from './commands/apply.js';
import { createSetupCommand } from './commands/setup.js'; import { createCreateCommand } from './commands/create.js';
import { createEditCommand } from './commands/edit.js';
import { createClaudeCommand } from './commands/claude.js'; import { createClaudeCommand } from './commands/claude.js';
import { createProjectCommand } from './commands/project.js'; import { createProjectCommand } from './commands/project.js';
import { createBackupCommand, createRestoreCommand } from './commands/backup.js'; import { createBackupCommand, createRestoreCommand } from './commands/backup.js';
@@ -15,13 +17,14 @@ import { createLoginCommand, createLogoutCommand } from './commands/auth.js';
import { ApiClient } from './api-client.js'; import { ApiClient } from './api-client.js';
import { loadConfig } from './config/index.js'; import { loadConfig } from './config/index.js';
import { loadCredentials } from './auth/index.js'; import { loadCredentials } from './auth/index.js';
import { resolveNameOrId } from './commands/shared.js';
export function createProgram(): Command { export function createProgram(): Command {
const program = new Command() const program = new Command()
.name(APP_NAME) .name(APP_NAME)
.description('Manage MCP servers like kubectl manages containers') .description('Manage MCP servers like kubectl manages containers')
.version(APP_VERSION, '-v, --version') .version(APP_VERSION, '-v, --version')
.option('-o, --output <format>', 'output format (table, json, yaml)', 'table') .enablePositionalOptions()
.option('--daemon-url <url>', 'mcplocal daemon URL') .option('--daemon-url <url>', 'mcplocal daemon URL')
.option('--direct', 'bypass mcplocal and connect directly to mcpd'); .option('--direct', 'bypass mcplocal and connect directly to mcpd');
@@ -45,15 +48,27 @@ export function createProgram(): Command {
const client = new ApiClient({ baseUrl, token: creds?.token ?? undefined }); const client = new ApiClient({ baseUrl, token: creds?.token ?? undefined });
const fetchResource = async (resource: string, id?: string): Promise<unknown[]> => { const fetchResource = async (resource: string, nameOrId?: string): Promise<unknown[]> => {
if (id) { if (nameOrId) {
let id: string;
try {
id = await resolveNameOrId(client, resource, nameOrId);
} catch {
id = nameOrId;
}
const item = await client.get(`/api/v1/${resource}/${id}`); const item = await client.get(`/api/v1/${resource}/${id}`);
return [item]; return [item];
} }
return client.get<unknown[]>(`/api/v1/${resource}`); return client.get<unknown[]>(`/api/v1/${resource}`);
}; };
const fetchSingleResource = async (resource: string, id: string): Promise<unknown> => { const fetchSingleResource = async (resource: string, nameOrId: string): Promise<unknown> => {
let id: string;
try {
id = await resolveNameOrId(client, resource, nameOrId);
} catch {
id = nameOrId;
}
return client.get(`/api/v1/${resource}/${id}`); return client.get(`/api/v1/${resource}/${id}`);
}; };
@@ -63,11 +78,28 @@ export function createProgram(): Command {
})); }));
program.addCommand(createDescribeCommand({ program.addCommand(createDescribeCommand({
client,
fetchResource: fetchSingleResource, fetchResource: fetchSingleResource,
fetchInspect: async (id: string) => client.get(`/api/v1/instances/${id}/inspect`),
log: (...args) => console.log(...args), log: (...args) => console.log(...args),
})); }));
program.addCommand(createInstanceCommands({ program.addCommand(createDeleteCommand({
client,
log: (...args) => console.log(...args),
}));
program.addCommand(createLogsCommand({
client,
log: (...args) => console.log(...args),
}));
program.addCommand(createCreateCommand({
client,
log: (...args) => console.log(...args),
}));
program.addCommand(createEditCommand({
client, client,
log: (...args) => console.log(...args), log: (...args) => console.log(...args),
})); }));
@@ -77,33 +109,6 @@ export function createProgram(): Command {
log: (...args) => console.log(...args), log: (...args) => console.log(...args),
})); }));
program.addCommand(createSetupCommand({
client,
prompt: {
async input(message) {
const { default: inquirer } = await import('inquirer');
const { answer } = await inquirer.prompt([{ type: 'input', name: 'answer', message }]);
return answer as string;
},
async password(message) {
const { default: inquirer } = await import('inquirer');
const { answer } = await inquirer.prompt([{ type: 'password', name: 'answer', message }]);
return answer as string;
},
async select(message, choices) {
const { default: inquirer } = await import('inquirer');
const { answer } = await inquirer.prompt([{ type: 'list', name: 'answer', message, choices }]);
return answer;
},
async confirm(message) {
const { default: inquirer } = await import('inquirer');
const { answer } = await inquirer.prompt([{ type: 'confirm', name: 'answer', message }]);
return answer as boolean;
},
},
log: (...args) => console.log(...args),
}));
program.addCommand(createClaudeCommand({ program.addCommand(createClaudeCommand({
client, client,
log: (...args) => console.log(...args), log: (...args) => console.log(...args),

View File

@@ -24,9 +24,10 @@ describe('createProgram', () => {
expect(status).toBeDefined(); expect(status).toBeDefined();
}); });
it('has output option', () => { it('subcommands have output option', () => {
const program = createProgram(); const program = createProgram();
const opt = program.options.find((o) => o.long === '--output'); const get = program.commands.find((c) => c.name() === 'get');
const opt = get?.options.find((o) => o.long === '--output');
expect(opt).toBeDefined(); expect(opt).toBeDefined();
}); });

View File

@@ -86,9 +86,6 @@ servers:
servers: servers:
- name: test - name: test
transport: STDIO transport: STDIO
profiles:
- name: default
server: test
`); `);
const cmd = createApplyCommand({ client, log }); const cmd = createApplyCommand({ client, log });
@@ -97,52 +94,51 @@ profiles:
expect(client.post).not.toHaveBeenCalled(); expect(client.post).not.toHaveBeenCalled();
expect(output.join('\n')).toContain('Dry run'); expect(output.join('\n')).toContain('Dry run');
expect(output.join('\n')).toContain('1 server(s)'); expect(output.join('\n')).toContain('1 server(s)');
expect(output.join('\n')).toContain('1 profile(s)');
rmSync(tmpDir, { recursive: true, force: true }); rmSync(tmpDir, { recursive: true, force: true });
}); });
it('applies profiles with server lookup', async () => { it('applies secrets', async () => {
const configPath = join(tmpDir, 'config.yaml');
writeFileSync(configPath, `
secrets:
- name: ha-creds
data:
TOKEN: abc123
URL: https://ha.local
`);
const cmd = createApplyCommand({ client, log });
await cmd.parseAsync([configPath], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/secrets', expect.objectContaining({
name: 'ha-creds',
data: { TOKEN: 'abc123', URL: 'https://ha.local' },
}));
expect(output.join('\n')).toContain('Created secret: ha-creds');
rmSync(tmpDir, { recursive: true, force: true });
});
it('updates existing secrets', async () => {
vi.mocked(client.get).mockImplementation(async (url: string) => { vi.mocked(client.get).mockImplementation(async (url: string) => {
if (url === '/api/v1/servers') return [{ id: 'srv-1', name: 'slack' }]; if (url === '/api/v1/secrets') return [{ id: 'sec-1', name: 'ha-creds' }];
return []; return [];
}); });
const configPath = join(tmpDir, 'config.yaml'); const configPath = join(tmpDir, 'config.yaml');
writeFileSync(configPath, ` writeFileSync(configPath, `
profiles: secrets:
- name: default - name: ha-creds
server: slack data:
envOverrides: TOKEN: new-token
SLACK_TOKEN: "xoxb-test"
`); `);
const cmd = createApplyCommand({ client, log }); const cmd = createApplyCommand({ client, log });
await cmd.parseAsync([configPath], { from: 'user' }); await cmd.parseAsync([configPath], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/profiles', expect.objectContaining({ expect(client.put).toHaveBeenCalledWith('/api/v1/secrets/sec-1', { data: { TOKEN: 'new-token' } });
name: 'default', expect(output.join('\n')).toContain('Updated secret: ha-creds');
serverId: 'srv-1',
envOverrides: { SLACK_TOKEN: 'xoxb-test' },
}));
expect(output.join('\n')).toContain('Created profile: default');
rmSync(tmpDir, { recursive: true, force: true });
});
it('skips profiles when server not found', async () => {
const configPath = join(tmpDir, 'config.yaml');
writeFileSync(configPath, `
profiles:
- name: default
server: nonexistent
`);
const cmd = createApplyCommand({ client, log });
await cmd.parseAsync([configPath], { from: 'user' });
expect(client.post).not.toHaveBeenCalled();
expect(output.join('\n')).toContain("Skipping profile 'default'");
rmSync(tmpDir, { recursive: true, force: true }); rmSync(tmpDir, { recursive: true, force: true });
}); });

View File

@@ -0,0 +1,123 @@
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', 'API_KEY=secretRef:creds:API_KEY',
'--env', 'BASE_URL=http://localhost',
], { 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")'],
env: [
{ name: 'API_KEY', valueFrom: { secretRef: { name: 'creds', key: 'API_KEY' } } },
{ name: 'BASE_URL', value: 'http://localhost' },
],
});
});
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 secret', () => {
it('creates a secret with --data flags', async () => {
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync([
'secret', 'ha-creds',
'--data', 'TOKEN=abc123',
'--data', 'URL=https://ha.local',
], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/secrets', {
name: 'ha-creds',
data: { TOKEN: 'abc123', URL: 'https://ha.local' },
});
expect(output.join('\n')).toContain("secret 'test' created");
});
it('creates a secret with empty data', async () => {
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync(['secret', 'empty-secret'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/secrets', {
name: 'empty-secret',
data: {},
});
});
});
describe('create project', () => {
it('creates a project', async () => {
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync(['project', 'my-project', '-d', 'A test project'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/projects', {
name: 'my-project',
description: 'A test project',
});
expect(output.join('\n')).toContain("project 'test' created");
});
it('creates a project with no description', async () => {
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync(['project', 'minimal'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/projects', {
name: 'minimal',
description: '',
});
});
});
});

View File

@@ -1,42 +1,59 @@
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { createDescribeCommand } from '../../src/commands/describe.js'; import { createDescribeCommand } from '../../src/commands/describe.js';
import type { DescribeCommandDeps } from '../../src/commands/describe.js'; import type { DescribeCommandDeps } from '../../src/commands/describe.js';
import type { ApiClient } from '../../src/api-client.js';
function mockClient(): ApiClient {
return {
get: vi.fn(async () => []),
post: vi.fn(async () => ({})),
put: vi.fn(async () => ({})),
delete: vi.fn(async () => {}),
} as unknown as ApiClient;
}
function makeDeps(item: unknown = {}): DescribeCommandDeps & { output: string[] } { function makeDeps(item: unknown = {}): DescribeCommandDeps & { output: string[] } {
const output: string[] = []; const output: string[] = [];
return { return {
output, output,
client: mockClient(),
fetchResource: vi.fn(async () => item), fetchResource: vi.fn(async () => item),
log: (...args: string[]) => output.push(args.join(' ')), log: (...args: string[]) => output.push(args.join(' ')),
}; };
} }
describe('describe command', () => { describe('describe command', () => {
it('shows detailed server info', async () => { it('shows detailed server info with sections', async () => {
const deps = makeDeps({ const deps = makeDeps({
id: 'srv-1', id: 'srv-1',
name: 'slack', name: 'slack',
transport: 'STDIO', transport: 'STDIO',
packageName: '@slack/mcp', packageName: '@slack/mcp',
dockerImage: null, dockerImage: null,
envTemplate: [], env: [],
createdAt: '2025-01-01',
}); });
const cmd = createDescribeCommand(deps); const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'server', 'srv-1']); await cmd.parseAsync(['node', 'test', 'server', 'srv-1']);
expect(deps.fetchResource).toHaveBeenCalledWith('servers', 'srv-1'); expect(deps.fetchResource).toHaveBeenCalledWith('servers', 'srv-1');
const text = deps.output.join('\n'); const text = deps.output.join('\n');
expect(text).toContain('--- Server ---'); expect(text).toContain('=== Server: slack ===');
expect(text).toContain('name: slack'); expect(text).toContain('Name:');
expect(text).toContain('transport: STDIO'); expect(text).toContain('slack');
expect(text).toContain('dockerImage: -'); expect(text).toContain('Transport:');
expect(text).toContain('STDIO');
expect(text).toContain('Package:');
expect(text).toContain('@slack/mcp');
expect(text).toContain('Metadata:');
expect(text).toContain('ID:');
}); });
it('resolves resource aliases', async () => { it('resolves resource aliases', async () => {
const deps = makeDeps({ id: 'p1' }); const deps = makeDeps({ id: 's1' });
const cmd = createDescribeCommand(deps); const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'prof', 'p1']); await cmd.parseAsync(['node', 'test', 'sec', 's1']);
expect(deps.fetchResource).toHaveBeenCalledWith('profiles', 'p1'); expect(deps.fetchResource).toHaveBeenCalledWith('secrets', 's1');
}); });
it('outputs JSON format', async () => { it('outputs JSON format', async () => {
@@ -55,31 +72,71 @@ describe('describe command', () => {
expect(deps.output[0]).toContain('name: slack'); expect(deps.output[0]).toContain('name: slack');
}); });
it('formats nested objects', async () => { it('shows project detail', async () => {
const deps = makeDeps({ const deps = makeDeps({
id: 'srv-1', id: 'proj-1',
name: 'slack', name: 'my-project',
metadata: { version: '1.0', nested: { deep: true } }, description: 'A test project',
ownerId: 'user-1',
createdAt: '2025-01-01',
}); });
const cmd = createDescribeCommand(deps); const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'server', 'srv-1']); await cmd.parseAsync(['node', 'test', 'project', 'proj-1']);
const text = deps.output.join('\n'); const text = deps.output.join('\n');
expect(text).toContain('metadata:'); expect(text).toContain('=== Project: my-project ===');
expect(text).toContain('version: 1.0'); expect(text).toContain('A test project');
expect(text).toContain('user-1');
}); });
it('formats arrays correctly', async () => { it('shows secret detail with masked values', async () => {
const deps = makeDeps({ const deps = makeDeps({
id: 'srv-1', id: 'sec-1',
permissions: ['read', 'write'], name: 'ha-creds',
envTemplate: [], data: { TOKEN: 'abc123', URL: 'https://ha.local' },
createdAt: '2025-01-01',
}); });
const cmd = createDescribeCommand(deps); const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'server', 'srv-1']); await cmd.parseAsync(['node', 'test', 'secret', 'sec-1']);
const text = deps.output.join('\n'); const text = deps.output.join('\n');
expect(text).toContain('permissions: read, write'); expect(text).toContain('=== Secret: ha-creds ===');
expect(text).toContain('envTemplate: []'); expect(text).toContain('TOKEN');
expect(text).toContain('***');
expect(text).not.toContain('abc123');
expect(text).toContain('use --show-values to reveal');
});
it('shows secret detail with revealed values when --show-values', async () => {
const deps = makeDeps({
id: 'sec-1',
name: 'ha-creds',
data: { TOKEN: 'abc123' },
createdAt: '2025-01-01',
});
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'secret', 'sec-1', '--show-values']);
const text = deps.output.join('\n');
expect(text).toContain('abc123');
expect(text).not.toContain('***');
});
it('shows instance detail with container info', async () => {
const deps = makeDeps({
id: 'inst-1',
serverId: 'srv-1',
status: 'RUNNING',
containerId: 'abc123',
port: 3000,
createdAt: '2025-01-01',
});
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'instance', 'inst-1']);
const text = deps.output.join('\n');
expect(text).toContain('=== Instance: inst-1 ===');
expect(text).toContain('RUNNING');
expect(text).toContain('abc123');
}); });
}); });

View File

@@ -0,0 +1,153 @@
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');
});
});

View File

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

View File

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

View File

@@ -21,91 +21,9 @@ describe('project command', () => {
output = []; output = [];
}); });
describe('list', () => { it('creates command with alias', () => {
it('shows no projects message when empty', async () => {
const cmd = createProjectCommand({ client, log }); const cmd = createProjectCommand({ client, log });
await cmd.parseAsync(['list'], { from: 'user' }); expect(cmd.name()).toBe('project');
expect(output.join('\n')).toContain('No projects found'); expect(cmd.alias()).toBe('proj');
});
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([
{ id: 'prof-1', name: 'default', serverId: 'srv-1' },
]);
const cmd = createProjectCommand({ client, log });
await cmd.parseAsync(['profiles', 'proj-1'], { from: 'user' });
expect(client.get).toHaveBeenCalledWith('/api/v1/projects/proj-1/profiles');
expect(output.join('\n')).toContain('default');
});
it('shows empty message when no profiles', async () => {
const cmd = createProjectCommand({ client, log });
await cmd.parseAsync(['profiles', 'proj-1'], { from: 'user' });
expect(output.join('\n')).toContain('No profiles assigned');
});
});
describe('set-profiles', () => {
it('sets profiles for a project', async () => {
const cmd = createProjectCommand({ client, log });
await cmd.parseAsync(['set-profiles', 'proj-1', 'prof-1', 'prof-2'], { from: 'user' });
expect(client.put).toHaveBeenCalledWith('/api/v1/projects/proj-1/profiles', {
profileIds: ['prof-1', 'prof-2'],
});
expect(output.join('\n')).toContain('2 profile(s)');
});
}); });
}); });

View File

@@ -1,141 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createSetupCommand } from '../../src/commands/setup.js';
import type { ApiClient } from '../../src/api-client.js';
import type { SetupPromptDeps } from '../../src/commands/setup.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;
}
function mockPrompt(answers: Record<string, string | boolean>): SetupPromptDeps {
const answersQueue = { ...answers };
return {
input: vi.fn(async (message: string) => {
for (const [key, val] of Object.entries(answersQueue)) {
if (message.toLowerCase().includes(key.toLowerCase()) && typeof val === 'string') {
delete answersQueue[key];
return val;
}
}
return '';
}),
password: vi.fn(async () => 'secret-value'),
select: vi.fn(async () => 'STDIO') as SetupPromptDeps['select'],
confirm: vi.fn(async (message: string) => {
if (message.includes('profile')) return true;
if (message.includes('secret')) return false;
if (message.includes('another')) return false;
return false;
}),
};
}
describe('setup command', () => {
let client: ReturnType<typeof mockClient>;
let output: string[];
const log = (...args: unknown[]) => output.push(args.map(String).join(' '));
beforeEach(() => {
client = mockClient();
output = [];
});
it('creates server with prompted values', async () => {
const prompt = mockPrompt({
'transport': 'STDIO',
'npm package': '@anthropic/slack-mcp',
'docker image': '',
'description': 'Slack server',
'profile name': 'default',
'environment variable name': '',
});
const cmd = createSetupCommand({ client, prompt, log });
await cmd.parseAsync(['slack'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/servers', expect.objectContaining({
name: 'slack',
transport: 'STDIO',
}));
expect(output.join('\n')).toContain("Server 'test' created");
});
it('creates profile with env vars', async () => {
vi.mocked(client.post)
.mockResolvedValueOnce({ id: 'srv-1', name: 'slack' }) // server create
.mockResolvedValueOnce({ id: 'prof-1', name: 'default' }); // profile create
const prompt = mockPrompt({
'transport': 'STDIO',
'npm package': '',
'docker image': '',
'description': '',
'profile name': 'default',
});
// Override confirm to create profile and add one env var
let confirmCallCount = 0;
vi.mocked(prompt.confirm).mockImplementation(async (msg: string) => {
confirmCallCount++;
if (msg.includes('profile')) return true;
if (msg.includes('secret')) return true;
if (msg.includes('another')) return false;
return false;
});
// Override input to provide env var name then empty to stop
let inputCallCount = 0;
vi.mocked(prompt.input).mockImplementation(async (msg: string) => {
inputCallCount++;
if (msg.includes('Profile name')) return 'default';
if (msg.includes('variable name') && inputCallCount <= 8) return 'API_KEY';
if (msg.includes('variable name')) return '';
return '';
});
const cmd = createSetupCommand({ client, prompt, log });
await cmd.parseAsync(['slack'], { from: 'user' });
expect(client.post).toHaveBeenCalledTimes(2);
const profileCall = vi.mocked(client.post).mock.calls[1];
expect(profileCall?.[0]).toBe('/api/v1/profiles');
expect(profileCall?.[1]).toEqual(expect.objectContaining({
name: 'default',
serverId: 'srv-1',
}));
});
it('exits if server creation fails', async () => {
vi.mocked(client.post).mockRejectedValue(new Error('conflict'));
const prompt = mockPrompt({
'npm package': '',
'docker image': '',
'description': '',
});
const cmd = createSetupCommand({ client, prompt, log });
await cmd.parseAsync(['slack'], { from: 'user' });
expect(output.join('\n')).toContain('Failed to create server');
expect(client.post).toHaveBeenCalledTimes(1); // Only server create, no profile
});
it('skips profile creation when declined', async () => {
const prompt = mockPrompt({
'npm package': '',
'docker image': '',
'description': '',
});
vi.mocked(prompt.confirm).mockResolvedValue(false);
const cmd = createSetupCommand({ client, prompt, log });
await cmd.parseAsync(['test-server'], { from: 'user' });
expect(client.post).toHaveBeenCalledTimes(1); // Only server create
expect(output.join('\n')).toContain('Setup complete');
});
});

View File

@@ -16,26 +16,21 @@ describe('CLI command registration (e2e)', () => {
expect(commandNames).toContain('logout'); expect(commandNames).toContain('logout');
expect(commandNames).toContain('get'); expect(commandNames).toContain('get');
expect(commandNames).toContain('describe'); expect(commandNames).toContain('describe');
expect(commandNames).toContain('instance'); expect(commandNames).toContain('delete');
expect(commandNames).toContain('logs');
expect(commandNames).toContain('apply'); expect(commandNames).toContain('apply');
expect(commandNames).toContain('setup'); expect(commandNames).toContain('create');
expect(commandNames).toContain('edit');
expect(commandNames).toContain('claude'); expect(commandNames).toContain('claude');
expect(commandNames).toContain('project'); expect(commandNames).toContain('project');
expect(commandNames).toContain('backup');
expect(commandNames).toContain('restore');
}); });
it('instance command has lifecycle subcommands', () => { it('instance command is removed (use get/delete/logs instead)', () => {
const program = createProgram(); const program = createProgram();
const instance = program.commands.find((c) => c.name() === 'instance'); const commandNames = program.commands.map((c) => c.name());
expect(instance).toBeDefined(); expect(commandNames).not.toContain('instance');
const subcommands = instance!.commands.map((c) => c.name());
expect(subcommands).toContain('list');
expect(subcommands).toContain('start');
expect(subcommands).toContain('stop');
expect(subcommands).toContain('restart');
expect(subcommands).toContain('remove');
expect(subcommands).toContain('logs');
expect(subcommands).toContain('inspect');
}); });
it('claude command has config management subcommands', () => { it('claude command has config management subcommands', () => {
@@ -50,18 +45,11 @@ describe('CLI command registration (e2e)', () => {
expect(subcommands).toContain('remove'); expect(subcommands).toContain('remove');
}); });
it('project command has CRUD subcommands', () => { it('project command exists with alias', () => {
const program = createProgram(); const program = createProgram();
const project = program.commands.find((c) => c.name() === 'project'); const project = program.commands.find((c) => c.name() === 'project');
expect(project).toBeDefined(); expect(project).toBeDefined();
expect(project!.alias()).toBe('proj');
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');
}); });
it('displays version', () => { it('displays version', () => {

View File

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

View File

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

View File

@@ -57,12 +57,15 @@ model McpServer {
dockerImage String? dockerImage String?
transport Transport @default(STDIO) transport Transport @default(STDIO)
repositoryUrl String? repositoryUrl String?
envTemplate Json @default("[]") externalUrl String?
command Json?
containerPort Int?
replicas Int @default(1)
env Json @default("[]")
version Int @default(1) version Int @default(1)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
profiles McpProfile[]
instances McpInstance[] instances McpInstance[]
@@index([name]) @@index([name])
@@ -74,23 +77,17 @@ enum Transport {
STREAMABLE_HTTP STREAMABLE_HTTP
} }
// ── MCP Profiles ── // ── Secrets ──
model McpProfile { model Secret {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String @unique
serverId String data Json @default("{}")
permissions Json @default("[]")
envOverrides Json @default("{}")
version Int @default(1) version Int @default(1)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
server McpServer @relation(fields: [serverId], references: [id], onDelete: Cascade) @@index([name])
projects ProjectMcpProfile[]
@@unique([name, serverId])
@@index([serverId])
} }
// ── Projects ── // ── Projects ──
@@ -105,27 +102,11 @@ model Project {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
profiles ProjectMcpProfile[]
@@index([name]) @@index([name])
@@index([ownerId]) @@index([ownerId])
} }
// ── Project <-> Profile join table ──
model ProjectMcpProfile {
id String @id @default(cuid())
projectId String
profileId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
profile McpProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
@@unique([projectId, profileId])
@@index([projectId])
@@index([profileId])
}
// ── MCP Instances (running containers) ── // ── MCP Instances (running containers) ──
model McpInstance { model McpInstance {

View File

@@ -4,9 +4,8 @@ export type {
User, User,
Session, Session,
McpServer, McpServer,
McpProfile, Secret,
Project, Project,
ProjectMcpProfile,
McpInstance, McpInstance,
AuditLog, AuditLog,
Role, Role,

View File

@@ -6,11 +6,10 @@ export interface SeedServer {
packageName: string; packageName: string;
transport: 'STDIO' | 'SSE' | 'STREAMABLE_HTTP'; transport: 'STDIO' | 'SSE' | 'STREAMABLE_HTTP';
repositoryUrl: string; repositoryUrl: string;
envTemplate: Array<{ env: Array<{
name: string; name: string;
description: string; value?: string;
isSecret: boolean; valueFrom?: { secretRef: { name: string; key: string } };
setupUrl?: string;
}>; }>;
} }
@@ -21,19 +20,7 @@ export const defaultServers: SeedServer[] = [
packageName: '@anthropic/slack-mcp', packageName: '@anthropic/slack-mcp',
transport: 'STDIO', transport: 'STDIO',
repositoryUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/slack', repositoryUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/slack',
envTemplate: [ env: [],
{
name: 'SLACK_BOT_TOKEN',
description: 'Slack Bot User OAuth Token (xoxb-...)',
isSecret: true,
setupUrl: 'https://api.slack.com/apps',
},
{
name: 'SLACK_TEAM_ID',
description: 'Slack Workspace Team ID',
isSecret: false,
},
],
}, },
{ {
name: 'jira', name: 'jira',
@@ -41,24 +28,7 @@ export const defaultServers: SeedServer[] = [
packageName: '@anthropic/jira-mcp', packageName: '@anthropic/jira-mcp',
transport: 'STDIO', transport: 'STDIO',
repositoryUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/jira', repositoryUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/jira',
envTemplate: [ env: [],
{
name: 'JIRA_URL',
description: 'Jira instance URL (e.g., https://company.atlassian.net)',
isSecret: false,
},
{
name: 'JIRA_EMAIL',
description: 'Jira account email',
isSecret: false,
},
{
name: 'JIRA_API_TOKEN',
description: 'Jira API token',
isSecret: true,
setupUrl: 'https://id.atlassian.com/manage-profile/security/api-tokens',
},
],
}, },
{ {
name: 'github', name: 'github',
@@ -66,14 +36,7 @@ export const defaultServers: SeedServer[] = [
packageName: '@anthropic/github-mcp', packageName: '@anthropic/github-mcp',
transport: 'STDIO', transport: 'STDIO',
repositoryUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/github', repositoryUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/github',
envTemplate: [ env: [],
{
name: 'GITHUB_TOKEN',
description: 'GitHub Personal Access Token',
isSecret: true,
setupUrl: 'https://github.com/settings/tokens',
},
],
}, },
{ {
name: 'terraform', name: 'terraform',
@@ -81,7 +44,7 @@ export const defaultServers: SeedServer[] = [
packageName: '@anthropic/terraform-mcp', packageName: '@anthropic/terraform-mcp',
transport: 'STDIO', transport: 'STDIO',
repositoryUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/terraform', repositoryUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/terraform',
envTemplate: [], env: [],
}, },
]; ];
@@ -99,7 +62,7 @@ export async function seedMcpServers(
packageName: server.packageName, packageName: server.packageName,
transport: server.transport, transport: server.transport,
repositoryUrl: server.repositoryUrl, repositoryUrl: server.repositoryUrl,
envTemplate: server.envTemplate, env: server.env,
}, },
create: { create: {
name: server.name, name: server.name,
@@ -107,7 +70,7 @@ export async function seedMcpServers(
packageName: server.packageName, packageName: server.packageName,
transport: server.transport, transport: server.transport,
repositoryUrl: server.repositoryUrl, repositoryUrl: server.repositoryUrl,
envTemplate: server.envTemplate, env: server.env,
}, },
}); });
created++; created++;

View File

@@ -48,9 +48,8 @@ export async function cleanupTestDb(): Promise<void> {
export async function clearAllTables(client: PrismaClient): Promise<void> { export async function clearAllTables(client: PrismaClient): Promise<void> {
// Delete in order respecting foreign keys // Delete in order respecting foreign keys
await client.auditLog.deleteMany(); await client.auditLog.deleteMany();
await client.projectMcpProfile.deleteMany();
await client.mcpInstance.deleteMany(); await client.mcpInstance.deleteMany();
await client.mcpProfile.deleteMany(); await client.secret.deleteMany();
await client.session.deleteMany(); await client.session.deleteMany();
await client.project.deleteMany(); await client.project.deleteMany();
await client.mcpServer.deleteMany(); await client.mcpServer.deleteMany();

View File

@@ -123,7 +123,7 @@ describe('McpServer', () => {
const server = await createServer(); const server = await createServer();
expect(server.transport).toBe('STDIO'); expect(server.transport).toBe('STDIO');
expect(server.version).toBe(1); expect(server.version).toBe(1);
expect(server.envTemplate).toEqual([]); expect(server.env).toEqual([]);
}); });
it('enforces unique name', async () => { it('enforces unique name', async () => {
@@ -131,18 +131,18 @@ describe('McpServer', () => {
await expect(createServer({ name: 'slack' })).rejects.toThrow(); await expect(createServer({ name: 'slack' })).rejects.toThrow();
}); });
it('stores envTemplate as JSON', async () => { it('stores env as JSON', async () => {
const server = await prisma.mcpServer.create({ const server = await prisma.mcpServer.create({
data: { data: {
name: 'with-env', name: 'with-env',
envTemplate: [ env: [
{ name: 'API_KEY', description: 'Key', isSecret: true }, { name: 'API_KEY', value: 'test-key' },
], ],
}, },
}); });
const envTemplate = server.envTemplate as Array<{ name: string }>; const env = server.env as Array<{ name: string }>;
expect(envTemplate).toHaveLength(1); expect(env).toHaveLength(1);
expect(envTemplate[0].name).toBe('API_KEY'); expect(env[0].name).toBe('API_KEY');
}); });
it('supports SSE transport', async () => { it('supports SSE transport', async () => {
@@ -151,43 +151,46 @@ describe('McpServer', () => {
}); });
}); });
// ── McpProfile model ── // ── Secret model ──
describe('McpProfile', () => { describe('Secret', () => {
it('creates a profile linked to server', async () => { it('creates a secret with defaults', async () => {
const server = await createServer(); const secret = await prisma.secret.create({
const profile = await prisma.mcpProfile.create({ data: { name: 'my-secret' },
});
expect(secret.name).toBe('my-secret');
expect(secret.data).toEqual({});
expect(secret.version).toBe(1);
});
it('stores key-value data as JSON', async () => {
const secret = await prisma.secret.create({
data: { data: {
name: 'readonly', name: 'api-keys',
serverId: server.id, data: { API_KEY: 'test-key', API_SECRET: 'test-secret' },
permissions: ['read'],
}, },
}); });
expect(profile.name).toBe('readonly'); const data = secret.data as Record<string, string>;
expect(profile.serverId).toBe(server.id); expect(data['API_KEY']).toBe('test-key');
expect(data['API_SECRET']).toBe('test-secret');
}); });
it('enforces unique name per server', async () => { it('enforces unique name', async () => {
const server = await createServer(); await prisma.secret.create({ data: { name: 'dup-secret' } });
const data = { name: 'default', serverId: server.id }; await expect(prisma.secret.create({ data: { name: 'dup-secret' } })).rejects.toThrow();
await prisma.mcpProfile.create({ data });
await expect(prisma.mcpProfile.create({ data })).rejects.toThrow();
}); });
it('allows same profile name on different servers', async () => { it('updates data', async () => {
const server1 = await createServer({ name: 'server-1' }); const secret = await prisma.secret.create({
const server2 = await createServer({ name: 'server-2' }); data: { name: 'updatable', data: { KEY: 'old' } },
await prisma.mcpProfile.create({ data: { name: 'default', serverId: server1.id } });
const profile2 = await prisma.mcpProfile.create({ data: { name: 'default', serverId: server2.id } });
expect(profile2.name).toBe('default');
}); });
const updated = await prisma.secret.update({
it('cascades delete when server is deleted', async () => { where: { id: secret.id },
const server = await createServer(); data: { data: { KEY: 'new', EXTRA: 'added' } },
await prisma.mcpProfile.create({ data: { name: 'test', serverId: server.id } }); });
await prisma.mcpServer.delete({ where: { id: server.id } }); const data = updated.data as Record<string, string>;
const profiles = await prisma.mcpProfile.findMany({ where: { serverId: server.id } }); expect(data['KEY']).toBe('new');
expect(profiles).toHaveLength(0); expect(data['EXTRA']).toBe('added');
}); });
}); });
@@ -220,62 +223,6 @@ describe('Project', () => {
}); });
}); });
// ── ProjectMcpProfile (join table) ──
describe('ProjectMcpProfile', () => {
it('links project to profile', async () => {
const user = await createUser();
const server = await createServer();
const profile = await prisma.mcpProfile.create({
data: { name: 'default', serverId: server.id },
});
const project = await prisma.project.create({
data: { name: 'test-project', ownerId: user.id },
});
const link = await prisma.projectMcpProfile.create({
data: { projectId: project.id, profileId: profile.id },
});
expect(link.projectId).toBe(project.id);
expect(link.profileId).toBe(profile.id);
});
it('enforces unique project+profile combination', async () => {
const user = await createUser();
const server = await createServer();
const profile = await prisma.mcpProfile.create({
data: { name: 'default', serverId: server.id },
});
const project = await prisma.project.create({
data: { name: 'test-project', ownerId: user.id },
});
const data = { projectId: project.id, profileId: profile.id };
await prisma.projectMcpProfile.create({ data });
await expect(prisma.projectMcpProfile.create({ data })).rejects.toThrow();
});
it('loads profiles through project include', async () => {
const user = await createUser();
const server = await createServer();
const profile = await prisma.mcpProfile.create({
data: { name: 'slack-ro', serverId: server.id },
});
const project = await prisma.project.create({
data: { name: 'reports', ownerId: user.id },
});
await prisma.projectMcpProfile.create({
data: { projectId: project.id, profileId: profile.id },
});
const loaded = await prisma.project.findUnique({
where: { id: project.id },
include: { profiles: { include: { profile: true } } },
});
expect(loaded!.profiles).toHaveLength(1);
expect(loaded!.profiles[0].profile.name).toBe('slack-ro');
});
});
// ── McpInstance model ── // ── McpInstance model ──

View File

@@ -41,13 +41,11 @@ describe('seedMcpServers', () => {
expect(servers).toHaveLength(defaultServers.length); expect(servers).toHaveLength(defaultServers.length);
}); });
it('seeds envTemplate correctly', async () => { it('seeds env correctly', async () => {
await seedMcpServers(prisma); await seedMcpServers(prisma);
const slack = await prisma.mcpServer.findUnique({ where: { name: 'slack' } }); const slack = await prisma.mcpServer.findUnique({ where: { name: 'slack' } });
const envTemplate = slack!.envTemplate as Array<{ name: string; isSecret: boolean }>; const env = slack!.env as Array<{ name: string; value?: string }>;
expect(envTemplate).toHaveLength(2); expect(env).toEqual([]);
expect(envTemplate[0].name).toBe('SLACK_BOT_TOKEN');
expect(envTemplate[0].isSecret).toBe(true);
}); });
it('accepts custom server list', async () => { it('accepts custom server list', async () => {
@@ -58,7 +56,7 @@ describe('seedMcpServers', () => {
packageName: '@test/custom', packageName: '@test/custom',
transport: 'STDIO' as const, transport: 'STDIO' as const,
repositoryUrl: 'https://example.com', repositoryUrl: 'https://example.com',
envTemplate: [], env: [],
}, },
]; ];
const count = await seedMcpServers(prisma, custom); const count = await seedMcpServers(prisma, custom);

View File

@@ -5,14 +5,14 @@ import { createServer } from './server.js';
import { setupGracefulShutdown } from './utils/index.js'; import { setupGracefulShutdown } from './utils/index.js';
import { import {
McpServerRepository, McpServerRepository,
McpProfileRepository, SecretRepository,
McpInstanceRepository, McpInstanceRepository,
ProjectRepository, ProjectRepository,
AuditLogRepository, AuditLogRepository,
} from './repositories/index.js'; } from './repositories/index.js';
import { import {
McpServerService, McpServerService,
McpProfileService, SecretService,
InstanceService, InstanceService,
ProjectService, ProjectService,
AuditLogService, AuditLogService,
@@ -26,7 +26,7 @@ import {
} from './services/index.js'; } from './services/index.js';
import { import {
registerMcpServerRoutes, registerMcpServerRoutes,
registerMcpProfileRoutes, registerSecretRoutes,
registerInstanceRoutes, registerInstanceRoutes,
registerProjectRoutes, registerProjectRoutes,
registerAuditLogRoutes, registerAuditLogRoutes,
@@ -50,7 +50,7 @@ async function main(): Promise<void> {
// Repositories // Repositories
const serverRepo = new McpServerRepository(prisma); const serverRepo = new McpServerRepository(prisma);
const profileRepo = new McpProfileRepository(prisma); const secretRepo = new SecretRepository(prisma);
const instanceRepo = new McpInstanceRepository(prisma); const instanceRepo = new McpInstanceRepository(prisma);
const projectRepo = new ProjectRepository(prisma); const projectRepo = new ProjectRepository(prisma);
const auditLogRepo = new AuditLogRepository(prisma); const auditLogRepo = new AuditLogRepository(prisma);
@@ -60,16 +60,17 @@ async function main(): Promise<void> {
// Services // Services
const serverService = new McpServerService(serverRepo); const serverService = new McpServerService(serverRepo);
const profileService = new McpProfileService(profileRepo, serverRepo); const instanceService = new InstanceService(instanceRepo, serverRepo, orchestrator, secretRepo);
const instanceService = new InstanceService(instanceRepo, serverRepo, orchestrator); serverService.setInstanceService(instanceService);
const projectService = new ProjectService(projectRepo, profileRepo, serverRepo); const secretService = new SecretService(secretRepo);
const projectService = new ProjectService(projectRepo, serverRepo);
const auditLogService = new AuditLogService(auditLogRepo); const auditLogService = new AuditLogService(auditLogRepo);
const metricsCollector = new MetricsCollector(); const metricsCollector = new MetricsCollector();
const healthAggregator = new HealthAggregator(metricsCollector, orchestrator); const healthAggregator = new HealthAggregator(metricsCollector, orchestrator);
const backupService = new BackupService(serverRepo, profileRepo, projectRepo); const backupService = new BackupService(serverRepo, projectRepo, secretRepo);
const restoreService = new RestoreService(serverRepo, profileRepo, projectRepo); const restoreService = new RestoreService(serverRepo, projectRepo, secretRepo);
const authService = new AuthService(prisma); const authService = new AuthService(prisma);
const mcpProxyService = new McpProxyService(instanceRepo); const mcpProxyService = new McpProxyService(instanceRepo, serverRepo);
// Server // Server
const app = await createServer(config, { const app = await createServer(config, {
@@ -86,8 +87,8 @@ async function main(): Promise<void> {
}); });
// Routes // Routes
registerMcpServerRoutes(app, serverService); registerMcpServerRoutes(app, serverService, instanceService);
registerMcpProfileRoutes(app, profileService); registerSecretRoutes(app, secretService);
registerInstanceRoutes(app, instanceService); registerInstanceRoutes(app, instanceService);
registerProjectRoutes(app, projectService); registerProjectRoutes(app, projectService);
registerAuditLogRoutes(app, auditLogService); registerAuditLogRoutes(app, auditLogService);

View File

@@ -1,6 +1,6 @@
export type { IMcpServerRepository, IMcpProfileRepository, IMcpInstanceRepository, IAuditLogRepository, AuditLogFilter } from './interfaces.js'; export type { IMcpServerRepository, IMcpInstanceRepository, ISecretRepository, IAuditLogRepository, AuditLogFilter } from './interfaces.js';
export { McpServerRepository } from './mcp-server.repository.js'; export { McpServerRepository } from './mcp-server.repository.js';
export { McpProfileRepository } from './mcp-profile.repository.js'; export { SecretRepository } from './secret.repository.js';
export type { IProjectRepository } from './project.repository.js'; export type { IProjectRepository } from './project.repository.js';
export { ProjectRepository } from './project.repository.js'; export { ProjectRepository } from './project.repository.js';
export { McpInstanceRepository } from './mcp-instance.repository.js'; export { McpInstanceRepository } from './mcp-instance.repository.js';

View File

@@ -1,6 +1,6 @@
import type { McpServer, McpProfile, McpInstance, AuditLog, InstanceStatus } from '@prisma/client'; import type { McpServer, McpInstance, AuditLog, Secret, InstanceStatus } from '@prisma/client';
import type { CreateMcpServerInput, UpdateMcpServerInput } from '../validation/mcp-server.schema.js'; import type { CreateMcpServerInput, UpdateMcpServerInput } from '../validation/mcp-server.schema.js';
import type { CreateMcpProfileInput, UpdateMcpProfileInput } from '../validation/mcp-profile.schema.js'; import type { CreateSecretInput, UpdateSecretInput } from '../validation/secret.schema.js';
export interface IMcpServerRepository { export interface IMcpServerRepository {
findAll(): Promise<McpServer[]>; findAll(): Promise<McpServer[]>;
@@ -20,12 +20,12 @@ export interface IMcpInstanceRepository {
delete(id: string): Promise<void>; delete(id: string): Promise<void>;
} }
export interface IMcpProfileRepository { export interface ISecretRepository {
findAll(serverId?: string): Promise<McpProfile[]>; findAll(): Promise<Secret[]>;
findById(id: string): Promise<McpProfile | null>; findById(id: string): Promise<Secret | null>;
findByServerAndName(serverId: string, name: string): Promise<McpProfile | null>; findByName(name: string): Promise<Secret | null>;
create(data: CreateMcpProfileInput): Promise<McpProfile>; create(data: CreateSecretInput): Promise<Secret>;
update(id: string, data: UpdateMcpProfileInput): Promise<McpProfile>; update(id: string, data: UpdateSecretInput): Promise<Secret>;
delete(id: string): Promise<void>; delete(id: string): Promise<void>;
} }

View File

@@ -1,46 +0,0 @@
import type { PrismaClient, McpProfile } from '@prisma/client';
import type { IMcpProfileRepository } from './interfaces.js';
import type { CreateMcpProfileInput, UpdateMcpProfileInput } from '../validation/mcp-profile.schema.js';
export class McpProfileRepository implements IMcpProfileRepository {
constructor(private readonly prisma: PrismaClient) {}
async findAll(serverId?: string): Promise<McpProfile[]> {
const where = serverId !== undefined ? { serverId } : {};
return this.prisma.mcpProfile.findMany({ where, orderBy: { name: 'asc' } });
}
async findById(id: string): Promise<McpProfile | null> {
return this.prisma.mcpProfile.findUnique({ where: { id } });
}
async findByServerAndName(serverId: string, name: string): Promise<McpProfile | null> {
return this.prisma.mcpProfile.findUnique({
where: { name_serverId: { name, serverId } },
});
}
async create(data: CreateMcpProfileInput): Promise<McpProfile> {
return this.prisma.mcpProfile.create({
data: {
name: data.name,
serverId: data.serverId,
permissions: data.permissions,
envOverrides: data.envOverrides,
},
});
}
async update(id: string, data: UpdateMcpProfileInput): Promise<McpProfile> {
const updateData: Record<string, unknown> = {};
if (data.name !== undefined) updateData['name'] = data.name;
if (data.permissions !== undefined) updateData['permissions'] = data.permissions;
if (data.envOverrides !== undefined) updateData['envOverrides'] = data.envOverrides;
return this.prisma.mcpProfile.update({ where: { id }, data: updateData });
}
async delete(id: string): Promise<void> {
await this.prisma.mcpProfile.delete({ where: { id } });
}
}

View File

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

View File

@@ -8,8 +8,6 @@ export interface IProjectRepository {
create(data: CreateProjectInput & { ownerId: string }): Promise<Project>; create(data: CreateProjectInput & { ownerId: string }): Promise<Project>;
update(id: string, data: UpdateProjectInput): Promise<Project>; update(id: string, data: UpdateProjectInput): Promise<Project>;
delete(id: string): Promise<void>; delete(id: string): Promise<void>;
setProfiles(projectId: string, profileIds: string[]): Promise<void>;
getProfileIds(projectId: string): Promise<string[]>;
} }
export class ProjectRepository implements IProjectRepository { export class ProjectRepository implements IProjectRepository {
@@ -48,22 +46,4 @@ export class ProjectRepository implements IProjectRepository {
await this.prisma.project.delete({ where: { id } }); await this.prisma.project.delete({ where: { id } });
} }
async setProfiles(projectId: string, profileIds: string[]): Promise<void> {
await this.prisma.$transaction([
this.prisma.projectMcpProfile.deleteMany({ where: { projectId } }),
...profileIds.map((profileId) =>
this.prisma.projectMcpProfile.create({
data: { projectId, profileId },
}),
),
]);
}
async getProfileIds(projectId: string): Promise<string[]> {
const links = await this.prisma.projectMcpProfile.findMany({
where: { projectId },
select: { profileId: true },
});
return links.map((l) => l.profileId);
}
} }

View File

@@ -0,0 +1,39 @@
import { type PrismaClient, type Secret } from '@prisma/client';
import type { ISecretRepository } from './interfaces.js';
import type { CreateSecretInput, UpdateSecretInput } from '../validation/secret.schema.js';
export class SecretRepository implements ISecretRepository {
constructor(private readonly prisma: PrismaClient) {}
async findAll(): Promise<Secret[]> {
return this.prisma.secret.findMany({ orderBy: { name: 'asc' } });
}
async findById(id: string): Promise<Secret | null> {
return this.prisma.secret.findUnique({ where: { id } });
}
async findByName(name: string): Promise<Secret | null> {
return this.prisma.secret.findUnique({ where: { name } });
}
async create(data: CreateSecretInput): Promise<Secret> {
return this.prisma.secret.create({
data: {
name: data.name,
data: data.data,
},
});
}
async update(id: string, data: UpdateSecretInput): Promise<Secret> {
return this.prisma.secret.update({
where: { id },
data: { data: data.data },
});
}
async delete(id: string): Promise<void> {
await this.prisma.secret.delete({ where: { id } });
}
}

View File

@@ -13,7 +13,7 @@ export function registerBackupRoutes(app: FastifyInstance, deps: BackupDeps): vo
app.post<{ app.post<{
Body: { Body: {
password?: string; password?: string;
resources?: Array<'servers' | 'profiles' | 'projects'>; resources?: Array<'servers' | 'secrets' | 'projects'>;
}; };
}>('/api/v1/backup', async (request) => { }>('/api/v1/backup', async (request) => {
const opts: BackupOptions = {}; const opts: BackupOptions = {};
@@ -51,7 +51,7 @@ export function registerBackupRoutes(app: FastifyInstance, deps: BackupDeps): vo
const result = await deps.restoreService.restore(bundle, restoreOpts); const result = await deps.restoreService.restore(bundle, restoreOpts);
if (result.errors.length > 0 && result.serversCreated === 0 && result.profilesCreated === 0 && result.projectsCreated === 0) { if (result.errors.length > 0 && result.serversCreated === 0 && result.secretsCreated === 0 && result.projectsCreated === 0) {
reply.code(422); reply.code(422);
} }

View File

@@ -1,7 +1,7 @@
export { registerHealthRoutes } from './health.js'; export { registerHealthRoutes } from './health.js';
export type { HealthDeps } from './health.js'; export type { HealthDeps } from './health.js';
export { registerMcpServerRoutes } from './mcp-servers.js'; export { registerMcpServerRoutes } from './mcp-servers.js';
export { registerMcpProfileRoutes } from './mcp-profiles.js'; export { registerSecretRoutes } from './secrets.js';
export { registerProjectRoutes } from './projects.js'; export { registerProjectRoutes } from './projects.js';
export { registerInstanceRoutes } from './instances.js'; export { registerInstanceRoutes } from './instances.js';
export { registerAuditLogRoutes } from './audit-logs.js'; export { registerAuditLogRoutes } from './audit-logs.js';

View File

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

View File

@@ -1,27 +0,0 @@
import type { FastifyInstance } from 'fastify';
import type { McpProfileService } from '../services/mcp-profile.service.js';
export function registerMcpProfileRoutes(app: FastifyInstance, service: McpProfileService): void {
app.get<{ Querystring: { serverId?: string } }>('/api/v1/profiles', async (request) => {
return service.list(request.query.serverId);
});
app.get<{ Params: { id: string } }>('/api/v1/profiles/:id', async (request) => {
return service.getById(request.params.id);
});
app.post('/api/v1/profiles', async (request, reply) => {
const profile = await service.create(request.body);
reply.code(201);
return profile;
});
app.put<{ Params: { id: string } }>('/api/v1/profiles/:id', async (request) => {
return service.update(request.params.id, request.body);
});
app.delete<{ Params: { id: string } }>('/api/v1/profiles/:id', async (request, reply) => {
await service.delete(request.params.id);
reply.code(204);
});
}

View File

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

View File

@@ -26,18 +26,4 @@ export function registerProjectRoutes(app: FastifyInstance, service: ProjectServ
await service.delete(request.params.id); await service.delete(request.params.id);
reply.code(204); reply.code(204);
}); });
// Profile associations
app.get<{ Params: { id: string } }>('/api/v1/projects/:id/profiles', async (request) => {
return service.getProfiles(request.params.id);
});
app.put<{ Params: { id: string } }>('/api/v1/projects/:id/profiles', async (request) => {
return service.setProfiles(request.params.id, request.body);
});
// MCP config generation
app.get<{ Params: { id: string } }>('/api/v1/projects/:id/mcp-config', async (request) => {
return service.getMcpConfig(request.params.id);
});
} }

View File

@@ -0,0 +1,30 @@
import type { FastifyInstance } from 'fastify';
import type { SecretService } from '../services/secret.service.js';
export function registerSecretRoutes(
app: FastifyInstance,
service: SecretService,
): void {
app.get('/api/v1/secrets', async () => {
return service.list();
});
app.get<{ Params: { id: string } }>('/api/v1/secrets/:id', async (request) => {
return service.getById(request.params.id);
});
app.post('/api/v1/secrets', async (request, reply) => {
const secret = await service.create(request.body);
reply.code(201);
return secret;
});
app.put<{ Params: { id: string } }>('/api/v1/secrets/:id', async (request) => {
return service.update(request.params.id, request.body);
});
app.delete<{ Params: { id: string } }>('/api/v1/secrets/:id', async (request, reply) => {
await service.delete(request.params.id);
reply.code(204);
});
}

View File

@@ -1,4 +1,4 @@
import type { IMcpServerRepository, IMcpProfileRepository } from '../../repositories/interfaces.js'; import type { IMcpServerRepository, ISecretRepository } from '../../repositories/interfaces.js';
import type { IProjectRepository } from '../../repositories/project.repository.js'; import type { IProjectRepository } from '../../repositories/project.repository.js';
import { encrypt, isSensitiveKey } from './crypto.js'; import { encrypt, isSensitiveKey } from './crypto.js';
import type { EncryptedPayload } from './crypto.js'; import type { EncryptedPayload } from './crypto.js';
@@ -10,7 +10,7 @@ export interface BackupBundle {
createdAt: string; createdAt: string;
encrypted: boolean; encrypted: boolean;
servers: BackupServer[]; servers: BackupServer[];
profiles: BackupProfile[]; secrets: BackupSecret[];
projects: BackupProject[]; projects: BackupProject[];
encryptedSecrets?: EncryptedPayload; encryptedSecrets?: EncryptedPayload;
} }
@@ -22,39 +22,36 @@ export interface BackupServer {
dockerImage: string | null; dockerImage: string | null;
transport: string; transport: string;
repositoryUrl: string | null; repositoryUrl: string | null;
envTemplate: unknown; env: unknown;
} }
export interface BackupProfile { export interface BackupSecret {
name: string; name: string;
serverName: string; data: Record<string, string>;
permissions: unknown;
envOverrides: unknown;
} }
export interface BackupProject { export interface BackupProject {
name: string; name: string;
description: string; description: string;
profileNames: string[];
} }
export interface BackupOptions { export interface BackupOptions {
password?: string; password?: string;
resources?: Array<'servers' | 'profiles' | 'projects'>; resources?: Array<'servers' | 'secrets' | 'projects'>;
} }
export class BackupService { export class BackupService {
constructor( constructor(
private serverRepo: IMcpServerRepository, private serverRepo: IMcpServerRepository,
private profileRepo: IMcpProfileRepository,
private projectRepo: IProjectRepository, private projectRepo: IProjectRepository,
private secretRepo: ISecretRepository,
) {} ) {}
async createBackup(options?: BackupOptions): Promise<BackupBundle> { async createBackup(options?: BackupOptions): Promise<BackupBundle> {
const resources = options?.resources ?? ['servers', 'profiles', 'projects']; const resources = options?.resources ?? ['servers', 'secrets', 'projects'];
let servers: BackupServer[] = []; let servers: BackupServer[] = [];
let profiles: BackupProfile[] = []; let secrets: BackupSecret[] = [];
let projects: BackupProject[] = []; let projects: BackupProject[] = [];
if (resources.includes('servers')) { if (resources.includes('servers')) {
@@ -66,44 +63,24 @@ export class BackupService {
dockerImage: s.dockerImage, dockerImage: s.dockerImage,
transport: s.transport, transport: s.transport,
repositoryUrl: s.repositoryUrl, repositoryUrl: s.repositoryUrl,
envTemplate: s.envTemplate, env: s.env,
})); }));
} }
if (resources.includes('profiles')) { if (resources.includes('secrets')) {
const allProfiles = await this.profileRepo.findAll(); const allSecrets = await this.secretRepo.findAll();
const serverMap = new Map<string, string>(); secrets = allSecrets.map((s) => ({
const allServers = await this.serverRepo.findAll(); name: s.name,
for (const s of allServers) { data: s.data as Record<string, string>,
serverMap.set(s.id, s.name);
}
profiles = allProfiles.map((p) => ({
name: p.name,
serverName: serverMap.get(p.serverId) ?? p.serverId,
permissions: p.permissions,
envOverrides: p.envOverrides,
})); }));
} }
if (resources.includes('projects')) { if (resources.includes('projects')) {
const allProjects = await this.projectRepo.findAll(); const allProjects = await this.projectRepo.findAll();
const allProfiles = await this.profileRepo.findAll(); projects = allProjects.map((proj) => ({
const profileMap = new Map<string, string>();
for (const p of allProfiles) {
profileMap.set(p.id, p.name);
}
projects = await Promise.all(
allProjects.map(async (proj) => {
const profileIds = await this.projectRepo.getProfileIds(proj.id);
return {
name: proj.name, name: proj.name,
description: proj.description, description: proj.description,
profileNames: profileIds.map((id) => profileMap.get(id) ?? id), }));
};
}),
);
} }
const bundle: BackupBundle = { const bundle: BackupBundle = {
@@ -112,29 +89,26 @@ export class BackupService {
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
encrypted: false, encrypted: false,
servers, servers,
profiles, secrets,
projects, projects,
}; };
if (options?.password) { if (options?.password && secrets.length > 0) {
// Collect sensitive values and encrypt them // Collect sensitive values from secrets and encrypt them
const secrets: Record<string, string> = {}; const sensitiveData: Record<string, string> = {};
for (const profile of profiles) { for (const secret of secrets) {
const overrides = profile.envOverrides as Record<string, string> | null; for (const [key, value] of Object.entries(secret.data)) {
if (overrides) {
for (const [key, value] of Object.entries(overrides)) {
if (isSensitiveKey(key)) { if (isSensitiveKey(key)) {
const secretKey = `profile:${profile.name}:${key}`; const secretKey = `secret:${secret.name}:${key}`;
secrets[secretKey] = value; sensitiveData[secretKey] = value;
(overrides as Record<string, string>)[key] = `__ENCRYPTED:${secretKey}__`; secret.data[key] = `__ENCRYPTED:${secretKey}__`;
}
} }
} }
} }
if (Object.keys(secrets).length > 0) { if (Object.keys(sensitiveData).length > 0) {
bundle.encrypted = true; bundle.encrypted = true;
bundle.encryptedSecrets = encrypt(JSON.stringify(secrets), options.password); bundle.encryptedSecrets = encrypt(JSON.stringify(sensitiveData), options.password);
} }
} }

View File

@@ -1,5 +1,5 @@
export { BackupService } from './backup-service.js'; export { BackupService } from './backup-service.js';
export type { BackupBundle, BackupServer, BackupProfile, BackupProject, BackupOptions } from './backup-service.js'; export type { BackupBundle, BackupServer, BackupSecret, BackupProject, BackupOptions } from './backup-service.js';
export { RestoreService } from './restore-service.js'; export { RestoreService } from './restore-service.js';
export type { RestoreOptions, RestoreResult, ConflictStrategy } from './restore-service.js'; export type { RestoreOptions, RestoreResult, ConflictStrategy } from './restore-service.js';
export { encrypt, decrypt, isSensitiveKey } from './crypto.js'; export { encrypt, decrypt, isSensitiveKey } from './crypto.js';

View File

@@ -1,4 +1,4 @@
import type { IMcpServerRepository, IMcpProfileRepository } from '../../repositories/interfaces.js'; import type { IMcpServerRepository, ISecretRepository } from '../../repositories/interfaces.js';
import type { IProjectRepository } from '../../repositories/project.repository.js'; import type { IProjectRepository } from '../../repositories/project.repository.js';
import { decrypt } from './crypto.js'; import { decrypt } from './crypto.js';
import type { BackupBundle } from './backup-service.js'; import type { BackupBundle } from './backup-service.js';
@@ -13,8 +13,8 @@ export interface RestoreOptions {
export interface RestoreResult { export interface RestoreResult {
serversCreated: number; serversCreated: number;
serversSkipped: number; serversSkipped: number;
profilesCreated: number; secretsCreated: number;
profilesSkipped: number; secretsSkipped: number;
projectsCreated: number; projectsCreated: number;
projectsSkipped: number; projectsSkipped: number;
errors: string[]; errors: string[];
@@ -23,8 +23,8 @@ export interface RestoreResult {
export class RestoreService { export class RestoreService {
constructor( constructor(
private serverRepo: IMcpServerRepository, private serverRepo: IMcpServerRepository,
private profileRepo: IMcpProfileRepository,
private projectRepo: IProjectRepository, private projectRepo: IProjectRepository,
private secretRepo: ISecretRepository,
) {} ) {}
validateBundle(bundle: unknown): bundle is BackupBundle { validateBundle(bundle: unknown): bundle is BackupBundle {
@@ -33,7 +33,7 @@ export class RestoreService {
return ( return (
typeof b['version'] === 'string' && typeof b['version'] === 'string' &&
Array.isArray(b['servers']) && Array.isArray(b['servers']) &&
Array.isArray(b['profiles']) && Array.isArray(b['secrets']) &&
Array.isArray(b['projects']) Array.isArray(b['projects'])
); );
} }
@@ -43,46 +43,42 @@ export class RestoreService {
const result: RestoreResult = { const result: RestoreResult = {
serversCreated: 0, serversCreated: 0,
serversSkipped: 0, serversSkipped: 0,
profilesCreated: 0, secretsCreated: 0,
profilesSkipped: 0, secretsSkipped: 0,
projectsCreated: 0, projectsCreated: 0,
projectsSkipped: 0, projectsSkipped: 0,
errors: [], errors: [],
}; };
// Decrypt secrets if encrypted // Decrypt secrets if encrypted
let secrets: Record<string, string> = {}; let decryptedSecrets: Record<string, string> = {};
if (bundle.encrypted && bundle.encryptedSecrets) { if (bundle.encrypted && bundle.encryptedSecrets) {
if (!options?.password) { if (!options?.password) {
result.errors.push('Backup is encrypted but no password provided'); result.errors.push('Backup is encrypted but no password provided');
return result; return result;
} }
try { try {
secrets = JSON.parse(decrypt(bundle.encryptedSecrets, options.password)) as Record<string, string>; decryptedSecrets = JSON.parse(decrypt(bundle.encryptedSecrets, options.password)) as Record<string, string>;
} catch { } catch {
result.errors.push('Failed to decrypt backup - incorrect password or corrupted data'); result.errors.push('Failed to decrypt backup - incorrect password or corrupted data');
return result; return result;
} }
} }
// Restore secrets into profile envOverrides // Restore encrypted values into secret data
for (const profile of bundle.profiles) { for (const secret of bundle.secrets) {
const overrides = profile.envOverrides as Record<string, string> | null; for (const [key, value] of Object.entries(secret.data)) {
if (overrides) {
for (const [key, value] of Object.entries(overrides)) {
if (typeof value === 'string' && value.startsWith('__ENCRYPTED:') && value.endsWith('__')) { if (typeof value === 'string' && value.startsWith('__ENCRYPTED:') && value.endsWith('__')) {
const secretKey = value.slice(12, -2); const secretKey = value.slice(12, -2);
const decrypted = secrets[secretKey]; const decrypted = decryptedSecrets[secretKey];
if (decrypted !== undefined) { if (decrypted !== undefined) {
overrides[key] = decrypted; secret.data[key] = decrypted;
}
} }
} }
} }
} }
// Restore servers // Restore servers
const serverNameToId = new Map<string, string>();
for (const server of bundle.servers) { for (const server of bundle.servers) {
try { try {
const existing = await this.serverRepo.findByName(server.name); const existing = await this.serverRepo.findByName(server.name);
@@ -93,7 +89,6 @@ export class RestoreService {
} }
if (strategy === 'skip') { if (strategy === 'skip') {
result.serversSkipped++; result.serversSkipped++;
serverNameToId.set(server.name, existing.id);
continue; continue;
} }
// overwrite // overwrite
@@ -105,7 +100,6 @@ export class RestoreService {
if (server.dockerImage) updateData.dockerImage = server.dockerImage; if (server.dockerImage) updateData.dockerImage = server.dockerImage;
if (server.repositoryUrl) updateData.repositoryUrl = server.repositoryUrl; if (server.repositoryUrl) updateData.repositoryUrl = server.repositoryUrl;
await this.serverRepo.update(existing.id, updateData); await this.serverRepo.update(existing.id, updateData);
serverNameToId.set(server.name, existing.id);
result.serversCreated++; result.serversCreated++;
continue; continue;
} }
@@ -114,66 +108,45 @@ export class RestoreService {
name: server.name, name: server.name,
description: server.description, description: server.description,
transport: server.transport as 'STDIO' | 'SSE' | 'STREAMABLE_HTTP', transport: server.transport as 'STDIO' | 'SSE' | 'STREAMABLE_HTTP',
envTemplate: (server.envTemplate ?? []) as Array<{ name: string; description: string; isSecret: boolean }>, replicas: (server as { replicas?: number }).replicas ?? 1,
env: (server.env ?? []) as Array<{ name: string; value?: string; valueFrom?: { secretRef: { name: string; key: string } } }>,
}; };
if (server.packageName) createData.packageName = server.packageName; if (server.packageName) createData.packageName = server.packageName;
if (server.dockerImage) createData.dockerImage = server.dockerImage; if (server.dockerImage) createData.dockerImage = server.dockerImage;
if (server.repositoryUrl) createData.repositoryUrl = server.repositoryUrl; if (server.repositoryUrl) createData.repositoryUrl = server.repositoryUrl;
const created = await this.serverRepo.create(createData); const created = await this.serverRepo.create(createData);
serverNameToId.set(server.name, created.id);
result.serversCreated++; result.serversCreated++;
} catch (err) { } catch (err) {
result.errors.push(`Failed to restore server "${server.name}": ${err instanceof Error ? err.message : String(err)}`); result.errors.push(`Failed to restore server "${server.name}": ${err instanceof Error ? err.message : String(err)}`);
} }
} }
// Restore profiles // Restore secrets
const profileNameToId = new Map<string, string>(); for (const secret of bundle.secrets) {
for (const profile of bundle.profiles) {
try { try {
const serverId = serverNameToId.get(profile.serverName); const existing = await this.secretRepo.findByName(secret.name);
if (!serverId) {
// Try to find server by name in DB
const server = await this.serverRepo.findByName(profile.serverName);
if (!server) {
result.errors.push(`Profile "${profile.name}" references unknown server "${profile.serverName}"`);
continue;
}
serverNameToId.set(profile.serverName, server.id);
}
const sid = serverNameToId.get(profile.serverName)!;
const existing = await this.profileRepo.findByServerAndName(sid, profile.name);
if (existing) { if (existing) {
if (strategy === 'fail') { if (strategy === 'fail') {
result.errors.push(`Profile "${profile.name}" already exists for server "${profile.serverName}"`); result.errors.push(`Secret "${secret.name}" already exists`);
return result; return result;
} }
if (strategy === 'skip') { if (strategy === 'skip') {
result.profilesSkipped++; result.secretsSkipped++;
profileNameToId.set(profile.name, existing.id);
continue; continue;
} }
// overwrite // overwrite
await this.profileRepo.update(existing.id, { await this.secretRepo.update(existing.id, { data: secret.data });
permissions: profile.permissions as string[], result.secretsCreated++;
envOverrides: profile.envOverrides as Record<string, string>,
});
profileNameToId.set(profile.name, existing.id);
result.profilesCreated++;
continue; continue;
} }
const created = await this.profileRepo.create({ await this.secretRepo.create({
name: profile.name, name: secret.name,
serverId: sid, data: secret.data,
permissions: profile.permissions as string[],
envOverrides: profile.envOverrides as Record<string, string>,
}); });
profileNameToId.set(profile.name, created.id); result.secretsCreated++;
result.profilesCreated++;
} catch (err) { } catch (err) {
result.errors.push(`Failed to restore profile "${profile.name}": ${err instanceof Error ? err.message : String(err)}`); result.errors.push(`Failed to restore secret "${secret.name}": ${err instanceof Error ? err.message : String(err)}`);
} }
} }
@@ -190,29 +163,17 @@ export class RestoreService {
result.projectsSkipped++; result.projectsSkipped++;
continue; continue;
} }
// overwrite - update and set profiles // overwrite
await this.projectRepo.update(existing.id, { description: project.description }); await this.projectRepo.update(existing.id, { description: project.description });
const profileIds = project.profileNames
.map((name) => profileNameToId.get(name))
.filter((id): id is string => id !== undefined);
if (profileIds.length > 0) {
await this.projectRepo.setProfiles(existing.id, profileIds);
}
result.projectsCreated++; result.projectsCreated++;
continue; continue;
} }
const created = await this.projectRepo.create({ await this.projectRepo.create({
name: project.name, name: project.name,
description: project.description, description: project.description,
ownerId: 'system', ownerId: 'system',
}); });
const profileIds = project.profileNames
.map((name) => profileNameToId.get(name))
.filter((id): id is string => id !== undefined);
if (profileIds.length > 0) {
await this.projectRepo.setProfiles(created.id, profileIds);
}
result.projectsCreated++; result.projectsCreated++;
} catch (err) { } catch (err) {
result.errors.push(`Failed to restore project "${project.name}": ${err instanceof Error ? err.message : String(err)}`); result.errors.push(`Failed to restore project "${project.name}": ${err instanceof Error ? err.message : String(err)}`);

View File

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

View File

@@ -0,0 +1,44 @@
import type { McpServer } from '@prisma/client';
import type { ISecretRepository } from '../repositories/interfaces.js';
import type { ServerEnvEntry } from '../validation/mcp-server.schema.js';
/**
* Resolve a server's env entries into a flat key-value map.
* - Inline `value` entries are used directly.
* - `valueFrom.secretRef` entries are looked up from the secret repository.
* Throws if a referenced secret or key is missing.
*/
export async function resolveServerEnv(
server: McpServer,
secretRepo: ISecretRepository,
): Promise<Record<string, string>> {
const entries = server.env as ServerEnvEntry[];
if (!entries || entries.length === 0) return {};
const result: Record<string, string> = {};
const secretCache = new Map<string, Record<string, string>>();
for (const entry of entries) {
if (entry.value !== undefined) {
result[entry.name] = entry.value;
} else if (entry.valueFrom?.secretRef) {
const { name: secretName, key } = entry.valueFrom.secretRef;
if (!secretCache.has(secretName)) {
const secret = await secretRepo.findByName(secretName);
if (!secret) {
throw new Error(`Secret '${secretName}' not found (referenced by server '${server.name}' env '${entry.name}')`);
}
secretCache.set(secretName, secret.data as Record<string, string>);
}
const data = secretCache.get(secretName)!;
if (!(key in data)) {
throw new Error(`Key '${key}' not found in secret '${secretName}' (referenced by server '${server.name}' env '${entry.name}')`);
}
result[entry.name] = data[key]!;
}
}
return result;
}

View File

@@ -1,9 +1,10 @@
export { McpServerService, NotFoundError, ConflictError } from './mcp-server.service.js'; export { McpServerService, NotFoundError, ConflictError } from './mcp-server.service.js';
export { McpProfileService } from './mcp-profile.service.js'; export { SecretService } from './secret.service.js';
export { resolveServerEnv } from './env-resolver.js';
export { ProjectService } from './project.service.js'; export { ProjectService } from './project.service.js';
export { InstanceService, InvalidStateError } from './instance.service.js'; export { InstanceService, InvalidStateError } from './instance.service.js';
export { generateMcpConfig } from './mcp-config-generator.js'; export { generateMcpConfig } from './mcp-config-generator.js';
export type { McpConfig, McpConfigServer, ProfileWithServer } from './mcp-config-generator.js'; export type { McpConfig, McpConfigServer } from './mcp-config-generator.js';
export type { McpOrchestrator, ContainerSpec, ContainerInfo, ContainerLogs } from './orchestrator.js'; export type { McpOrchestrator, ContainerSpec, ContainerInfo, ContainerLogs } from './orchestrator.js';
export { DEFAULT_MEMORY_LIMIT, DEFAULT_NANO_CPUS } from './orchestrator.js'; export { DEFAULT_MEMORY_LIMIT, DEFAULT_NANO_CPUS } from './orchestrator.js';
export { DockerContainerManager } from './docker/container-manager.js'; export { DockerContainerManager } from './docker/container-manager.js';

View File

@@ -1,7 +1,8 @@
import type { McpInstance } from '@prisma/client'; import type { McpInstance } from '@prisma/client';
import type { IMcpInstanceRepository, IMcpServerRepository } from '../repositories/interfaces.js'; import type { IMcpInstanceRepository, IMcpServerRepository, ISecretRepository } from '../repositories/interfaces.js';
import type { McpOrchestrator, ContainerSpec, ContainerInfo } from './orchestrator.js'; import type { McpOrchestrator, ContainerSpec, ContainerInfo } from './orchestrator.js';
import { NotFoundError } from './mcp-server.service.js'; import { NotFoundError } from './mcp-server.service.js';
import { resolveServerEnv } from './env-resolver.js';
export class InvalidStateError extends Error { export class InvalidStateError extends Error {
readonly statusCode = 409; readonly statusCode = 409;
@@ -16,6 +17,7 @@ export class InstanceService {
private instanceRepo: IMcpInstanceRepository, private instanceRepo: IMcpInstanceRepository,
private serverRepo: IMcpServerRepository, private serverRepo: IMcpServerRepository,
private orchestrator: McpOrchestrator, private orchestrator: McpOrchestrator,
private secretRepo?: ISecretRepository,
) {} ) {}
async list(serverId?: string): Promise<McpInstance[]> { async list(serverId?: string): Promise<McpInstance[]> {
@@ -28,81 +30,46 @@ export class InstanceService {
return instance; return instance;
} }
async start(serverId: string, opts?: { env?: Record<string, string>; hostPort?: number }): Promise<McpInstance> { /**
* Reconcile instances for a server to match desired replica count.
* - If fewer running instances than replicas: start new ones
* - If more running instances than replicas: remove excess (oldest first)
*/
async reconcile(serverId: string): Promise<McpInstance[]> {
const server = await this.serverRepo.findById(serverId); const server = await this.serverRepo.findById(serverId);
if (!server) throw new NotFoundError(`McpServer '${serverId}' not found`); if (!server) throw new NotFoundError(`McpServer '${serverId}' not found`);
const image = server.dockerImage ?? server.packageName ?? server.name; const instances = await this.instanceRepo.findAll(serverId);
const active = instances.filter((i) => i.status === 'RUNNING' || i.status === 'STARTING');
const desired = server.replicas;
// Create DB record first in STARTING state if (active.length < desired) {
let instance = await this.instanceRepo.create({ // Scale up
serverId, const toStart = desired - active.length;
status: 'STARTING', for (let i = 0; i < toStart; i++) {
}); await this.startOne(serverId);
try {
const spec: ContainerSpec = {
image,
name: `mcpctl-${server.name}-${instance.id}`,
hostPort: opts?.hostPort ?? null,
labels: {
'mcpctl.server-id': serverId,
'mcpctl.instance-id': instance.id,
},
};
if (server.transport === 'SSE' || server.transport === 'STREAMABLE_HTTP') {
spec.containerPort = 3000;
} }
if (opts?.env) { } else if (active.length > desired) {
spec.env = opts.env; // Scale down — remove oldest first
} const excess = active
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
const containerInfo = await this.orchestrator.createContainer(spec); .slice(0, active.length - desired);
for (const inst of excess) {
const updateFields: { containerId: string; port?: number } = { await this.removeOne(inst);
containerId: containerInfo.containerId,
};
if (containerInfo.port !== undefined) {
updateFields.port = containerInfo.port;
}
instance = await this.instanceRepo.updateStatus(instance.id, 'RUNNING', updateFields);
} catch (err) {
// Mark as ERROR if container creation fails
instance = await this.instanceRepo.updateStatus(instance.id, 'ERROR', {
metadata: { error: err instanceof Error ? err.message : String(err) },
});
}
return instance;
}
async stop(id: string): Promise<McpInstance> {
const instance = await this.getById(id);
if (instance.status === 'STOPPED') {
throw new InvalidStateError(`Instance '${id}' is already stopped`);
}
if (!instance.containerId) {
return this.instanceRepo.updateStatus(id, 'STOPPED');
}
await this.instanceRepo.updateStatus(id, 'STOPPING');
try {
await this.orchestrator.stopContainer(instance.containerId);
return await this.instanceRepo.updateStatus(id, 'STOPPED');
} catch (err) {
return await this.instanceRepo.updateStatus(id, 'ERROR', {
metadata: { error: err instanceof Error ? err.message : String(err) },
});
} }
} }
async restart(id: string): Promise<McpInstance> { return this.instanceRepo.findAll(serverId);
}
/**
* Remove an instance (stop container + delete DB record).
* Does NOT reconcile — caller should reconcile after if needed.
*/
async remove(id: string): Promise<{ serverId: string }> {
const instance = await this.getById(id); const instance = await this.getById(id);
// Stop if running if (instance.containerId) {
if (instance.containerId && (instance.status === 'RUNNING' || instance.status === 'STARTING')) {
try { try {
await this.orchestrator.stopContainer(instance.containerId); await this.orchestrator.stopContainer(instance.containerId);
} catch { } catch {
@@ -116,9 +83,29 @@ export class InstanceService {
} }
await this.instanceRepo.delete(id); await this.instanceRepo.delete(id);
return { serverId: instance.serverId };
}
// Start a fresh instance for the same server /**
return this.start(instance.serverId); * Remove all instances for a server (used before server deletion).
* Stops all containers so Prisma cascade only cleans up DB records.
*/
async removeAllForServer(serverId: string): Promise<void> {
const instances = await this.instanceRepo.findAll(serverId);
for (const inst of instances) {
if (inst.containerId) {
try {
await this.orchestrator.stopContainer(inst.containerId);
} catch {
// best-effort
}
try {
await this.orchestrator.removeContainer(inst.containerId, true);
} catch {
// best-effort
}
}
}
} }
async inspect(id: string): Promise<ContainerInfo> { async inspect(id: string): Promise<ContainerInfo> {
@@ -129,20 +116,6 @@ export class InstanceService {
return this.orchestrator.inspectContainer(instance.containerId); return this.orchestrator.inspectContainer(instance.containerId);
} }
async remove(id: string): Promise<void> {
const instance = await this.getById(id);
if (instance.containerId) {
try {
await this.orchestrator.removeContainer(instance.containerId, true);
} catch {
// Container may already be gone, proceed with DB cleanup
}
}
await this.instanceRepo.delete(id);
}
async getLogs(id: string, opts?: { tail?: number }): Promise<{ stdout: string; stderr: string }> { async getLogs(id: string, opts?: { tail?: number }): Promise<{ stdout: string; stderr: string }> {
const instance = await this.getById(id); const instance = await this.getById(id);
if (!instance.containerId) { if (!instance.containerId) {
@@ -151,4 +124,88 @@ export class InstanceService {
return this.orchestrator.getContainerLogs(instance.containerId, opts); return this.orchestrator.getContainerLogs(instance.containerId, opts);
} }
/** Start a single instance for a server. */
private async startOne(serverId: string): Promise<McpInstance> {
const server = await this.serverRepo.findById(serverId);
if (!server) throw new NotFoundError(`McpServer '${serverId}' not found`);
// External servers don't need container management
if (server.externalUrl) {
return this.instanceRepo.create({
serverId,
status: 'RUNNING',
metadata: { external: true, url: server.externalUrl },
});
}
const image = server.dockerImage ?? server.packageName ?? server.name;
let instance = await this.instanceRepo.create({
serverId,
status: 'STARTING',
});
try {
const spec: ContainerSpec = {
image,
name: `mcpctl-${server.name}-${instance.id}`,
hostPort: null,
labels: {
'mcpctl.server-id': serverId,
'mcpctl.instance-id': instance.id,
},
};
if (server.transport === 'SSE' || server.transport === 'STREAMABLE_HTTP') {
spec.containerPort = server.containerPort ?? 3000;
}
const command = server.command as string[] | null;
if (command) {
spec.command = command;
}
// Resolve env vars from inline values and secret refs
if (this.secretRepo) {
try {
const resolvedEnv = await resolveServerEnv(server, this.secretRepo);
if (Object.keys(resolvedEnv).length > 0) {
spec.env = resolvedEnv;
}
} catch (envErr) {
// Log but don't prevent startup — env resolution failures are non-fatal
// The container may still work if env vars are optional
}
}
const containerInfo = await this.orchestrator.createContainer(spec);
const updateFields: { containerId: string; port?: number } = {
containerId: containerInfo.containerId,
};
if (containerInfo.port !== undefined) {
updateFields.port = containerInfo.port;
}
instance = await this.instanceRepo.updateStatus(instance.id, 'RUNNING', updateFields);
} catch (err) {
instance = await this.instanceRepo.updateStatus(instance.id, 'ERROR', {
metadata: { error: err instanceof Error ? err.message : String(err) },
});
}
return instance;
}
/** Stop and remove a single instance. */
private async removeOne(instance: McpInstance): Promise<void> {
if (instance.containerId) {
try {
await this.orchestrator.stopContainer(instance.containerId);
} catch { /* best-effort */ }
try {
await this.orchestrator.removeContainer(instance.containerId, true);
} catch { /* best-effort */ }
}
await this.instanceRepo.delete(instance.id);
}
} }

View File

@@ -1,4 +1,4 @@
import type { McpServer, McpProfile } from '@prisma/client'; import type { McpServer } from '@prisma/client';
export interface McpConfigServer { export interface McpConfigServer {
command: string; command: string;
@@ -10,49 +10,25 @@ export interface McpConfig {
mcpServers: Record<string, McpConfigServer>; mcpServers: Record<string, McpConfigServer>;
} }
export interface ProfileWithServer {
profile: McpProfile;
server: McpServer;
}
/** /**
* Generate .mcp.json config from a project's profiles. * Generate .mcp.json config from servers with their resolved env vars.
* Secret env vars are excluded from the output — they must be injected at runtime.
*/ */
export function generateMcpConfig(profiles: ProfileWithServer[]): McpConfig { export function generateMcpConfig(
servers: Array<{ server: McpServer; resolvedEnv: Record<string, string> }>,
): McpConfig {
const mcpServers: Record<string, McpConfigServer> = {}; const mcpServers: Record<string, McpConfigServer> = {};
for (const { profile, server } of profiles) { for (const { server, resolvedEnv } of servers) {
const key = `${server.name}--${profile.name}`;
const envTemplate = server.envTemplate as Array<{
name: string;
isSecret: boolean;
defaultValue?: string;
}>;
const envOverrides = profile.envOverrides as Record<string, string>;
// Build env: only include non-secret env vars
const env: Record<string, string> = {};
for (const entry of envTemplate) {
if (entry.isSecret) continue; // Never include secrets in config output
const override = envOverrides[entry.name];
if (override !== undefined) {
env[entry.name] = override;
} else if (entry.defaultValue !== undefined) {
env[entry.name] = entry.defaultValue;
}
}
const config: McpConfigServer = { const config: McpConfigServer = {
command: 'npx', command: 'npx',
args: ['-y', server.packageName ?? server.name], args: ['-y', server.packageName ?? server.name],
}; };
if (Object.keys(env).length > 0) { if (Object.keys(resolvedEnv).length > 0) {
config.env = env; config.env = resolvedEnv;
} }
mcpServers[key] = config; mcpServers[server.name] = config;
} }
return { mcpServers }; return { mcpServers };

View File

@@ -1,62 +0,0 @@
import type { McpProfile } from '@prisma/client';
import type { IMcpProfileRepository, IMcpServerRepository } from '../repositories/interfaces.js';
import { CreateMcpProfileSchema, UpdateMcpProfileSchema } from '../validation/mcp-profile.schema.js';
import { NotFoundError, ConflictError } from './mcp-server.service.js';
export class McpProfileService {
constructor(
private readonly profileRepo: IMcpProfileRepository,
private readonly serverRepo: IMcpServerRepository,
) {}
async list(serverId?: string): Promise<McpProfile[]> {
return this.profileRepo.findAll(serverId);
}
async getById(id: string): Promise<McpProfile> {
const profile = await this.profileRepo.findById(id);
if (profile === null) {
throw new NotFoundError(`Profile not found: ${id}`);
}
return profile;
}
async create(input: unknown): Promise<McpProfile> {
const data = CreateMcpProfileSchema.parse(input);
// Verify server exists
const server = await this.serverRepo.findById(data.serverId);
if (server === null) {
throw new NotFoundError(`Server not found: ${data.serverId}`);
}
// Check unique name per server
const existing = await this.profileRepo.findByServerAndName(data.serverId, data.name);
if (existing !== null) {
throw new ConflictError(`Profile "${data.name}" already exists for server "${server.name}"`);
}
return this.profileRepo.create(data);
}
async update(id: string, input: unknown): Promise<McpProfile> {
const data = UpdateMcpProfileSchema.parse(input);
const profile = await this.getById(id);
// If renaming, check uniqueness
if (data.name !== undefined && data.name !== profile.name) {
const existing = await this.profileRepo.findByServerAndName(profile.serverId, data.name);
if (existing !== null) {
throw new ConflictError(`Profile "${data.name}" already exists for this server`);
}
}
return this.profileRepo.update(id, data);
}
async delete(id: string): Promise<void> {
await this.getById(id);
await this.profileRepo.delete(id);
}
}

View File

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

View File

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

View File

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

View File

@@ -1,15 +1,12 @@
import type { Project } from '@prisma/client'; import type { Project } from '@prisma/client';
import type { IProjectRepository } from '../repositories/project.repository.js'; import type { IProjectRepository } from '../repositories/project.repository.js';
import type { IMcpProfileRepository, IMcpServerRepository } from '../repositories/interfaces.js'; import type { IMcpServerRepository } from '../repositories/interfaces.js';
import { CreateProjectSchema, UpdateProjectSchema, UpdateProjectProfilesSchema } from '../validation/project.schema.js'; import { CreateProjectSchema, UpdateProjectSchema } from '../validation/project.schema.js';
import { NotFoundError, ConflictError } from './mcp-server.service.js'; import { NotFoundError, ConflictError } from './mcp-server.service.js';
import { generateMcpConfig } from './mcp-config-generator.js';
import type { McpConfig, ProfileWithServer } from './mcp-config-generator.js';
export class ProjectService { export class ProjectService {
constructor( constructor(
private readonly projectRepo: IProjectRepository, private readonly projectRepo: IProjectRepository,
private readonly profileRepo: IMcpProfileRepository,
private readonly serverRepo: IMcpServerRepository, private readonly serverRepo: IMcpServerRepository,
) {} ) {}
@@ -46,41 +43,4 @@ export class ProjectService {
await this.getById(id); await this.getById(id);
await this.projectRepo.delete(id); await this.projectRepo.delete(id);
} }
async setProfiles(projectId: string, input: unknown): Promise<string[]> {
const { profileIds } = UpdateProjectProfilesSchema.parse(input);
await this.getById(projectId);
// Verify all profiles exist
for (const profileId of profileIds) {
const profile = await this.profileRepo.findById(profileId);
if (profile === null) {
throw new NotFoundError(`Profile not found: ${profileId}`);
}
}
await this.projectRepo.setProfiles(projectId, profileIds);
return profileIds;
}
async getProfiles(projectId: string): Promise<string[]> {
await this.getById(projectId);
return this.projectRepo.getProfileIds(projectId);
}
async getMcpConfig(projectId: string): Promise<McpConfig> {
await this.getById(projectId);
const profileIds = await this.projectRepo.getProfileIds(projectId);
const profilesWithServers: ProfileWithServer[] = [];
for (const profileId of profileIds) {
const profile = await this.profileRepo.findById(profileId);
if (profile === null) continue;
const server = await this.serverRepo.findById(profile.serverId);
if (server === null) continue;
profilesWithServers.push({ profile, server });
}
return generateMcpConfig(profilesWithServers);
}
} }

View File

@@ -0,0 +1,54 @@
import type { Secret } from '@prisma/client';
import type { ISecretRepository } from '../repositories/interfaces.js';
import { CreateSecretSchema, UpdateSecretSchema } from '../validation/secret.schema.js';
import { NotFoundError, ConflictError } from './mcp-server.service.js';
export class SecretService {
constructor(private readonly repo: ISecretRepository) {}
async list(): Promise<Secret[]> {
return this.repo.findAll();
}
async getById(id: string): Promise<Secret> {
const secret = await this.repo.findById(id);
if (secret === null) {
throw new NotFoundError(`Secret not found: ${id}`);
}
return secret;
}
async getByName(name: string): Promise<Secret> {
const secret = await this.repo.findByName(name);
if (secret === null) {
throw new NotFoundError(`Secret not found: ${name}`);
}
return secret;
}
async create(input: unknown): Promise<Secret> {
const data = CreateSecretSchema.parse(input);
const existing = await this.repo.findByName(data.name);
if (existing !== null) {
throw new ConflictError(`Secret already exists: ${data.name}`);
}
return this.repo.create(data);
}
async update(id: string, input: unknown): Promise<Secret> {
const data = UpdateSecretSchema.parse(input);
// Verify exists
await this.getById(id);
return this.repo.update(id, data);
}
async delete(id: string): Promise<void> {
// Verify exists
await this.getById(id);
await this.repo.delete(id);
}
}

View File

@@ -1,6 +1,4 @@
export { CreateMcpServerSchema, UpdateMcpServerSchema } from './mcp-server.schema.js'; export { CreateMcpServerSchema, UpdateMcpServerSchema } from './mcp-server.schema.js';
export type { CreateMcpServerInput, UpdateMcpServerInput } from './mcp-server.schema.js'; export type { CreateMcpServerInput, UpdateMcpServerInput } from './mcp-server.schema.js';
export { CreateMcpProfileSchema, UpdateMcpProfileSchema } from './mcp-profile.schema.js'; export { CreateProjectSchema, UpdateProjectSchema } from './project.schema.js';
export type { CreateMcpProfileInput, UpdateMcpProfileInput } from './mcp-profile.schema.js'; export type { CreateProjectInput, UpdateProjectInput } from './project.schema.js';
export { CreateProjectSchema, UpdateProjectSchema, UpdateProjectProfilesSchema } from './project.schema.js';
export type { CreateProjectInput, UpdateProjectInput, UpdateProjectProfilesInput } from './project.schema.js';

View File

@@ -1,17 +0,0 @@
import { z } from 'zod';
export const CreateMcpProfileSchema = z.object({
name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'),
serverId: z.string().min(1),
permissions: z.array(z.string()).default([]),
envOverrides: z.record(z.string()).default({}),
});
export const UpdateMcpProfileSchema = z.object({
name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/).optional(),
permissions: z.array(z.string()).optional(),
envOverrides: z.record(z.string()).optional(),
});
export type CreateMcpProfileInput = z.infer<typeof CreateMcpProfileSchema>;
export type UpdateMcpProfileInput = z.infer<typeof UpdateMcpProfileSchema>;

View File

@@ -1,12 +1,23 @@
import { z } from 'zod'; import { z } from 'zod';
const EnvTemplateEntrySchema = z.object({ const SecretRefSchema = z.object({
name: z.string().min(1).max(100), name: z.string().min(1),
description: z.string().max(500).default(''), key: z.string().min(1),
isSecret: z.boolean().default(false),
setupUrl: z.string().url().optional(),
}); });
export const ServerEnvEntrySchema = z.object({
name: z.string().min(1).max(100),
value: z.string().optional(),
valueFrom: z.object({
secretRef: SecretRefSchema,
}).optional(),
}).refine(
(e) => (e.value !== undefined) !== (e.valueFrom !== undefined),
{ message: 'Exactly one of value or valueFrom must be set' },
);
export type ServerEnvEntry = z.infer<typeof ServerEnvEntrySchema>;
export const CreateMcpServerSchema = z.object({ export const CreateMcpServerSchema = z.object({
name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'), name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'),
description: z.string().max(1000).default(''), description: z.string().max(1000).default(''),
@@ -14,7 +25,11 @@ export const CreateMcpServerSchema = z.object({
dockerImage: z.string().max(200).optional(), dockerImage: z.string().max(200).optional(),
transport: z.enum(['STDIO', 'SSE', 'STREAMABLE_HTTP']).default('STDIO'), transport: z.enum(['STDIO', 'SSE', 'STREAMABLE_HTTP']).default('STDIO'),
repositoryUrl: z.string().url().optional(), repositoryUrl: z.string().url().optional(),
envTemplate: z.array(EnvTemplateEntrySchema).default([]), 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),
env: z.array(ServerEnvEntrySchema).default([]),
}); });
export const UpdateMcpServerSchema = z.object({ export const UpdateMcpServerSchema = z.object({
@@ -23,7 +38,11 @@ export const UpdateMcpServerSchema = z.object({
dockerImage: z.string().max(200).nullable().optional(), dockerImage: z.string().max(200).nullable().optional(),
transport: z.enum(['STDIO', 'SSE', 'STREAMABLE_HTTP']).optional(), transport: z.enum(['STDIO', 'SSE', 'STREAMABLE_HTTP']).optional(),
repositoryUrl: z.string().url().nullable().optional(), repositoryUrl: z.string().url().nullable().optional(),
envTemplate: z.array(EnvTemplateEntrySchema).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(),
env: z.array(ServerEnvEntrySchema).optional(),
}); });
export type CreateMcpServerInput = z.infer<typeof CreateMcpServerSchema>; export type CreateMcpServerInput = z.infer<typeof CreateMcpServerSchema>;

View File

@@ -9,10 +9,5 @@ export const UpdateProjectSchema = z.object({
description: z.string().max(1000).optional(), description: z.string().max(1000).optional(),
}); });
export const UpdateProjectProfilesSchema = z.object({
profileIds: z.array(z.string().min(1)).min(0),
});
export type CreateProjectInput = z.infer<typeof CreateProjectSchema>; export type CreateProjectInput = z.infer<typeof CreateProjectSchema>;
export type UpdateProjectInput = z.infer<typeof UpdateProjectSchema>; export type UpdateProjectInput = z.infer<typeof UpdateProjectSchema>;
export type UpdateProjectProfilesInput = z.infer<typeof UpdateProjectProfilesSchema>;

View File

@@ -0,0 +1,13 @@
import { z } from 'zod';
export const CreateSecretSchema = z.object({
name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'),
data: z.record(z.string()).default({}),
});
export const UpdateSecretSchema = z.object({
data: z.record(z.string()),
});
export type CreateSecretInput = z.infer<typeof CreateSecretSchema>;
export type UpdateSecretInput = z.infer<typeof UpdateSecretSchema>;

View File

@@ -4,7 +4,7 @@ import { BackupService } from '../src/services/backup/backup-service.js';
import { RestoreService } from '../src/services/backup/restore-service.js'; import { RestoreService } from '../src/services/backup/restore-service.js';
import { encrypt, decrypt, isSensitiveKey } from '../src/services/backup/crypto.js'; import { encrypt, decrypt, isSensitiveKey } from '../src/services/backup/crypto.js';
import { registerBackupRoutes } from '../src/routes/backup.js'; import { registerBackupRoutes } from '../src/routes/backup.js';
import type { IMcpServerRepository, IMcpProfileRepository } from '../src/repositories/interfaces.js'; import type { IMcpServerRepository, ISecretRepository } from '../src/repositories/interfaces.js';
import type { IProjectRepository } from '../src/repositories/project.repository.js'; import type { IProjectRepository } from '../src/repositories/project.repository.js';
// Mock data // Mock data
@@ -12,19 +12,19 @@ const mockServers = [
{ {
id: 's1', name: 'github', description: 'GitHub MCP', packageName: '@mcp/github', id: 's1', name: 'github', description: 'GitHub MCP', packageName: '@mcp/github',
dockerImage: null, transport: 'STDIO' as const, repositoryUrl: null, dockerImage: null, transport: 'STDIO' as const, repositoryUrl: null,
envTemplate: [], version: 1, createdAt: new Date(), updatedAt: new Date(), env: [], version: 1, createdAt: new Date(), updatedAt: new Date(),
}, },
{ {
id: 's2', name: 'slack', description: 'Slack MCP', packageName: null, id: 's2', name: 'slack', description: 'Slack MCP', packageName: null,
dockerImage: 'mcp/slack:latest', transport: 'SSE' as const, repositoryUrl: null, dockerImage: 'mcp/slack:latest', transport: 'SSE' as const, repositoryUrl: null,
envTemplate: [], version: 1, createdAt: new Date(), updatedAt: new Date(), env: [], version: 1, createdAt: new Date(), updatedAt: new Date(),
}, },
]; ];
const mockProfiles = [ const mockSecrets = [
{ {
id: 'p1', name: 'default', serverId: 's1', permissions: ['read'], id: 'sec1', name: 'github-secrets',
envOverrides: { GITHUB_TOKEN: 'ghp_secret123' }, data: { GITHUB_TOKEN: 'ghp_secret123' },
version: 1, createdAt: new Date(), updatedAt: new Date(), version: 1, createdAt: new Date(), updatedAt: new Date(),
}, },
]; ];
@@ -41,19 +41,19 @@ function mockServerRepo(): IMcpServerRepository {
findAll: vi.fn(async () => [...mockServers]), findAll: vi.fn(async () => [...mockServers]),
findById: vi.fn(async (id: string) => mockServers.find((s) => s.id === id) ?? null), findById: vi.fn(async (id: string) => mockServers.find((s) => s.id === id) ?? null),
findByName: vi.fn(async (name: string) => mockServers.find((s) => s.name === name) ?? null), findByName: vi.fn(async (name: string) => mockServers.find((s) => s.name === name) ?? null),
create: vi.fn(async (data) => ({ id: 'new-s', ...data, envTemplate: [], version: 1, createdAt: new Date(), updatedAt: new Date() } as typeof mockServers[0])), create: vi.fn(async (data) => ({ id: 'new-s', ...data, env: [], version: 1, createdAt: new Date(), updatedAt: new Date() } as typeof mockServers[0])),
update: vi.fn(async (id, data) => ({ ...mockServers.find((s) => s.id === id)!, ...data })), update: vi.fn(async (id, data) => ({ ...mockServers.find((s) => s.id === id)!, ...data })),
delete: vi.fn(async () => {}), delete: vi.fn(async () => {}),
}; };
} }
function mockProfileRepo(): IMcpProfileRepository { function mockSecretRepo(): ISecretRepository {
return { return {
findAll: vi.fn(async () => [...mockProfiles]), findAll: vi.fn(async () => [...mockSecrets]),
findById: vi.fn(async (id: string) => mockProfiles.find((p) => p.id === id) ?? null), findById: vi.fn(async (id: string) => mockSecrets.find((s) => s.id === id) ?? null),
findByServerAndName: vi.fn(async () => null), findByName: vi.fn(async (name: string) => mockSecrets.find((s) => s.name === name) ?? null),
create: vi.fn(async (data) => ({ id: 'new-p', ...data, version: 1, createdAt: new Date(), updatedAt: new Date() } as typeof mockProfiles[0])), create: vi.fn(async (data) => ({ id: 'new-sec', ...data, version: 1, createdAt: new Date(), updatedAt: new Date() } as typeof mockSecrets[0])),
update: vi.fn(async (id, data) => ({ ...mockProfiles.find((p) => p.id === id)!, ...data })), update: vi.fn(async (id, data) => ({ ...mockSecrets.find((s) => s.id === id)!, ...data })),
delete: vi.fn(async () => {}), delete: vi.fn(async () => {}),
}; };
} }
@@ -66,8 +66,6 @@ function mockProjectRepo(): IProjectRepository {
create: vi.fn(async (data) => ({ id: 'new-proj', ...data, version: 1, createdAt: new Date(), updatedAt: new Date() } as typeof mockProjects[0])), create: vi.fn(async (data) => ({ id: 'new-proj', ...data, version: 1, createdAt: new Date(), updatedAt: new Date() } as typeof mockProjects[0])),
update: vi.fn(async (id, data) => ({ ...mockProjects.find((p) => p.id === id)!, ...data })), update: vi.fn(async (id, data) => ({ ...mockProjects.find((p) => p.id === id)!, ...data })),
delete: vi.fn(async () => {}), delete: vi.fn(async () => {}),
setProfiles: vi.fn(async () => {}),
getProfileIds: vi.fn(async () => ['p1']),
}; };
} }
@@ -112,7 +110,7 @@ describe('BackupService', () => {
let backupService: BackupService; let backupService: BackupService;
beforeEach(() => { beforeEach(() => {
backupService = new BackupService(mockServerRepo(), mockProfileRepo(), mockProjectRepo()); backupService = new BackupService(mockServerRepo(), mockProjectRepo(), mockSecretRepo());
}); });
it('creates backup with all resources', async () => { it('creates backup with all resources', async () => {
@@ -121,43 +119,43 @@ describe('BackupService', () => {
expect(bundle.version).toBe('1'); expect(bundle.version).toBe('1');
expect(bundle.encrypted).toBe(false); expect(bundle.encrypted).toBe(false);
expect(bundle.servers).toHaveLength(2); expect(bundle.servers).toHaveLength(2);
expect(bundle.profiles).toHaveLength(1); expect(bundle.secrets).toHaveLength(1);
expect(bundle.projects).toHaveLength(1); expect(bundle.projects).toHaveLength(1);
expect(bundle.servers[0]!.name).toBe('github'); expect(bundle.servers[0]!.name).toBe('github');
expect(bundle.profiles[0]!.serverName).toBe('github'); expect(bundle.secrets[0]!.name).toBe('github-secrets');
expect(bundle.projects[0]!.name).toBe('my-project'); expect(bundle.projects[0]!.name).toBe('my-project');
}); });
it('filters resources', async () => { it('filters resources', async () => {
const bundle = await backupService.createBackup({ resources: ['servers'] }); const bundle = await backupService.createBackup({ resources: ['servers'] });
expect(bundle.servers).toHaveLength(2); expect(bundle.servers).toHaveLength(2);
expect(bundle.profiles).toHaveLength(0); expect(bundle.secrets).toHaveLength(0);
expect(bundle.projects).toHaveLength(0); expect(bundle.projects).toHaveLength(0);
}); });
it('encrypts sensitive env values when password provided', async () => { it('encrypts sensitive secret values when password provided', async () => {
const bundle = await backupService.createBackup({ password: 'test-pass' }); const bundle = await backupService.createBackup({ password: 'test-pass' });
expect(bundle.encrypted).toBe(true); expect(bundle.encrypted).toBe(true);
expect(bundle.encryptedSecrets).toBeDefined(); expect(bundle.encryptedSecrets).toBeDefined();
// The GITHUB_TOKEN should be replaced with placeholder // The GITHUB_TOKEN should be replaced with placeholder
const overrides = bundle.profiles[0]!.envOverrides as Record<string, string>; const data = bundle.secrets[0]!.data;
expect(overrides['GITHUB_TOKEN']).toContain('__ENCRYPTED:'); expect(data['GITHUB_TOKEN']).toContain('__ENCRYPTED:');
}); });
it('handles empty repositories', async () => { it('handles empty repositories', async () => {
const emptyServerRepo = mockServerRepo(); const emptyServerRepo = mockServerRepo();
(emptyServerRepo.findAll as ReturnType<typeof vi.fn>).mockResolvedValue([]); (emptyServerRepo.findAll as ReturnType<typeof vi.fn>).mockResolvedValue([]);
const emptyProfileRepo = mockProfileRepo(); const emptySecretRepo = mockSecretRepo();
(emptyProfileRepo.findAll as ReturnType<typeof vi.fn>).mockResolvedValue([]); (emptySecretRepo.findAll as ReturnType<typeof vi.fn>).mockResolvedValue([]);
const emptyProjectRepo = mockProjectRepo(); const emptyProjectRepo = mockProjectRepo();
(emptyProjectRepo.findAll as ReturnType<typeof vi.fn>).mockResolvedValue([]); (emptyProjectRepo.findAll as ReturnType<typeof vi.fn>).mockResolvedValue([]);
const service = new BackupService(emptyServerRepo, emptyProfileRepo, emptyProjectRepo); const service = new BackupService(emptyServerRepo, emptyProjectRepo, emptySecretRepo);
const bundle = await service.createBackup(); const bundle = await service.createBackup();
expect(bundle.servers).toHaveLength(0); expect(bundle.servers).toHaveLength(0);
expect(bundle.profiles).toHaveLength(0); expect(bundle.secrets).toHaveLength(0);
expect(bundle.projects).toHaveLength(0); expect(bundle.projects).toHaveLength(0);
}); });
}); });
@@ -165,18 +163,18 @@ describe('BackupService', () => {
describe('RestoreService', () => { describe('RestoreService', () => {
let restoreService: RestoreService; let restoreService: RestoreService;
let serverRepo: IMcpServerRepository; let serverRepo: IMcpServerRepository;
let profileRepo: IMcpProfileRepository; let secretRepo: ISecretRepository;
let projectRepo: IProjectRepository; let projectRepo: IProjectRepository;
beforeEach(() => { beforeEach(() => {
serverRepo = mockServerRepo(); serverRepo = mockServerRepo();
profileRepo = mockProfileRepo(); secretRepo = mockSecretRepo();
projectRepo = mockProjectRepo(); projectRepo = mockProjectRepo();
// Default: nothing exists yet // Default: nothing exists yet
(serverRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null); (serverRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
(profileRepo.findByServerAndName as ReturnType<typeof vi.fn>).mockResolvedValue(null); (secretRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
(projectRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null); (projectRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
restoreService = new RestoreService(serverRepo, profileRepo, projectRepo); restoreService = new RestoreService(serverRepo, projectRepo, secretRepo);
}); });
const validBundle = { const validBundle = {
@@ -184,9 +182,9 @@ describe('RestoreService', () => {
mcpctlVersion: '0.1.0', mcpctlVersion: '0.1.0',
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
encrypted: false, encrypted: false,
servers: [{ name: 'github', description: 'GitHub', packageName: null, dockerImage: null, transport: 'STDIO', repositoryUrl: null, envTemplate: [] }], servers: [{ name: 'github', description: 'GitHub', packageName: null, dockerImage: null, transport: 'STDIO', repositoryUrl: null, env: [] }],
profiles: [{ name: 'default', serverName: 'github', permissions: ['read'], envOverrides: {} }], secrets: [{ name: 'github-secrets', data: { GITHUB_TOKEN: 'ghp_123' } }],
projects: [{ name: 'test-proj', description: 'Test', profileNames: ['default'] }], projects: [{ name: 'test-proj', description: 'Test' }],
}; };
it('validates valid bundle', () => { it('validates valid bundle', () => {
@@ -203,11 +201,11 @@ describe('RestoreService', () => {
const result = await restoreService.restore(validBundle); const result = await restoreService.restore(validBundle);
expect(result.serversCreated).toBe(1); expect(result.serversCreated).toBe(1);
expect(result.profilesCreated).toBe(1); expect(result.secretsCreated).toBe(1);
expect(result.projectsCreated).toBe(1); expect(result.projectsCreated).toBe(1);
expect(result.errors).toHaveLength(0); expect(result.errors).toHaveLength(0);
expect(serverRepo.create).toHaveBeenCalled(); expect(serverRepo.create).toHaveBeenCalled();
expect(profileRepo.create).toHaveBeenCalled(); expect(secretRepo.create).toHaveBeenCalled();
expect(projectRepo.create).toHaveBeenCalled(); expect(projectRepo.create).toHaveBeenCalled();
}); });
@@ -242,17 +240,17 @@ describe('RestoreService', () => {
}); });
it('restores encrypted bundle with correct password', async () => { it('restores encrypted bundle with correct password', async () => {
const secrets = { 'profile:default:API_KEY': 'secret-val' }; const encryptedData = { 'secret:github-secrets:GITHUB_TOKEN': 'ghp_decrypted' };
const encBundle = { const encBundle = {
...validBundle, ...validBundle,
encrypted: true, encrypted: true,
encryptedSecrets: encrypt(JSON.stringify(secrets), 'test-pw'), encryptedSecrets: encrypt(JSON.stringify(encryptedData), 'test-pw'),
profiles: [{ ...validBundle.profiles[0]!, envOverrides: { API_KEY: '__ENCRYPTED:profile:default:API_KEY__' } }], secrets: [{ name: 'github-secrets', data: { GITHUB_TOKEN: '__ENCRYPTED:secret:github-secrets:GITHUB_TOKEN__' } }],
}; };
const result = await restoreService.restore(encBundle, { password: 'test-pw' }); const result = await restoreService.restore(encBundle, { password: 'test-pw' });
expect(result.errors).toHaveLength(0); expect(result.errors).toHaveLength(0);
expect(result.profilesCreated).toBe(1); expect(result.secretsCreated).toBe(1);
}); });
it('fails with wrong decryption password', async () => { it('fails with wrong decryption password', async () => {
@@ -272,17 +270,17 @@ describe('Backup Routes', () => {
beforeEach(() => { beforeEach(() => {
const sRepo = mockServerRepo(); const sRepo = mockServerRepo();
const pRepo = mockProfileRepo(); const secRepo = mockSecretRepo();
const prRepo = mockProjectRepo(); const prRepo = mockProjectRepo();
backupService = new BackupService(sRepo, pRepo, prRepo); backupService = new BackupService(sRepo, prRepo, secRepo);
const rSRepo = mockServerRepo(); const rSRepo = mockServerRepo();
(rSRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null); (rSRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
const rPRepo = mockProfileRepo(); const rSecRepo = mockSecretRepo();
(rPRepo.findByServerAndName as ReturnType<typeof vi.fn>).mockResolvedValue(null); (rSecRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
const rPrRepo = mockProjectRepo(); const rPrRepo = mockProjectRepo();
(rPrRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null); (rPrRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
restoreService = new RestoreService(rSRepo, rPRepo, rPrRepo); restoreService = new RestoreService(rSRepo, rPrRepo, rSecRepo);
}); });
async function buildApp() { async function buildApp() {
@@ -303,7 +301,7 @@ describe('Backup Routes', () => {
const body = res.json(); const body = res.json();
expect(body.version).toBe('1'); expect(body.version).toBe('1');
expect(body.servers).toBeDefined(); expect(body.servers).toBeDefined();
expect(body.profiles).toBeDefined(); expect(body.secrets).toBeDefined();
expect(body.projects).toBeDefined(); expect(body.projects).toBeDefined();
}); });

View File

@@ -0,0 +1,112 @@
import { describe, it, expect, vi } from 'vitest';
import { resolveServerEnv } from '../src/services/env-resolver.js';
import type { ISecretRepository } from '../src/repositories/interfaces.js';
import type { McpServer } from '@prisma/client';
function makeServer(env: unknown[]): McpServer {
return {
id: 'srv-1',
name: 'test-server',
description: '',
packageName: null,
dockerImage: 'test:latest',
transport: 'STDIO',
repositoryUrl: null,
externalUrl: null,
command: null,
containerPort: null,
replicas: 1,
env,
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
} as McpServer;
}
function mockSecretRepo(secrets: Record<string, Record<string, string>>): ISecretRepository {
return {
findAll: vi.fn(async () => []),
findById: vi.fn(async () => null),
findByName: vi.fn(async (name: string) => {
const data = secrets[name];
if (!data) return null;
return { id: `sec-${name}`, name, data, version: 1, createdAt: new Date(), updatedAt: new Date() };
}),
create: vi.fn(async () => ({} as never)),
update: vi.fn(async () => ({} as never)),
delete: vi.fn(async () => {}),
};
}
describe('resolveServerEnv', () => {
it('resolves inline values', async () => {
const server = makeServer([
{ name: 'FOO', value: 'bar' },
{ name: 'BAZ', value: 'qux' },
]);
const repo = mockSecretRepo({});
const result = await resolveServerEnv(server, repo);
expect(result).toEqual({ FOO: 'bar', BAZ: 'qux' });
});
it('resolves secret references', async () => {
const server = makeServer([
{ name: 'TOKEN', valueFrom: { secretRef: { name: 'ha-creds', key: 'HOMEASSISTANT_TOKEN' } } },
]);
const repo = mockSecretRepo({
'ha-creds': { HOMEASSISTANT_TOKEN: 'secret-token-123' },
});
const result = await resolveServerEnv(server, repo);
expect(result).toEqual({ TOKEN: 'secret-token-123' });
});
it('handles mixed inline and secret refs', async () => {
const server = makeServer([
{ name: 'URL', value: 'https://ha.local' },
{ name: 'TOKEN', valueFrom: { secretRef: { name: 'creds', key: 'TOKEN' } } },
]);
const repo = mockSecretRepo({
creds: { TOKEN: 'my-token' },
});
const result = await resolveServerEnv(server, repo);
expect(result).toEqual({ URL: 'https://ha.local', TOKEN: 'my-token' });
});
it('caches secret lookups', async () => {
const server = makeServer([
{ name: 'A', valueFrom: { secretRef: { name: 'shared', key: 'KEY_A' } } },
{ name: 'B', valueFrom: { secretRef: { name: 'shared', key: 'KEY_B' } } },
]);
const repo = mockSecretRepo({
shared: { KEY_A: 'val-a', KEY_B: 'val-b' },
});
const result = await resolveServerEnv(server, repo);
expect(result).toEqual({ A: 'val-a', B: 'val-b' });
expect(repo.findByName).toHaveBeenCalledTimes(1);
});
it('throws when secret not found', async () => {
const server = makeServer([
{ name: 'TOKEN', valueFrom: { secretRef: { name: 'missing', key: 'TOKEN' } } },
]);
const repo = mockSecretRepo({});
await expect(resolveServerEnv(server, repo)).rejects.toThrow("Secret 'missing' not found");
});
it('throws when secret key not found', async () => {
const server = makeServer([
{ name: 'TOKEN', valueFrom: { secretRef: { name: 'creds', key: 'NONEXISTENT' } } },
]);
const repo = mockSecretRepo({
creds: { OTHER_KEY: 'val' },
});
await expect(resolveServerEnv(server, repo)).rejects.toThrow("Key 'NONEXISTENT' not found in secret 'creds'");
});
it('returns empty map for empty env', async () => {
const server = makeServer([]);
const repo = mockSecretRepo({});
const result = await resolveServerEnv(server, repo);
expect(result).toEqual({});
});
});

View File

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

View File

@@ -1,22 +1,8 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { generateMcpConfig } from '../src/services/mcp-config-generator.js'; import { generateMcpConfig } from '../src/services/mcp-config-generator.js';
import type { ProfileWithServer } from '../src/services/mcp-config-generator.js'; import type { McpServer } from '@prisma/client';
function makeProfile(overrides: Partial<ProfileWithServer['profile']> = {}): ProfileWithServer['profile'] { function makeServer(overrides: Partial<McpServer> = {}): McpServer {
return {
id: 'p1',
name: 'default',
serverId: 's1',
permissions: [],
envOverrides: {},
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
function makeServer(overrides: Partial<ProfileWithServer['server']> = {}): ProfileWithServer['server'] {
return { return {
id: 's1', id: 's1',
name: 'slack', name: 'slack',
@@ -25,7 +11,7 @@ function makeServer(overrides: Partial<ProfileWithServer['server']> = {}): Profi
dockerImage: null, dockerImage: null,
transport: 'STDIO', transport: 'STDIO',
repositoryUrl: null, repositoryUrl: null,
envTemplate: [], env: [],
version: 1, version: 1,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
@@ -34,76 +20,51 @@ function makeServer(overrides: Partial<ProfileWithServer['server']> = {}): Profi
} }
describe('generateMcpConfig', () => { describe('generateMcpConfig', () => {
it('returns empty mcpServers for empty profiles', () => { it('returns empty mcpServers for empty input', () => {
const result = generateMcpConfig([]); const result = generateMcpConfig([]);
expect(result).toEqual({ mcpServers: {} }); expect(result).toEqual({ mcpServers: {} });
}); });
it('generates config for a single profile', () => { it('generates config for a single server', () => {
const result = generateMcpConfig([ const result = generateMcpConfig([
{ profile: makeProfile(), server: makeServer() }, { server: makeServer(), resolvedEnv: {} },
]); ]);
expect(result.mcpServers['slack--default']).toBeDefined(); expect(result.mcpServers['slack']).toBeDefined();
expect(result.mcpServers['slack--default']?.command).toBe('npx'); expect(result.mcpServers['slack']?.command).toBe('npx');
expect(result.mcpServers['slack--default']?.args).toEqual(['-y', '@anthropic/slack-mcp']); expect(result.mcpServers['slack']?.args).toEqual(['-y', '@anthropic/slack-mcp']);
}); });
it('excludes secret env vars from output', () => { it('includes resolved env when present', () => {
const server = makeServer({
envTemplate: [
{ name: 'SLACK_BOT_TOKEN', description: 'Token', isSecret: true },
{ name: 'SLACK_TEAM_ID', description: 'Team', isSecret: false, defaultValue: 'T123' },
] as never,
});
const result = generateMcpConfig([ const result = generateMcpConfig([
{ profile: makeProfile(), server }, { server: makeServer(), resolvedEnv: { SLACK_TEAM_ID: 'T123' } },
]); ]);
const config = result.mcpServers['slack--default']; const config = result.mcpServers['slack'];
expect(config?.env).toBeDefined(); expect(config?.env).toBeDefined();
expect(config?.env?.['SLACK_TEAM_ID']).toBe('T123'); expect(config?.env?.['SLACK_TEAM_ID']).toBe('T123');
expect(config?.env?.['SLACK_BOT_TOKEN']).toBeUndefined();
}); });
it('applies env overrides from profile (non-secret only)', () => { it('omits env when resolvedEnv is empty', () => {
const server = makeServer({ const result = generateMcpConfig([
envTemplate: [ { server: makeServer(), resolvedEnv: {} },
{ name: 'API_URL', description: 'URL', isSecret: false }, ]);
] as never, expect(result.mcpServers['slack']?.env).toBeUndefined();
});
const profile = makeProfile({
envOverrides: { API_URL: 'https://staging.example.com' } as never,
});
const result = generateMcpConfig([{ profile, server }]);
expect(result.mcpServers['slack--default']?.env?.['API_URL']).toBe('https://staging.example.com');
}); });
it('generates multiple server configs', () => { it('generates multiple server configs', () => {
const result = generateMcpConfig([ const result = generateMcpConfig([
{ profile: makeProfile({ name: 'readonly' }), server: makeServer({ name: 'slack' }) }, { server: makeServer({ name: 'slack' }), resolvedEnv: {} },
{ profile: makeProfile({ name: 'default', id: 'p2' }), server: makeServer({ name: 'github', id: 's2', packageName: '@anthropic/github-mcp' }) }, { server: makeServer({ name: 'github', id: 's2', packageName: '@anthropic/github-mcp' }), resolvedEnv: {} },
]); ]);
expect(Object.keys(result.mcpServers)).toHaveLength(2); expect(Object.keys(result.mcpServers)).toHaveLength(2);
expect(result.mcpServers['slack--readonly']).toBeDefined(); expect(result.mcpServers['slack']).toBeDefined();
expect(result.mcpServers['github--default']).toBeDefined(); expect(result.mcpServers['github']).toBeDefined();
});
it('omits env when no non-secret vars have values', () => {
const server = makeServer({
envTemplate: [
{ name: 'TOKEN', description: 'Secret', isSecret: true },
] as never,
});
const result = generateMcpConfig([
{ profile: makeProfile(), server },
]);
expect(result.mcpServers['slack--default']?.env).toBeUndefined();
}); });
it('uses server name as fallback when packageName is null', () => { it('uses server name as fallback when packageName is null', () => {
const server = makeServer({ packageName: null }); const server = makeServer({ packageName: null });
const result = generateMcpConfig([ const result = generateMcpConfig([
{ profile: makeProfile(), server }, { server, resolvedEnv: {} },
]); ]);
expect(result.mcpServers['slack--default']?.args).toEqual(['-y', 'slack']); expect(result.mcpServers['slack']?.args).toEqual(['-y', 'slack']);
}); });
}); });

View File

@@ -1,128 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { McpProfileService } from '../src/services/mcp-profile.service.js';
import { NotFoundError, ConflictError } from '../src/services/mcp-server.service.js';
import type { IMcpProfileRepository, IMcpServerRepository } from '../src/repositories/interfaces.js';
function mockProfileRepo(): IMcpProfileRepository {
return {
findAll: vi.fn(async () => []),
findById: vi.fn(async () => null),
findByServerAndName: vi.fn(async () => null),
create: vi.fn(async (data) => ({
id: 'new-id',
name: data.name,
serverId: data.serverId,
permissions: data.permissions ?? [],
envOverrides: data.envOverrides ?? {},
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
})),
update: vi.fn(async (id, data) => ({
id,
name: data.name ?? 'test',
serverId: 'srv-1',
permissions: data.permissions ?? [],
envOverrides: data.envOverrides ?? {},
version: 2,
createdAt: new Date(),
updatedAt: new Date(),
})),
delete: vi.fn(async () => {}),
};
}
function mockServerRepo(): IMcpServerRepository {
return {
findAll: vi.fn(async () => []),
findById: vi.fn(async () => null),
findByName: vi.fn(async () => null),
create: vi.fn(async () => ({} as never)),
update: vi.fn(async () => ({} as never)),
delete: vi.fn(async () => {}),
};
}
describe('McpProfileService', () => {
let profileRepo: ReturnType<typeof mockProfileRepo>;
let serverRepo: ReturnType<typeof mockServerRepo>;
let service: McpProfileService;
beforeEach(() => {
profileRepo = mockProfileRepo();
serverRepo = mockServerRepo();
service = new McpProfileService(profileRepo, serverRepo);
});
describe('list', () => {
it('returns all profiles', async () => {
await service.list();
expect(profileRepo.findAll).toHaveBeenCalledWith(undefined);
});
it('filters by serverId', async () => {
await service.list('srv-1');
expect(profileRepo.findAll).toHaveBeenCalledWith('srv-1');
});
});
describe('getById', () => {
it('returns profile when found', async () => {
vi.mocked(profileRepo.findById).mockResolvedValue({ id: '1', name: 'test' } as never);
const result = await service.getById('1');
expect(result.id).toBe('1');
});
it('throws NotFoundError when not found', async () => {
await expect(service.getById('missing')).rejects.toThrow(NotFoundError);
});
});
describe('create', () => {
it('creates a profile when server exists', async () => {
vi.mocked(serverRepo.findById).mockResolvedValue({ id: 'srv-1', name: 'test' } as never);
const result = await service.create({ name: 'readonly', serverId: 'srv-1' });
expect(result.name).toBe('readonly');
});
it('throws NotFoundError when server does not exist', async () => {
await expect(service.create({ name: 'test', serverId: 'missing' })).rejects.toThrow(NotFoundError);
});
it('throws ConflictError when profile name exists for server', async () => {
vi.mocked(serverRepo.findById).mockResolvedValue({ id: 'srv-1', name: 'test' } as never);
vi.mocked(profileRepo.findByServerAndName).mockResolvedValue({ id: '1' } as never);
await expect(service.create({ name: 'dup', serverId: 'srv-1' })).rejects.toThrow(ConflictError);
});
});
describe('update', () => {
it('updates an existing profile', async () => {
vi.mocked(profileRepo.findById).mockResolvedValue({ id: '1', name: 'old', serverId: 'srv-1' } as never);
await service.update('1', { permissions: ['read'] });
expect(profileRepo.update).toHaveBeenCalled();
});
it('checks uniqueness when renaming', async () => {
vi.mocked(profileRepo.findById).mockResolvedValue({ id: '1', name: 'old', serverId: 'srv-1' } as never);
vi.mocked(profileRepo.findByServerAndName).mockResolvedValue({ id: '2' } as never);
await expect(service.update('1', { name: 'taken' })).rejects.toThrow(ConflictError);
});
it('throws NotFoundError when profile does not exist', async () => {
await expect(service.update('missing', {})).rejects.toThrow(NotFoundError);
});
});
describe('delete', () => {
it('deletes an existing profile', async () => {
vi.mocked(profileRepo.findById).mockResolvedValue({ id: '1' } as never);
await service.delete('1');
expect(profileRepo.delete).toHaveBeenCalledWith('1');
});
it('throws NotFoundError when profile does not exist', async () => {
await expect(service.delete('missing')).rejects.toThrow(NotFoundError);
});
});
});

View File

@@ -0,0 +1,727 @@
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest';
import Fastify from 'fastify';
import type { FastifyInstance } from 'fastify';
import http from 'node:http';
import { McpServerService } from '../src/services/mcp-server.service.js';
import { InstanceService } from '../src/services/instance.service.js';
import { McpProxyService } from '../src/services/mcp-proxy-service.js';
import { AuditLogService } from '../src/services/audit-log.service.js';
import { errorHandler } from '../src/middleware/error-handler.js';
import { registerMcpServerRoutes } from '../src/routes/mcp-servers.js';
import { registerInstanceRoutes } from '../src/routes/instances.js';
import { registerMcpProxyRoutes } from '../src/routes/mcp-proxy.js';
import type {
IMcpServerRepository,
IMcpInstanceRepository,
IAuditLogRepository,
} from '../src/repositories/interfaces.js';
import type { McpOrchestrator } from '../src/services/orchestrator.js';
import type { McpServer, McpInstance, InstanceStatus } from '@prisma/client';
// ---------------------------------------------------------------------------
// In-memory repository implementations (stateful mocks)
// ---------------------------------------------------------------------------
function createInMemoryServerRepo(): IMcpServerRepository {
const servers = new Map<string, McpServer>();
let nextId = 1;
return {
findAll: vi.fn(async () => [...servers.values()]),
findById: vi.fn(async (id: string) => servers.get(id) ?? null),
findByName: vi.fn(async (name: string) => [...servers.values()].find((s) => s.name === name) ?? null),
create: vi.fn(async (data) => {
const id = `srv-${nextId++}`;
const server = {
id,
name: data.name,
description: data.description ?? '',
packageName: data.packageName ?? null,
dockerImage: data.dockerImage ?? null,
transport: data.transport ?? 'STDIO',
repositoryUrl: data.repositoryUrl ?? null,
externalUrl: data.externalUrl ?? null,
command: data.command ?? null,
containerPort: data.containerPort ?? null,
replicas: data.replicas ?? 1,
env: data.env ?? [],
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,
env: [
{ name: 'HOMEASSISTANT_TOKEN', value: 'placeholder' },
],
},
});
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")'],
env: [
{ name: 'HOMEASSISTANT_URL', value: 'http://localhost:8123' },
{ name: 'HOMEASSISTANT_TOKEN', valueFrom: { secretRef: { name: 'ha-secrets', key: 'token' } } },
],
},
});
expect(createRes.statusCode).toBe(201);
const server = createRes.json<{ id: string; name: string; dockerImage: string; command: string[] }>();
expect(server.name).toBe('ha-mcp-docker');
expect(server.dockerImage).toBe('ghcr.io/homeassistant-ai/ha-mcp:2.4');
expect(server.command).toEqual(['python', '-c', 'print("hello")']);
// 2. Verify instance was auto-created with container
const instancesRes = await app.inject({
method: 'GET',
url: `/api/v1/instances?serverId=${server.id}`,
});
expect(instancesRes.statusCode).toBe(200);
const instances = instancesRes.json<Array<{ id: string; status: string; containerId: string }>>();
expect(instances).toHaveLength(1);
expect(instances[0]!.status).toBe('RUNNING');
expect(instances[0]!.containerId).toBeTruthy();
// 3. Verify orchestrator was called with correct spec
expect(orchestrator.createContainer).toHaveBeenCalledTimes(1);
const spec = vi.mocked(orchestrator.createContainer).mock.calls[0]![0];
expect(spec.image).toBe('ghcr.io/homeassistant-ai/ha-mcp:2.4');
expect(spec.containerPort).toBe(3000);
expect(spec.command).toEqual(['python', '-c', 'print("hello")']);
});
it('marks instance as ERROR when Docker fails', async () => {
vi.mocked(orchestrator.createContainer).mockRejectedValueOnce(new Error('Docker socket unavailable'));
// Creating server triggers reconcile which tries to create container → fails
const createRes = await app.inject({
method: 'POST',
url: '/api/v1/servers',
payload: {
name: 'failing-server',
description: 'Will fail to start',
dockerImage: 'some-image:latest',
transport: 'STDIO',
},
});
expect(createRes.statusCode).toBe(201);
const server = createRes.json<{ id: string }>();
const instancesRes = await app.inject({
method: 'GET',
url: `/api/v1/instances?serverId=${server.id}`,
});
const instances = instancesRes.json<Array<{ id: string; status: string }>>();
expect(instances).toHaveLength(1);
expect(instances[0]!.status).toBe('ERROR');
});
});
describe('full lifecycle', () => {
let app: FastifyInstance;
let orchestrator: ReturnType<typeof createMockOrchestrator>;
beforeEach(async () => {
orchestrator = createMockOrchestrator();
app = await buildTestApp({
serverRepo: createInMemoryServerRepo(),
instanceRepo: createInMemoryInstanceRepo(),
auditLogRepo: createInMemoryAuditLogRepo(),
orchestrator,
});
});
afterAll(async () => {
if (app) await app.close();
});
it('register → auto-create → list → delete instance (reconcile) → delete server (cascade)', async () => {
// Register (auto-creates instance via reconcile)
const createRes = await app.inject({
method: 'POST',
url: '/api/v1/servers',
payload: {
name: 'lifecycle-test',
description: 'Full lifecycle',
dockerImage: 'test:latest',
transport: 'SSE',
containerPort: 8080,
},
});
expect(createRes.statusCode).toBe(201);
const server = createRes.json<{ id: string }>();
// List instances (auto-created)
const listRes = await app.inject({
method: 'GET',
url: `/api/v1/instances?serverId=${server.id}`,
});
expect(listRes.statusCode).toBe(200);
const instances = listRes.json<Array<{ id: string; status: string }>>();
expect(instances).toHaveLength(1);
expect(instances[0]!.status).toBe('RUNNING');
const instanceId = instances[0]!.id;
// Delete instance → triggers reconcile → new instance auto-created
const removeRes = await app.inject({
method: 'DELETE',
url: `/api/v1/instances/${instanceId}`,
});
expect(removeRes.statusCode).toBe(204);
// Verify a replacement instance was created (reconcile)
const listAfter = await app.inject({
method: 'GET',
url: `/api/v1/instances?serverId=${server.id}`,
});
const afterInstances = listAfter.json<Array<{ id: string }>>();
expect(afterInstances).toHaveLength(1);
expect(afterInstances[0]!.id).not.toBe(instanceId); // New instance, not the old one
// Delete server (cascade removes all instances)
const deleteRes = await app.inject({
method: 'DELETE',
url: `/api/v1/servers/${server.id}`,
});
expect(deleteRes.statusCode).toBe(204);
// Verify server is gone
const serversAfter = await app.inject({ method: 'GET', url: '/api/v1/servers' });
expect(serversAfter.json<unknown[]>()).toHaveLength(0);
});
it('external server lifecycle: register → auto-create → proxy → delete server (cascade)', async () => {
// Register external (auto-creates virtual instance)
const createRes = await app.inject({
method: 'POST',
url: '/api/v1/servers',
payload: {
name: 'external-lifecycle',
transport: 'STREAMABLE_HTTP',
externalUrl: `http://localhost:${fakeMcpPort}`,
},
});
const server = createRes.json<{ id: string }>();
// Verify auto-created instance
const instancesRes = await app.inject({
method: 'GET',
url: `/api/v1/instances?serverId=${server.id}`,
});
const instances = instancesRes.json<Array<{ id: string; status: string; containerId: string | null }>>();
expect(instances).toHaveLength(1);
expect(instances[0]!.status).toBe('RUNNING');
expect(instances[0]!.containerId).toBeNull();
// Proxy tools/list
const proxyRes = await app.inject({
method: 'POST',
url: '/api/v1/mcp/proxy',
headers: { authorization: 'Bearer test-token' },
payload: { serverId: server.id, method: 'tools/list' },
});
expect(proxyRes.statusCode).toBe(200);
expect(proxyRes.json<{ result: { tools: unknown[] } }>().result.tools.length).toBeGreaterThan(0);
// Docker orchestrator should NOT have been called (external server)
expect(orchestrator.createContainer).not.toHaveBeenCalled();
expect(orchestrator.stopContainer).not.toHaveBeenCalled();
// Delete server (cascade)
const deleteRes = await app.inject({
method: 'DELETE',
url: `/api/v1/servers/${server.id}`,
});
expect(deleteRes.statusCode).toBe(204);
});
});
describe('proxy authentication', () => {
let app: FastifyInstance;
beforeEach(async () => {
app = await buildTestApp({
serverRepo: createInMemoryServerRepo(),
instanceRepo: createInMemoryInstanceRepo(),
auditLogRepo: createInMemoryAuditLogRepo(),
orchestrator: createMockOrchestrator(),
});
});
afterAll(async () => {
if (app) await app.close();
});
it('rejects proxy calls without auth header', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/v1/mcp/proxy',
payload: { serverId: 'srv-1', method: 'tools/list' },
});
// Auth middleware rejects with 401 (no Bearer token)
expect(res.statusCode).toBe(401);
});
});
describe('server update flow', () => {
let app: FastifyInstance;
beforeEach(async () => {
app = await buildTestApp({
serverRepo: createInMemoryServerRepo(),
instanceRepo: createInMemoryInstanceRepo(),
auditLogRepo: createInMemoryAuditLogRepo(),
orchestrator: createMockOrchestrator(),
});
});
afterAll(async () => {
if (app) await app.close();
});
it('creates and updates server fields', async () => {
// Create (with replicas: 0 to avoid creating instances in this test)
const createRes = await app.inject({
method: 'POST',
url: '/api/v1/servers',
payload: {
name: 'updatable',
description: 'Original desc',
transport: 'STDIO',
replicas: 0,
},
});
expect(createRes.statusCode).toBe(201);
const server = createRes.json<{ id: string; description: string }>();
expect(server.description).toBe('Original desc');
// Update
const updateRes = await app.inject({
method: 'PUT',
url: `/api/v1/servers/${server.id}`,
payload: {
description: 'Updated desc',
externalUrl: `http://localhost:${fakeMcpPort}`,
transport: 'STREAMABLE_HTTP',
},
});
expect(updateRes.statusCode).toBe(200);
const updated = updateRes.json<{ description: string; externalUrl: string; transport: string }>();
expect(updated.description).toBe('Updated desc');
expect(updated.externalUrl).toBe(`http://localhost:${fakeMcpPort}`);
expect(updated.transport).toBe('STREAMABLE_HTTP');
// Fetch to verify persistence
const getRes = await app.inject({
method: 'GET',
url: `/api/v1/servers/${server.id}`,
});
expect(getRes.json<{ description: string }>().description).toBe('Updated desc');
});
});
});

View File

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

View File

@@ -15,7 +15,7 @@ function mockRepo(): IMcpServerRepository {
dockerImage: null, dockerImage: null,
transport: data.transport ?? 'STDIO', transport: data.transport ?? 'STDIO',
repositoryUrl: data.repositoryUrl ?? null, repositoryUrl: data.repositoryUrl ?? null,
envTemplate: data.envTemplate ?? [], env: data.env ?? [],
version: 1, version: 1,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
@@ -28,7 +28,7 @@ function mockRepo(): IMcpServerRepository {
dockerImage: null, dockerImage: null,
transport: 'STDIO' as const, transport: 'STDIO' as const,
repositoryUrl: null, repositoryUrl: null,
envTemplate: [], env: [],
version: 2, version: 2,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),

View File

@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ProjectService } from '../src/services/project.service.js'; import { ProjectService } from '../src/services/project.service.js';
import { NotFoundError, ConflictError } from '../src/services/mcp-server.service.js'; import { NotFoundError, ConflictError } from '../src/services/mcp-server.service.js';
import type { IProjectRepository } from '../src/repositories/project.repository.js'; import type { IProjectRepository } from '../src/repositories/project.repository.js';
import type { IMcpProfileRepository, IMcpServerRepository } from '../src/repositories/interfaces.js'; import type { IMcpServerRepository } from '../src/repositories/interfaces.js';
function mockProjectRepo(): IProjectRepository { function mockProjectRepo(): IProjectRepository {
return { return {
@@ -23,19 +23,6 @@ function mockProjectRepo(): IProjectRepository {
createdAt: new Date(), updatedAt: new Date(), createdAt: new Date(), updatedAt: new Date(),
})), })),
delete: vi.fn(async () => {}), delete: vi.fn(async () => {}),
setProfiles: vi.fn(async () => {}),
getProfileIds: vi.fn(async () => []),
};
}
function mockProfileRepo(): IMcpProfileRepository {
return {
findAll: vi.fn(async () => []),
findById: vi.fn(async () => null),
findByServerAndName: vi.fn(async () => null),
create: vi.fn(async () => ({} as never)),
update: vi.fn(async () => ({} as never)),
delete: vi.fn(async () => {}),
}; };
} }
@@ -52,15 +39,13 @@ function mockServerRepo(): IMcpServerRepository {
describe('ProjectService', () => { describe('ProjectService', () => {
let projectRepo: ReturnType<typeof mockProjectRepo>; let projectRepo: ReturnType<typeof mockProjectRepo>;
let profileRepo: ReturnType<typeof mockProfileRepo>;
let serverRepo: ReturnType<typeof mockServerRepo>; let serverRepo: ReturnType<typeof mockServerRepo>;
let service: ProjectService; let service: ProjectService;
beforeEach(() => { beforeEach(() => {
projectRepo = mockProjectRepo(); projectRepo = mockProjectRepo();
profileRepo = mockProfileRepo();
serverRepo = mockServerRepo(); serverRepo = mockServerRepo();
service = new ProjectService(projectRepo, profileRepo, serverRepo); service = new ProjectService(projectRepo, serverRepo);
}); });
describe('create', () => { describe('create', () => {
@@ -86,55 +71,6 @@ describe('ProjectService', () => {
}); });
}); });
describe('setProfiles', () => {
it('sets profile associations', async () => {
vi.mocked(projectRepo.findById).mockResolvedValue({ id: 'p1' } as never);
vi.mocked(profileRepo.findById).mockResolvedValue({ id: 'prof-1' } as never);
const result = await service.setProfiles('p1', { profileIds: ['prof-1'] });
expect(result).toEqual(['prof-1']);
expect(projectRepo.setProfiles).toHaveBeenCalledWith('p1', ['prof-1']);
});
it('throws NotFoundError for missing profile', async () => {
vi.mocked(projectRepo.findById).mockResolvedValue({ id: 'p1' } as never);
await expect(service.setProfiles('p1', { profileIds: ['missing'] })).rejects.toThrow(NotFoundError);
});
it('throws NotFoundError for missing project', async () => {
await expect(service.setProfiles('missing', { profileIds: [] })).rejects.toThrow(NotFoundError);
});
});
describe('getMcpConfig', () => {
it('returns empty config for project with no profiles', async () => {
vi.mocked(projectRepo.findById).mockResolvedValue({ id: 'p1' } as never);
const result = await service.getMcpConfig('p1');
expect(result).toEqual({ mcpServers: {} });
});
it('generates config from profiles', async () => {
vi.mocked(projectRepo.findById).mockResolvedValue({ id: 'p1' } as never);
vi.mocked(projectRepo.getProfileIds).mockResolvedValue(['prof-1']);
vi.mocked(profileRepo.findById).mockResolvedValue({
id: 'prof-1', name: 'default', serverId: 's1',
permissions: [], envOverrides: {},
version: 1, createdAt: new Date(), updatedAt: new Date(),
});
vi.mocked(serverRepo.findById).mockResolvedValue({
id: 's1', name: 'slack', description: '', packageName: '@anthropic/slack-mcp',
dockerImage: null, transport: 'STDIO', repositoryUrl: null, envTemplate: [],
version: 1, createdAt: new Date(), updatedAt: new Date(),
});
const result = await service.getMcpConfig('p1');
expect(result.mcpServers['slack--default']).toBeDefined();
});
it('throws NotFoundError for missing project', async () => {
await expect(service.getMcpConfig('missing')).rejects.toThrow(NotFoundError);
});
});
describe('delete', () => { describe('delete', () => {
it('deletes project', async () => { it('deletes project', async () => {
vi.mocked(projectRepo.findById).mockResolvedValue({ id: 'p1' } as never); vi.mocked(projectRepo.findById).mockResolvedValue({ id: 'p1' } as never);

View File

@@ -0,0 +1,170 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import Fastify from 'fastify';
import type { FastifyInstance } from 'fastify';
import { registerSecretRoutes } from '../src/routes/secrets.js';
import { SecretService } from '../src/services/secret.service.js';
import { errorHandler } from '../src/middleware/error-handler.js';
import type { ISecretRepository } from '../src/repositories/interfaces.js';
let app: FastifyInstance;
function mockRepo(): ISecretRepository {
let lastCreated: Record<string, unknown> | null = null;
return {
findAll: vi.fn(async () => [
{ id: '1', name: 'ha-creds', data: { TOKEN: 'abc' }, version: 1, createdAt: new Date(), updatedAt: new Date() },
]),
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) => {
const secret = {
id: 'new-id',
name: data.name,
data: data.data ?? {},
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
};
lastCreated = secret;
return secret;
}),
update: vi.fn(async (id, data) => {
const secret = {
id,
name: 'ha-creds',
data: data.data,
version: 2,
createdAt: new Date(),
updatedAt: new Date(),
};
lastCreated = secret;
return secret;
}),
delete: vi.fn(async () => {}),
};
}
afterEach(async () => {
if (app) await app.close();
});
function createApp(repo: ISecretRepository) {
app = Fastify({ logger: false });
app.setErrorHandler(errorHandler);
const service = new SecretService(repo);
registerSecretRoutes(app, service);
return app.ready();
}
describe('Secret Routes', () => {
describe('GET /api/v1/secrets', () => {
it('returns secret list', async () => {
const repo = mockRepo();
await createApp(repo);
const res = await app.inject({ method: 'GET', url: '/api/v1/secrets' });
expect(res.statusCode).toBe(200);
const body = res.json<Array<{ name: string }>>();
expect(body).toHaveLength(1);
expect(body[0]?.name).toBe('ha-creds');
});
});
describe('GET /api/v1/secrets/:id', () => {
it('returns 404 when not found', async () => {
const repo = mockRepo();
await createApp(repo);
const res = await app.inject({ method: 'GET', url: '/api/v1/secrets/missing' });
expect(res.statusCode).toBe(404);
});
it('returns secret when found', async () => {
const repo = mockRepo();
vi.mocked(repo.findById).mockResolvedValue({ id: '1', name: 'ha-creds', data: { TOKEN: 'abc' } } as never);
await createApp(repo);
const res = await app.inject({ method: 'GET', url: '/api/v1/secrets/1' });
expect(res.statusCode).toBe(200);
});
});
describe('POST /api/v1/secrets', () => {
it('creates a secret and returns 201', async () => {
const repo = mockRepo();
await createApp(repo);
const res = await app.inject({
method: 'POST',
url: '/api/v1/secrets',
payload: { name: 'new-secret', data: { KEY: 'val' } },
});
expect(res.statusCode).toBe(201);
expect(res.json<{ name: string }>().name).toBe('new-secret');
});
it('returns 400 for invalid input', async () => {
const repo = mockRepo();
await createApp(repo);
const res = await app.inject({
method: 'POST',
url: '/api/v1/secrets',
payload: { name: '' },
});
expect(res.statusCode).toBe(400);
});
it('returns 409 when name already exists', async () => {
const repo = mockRepo();
vi.mocked(repo.findByName).mockResolvedValue({ id: '1' } as never);
await createApp(repo);
const res = await app.inject({
method: 'POST',
url: '/api/v1/secrets',
payload: { name: 'existing' },
});
expect(res.statusCode).toBe(409);
});
});
describe('PUT /api/v1/secrets/:id', () => {
it('updates a secret', async () => {
const repo = mockRepo();
vi.mocked(repo.findById).mockResolvedValue({ id: '1', name: 'ha-creds' } as never);
await createApp(repo);
const res = await app.inject({
method: 'PUT',
url: '/api/v1/secrets/1',
payload: { data: { TOKEN: 'new-val' } },
});
expect(res.statusCode).toBe(200);
});
it('returns 404 when not found', async () => {
const repo = mockRepo();
await createApp(repo);
const res = await app.inject({
method: 'PUT',
url: '/api/v1/secrets/missing',
payload: { data: { X: 'y' } },
});
expect(res.statusCode).toBe(404);
});
});
describe('DELETE /api/v1/secrets/:id', () => {
it('deletes a secret and returns 204', async () => {
const repo = mockRepo();
vi.mocked(repo.findById).mockResolvedValue({ id: '1', name: 'ha-creds' } as never);
await createApp(repo);
const res = await app.inject({ method: 'DELETE', url: '/api/v1/secrets/1' });
expect(res.statusCode).toBe(204);
});
it('returns 404 when not found', async () => {
const repo = mockRepo();
await createApp(repo);
const res = await app.inject({ method: 'DELETE', url: '/api/v1/secrets/missing' });
expect(res.statusCode).toBe(404);
});
});
});

View File

@@ -2,8 +2,6 @@ import { describe, it, expect } from 'vitest';
import { import {
CreateMcpServerSchema, CreateMcpServerSchema,
UpdateMcpServerSchema, UpdateMcpServerSchema,
CreateMcpProfileSchema,
UpdateMcpProfileSchema,
} from '../src/validation/index.js'; } from '../src/validation/index.js';
describe('CreateMcpServerSchema', () => { describe('CreateMcpServerSchema', () => {
@@ -14,7 +12,7 @@ describe('CreateMcpServerSchema', () => {
transport: 'STDIO', transport: 'STDIO',
}); });
expect(result.name).toBe('my-server'); expect(result.name).toBe('my-server');
expect(result.envTemplate).toEqual([]); expect(result.env).toEqual([]);
}); });
it('rejects empty name', () => { it('rejects empty name', () => {
@@ -39,15 +37,40 @@ describe('CreateMcpServerSchema', () => {
expect(result.transport).toBe('STDIO'); expect(result.transport).toBe('STDIO');
}); });
it('validates envTemplate entries', () => { it('validates env entries with inline value', () => {
const result = CreateMcpServerSchema.parse({ const result = CreateMcpServerSchema.parse({
name: 'test', name: 'test',
envTemplate: [ env: [
{ name: 'API_KEY', description: 'The key', isSecret: true }, { name: 'API_URL', value: 'https://example.com' },
], ],
}); });
expect(result.envTemplate).toHaveLength(1); expect(result.env).toHaveLength(1);
expect(result.envTemplate[0]?.isSecret).toBe(true); expect(result.env[0]?.value).toBe('https://example.com');
});
it('validates env entries with secretRef', () => {
const result = CreateMcpServerSchema.parse({
name: 'test',
env: [
{ name: 'API_KEY', valueFrom: { secretRef: { name: 'my-secret', key: 'api-key' } } },
],
});
expect(result.env).toHaveLength(1);
expect(result.env[0]?.valueFrom?.secretRef.name).toBe('my-secret');
});
it('rejects env entry with neither value nor valueFrom', () => {
expect(() => CreateMcpServerSchema.parse({
name: 'test',
env: [{ name: 'FOO' }],
})).toThrow();
});
it('rejects env entry with both value and valueFrom', () => {
expect(() => CreateMcpServerSchema.parse({
name: 'test',
env: [{ name: 'FOO', value: 'bar', valueFrom: { secretRef: { name: 'x', key: 'y' } } }],
})).toThrow();
}); });
it('rejects invalid transport', () => { it('rejects invalid transport', () => {
@@ -78,47 +101,3 @@ describe('UpdateMcpServerSchema', () => {
}); });
}); });
describe('CreateMcpProfileSchema', () => {
it('validates valid input', () => {
const result = CreateMcpProfileSchema.parse({
name: 'readonly',
serverId: 'server-123',
});
expect(result.name).toBe('readonly');
expect(result.permissions).toEqual([]);
expect(result.envOverrides).toEqual({});
});
it('rejects empty name', () => {
expect(() => CreateMcpProfileSchema.parse({ name: '', serverId: 'x' })).toThrow();
});
it('accepts permissions array', () => {
const result = CreateMcpProfileSchema.parse({
name: 'admin',
serverId: 'x',
permissions: ['read', 'write', 'delete'],
});
expect(result.permissions).toHaveLength(3);
});
it('accepts envOverrides', () => {
const result = CreateMcpProfileSchema.parse({
name: 'staging',
serverId: 'x',
envOverrides: { API_URL: 'https://staging.example.com' },
});
expect(result.envOverrides['API_URL']).toBe('https://staging.example.com');
});
});
describe('UpdateMcpProfileSchema', () => {
it('allows partial updates', () => {
const result = UpdateMcpProfileSchema.parse({ permissions: ['read'] });
expect(result.permissions).toEqual(['read']);
});
it('allows empty object', () => {
expect(UpdateMcpProfileSchema.parse({})).toBeDefined();
});
});

View File

@@ -2,4 +2,3 @@ export * from './types/index.js';
export * from './validation/index.js'; export * from './validation/index.js';
export * from './constants/index.js'; export * from './constants/index.js';
export * from './utils/index.js'; export * from './utils/index.js';
export * from './profiles/index.js';

View File

@@ -1,5 +0,0 @@
export type { ProfileTemplate, ProfileCategory, InstantiatedProfile } from './types.js';
export { profileTemplateSchema, envTemplateEntrySchema } from './types.js';
export { ProfileRegistry, defaultRegistry } from './registry.js';
export { validateTemplate, getMissingEnvVars, instantiateProfile, generateMcpJsonEntry } from './utils.js';
export * from './templates/index.js';

View File

@@ -1,67 +0,0 @@
import type { ProfileTemplate, ProfileCategory } from './types.js';
import { filesystemTemplate } from './templates/filesystem.js';
import { githubTemplate } from './templates/github.js';
import { postgresTemplate } from './templates/postgres.js';
import { slackTemplate } from './templates/slack.js';
import { memoryTemplate } from './templates/memory.js';
import { fetchTemplate } from './templates/fetch.js';
const builtinTemplates: ProfileTemplate[] = [
filesystemTemplate,
githubTemplate,
postgresTemplate,
slackTemplate,
memoryTemplate,
fetchTemplate,
];
export class ProfileRegistry {
private templates = new Map<string, ProfileTemplate>();
constructor(templates: ProfileTemplate[] = builtinTemplates) {
for (const t of templates) {
this.templates.set(t.id, t);
}
}
getAll(): ProfileTemplate[] {
return [...this.templates.values()];
}
getById(id: string): ProfileTemplate | undefined {
return this.templates.get(id);
}
getByCategory(category: ProfileCategory): ProfileTemplate[] {
return this.getAll().filter((t) => t.category === category);
}
getCategories(): ProfileCategory[] {
const cats = new Set<ProfileCategory>();
for (const t of this.templates.values()) {
cats.add(t.category);
}
return [...cats];
}
search(query: string): ProfileTemplate[] {
const q = query.toLowerCase();
return this.getAll().filter(
(t) =>
t.id.includes(q) ||
t.name.includes(q) ||
t.displayName.toLowerCase().includes(q) ||
t.description.toLowerCase().includes(q),
);
}
register(template: ProfileTemplate): void {
this.templates.set(template.id, template);
}
has(id: string): boolean {
return this.templates.has(id);
}
}
export const defaultRegistry = new ProfileRegistry();

View File

@@ -1,15 +0,0 @@
import type { ProfileTemplate } from '../types.js';
export const fetchTemplate: ProfileTemplate = {
id: 'fetch',
name: 'fetch',
displayName: 'Fetch',
description: 'Fetch and convert web pages to markdown for reading and analysis',
category: 'utility',
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-fetch'],
requiredEnvVars: [],
optionalEnvVars: [],
setupInstructions: 'No configuration required. Fetches web content and converts HTML to markdown.',
documentationUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/fetch',
};

View File

@@ -1,16 +0,0 @@
import type { ProfileTemplate } from '../types.js';
export const filesystemTemplate: ProfileTemplate = {
id: 'filesystem',
name: 'filesystem',
displayName: 'Filesystem',
description: 'Provides read/write access to local filesystem directories',
category: 'filesystem',
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-filesystem'],
requiredEnvVars: [],
optionalEnvVars: [],
setupInstructions:
'Append allowed directory paths as additional args. Example: npx -y @modelcontextprotocol/server-filesystem /home/user/docs',
documentationUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem',
};

View File

@@ -1,22 +0,0 @@
import type { ProfileTemplate } from '../types.js';
export const githubTemplate: ProfileTemplate = {
id: 'github',
name: 'github',
displayName: 'GitHub',
description: 'Interact with GitHub repositories, issues, pull requests, and more',
category: 'integration',
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-github'],
requiredEnvVars: [
{
name: 'GITHUB_PERSONAL_ACCESS_TOKEN',
description: 'GitHub personal access token with repo scope',
isSecret: true,
setupUrl: 'https://github.com/settings/tokens',
},
],
optionalEnvVars: [],
setupInstructions: 'Create a personal access token at GitHub Settings > Developer settings > Personal access tokens.',
documentationUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/github',
};

View File

@@ -1,6 +0,0 @@
export { filesystemTemplate } from './filesystem.js';
export { githubTemplate } from './github.js';
export { postgresTemplate } from './postgres.js';
export { slackTemplate } from './slack.js';
export { memoryTemplate } from './memory.js';
export { fetchTemplate } from './fetch.js';

View File

@@ -1,15 +0,0 @@
import type { ProfileTemplate } from '../types.js';
export const memoryTemplate: ProfileTemplate = {
id: 'memory',
name: 'memory',
displayName: 'Memory',
description: 'Persistent knowledge graph memory for storing and retrieving entities and relations',
category: 'utility',
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-memory'],
requiredEnvVars: [],
optionalEnvVars: [],
setupInstructions: 'No configuration required. Memory is stored locally in a JSON knowledge graph file.',
documentationUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/memory',
};

View File

@@ -1,21 +0,0 @@
import type { ProfileTemplate } from '../types.js';
export const postgresTemplate: ProfileTemplate = {
id: 'postgres',
name: 'postgres',
displayName: 'PostgreSQL',
description: 'Query and inspect PostgreSQL databases with read-only access',
category: 'database',
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-postgres'],
requiredEnvVars: [
{
name: 'DATABASE_URL',
description: 'PostgreSQL connection string (e.g., postgresql://user:pass@localhost:5432/dbname)',
isSecret: true,
},
],
optionalEnvVars: [],
setupInstructions: 'Provide a PostgreSQL connection string. The server provides read-only query access by default.',
documentationUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/postgres',
};

View File

@@ -1,28 +0,0 @@
import type { ProfileTemplate } from '../types.js';
export const slackTemplate: ProfileTemplate = {
id: 'slack',
name: 'slack',
displayName: 'Slack',
description: 'Read and send Slack messages, manage channels, and search workspace content',
category: 'integration',
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-slack'],
requiredEnvVars: [
{
name: 'SLACK_BOT_TOKEN',
description: 'Slack Bot User OAuth Token (starts with xoxb-)',
isSecret: true,
setupUrl: 'https://api.slack.com/apps',
},
{
name: 'SLACK_TEAM_ID',
description: 'Slack workspace/team ID',
isSecret: false,
},
],
optionalEnvVars: [],
setupInstructions:
'Create a Slack App at api.slack.com/apps, install it to your workspace, and copy the Bot User OAuth Token.',
documentationUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/slack',
};

View File

@@ -1,35 +0,0 @@
import { z } from 'zod';
export const envTemplateEntrySchema = z.object({
name: z.string().min(1),
description: z.string(),
isSecret: z.boolean(),
setupUrl: z.string().url().optional(),
defaultValue: z.string().optional(),
});
export const profileTemplateSchema = z.object({
id: z.string().min(1).regex(/^[a-z0-9-]+$/, 'ID must be lowercase alphanumeric with hyphens'),
name: z.string().min(1),
displayName: z.string().min(1),
description: z.string().min(1),
category: z.enum(['filesystem', 'database', 'integration', 'ai', 'utility', 'development']),
command: z.string().min(1),
args: z.array(z.string()),
requiredEnvVars: z.array(envTemplateEntrySchema).default([]),
optionalEnvVars: z.array(envTemplateEntrySchema).default([]),
setupInstructions: z.string().optional(),
documentationUrl: z.string().url().optional(),
});
export type ProfileTemplate = z.infer<typeof profileTemplateSchema>;
export type ProfileCategory = ProfileTemplate['category'];
export interface InstantiatedProfile {
name: string;
templateId: string;
command: string;
args: string[];
env: Record<string, string>;
}

View File

@@ -1,61 +0,0 @@
import { profileTemplateSchema } from './types.js';
import type { ProfileTemplate, InstantiatedProfile } from './types.js';
export function validateTemplate(template: unknown): { success: true; data: ProfileTemplate } | { success: false; errors: string[] } {
const result = profileTemplateSchema.safeParse(template);
if (result.success) {
return { success: true, data: result.data };
}
return {
success: false,
errors: result.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`),
};
}
export function getMissingEnvVars(template: ProfileTemplate, envValues: Record<string, string>): string[] {
return template.requiredEnvVars
.filter((e) => !envValues[e.name] && e.defaultValue === undefined)
.map((e) => e.name);
}
export function instantiateProfile(
template: ProfileTemplate,
envValues: Record<string, string>,
): InstantiatedProfile {
const missing = getMissingEnvVars(template, envValues);
if (missing.length > 0) {
throw new Error(`Missing required environment variables: ${missing.join(', ')}`);
}
const env: Record<string, string> = {};
for (const entry of template.requiredEnvVars) {
const value = envValues[entry.name] ?? entry.defaultValue;
if (value !== undefined) {
env[entry.name] = value;
}
}
for (const entry of template.optionalEnvVars) {
const value = envValues[entry.name] ?? entry.defaultValue;
if (value !== undefined) {
env[entry.name] = value;
}
}
return {
name: template.name,
templateId: template.id,
command: template.command,
args: [...template.args],
env,
};
}
export function generateMcpJsonEntry(profile: InstantiatedProfile): Record<string, unknown> {
return {
[profile.name]: {
command: profile.command,
args: profile.args,
env: profile.env,
},
};
}

View File

@@ -6,29 +6,19 @@ export interface McpServerConfig {
type: string; type: string;
command: string; command: string;
args: string[]; args: string[];
envTemplate: EnvTemplateEntry[]; env: EnvEntry[];
setupGuide?: string; setupGuide?: string;
} }
export interface EnvTemplateEntry { export interface EnvEntry {
name: string; name: string;
description: string; value?: string;
isSecret: boolean; valueFrom?: { secretRef: { name: string; key: string } };
setupUrl?: string;
defaultValue?: string;
}
export interface McpProfile {
name: string;
serverId: string;
config: Record<string, unknown>;
filterRules?: Record<string, unknown>;
} }
export interface McpProject { export interface McpProject {
name: string; name: string;
description?: string; description?: string;
profileIds: string[];
} }
// Service interfaces for dependency injection // Service interfaces for dependency injection