Compare commits
4 Commits
feat/creat
...
feat/repla
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca02340a4c | ||
|
|
02254f2aac | ||
|
|
540dd6fd63 | ||
| a05a4c4816 |
@@ -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
|
||||||
|
|||||||
@@ -11,12 +11,14 @@ servers:
|
|||||||
- "from ha_mcp.server import HomeAssistantSmartMCPServer; s = HomeAssistantSmartMCPServer(); s.mcp.run(transport='sse', host='0.0.0.0', port=3000)"
|
- "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):
|
# For connecting to an already-running instance (host.containers.internal for container-to-host):
|
||||||
externalUrl: "http://host.containers.internal:8086/mcp"
|
externalUrl: "http://host.containers.internal:8086/mcp"
|
||||||
envTemplate:
|
env:
|
||||||
- name: HOMEASSISTANT_URL
|
- name: HOMEASSISTANT_URL
|
||||||
description: "Home Assistant instance URL (e.g. https://ha.example.com)"
|
value: ""
|
||||||
- name: HOMEASSISTANT_TOKEN
|
- name: HOMEASSISTANT_TOKEN
|
||||||
description: "Home Assistant long-lived access token"
|
valueFrom:
|
||||||
isSecret: true
|
secretRef:
|
||||||
|
name: ha-secrets
|
||||||
|
key: token
|
||||||
|
|
||||||
profiles:
|
profiles:
|
||||||
- name: production
|
- name: production
|
||||||
|
|||||||
@@ -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(''),
|
||||||
@@ -15,29 +23,22 @@ const ServerSpecSchema = z.object({
|
|||||||
command: z.array(z.string()).optional(),
|
command: z.array(z.string()).optional(),
|
||||||
containerPort: z.number().int().min(1).max(65535).optional(),
|
containerPort: z.number().int().min(1).max(65535).optional(),
|
||||||
replicas: z.number().int().min(0).max(10).default(1),
|
replicas: z.number().int().min(0).max(10).default(1),
|
||||||
envTemplate: z.array(z.object({
|
env: z.array(ServerEnvEntrySchema).default([]),
|
||||||
name: z.string(),
|
|
||||||
description: z.string().default(''),
|
|
||||||
isSecret: z.boolean().default(false),
|
|
||||||
})).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([]),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -61,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;
|
||||||
}
|
}
|
||||||
@@ -84,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);
|
||||||
@@ -100,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}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,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 };
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { Command } from 'commander';
|
import { Command } from 'commander';
|
||||||
import type { ApiClient } from '../api-client.js';
|
import type { ApiClient } from '../api-client.js';
|
||||||
import { resolveNameOrId } from './shared.js';
|
|
||||||
|
|
||||||
export interface CreateCommandDeps {
|
export interface CreateCommandDeps {
|
||||||
client: ApiClient;
|
client: ApiClient;
|
||||||
log: (...args: unknown[]) => void;
|
log: (...args: unknown[]) => void;
|
||||||
@@ -11,17 +9,33 @@ function collect(value: string, prev: string[]): string[] {
|
|||||||
return [...prev, value];
|
return [...prev, value];
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseEnvTemplate(entries: string[]): Array<{ name: string; description: string; isSecret: boolean }> {
|
interface ServerEnvEntry {
|
||||||
|
name: string;
|
||||||
|
value?: string;
|
||||||
|
valueFrom?: { secretRef: { name: string; key: string } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseServerEnv(entries: string[]): ServerEnvEntry[] {
|
||||||
return entries.map((entry) => {
|
return entries.map((entry) => {
|
||||||
const parts = entry.split(':');
|
const eqIdx = entry.indexOf('=');
|
||||||
if (parts.length < 2) {
|
if (eqIdx === -1) {
|
||||||
throw new Error(`Invalid env-template format '${entry}'. Expected NAME:description[:isSecret]`);
|
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 {
|
return {
|
||||||
name: parts[0]!,
|
name: envName,
|
||||||
description: parts[1]!,
|
valueFrom: { secretRef: { name: parts[1]!, key: parts[2]! } },
|
||||||
isSecret: parts[2] === 'true',
|
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { name: envName, value: rhs };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +55,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
|
|||||||
const { client, log } = deps;
|
const { client, log } = deps;
|
||||||
|
|
||||||
const cmd = new Command('create')
|
const cmd = new Command('create')
|
||||||
.description('Create a resource (server, profile, project)');
|
.description('Create a resource (server, project)');
|
||||||
|
|
||||||
// --- create server ---
|
// --- create server ---
|
||||||
cmd.command('server')
|
cmd.command('server')
|
||||||
@@ -56,7 +70,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
|
|||||||
.option('--command <arg>', 'Command argument (repeat for multiple)', collect, [])
|
.option('--command <arg>', 'Command argument (repeat for multiple)', collect, [])
|
||||||
.option('--container-port <port>', 'Container port number')
|
.option('--container-port <port>', 'Container port number')
|
||||||
.option('--replicas <count>', 'Number of replicas', '1')
|
.option('--replicas <count>', 'Number of replicas', '1')
|
||||||
.option('--env-template <entry>', 'Env template (NAME:description[:isSecret], repeat for multiple)', collect, [])
|
.option('--env <entry>', 'Env var: KEY=value (inline) or KEY=secretRef:SECRET:KEY (secret ref, repeat for multiple)', collect, [])
|
||||||
.action(async (name: string, opts) => {
|
.action(async (name: string, opts) => {
|
||||||
const body: Record<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
name,
|
name,
|
||||||
@@ -70,31 +84,24 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
|
|||||||
if (opts.externalUrl) body.externalUrl = opts.externalUrl;
|
if (opts.externalUrl) body.externalUrl = opts.externalUrl;
|
||||||
if (opts.command.length > 0) body.command = opts.command;
|
if (opts.command.length > 0) body.command = opts.command;
|
||||||
if (opts.containerPort) body.containerPort = parseInt(opts.containerPort, 10);
|
if (opts.containerPort) body.containerPort = parseInt(opts.containerPort, 10);
|
||||||
if (opts.envTemplate.length > 0) body.envTemplate = parseEnvTemplate(opts.envTemplate);
|
if (opts.env.length > 0) body.env = parseServerEnv(opts.env);
|
||||||
|
|
||||||
const server = await client.post<{ id: string; name: string }>('/api/v1/servers', body);
|
const server = await client.post<{ id: string; name: string }>('/api/v1/servers', body);
|
||||||
log(`server '${server.name}' created (id: ${server.id})`);
|
log(`server '${server.name}' created (id: ${server.id})`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- create profile ---
|
// --- create secret ---
|
||||||
cmd.command('profile')
|
cmd.command('secret')
|
||||||
.description('Create a profile for an MCP server')
|
.description('Create a secret')
|
||||||
.argument('<name>', 'Profile name')
|
.argument('<name>', 'Secret name (lowercase, hyphens allowed)')
|
||||||
.requiredOption('--server <name-or-id>', 'Server name or ID')
|
.option('--data <entry>', 'Secret data KEY=value (repeat for multiple)', collect, [])
|
||||||
.option('--permissions <perm>', 'Permission (repeat for multiple)', collect, [])
|
|
||||||
.option('--env <entry>', 'Environment override KEY=value (repeat for multiple)', collect, [])
|
|
||||||
.action(async (name: string, opts) => {
|
.action(async (name: string, opts) => {
|
||||||
const serverId = await resolveNameOrId(client, 'servers', opts.server);
|
const data = parseEnvEntries(opts.data);
|
||||||
|
const secret = await client.post<{ id: string; name: string }>('/api/v1/secrets', {
|
||||||
const body: Record<string, unknown> = {
|
|
||||||
name,
|
name,
|
||||||
serverId,
|
data,
|
||||||
};
|
});
|
||||||
if (opts.permissions.length > 0) body.permissions = opts.permissions;
|
log(`secret '${secret.name}' created (id: ${secret.id})`);
|
||||||
if (opts.env.length > 0) body.envOverrides = parseEnvEntries(opts.env);
|
|
||||||
|
|
||||||
const profile = await client.post<{ id: string; name: string }>('/api/v1/profiles', body);
|
|
||||||
log(`profile '${profile.name}' created (id: ${profile.id})`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- create project ---
|
// --- create project ---
|
||||||
|
|||||||
@@ -34,15 +34,19 @@ function formatServerDetail(server: Record<string, unknown>): string {
|
|||||||
lines.push(` ${command.join(' ')}`);
|
lines.push(` ${command.join(' ')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const envTemplate = server.envTemplate as Array<{ name: string; description: string; isSecret?: boolean }> | undefined;
|
const env = server.env as Array<{ name: string; value?: string; valueFrom?: { secretRef: { name: string; key: string } } }> | undefined;
|
||||||
if (envTemplate && envTemplate.length > 0) {
|
if (env && env.length > 0) {
|
||||||
lines.push('');
|
lines.push('');
|
||||||
lines.push('Environment Template:');
|
lines.push('Environment:');
|
||||||
const nameW = Math.max(6, ...envTemplate.map((e) => e.name.length)) + 2;
|
const nameW = Math.max(6, ...env.map((e) => e.name.length)) + 2;
|
||||||
const descW = Math.max(12, ...envTemplate.map((e) => e.description.length)) + 2;
|
lines.push(` ${'NAME'.padEnd(nameW)}SOURCE`);
|
||||||
lines.push(` ${'NAME'.padEnd(nameW)}${'DESCRIPTION'.padEnd(descW)}SECRET`);
|
for (const e of env) {
|
||||||
for (const env of envTemplate) {
|
if (e.value !== undefined) {
|
||||||
lines.push(` ${env.name.padEnd(nameW)}${env.description.padEnd(descW)}${env.isSecret ? 'yes' : 'no'}`);
|
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}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,36 +96,6 @@ function formatInstanceDetail(instance: Record<string, unknown>, inspect?: Recor
|
|||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatProfileDetail(profile: Record<string, unknown>): string {
|
|
||||||
const lines: string[] = [];
|
|
||||||
lines.push(`=== Profile: ${profile.name} ===`);
|
|
||||||
lines.push(`${pad('Name:')}${profile.name}`);
|
|
||||||
lines.push(`${pad('Server ID:')}${profile.serverId}`);
|
|
||||||
|
|
||||||
const permissions = profile.permissions as string[] | undefined;
|
|
||||||
if (permissions && permissions.length > 0) {
|
|
||||||
lines.push(`${pad('Permissions:')}${permissions.join(', ')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const envOverrides = profile.envOverrides as Record<string, string> | undefined;
|
|
||||||
if (envOverrides && Object.keys(envOverrides).length > 0) {
|
|
||||||
lines.push('');
|
|
||||||
lines.push('Environment Overrides:');
|
|
||||||
const keyW = Math.max(4, ...Object.keys(envOverrides).map((k) => k.length)) + 2;
|
|
||||||
for (const [key, value] of Object.entries(envOverrides)) {
|
|
||||||
lines.push(` ${key.padEnd(keyW)}${value}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push('');
|
|
||||||
lines.push('Metadata:');
|
|
||||||
lines.push(` ${pad('ID:', 12)}${profile.id}`);
|
|
||||||
if (profile.createdAt) lines.push(` ${pad('Created:', 12)}${profile.createdAt}`);
|
|
||||||
if (profile.updatedAt) lines.push(` ${pad('Updated:', 12)}${profile.updatedAt}`);
|
|
||||||
|
|
||||||
return lines.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatProjectDetail(project: Record<string, unknown>): string {
|
function formatProjectDetail(project: Record<string, unknown>): string {
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
lines.push(`=== Project: ${project.name} ===`);
|
lines.push(`=== Project: ${project.name} ===`);
|
||||||
@@ -138,6 +112,37 @@ function formatProjectDetail(project: Record<string, unknown>): string {
|
|||||||
return lines.join('\n');
|
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 {
|
function formatGenericDetail(obj: Record<string, unknown>): string {
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
for (const [key, value] of Object.entries(obj)) {
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
@@ -167,10 +172,11 @@ function formatGenericDetail(obj: Record<string, unknown>): string {
|
|||||||
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 or name')
|
.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, idOrName: 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);
|
||||||
|
|
||||||
// Resolve name → ID
|
// Resolve name → ID
|
||||||
@@ -207,8 +213,8 @@ export function createDescribeCommand(deps: DescribeCommandDeps): Command {
|
|||||||
case 'instances':
|
case 'instances':
|
||||||
deps.log(formatInstanceDetail(item, inspect));
|
deps.log(formatInstanceDetail(item, inspect));
|
||||||
break;
|
break;
|
||||||
case 'profiles':
|
case 'secrets':
|
||||||
deps.log(formatProfileDetail(item));
|
deps.log(formatSecretDetail(item, opts.showValues === true));
|
||||||
break;
|
break;
|
||||||
case 'projects':
|
case 'projects':
|
||||||
deps.log(formatProjectDetail(item));
|
deps.log(formatProjectDetail(item));
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ export function createEditCommand(deps: EditCommandDeps): Command {
|
|||||||
const { client, log } = deps;
|
const { client, log } = deps;
|
||||||
|
|
||||||
return new Command('edit')
|
return new Command('edit')
|
||||||
.description('Edit a resource in your default editor (server, profile, project)')
|
.description('Edit a resource in your default editor (server, project)')
|
||||||
.argument('<resource>', 'Resource type (server, profile, project)')
|
.argument('<resource>', 'Resource type (server, project)')
|
||||||
.argument('<name-or-id>', 'Resource name or ID')
|
.argument('<name-or-id>', 'Resource name or ID')
|
||||||
.action(async (resourceArg: string, nameOrId: string) => {
|
.action(async (resourceArg: string, nameOrId: string) => {
|
||||||
const resource = resolveResource(resourceArg);
|
const resource = resolveResource(resourceArg);
|
||||||
@@ -47,7 +47,7 @@ export function createEditCommand(deps: EditCommandDeps): Command {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const validResources = ['servers', 'profiles', 'projects'];
|
const validResources = ['servers', 'secrets', 'projects'];
|
||||||
if (!validResources.includes(resource)) {
|
if (!validResources.includes(resource)) {
|
||||||
log(`Error: unknown resource type '${resourceArg}'`);
|
log(`Error: unknown resource type '${resourceArg}'`);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
|
|||||||
@@ -17,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;
|
||||||
@@ -30,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;
|
||||||
@@ -46,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 },
|
||||||
@@ -59,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' },
|
||||||
@@ -71,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:
|
||||||
@@ -91,21 +91,15 @@ function getColumnsForResource(resource: string): Column<Record<string, unknown>
|
|||||||
*/
|
*/
|
||||||
function toApplyFormat(resource: string, items: unknown[]): Record<string, unknown[]> {
|
function toApplyFormat(resource: string, items: unknown[]): Record<string, unknown[]> {
|
||||||
const cleaned = items.map((item) => {
|
const cleaned = items.map((item) => {
|
||||||
const obj = stripInternalFields(item as Record<string, unknown>);
|
return stripInternalFields(item as Record<string, unknown>);
|
||||||
// For profiles: convert serverId → server (name) for apply compat
|
|
||||||
// We can't resolve the name here without an API call, so keep serverId
|
|
||||||
// but also remove it's not in the apply schema. Actually profiles use
|
|
||||||
// "server" (name) in apply format but serverId from API. Keep serverId
|
|
||||||
// since it can still be used with apply (the apply command resolves names).
|
|
||||||
return obj;
|
|
||||||
});
|
});
|
||||||
return { [resource]: cleaned };
|
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 or name')
|
.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 }) => {
|
||||||
|
|||||||
@@ -1,60 +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('proj')
|
.alias('proj')
|
||||||
.description('Project-specific actions (create with "create project", list with "get projects")');
|
.description('Project-specific actions (create with "create project", list with "get projects")');
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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!');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -3,12 +3,12 @@ import type { ApiClient } from '../api-client.js';
|
|||||||
export const RESOURCE_ALIASES: Record<string, string> = {
|
export const RESOURCE_ALIASES: Record<string, string> = {
|
||||||
server: 'servers',
|
server: 'servers',
|
||||||
srv: 'servers',
|
srv: 'servers',
|
||||||
profile: 'profiles',
|
|
||||||
prof: 'profiles',
|
|
||||||
project: 'projects',
|
project: 'projects',
|
||||||
proj: 'projects',
|
proj: 'projects',
|
||||||
instance: 'instances',
|
instance: 'instances',
|
||||||
inst: 'instances',
|
inst: 'instances',
|
||||||
|
secret: 'secrets',
|
||||||
|
sec: 'secrets',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function resolveResource(name: string): string {
|
export function resolveResource(name: string): string {
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { createLogsCommand } from './commands/logs.js';
|
|||||||
import { createApplyCommand } from './commands/apply.js';
|
import { createApplyCommand } from './commands/apply.js';
|
||||||
import { createCreateCommand } from './commands/create.js';
|
import { createCreateCommand } from './commands/create.js';
|
||||||
import { createEditCommand } from './commands/edit.js';
|
import { createEditCommand } from './commands/edit.js';
|
||||||
import { createSetupCommand } from './commands/setup.js';
|
|
||||||
import { createClaudeCommand } from './commands/claude.js';
|
import { createClaudeCommand } from './commands/claude.js';
|
||||||
import { createProjectCommand } from './commands/project.js';
|
import { createProjectCommand } from './commands/project.js';
|
||||||
import { createBackupCommand, createRestoreCommand } from './commands/backup.js';
|
import { createBackupCommand, createRestoreCommand } from './commands/backup.js';
|
||||||
@@ -25,7 +24,7 @@ export function createProgram(): 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');
|
||||||
|
|
||||||
@@ -110,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),
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ describe('create command', () => {
|
|||||||
'--command', 'python',
|
'--command', 'python',
|
||||||
'--command', '-c',
|
'--command', '-c',
|
||||||
'--command', 'print("hello")',
|
'--command', 'print("hello")',
|
||||||
'--env-template', 'API_KEY:API key:true',
|
'--env', 'API_KEY=secretRef:creds:API_KEY',
|
||||||
'--env-template', 'BASE_URL:Base URL:false',
|
'--env', 'BASE_URL=http://localhost',
|
||||||
], { from: 'user' });
|
], { from: 'user' });
|
||||||
|
|
||||||
expect(client.post).toHaveBeenCalledWith('/api/v1/servers', {
|
expect(client.post).toHaveBeenCalledWith('/api/v1/servers', {
|
||||||
@@ -59,9 +59,9 @@ describe('create command', () => {
|
|||||||
containerPort: 3000,
|
containerPort: 3000,
|
||||||
replicas: 2,
|
replicas: 2,
|
||||||
command: ['python', '-c', 'print("hello")'],
|
command: ['python', '-c', 'print("hello")'],
|
||||||
envTemplate: [
|
env: [
|
||||||
{ name: 'API_KEY', description: 'API key', isSecret: true },
|
{ name: 'API_KEY', valueFrom: { secretRef: { name: 'creds', key: 'API_KEY' } } },
|
||||||
{ name: 'BASE_URL', description: 'Base URL', isSecret: false },
|
{ name: 'BASE_URL', value: 'http://localhost' },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -75,49 +75,28 @@ describe('create command', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('create profile', () => {
|
describe('create secret', () => {
|
||||||
it('creates a profile resolving server name', async () => {
|
it('creates a secret with --data flags', async () => {
|
||||||
vi.mocked(client.get).mockResolvedValue([
|
|
||||||
{ id: 'srv-abc', name: 'ha-mcp' },
|
|
||||||
]);
|
|
||||||
const cmd = createCreateCommand({ client, log });
|
|
||||||
await cmd.parseAsync(['profile', 'production', '--server', 'ha-mcp'], { from: 'user' });
|
|
||||||
expect(client.post).toHaveBeenCalledWith('/api/v1/profiles', expect.objectContaining({
|
|
||||||
name: 'production',
|
|
||||||
serverId: 'srv-abc',
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('parses --env KEY=value entries', async () => {
|
|
||||||
vi.mocked(client.get).mockResolvedValue([
|
|
||||||
{ id: 'srv-1', name: 'test' },
|
|
||||||
]);
|
|
||||||
const cmd = createCreateCommand({ client, log });
|
const cmd = createCreateCommand({ client, log });
|
||||||
await cmd.parseAsync([
|
await cmd.parseAsync([
|
||||||
'profile', 'dev',
|
'secret', 'ha-creds',
|
||||||
'--server', 'test',
|
'--data', 'TOKEN=abc123',
|
||||||
'--env', 'FOO=bar',
|
'--data', 'URL=https://ha.local',
|
||||||
'--env', 'SECRET=s3cr3t',
|
|
||||||
], { from: 'user' });
|
], { from: 'user' });
|
||||||
expect(client.post).toHaveBeenCalledWith('/api/v1/profiles', expect.objectContaining({
|
expect(client.post).toHaveBeenCalledWith('/api/v1/secrets', {
|
||||||
envOverrides: { FOO: 'bar', SECRET: 's3cr3t' },
|
name: 'ha-creds',
|
||||||
}));
|
data: { TOKEN: 'abc123', URL: 'https://ha.local' },
|
||||||
|
});
|
||||||
|
expect(output.join('\n')).toContain("secret 'test' created");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('passes permissions', async () => {
|
it('creates a secret with empty data', async () => {
|
||||||
vi.mocked(client.get).mockResolvedValue([
|
|
||||||
{ id: 'srv-1', name: 'test' },
|
|
||||||
]);
|
|
||||||
const cmd = createCreateCommand({ client, log });
|
const cmd = createCreateCommand({ client, log });
|
||||||
await cmd.parseAsync([
|
await cmd.parseAsync(['secret', 'empty-secret'], { from: 'user' });
|
||||||
'profile', 'admin',
|
expect(client.post).toHaveBeenCalledWith('/api/v1/secrets', {
|
||||||
'--server', 'test',
|
name: 'empty-secret',
|
||||||
'--permissions', 'read',
|
data: {},
|
||||||
'--permissions', 'write',
|
});
|
||||||
], { from: 'user' });
|
|
||||||
expect(client.post).toHaveBeenCalledWith('/api/v1/profiles', expect.objectContaining({
|
|
||||||
permissions: ['read', 'write'],
|
|
||||||
}));
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ describe('describe command', () => {
|
|||||||
transport: 'STDIO',
|
transport: 'STDIO',
|
||||||
packageName: '@slack/mcp',
|
packageName: '@slack/mcp',
|
||||||
dockerImage: null,
|
dockerImage: null,
|
||||||
envTemplate: [],
|
env: [],
|
||||||
createdAt: '2025-01-01',
|
createdAt: '2025-01-01',
|
||||||
});
|
});
|
||||||
const cmd = createDescribeCommand(deps);
|
const cmd = createDescribeCommand(deps);
|
||||||
@@ -50,10 +50,10 @@ describe('describe command', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
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 () => {
|
||||||
@@ -72,26 +72,6 @@ describe('describe command', () => {
|
|||||||
expect(deps.output[0]).toContain('name: slack');
|
expect(deps.output[0]).toContain('name: slack');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows profile with permissions and env overrides', async () => {
|
|
||||||
const deps = makeDeps({
|
|
||||||
id: 'p1',
|
|
||||||
name: 'production',
|
|
||||||
serverId: 'srv-1',
|
|
||||||
permissions: ['read', 'write'],
|
|
||||||
envOverrides: { FOO: 'bar', SECRET: 's3cr3t' },
|
|
||||||
createdAt: '2025-01-01',
|
|
||||||
});
|
|
||||||
const cmd = createDescribeCommand(deps);
|
|
||||||
await cmd.parseAsync(['node', 'test', 'profile', 'p1']);
|
|
||||||
|
|
||||||
const text = deps.output.join('\n');
|
|
||||||
expect(text).toContain('=== Profile: production ===');
|
|
||||||
expect(text).toContain('read, write');
|
|
||||||
expect(text).toContain('Environment Overrides:');
|
|
||||||
expect(text).toContain('FOO');
|
|
||||||
expect(text).toContain('bar');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows project detail', async () => {
|
it('shows project detail', async () => {
|
||||||
const deps = makeDeps({
|
const deps = makeDeps({
|
||||||
id: 'proj-1',
|
id: 'proj-1',
|
||||||
@@ -109,6 +89,39 @@ describe('describe command', () => {
|
|||||||
expect(text).toContain('user-1');
|
expect(text).toContain('user-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows secret detail with masked values', async () => {
|
||||||
|
const deps = makeDeps({
|
||||||
|
id: 'sec-1',
|
||||||
|
name: 'ha-creds',
|
||||||
|
data: { TOKEN: 'abc123', URL: 'https://ha.local' },
|
||||||
|
createdAt: '2025-01-01',
|
||||||
|
});
|
||||||
|
const cmd = createDescribeCommand(deps);
|
||||||
|
await cmd.parseAsync(['node', 'test', 'secret', 'sec-1']);
|
||||||
|
|
||||||
|
const text = deps.output.join('\n');
|
||||||
|
expect(text).toContain('=== Secret: ha-creds ===');
|
||||||
|
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 () => {
|
it('shows instance detail with container info', async () => {
|
||||||
const deps = makeDeps({
|
const deps = makeDeps({
|
||||||
id: 'inst-1',
|
id: 'inst-1',
|
||||||
|
|||||||
@@ -150,31 +150,4 @@ describe('edit command', () => {
|
|||||||
expect(output.join('\n')).toContain('immutable');
|
expect(output.join('\n')).toContain('immutable');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('edits a profile', async () => {
|
|
||||||
vi.mocked(client.get).mockImplementation(async (path: string) => {
|
|
||||||
if (path === '/api/v1/profiles') return [{ id: 'prof-1', name: 'production' }];
|
|
||||||
return {
|
|
||||||
id: 'prof-1', name: 'production', serverId: 'srv-1',
|
|
||||||
permissions: ['read'], envOverrides: { FOO: 'bar' },
|
|
||||||
createdAt: '2025-01-01', updatedAt: '2025-01-01', version: 1,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const cmd = createEditCommand({
|
|
||||||
client,
|
|
||||||
log,
|
|
||||||
getEditor: () => 'vi',
|
|
||||||
openEditor: (filePath) => {
|
|
||||||
const content = readFileSync(filePath, 'utf-8');
|
|
||||||
const modified = content.replace('FOO: bar', 'FOO: baz');
|
|
||||||
writeFileSync(filePath, modified, 'utf-8');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await cmd.parseAsync(['profile', 'production'], { from: 'user' });
|
|
||||||
|
|
||||||
expect(client.put).toHaveBeenCalledWith('/api/v1/profiles/prof-1', expect.objectContaining({
|
|
||||||
envOverrides: { FOO: 'baz' },
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -67,16 +67,6 @@ describe('get command', () => {
|
|||||||
expect(text).not.toContain('createdAt:');
|
expect(text).not.toContain('createdAt:');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('lists profiles with correct columns', async () => {
|
|
||||||
const deps = makeDeps([
|
|
||||||
{ 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 () => {
|
||||||
const deps = makeDeps([
|
const deps = makeDeps([
|
||||||
{ id: 'inst-1', serverId: 'srv-1', status: 'RUNNING', containerId: 'abc123def456', port: 3000 },
|
{ id: 'inst-1', serverId: 'srv-1', status: 'RUNNING', containerId: 'abc123def456', port: 3000 },
|
||||||
|
|||||||
@@ -45,12 +45,6 @@ describe('delete command', () => {
|
|||||||
expect(client.delete).toHaveBeenCalledWith('/api/v1/servers/srv-abc');
|
expect(client.delete).toHaveBeenCalledWith('/api/v1/servers/srv-abc');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deletes a profile', async () => {
|
|
||||||
const cmd = createDeleteCommand({ client, log });
|
|
||||||
await cmd.parseAsync(['profile', 'prof-1'], { from: 'user' });
|
|
||||||
expect(client.delete).toHaveBeenCalledWith('/api/v1/profiles/prof-1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('deletes a project', async () => {
|
it('deletes a project', async () => {
|
||||||
const cmd = createDeleteCommand({ client, log });
|
const cmd = createDeleteCommand({ client, log });
|
||||||
await cmd.parseAsync(['project', 'proj-1'], { from: 'user' });
|
await cmd.parseAsync(['project', 'proj-1'], { from: 'user' });
|
||||||
|
|||||||
@@ -21,32 +21,9 @@ describe('project command', () => {
|
|||||||
output = [];
|
output = [];
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('profiles', () => {
|
it('creates command with alias', () => {
|
||||||
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 });
|
const cmd = createProjectCommand({ client, log });
|
||||||
await cmd.parseAsync(['profiles', 'proj-1'], { from: 'user' });
|
expect(cmd.name()).toBe('project');
|
||||||
expect(client.get).toHaveBeenCalledWith('/api/v1/projects/proj-1/profiles');
|
expect(cmd.alias()).toBe('proj');
|
||||||
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)');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -21,7 +21,6 @@ describe('CLI command registration (e2e)', () => {
|
|||||||
expect(commandNames).toContain('apply');
|
expect(commandNames).toContain('apply');
|
||||||
expect(commandNames).toContain('create');
|
expect(commandNames).toContain('create');
|
||||||
expect(commandNames).toContain('edit');
|
expect(commandNames).toContain('edit');
|
||||||
expect(commandNames).toContain('setup');
|
|
||||||
expect(commandNames).toContain('claude');
|
expect(commandNames).toContain('claude');
|
||||||
expect(commandNames).toContain('project');
|
expect(commandNames).toContain('project');
|
||||||
expect(commandNames).toContain('backup');
|
expect(commandNames).toContain('backup');
|
||||||
@@ -46,19 +45,11 @@ describe('CLI command registration (e2e)', () => {
|
|||||||
expect(subcommands).toContain('remove');
|
expect(subcommands).toContain('remove');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('project command has action subcommands only', () => {
|
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('profiles');
|
|
||||||
expect(subcommands).toContain('set-profiles');
|
|
||||||
// create is now top-level (mcpctl create project)
|
|
||||||
expect(subcommands).not.toContain('create');
|
|
||||||
expect(subcommands).not.toContain('list');
|
|
||||||
expect(subcommands).not.toContain('show');
|
|
||||||
expect(subcommands).not.toContain('delete');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('displays version', () => {
|
it('displays version', () => {
|
||||||
|
|||||||
@@ -61,12 +61,11 @@ model McpServer {
|
|||||||
command Json?
|
command Json?
|
||||||
containerPort Int?
|
containerPort Int?
|
||||||
replicas Int @default(1)
|
replicas Int @default(1)
|
||||||
envTemplate Json @default("[]")
|
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])
|
||||||
@@ -78,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 ──
|
||||||
@@ -109,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 {
|
||||||
|
|||||||
@@ -4,9 +4,8 @@ export type {
|
|||||||
User,
|
User,
|
||||||
Session,
|
Session,
|
||||||
McpServer,
|
McpServer,
|
||||||
McpProfile,
|
Secret,
|
||||||
Project,
|
Project,
|
||||||
ProjectMcpProfile,
|
|
||||||
McpInstance,
|
McpInstance,
|
||||||
AuditLog,
|
AuditLog,
|
||||||
Role,
|
Role,
|
||||||
|
|||||||
@@ -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++;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 ──
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,15 +60,15 @@ async function main(): Promise<void> {
|
|||||||
|
|
||||||
// Services
|
// Services
|
||||||
const serverService = new McpServerService(serverRepo);
|
const serverService = new McpServerService(serverRepo);
|
||||||
const instanceService = new InstanceService(instanceRepo, serverRepo, orchestrator);
|
const instanceService = new InstanceService(instanceRepo, serverRepo, orchestrator, secretRepo);
|
||||||
serverService.setInstanceService(instanceService);
|
serverService.setInstanceService(instanceService);
|
||||||
const profileService = new McpProfileService(profileRepo, serverRepo);
|
const secretService = new SecretService(secretRepo);
|
||||||
const projectService = new ProjectService(projectRepo, profileRepo, serverRepo);
|
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, serverRepo);
|
const mcpProxyService = new McpProxyService(instanceRepo, serverRepo);
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ async function main(): Promise<void> {
|
|||||||
|
|
||||||
// Routes
|
// Routes
|
||||||
registerMcpServerRoutes(app, serverService, instanceService);
|
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);
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 } });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -30,7 +30,7 @@ export class McpServerRepository implements IMcpServerRepository {
|
|||||||
command: data.command ?? Prisma.DbNull,
|
command: data.command ?? Prisma.DbNull,
|
||||||
containerPort: data.containerPort ?? null,
|
containerPort: data.containerPort ?? null,
|
||||||
replicas: data.replicas,
|
replicas: data.replicas,
|
||||||
envTemplate: data.envTemplate,
|
env: data.env,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -46,7 +46,7 @@ export class McpServerRepository implements IMcpServerRepository {
|
|||||||
if (data.command !== undefined) updateData['command'] = data.command;
|
if (data.command !== undefined) updateData['command'] = data.command;
|
||||||
if (data.containerPort !== undefined) updateData['containerPort'] = data.containerPort;
|
if (data.containerPort !== undefined) updateData['containerPort'] = data.containerPort;
|
||||||
if (data.replicas !== undefined) updateData['replicas'] = data.replicas;
|
if (data.replicas !== undefined) updateData['replicas'] = data.replicas;
|
||||||
if (data.envTemplate !== undefined) updateData['envTemplate'] = data.envTemplate;
|
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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
39
src/mcpd/src/repositories/secret.repository.ts
Normal file
39
src/mcpd/src/repositories/secret.repository.ts
Normal 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 } });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
30
src/mcpd/src/routes/secrets.ts
Normal file
30
src/mcpd/src/routes/secrets.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -115,66 +109,44 @@ export class RestoreService {
|
|||||||
description: server.description,
|
description: server.description,
|
||||||
transport: server.transport as 'STDIO' | 'SSE' | 'STREAMABLE_HTTP',
|
transport: server.transport as 'STDIO' | 'SSE' | 'STREAMABLE_HTTP',
|
||||||
replicas: (server as { replicas?: number }).replicas ?? 1,
|
replicas: (server as { replicas?: number }).replicas ?? 1,
|
||||||
envTemplate: (server.envTemplate ?? []) as Array<{ name: string; description: string; isSecret: boolean }>,
|
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)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,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)}`);
|
||||||
|
|||||||
44
src/mcpd/src/services/env-resolver.ts
Normal file
44
src/mcpd/src/services/env-resolver.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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[]> {
|
||||||
@@ -162,6 +164,19 @@ export class InstanceService {
|
|||||||
spec.command = 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 containerInfo = await this.orchestrator.createContainer(spec);
|
||||||
|
|
||||||
const updateFields: { containerId: string; port?: number } = {
|
const updateFields: { containerId: string; port?: number } = {
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
54
src/mcpd/src/services/secret.service.ts
Normal file
54
src/mcpd/src/services/secret.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
|
||||||
|
|||||||
@@ -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>;
|
|
||||||
@@ -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(''),
|
||||||
@@ -18,7 +29,7 @@ export const CreateMcpServerSchema = z.object({
|
|||||||
command: z.array(z.string()).optional(),
|
command: z.array(z.string()).optional(),
|
||||||
containerPort: z.number().int().min(1).max(65535).optional(),
|
containerPort: z.number().int().min(1).max(65535).optional(),
|
||||||
replicas: z.number().int().min(0).max(10).default(1),
|
replicas: z.number().int().min(0).max(10).default(1),
|
||||||
envTemplate: z.array(EnvTemplateEntrySchema).default([]),
|
env: z.array(ServerEnvEntrySchema).default([]),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const UpdateMcpServerSchema = z.object({
|
export const UpdateMcpServerSchema = z.object({
|
||||||
@@ -31,7 +42,7 @@ export const UpdateMcpServerSchema = z.object({
|
|||||||
command: z.array(z.string()).nullable().optional(),
|
command: z.array(z.string()).nullable().optional(),
|
||||||
containerPort: z.number().int().min(1).max(65535).nullable().optional(),
|
containerPort: z.number().int().min(1).max(65535).nullable().optional(),
|
||||||
replicas: z.number().int().min(0).max(10).optional(),
|
replicas: z.number().int().min(0).max(10).optional(),
|
||||||
envTemplate: z.array(EnvTemplateEntrySchema).optional(),
|
env: z.array(ServerEnvEntrySchema).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type CreateMcpServerInput = z.infer<typeof CreateMcpServerSchema>;
|
export type CreateMcpServerInput = z.infer<typeof CreateMcpServerSchema>;
|
||||||
|
|||||||
@@ -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>;
|
|
||||||
|
|||||||
13
src/mcpd/src/validation/secret.schema.ts
Normal file
13
src/mcpd/src/validation/secret.schema.ts
Normal 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>;
|
||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
112
src/mcpd/tests/env-resolver.test.ts
Normal file
112
src/mcpd/tests/env-resolver.test.ts
Normal 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({});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -83,7 +83,7 @@ function makeServer(overrides: Partial<{ id: string; name: string; replicas: num
|
|||||||
command: overrides.command ?? null,
|
command: overrides.command ?? null,
|
||||||
containerPort: overrides.containerPort ?? null,
|
containerPort: overrides.containerPort ?? null,
|
||||||
replicas: overrides.replicas ?? 1,
|
replicas: overrides.replicas ?? 1,
|
||||||
envTemplate: [],
|
env: [],
|
||||||
version: 1,
|
version: 1,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
|
|||||||
@@ -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']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -44,7 +44,7 @@ function createInMemoryServerRepo(): IMcpServerRepository {
|
|||||||
command: data.command ?? null,
|
command: data.command ?? null,
|
||||||
containerPort: data.containerPort ?? null,
|
containerPort: data.containerPort ?? null,
|
||||||
replicas: data.replicas ?? 1,
|
replicas: data.replicas ?? 1,
|
||||||
envTemplate: data.envTemplate ?? [],
|
env: data.env ?? [],
|
||||||
version: 1,
|
version: 1,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
@@ -347,8 +347,8 @@ describe('MCP server full flow', () => {
|
|||||||
transport: 'STREAMABLE_HTTP',
|
transport: 'STREAMABLE_HTTP',
|
||||||
externalUrl: `http://localhost:${fakeMcpPort}`,
|
externalUrl: `http://localhost:${fakeMcpPort}`,
|
||||||
containerPort: 3000,
|
containerPort: 3000,
|
||||||
envTemplate: [
|
env: [
|
||||||
{ name: 'HOMEASSISTANT_TOKEN', description: 'HA token', isSecret: true },
|
{ name: 'HOMEASSISTANT_TOKEN', value: 'placeholder' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -463,9 +463,9 @@ describe('MCP server full flow', () => {
|
|||||||
transport: 'STREAMABLE_HTTP',
|
transport: 'STREAMABLE_HTTP',
|
||||||
containerPort: 3000,
|
containerPort: 3000,
|
||||||
command: ['python', '-c', 'print("hello")'],
|
command: ['python', '-c', 'print("hello")'],
|
||||||
envTemplate: [
|
env: [
|
||||||
{ name: 'HOMEASSISTANT_URL', description: 'HA URL' },
|
{ name: 'HOMEASSISTANT_URL', value: 'http://localhost:8123' },
|
||||||
{ name: 'HOMEASSISTANT_TOKEN', description: 'HA token', isSecret: true },
|
{ name: 'HOMEASSISTANT_TOKEN', valueFrom: { secretRef: { name: 'ha-secrets', key: 'token' } } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ function mockRepo(): IMcpServerRepository {
|
|||||||
command: null,
|
command: null,
|
||||||
containerPort: null,
|
containerPort: null,
|
||||||
replicas: data.replicas ?? 1,
|
replicas: data.replicas ?? 1,
|
||||||
envTemplate: [],
|
env: [],
|
||||||
version: 1,
|
version: 1,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
@@ -55,7 +55,7 @@ function mockRepo(): IMcpServerRepository {
|
|||||||
command: null,
|
command: null,
|
||||||
containerPort: null,
|
containerPort: null,
|
||||||
replicas: 1,
|
replicas: 1,
|
||||||
envTemplate: [],
|
env: [],
|
||||||
version: 2,
|
version: 2,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
170
src/mcpd/tests/secret-routes.test.ts
Normal file
170
src/mcpd/tests/secret-routes.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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';
|
|
||||||
|
|||||||
@@ -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';
|
|
||||||
@@ -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();
|
|
||||||
@@ -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',
|
|
||||||
};
|
|
||||||
@@ -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',
|
|
||||||
};
|
|
||||||
@@ -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',
|
|
||||||
};
|
|
||||||
@@ -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';
|
|
||||||
@@ -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',
|
|
||||||
};
|
|
||||||
@@ -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',
|
|
||||||
};
|
|
||||||
@@ -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',
|
|
||||||
};
|
|
||||||
@@ -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>;
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user